State Machine Events
An event is something that happened which may result in a state change. In a saga state machine, an event is used to correlate a message to a saga state machine instance. Saga state machine events are used to add behavior to a saga state machine, such as adding or updating saga state machine instance data, publishing or sending messages, and changing the instance's current state.
Declaring Events
Events are declared as public properties on the saga state machine with the Event<T>
property type, where T
is a valid message type.
In the example below, the SubmitOrder message is configured as an event. The event configuration also specifies the message property used to correlate the
event to a saga state machine instance. In this case, the Guid
property OrderId
is used.
public record SubmitOrder(Guid OrderId);
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;
public OrderStateMachine()
{
Event(() => SubmitOrder,
e => e.CorrelateById(x => x.Message.OrderId)
);
}
}
Event Conventions
There are several conventions applied when configuring event correlation in a saga state machine. These conventions may reduce the amount of configuration required for an event meeting a convention's criteria.
CorrelatedBy<Guid>
If an event message type implements the CorrelatedBy<Guid>
interface, the event will automatically be configured to correlate using the CorrelationId
property on that interface.
Property Name
If an event message has a CorrelationId
, CommandId
, or EventId
property and that properties type is Guid
, the event will automatically be configured
to correlate using the first property found (in that order).
Global Topology
It's also possible to configure the correlation property for a message type using GlobalTopology
. This configures the message type globally so that it is
automatically available to any saga state machine. However, a saga state machine can override the global settings by explicitly configuring the event
correlation.
GlobalTopology.Send.UseCorrelationId<SubmitOrder>(x => x.OrderId);
Initiating Events
The Initial
state is the starting point of all sagas. When an existing saga state machine instance cannot be found that correlates to an event behavior
defined for the Initial state, a new instance is created.
Events handled in the Initial state are initiating events that result in a newly created saga state machine instance is an instance does not already exist.
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public State Submitted { get; private set; } = null!;
public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;
public OrderStateMachine()
{
Initially(
When(SubmitOrder)
.Then(context =>
{
context.Saga.CustomerNumber = context.Message.CustomerNumber;
})
.TransitionTo(Submitted)
);
}
}
Handling Events
Event can be handled in any state, and can be configured using During
and specifying the states in which the event is accepted. In the example below,
the AcceptOrder
event is handled in the Submitted
state.
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public State Submitted { get; private set; } = null!;
public State Accepted { get; private set; } = null!;
public Event<AcceptOrder> AcceptOrder { get; private set; } = null!;
public OrderStateMachine()
{
During(Submitted,
When(AcceptOrder)
.Then(context =>
{
context.Saga.AcceptedAt = context.SentTime ?? DateTime.UtcNow;
})
.TransitionTo(Accepted)
);
}
}
Multiple states can be specified using During
to avoid duplicating behavior configuration. In the updated example below, the AcceptOrder
event is also
handled in the Accepted
state, to add some idempotency to the saga state machine.
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public State Submitted { get; private set; } = null!;
public State Accepted { get; private set; } = null!;
public Event<AcceptOrder> AcceptOrder { get; private set; } = null!;
public OrderStateMachine()
{
During(Submitted, Accepted,
When(AcceptOrder)
.Then(context =>
{
context.Saga.AcceptedAt ??= context.SentTime ?? DateTime.UtcNow;
})
.TransitionTo(Accepted)
);
}
}
Event Options
Several additional properties can be configured on the event, including:
Property | Type | Default | Description |
---|---|---|---|
ConfigureConsumeTopology | bool | true | When false, the event message type will not be configured on the broker. |
InsertOnInitial | bool | false | If true and the event is handled in the Initial state, the saga repository will attempt to insert a new saga instance before trying to load it. This option was introduced to deal with S-RANGE locks on SQL Server that would slow down inserts/updates due to querying for non-existent rows. |
ReadOnly | bool | false | When true, the saga state machine instance will not be persisted when handling this event. |
OnMissingInstance | delegate | null | Used to configure the behavior of an event when no matching instance is found. |
Read Only Events
A saga state machine instance is the source of truth for an instance. It's common to expose that state by handling an incoming request event and responding with the current state. To reduce saga repository resource usage, or in some cases to simply avoid updating the instance in the repository when nothing has been updated, a read-only event can be configured.
In the example below, an event handling the request is configured as read-only.
public record GetOrderState(Guid OrderId);
public record OrderState(Guid OrderId, string CurrentState);
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public Event<GetOrderState> OrderStateRequested { get; private set; } = null!;
public OrderStateMachine()
{
Event(() => GetOrderState, e =>
{
e.CorrelateById(x => x.Message.OrderId);
e.ReadOnly = true;
});
DuringAny(
When(OrderStateRequested)
.RespondAsync(async context => new OrderState(
context.Saga.CorrelationId,
await Accessor.Get(context).Name))
);
}
}
Accessor
is a saga state machine property that can be used to get the current state from a saga state machine instance.
On Missing Instance
When a non-initiating event (an event without a behavior in the Initial state) is received that does not match an existing saga state machine instance, the message is ignored by default. This can lead to a misunderstanding that messages are being "lost." In many cases, this may be due to message order, concurrency, or even timing.
The missing instance behavior can be configured for an event using the OnMissingInstance
method. In the example below the event is configured to
respond with OrderNotFound
when a saga state machine instance matching the OrderId
is not found. This ensures that the request doesn't time out
and receives a proper response.
public record RequestOrderCancellation(Guid OrderId);
public record OrderNotFound(Guid OrderId);
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public OrderStateMachine()
{
Event(() => OrderCancellationRequested, e =>
{
e.CorrelateById(context => context.Message.OrderId);
e.OnMissingInstance(m =>
{
return m.ExecuteAsync(x => x.RespondAsync(new OrderNotFound(x.OrderId)));
});
});
}
public Event<RequestOrderCancellation> OrderCancellationRequested { get; private set; }
}
Other missing instance options include Discard
, Fault
, and Execute
(a synchronous version of ExecuteAsync).
Redeliver on Missing Instance
Another option when a matching saga state machine instance is not found is to redeliver the message. Redelivery allows time for consumption of other events which may create a matching instance, after which the redelivered message would be correlated to the matching instance.
Redelivery can be configured as shown in the example below. The options are the same as configuring redelivery for exceptions.
public record OrderAddressValidated(Guid OrderId);
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public OrderStateMachine()
{
Event(() => OrderAddressValidated, e =>
{
e.CorrelateById(context => context.Message.OrderId);
e.OnMissingInstance(m => m.Redeliver(r =>
{
r.Interval(5, 1000);
r.OnRedeliveryLimitReached(n => n.Fault());
}));
});
}
public Event<OrderAddressValidated> OrderAddressValidated { get; private set; }
}
IF a matching saga state machine instance is not found, the message will be redelivered to the queue five times after which a fault (exception) will be produced if no matching instance is found.
Advanced Options
Like most things in MassTransit, the everyday use case of MassTransit should not need to use these options. But sometimes, you have to really dig in to make things happen.
Setting the Saga Factory
InsertOnInitial
and you have required properties that must be present or the insert will fail. Typically with SQL and not null
columns.On events that are in the Initial
state, a new instance of the saga will be created. You can use the SetSagaFactory
to control how the saga is instantiated.
public class OrderStateMachine :
MassTransitStateMachine<OrderState>
{
public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;
public OrderStateMachine()
{
Event(
() => SubmitOrder,
e =>
{
e.CorrelateById(cxt => cxt.Message.OrderId)
e.SetSagaFactory(cxt =>
{
// complex constructor logic
return new OrderState
{
CorrelationId = cxt.Message.OrderId
};
});
}
);
}
}