Stateful Sagas
Note!
A single stateful sagas can received command messages from any combination of external messages coming in from
transports like Rabbit MQ or Azure Service Bus and local command messages through the ICommandBus
. A StatefulSagaOf<T>
handler is just like any other Jasper handler, but with some extra conventions around the saga state.
As is so common in these docs, I would direct you to this from the old "EIP" book: Process Manager. A stateful saga in Jasper is used to coordinate long running workflows or to break large, logical transactions into a series of smaller steps. A stateful saga consists of a couple parts:
- A saga state document type that is persisted between saga messages
- A saga message handler that inherits from
StatefulSagaOf<T>
, where the "T" is the saga state document type - A saga persistence strategy registered in Jasper that knows how to load and persist the saga state documents
The StatefulSagaOf<T>
base class looks like this:
public abstract class StatefulSagaOf<TState>
{
public bool IsCompleted { get; protected set; }
public void MarkCompleted()
{
IsCompleted = true;
}
public TState State { get; set; }
}
Right now the options for saga persistence are
- The default in memory model
- EF Core with either Sql Server or Postgresql
- Marten and Postgresql
Inspired by Jimmy Bogard's example of ordering at a fast food restaurant, let's say that we are building a Jasper system to manage filling the order of Happy Meals at McDonald's. When you place an order in our McDonald's, various folks gather up the various parts of the order until it is completed, then calls out to the customer that the order is ready. In our system, we want to build out a saga handler for this work that can parallelize and coordinate all the work necessary to deliver the Happy Meal to our customers.
As a first step, let's say that the process of building out a Happy Meal starts with receiving this message shown below:
public class HappyMealOrder
{
public string Drink { get; set; }
public string Toy { get; set; }
public string SideDish { get; set; }
public string MainDish { get; set; }
}
Next, we need to track the current state of our happy meal order with a new document type that will be persisted across different messages being handled by our saga:
public class HappyMealOrderState
{
// Jasper wants you to make the saga state
// document have an "Id" property, but
// that can be overridden
public int Id { get; set; }
public HappyMealOrder Order { get; set; }
public bool DrinkReady { get; set; }
public bool ToyReady { get; set; }
public bool SideReady { get; set; }
public bool MainReady { get; set; }
// The order is complete if *everything*
// is complete
public bool IsOrderComplete()
{
return DrinkReady && ToyReady && SideReady && MainReady;
}
}
Finally, let's add a new saga handler for our new state document and a single message handler action to start the saga:
public class HappyMealSaga : StatefulSagaOf<HappyMealOrderState>
{
private int _orderIdSequence;
// This is a little bit cute, but the HappyMealOrderState type
// is known to be the saga state document, so it'll be treated as
// the state document, while the object[] will be treated as
// cascading messages
public (HappyMealOrderState, object[]) Starts(HappyMealOrder order)
{
var state = new HappyMealOrderState
{
Order = order,
Id = ++_orderIdSequence
};
return (state, chooseActions(order, state.Id).ToArray());
}
private IEnumerable<object> chooseActions(HappyMealOrder order, int stateId)
{
// choose the outgoing messages to other systems -- or the local
// system tracking all this -- to start having this happy meal
// order put together
if (order.Drink == "Soda") yield return new SodaRequested {OrderId = stateId};
// and others
}
}
There's a couple things to note about the Starts()
method up above:
- When Jasper sees a method named
Start
orStarts
, that indicates to Jasper that this is a possible beginning for a stateful saga and that the state document does not already exist HappyMealOrderState
is one of the types returned as a C# 7 tuple from this method, and Jasper will treat this as the state document for the saga and the saga persistence will save the document as part of executing that message. You could also just return theHappyMealOrderState
and useIMessageContext
to explicitly send other messages- The
object[]
part of the tuple are just cascading messages that would be sent out to initiate work like "go fill the soda" or "go find the right toy the child asked for"
If you're uncomfortable with C# tuples or just don't like the magic, you can effect the same outgoing messages by using this alternative:
public async Task<HappyMealOrderState> Starts(
HappyMealOrder order, // The first argument is assumed to be the message type
IMessageContext context) // Additional arguments are assumed to be services
{
var state = new HappyMealOrderState
{
Order = order,
Id = ++_orderIdSequence
};
if (order.Drink == "Soda") await context.Send(new SodaRequested {OrderId = state.Id});
// And other outgoing messages to coordinate gathering up the happy meal
return state;
}
Both of the examples above assume that the SodaRequested
messages will be sent to other systems, but it's perfectly possible
to use a stateful saga to manage processing that's handled completely within your system like this:
public async Task<HappyMealOrderState> Starts(
HappyMealOrder order, // The first argument is assumed to be the message type
IMessageContext context) // Additional arguments are assumed to be services
{
var state = new HappyMealOrderState
{
Order = order,
Id = ++_orderIdSequence
};
if (order.Drink == "Soda") await context.Enqueue(new SodaRequested {OrderId = state.Id});
// And other outgoing messages to coordinate gathering up the happy meal
return state;
}
Note!
The logical saga can be split across multiple classes inheriting from the SagaStatefulOf<T>
abstract class, as long as the
handler types all use the same state type
Updating and Completing Saga State
As we saw above, methods named Start
or Starts
are assumed to create a brand new state document for a logical saga. The next step is to handle additional methods that perform additional work within the saga, update the state document, and potentially close out the saga.
Related to the saga above, let's say that we receive a SodaFetched
message that denotes that the soda we requested at the onset of the saga is ready. We'll need to update the state to mark that the drink is ready, and if all the parts of the happy meal are ready, we'll tell some kind of IOrderService
to close the order and mark the saga as complete. That handler mthod could look like this:
public void Handle(
SodaFetched soda, // The first argument is the message type
IOrderService service // Additional arguments are injected services
)
{
State.DrinkReady = true;
// Determine if the happy meal is completely ready
if (State.IsOrderComplete())
{
// Maybe you need to remove this
// order from some kind of screen display
service.Close(State.Id);
// And we're done here, so let's mark the Saga as complete
MarkCompleted();
}
}
Things to note in the sample up above:
- The method name can be any of the valid handler methods outlined in Message Handler Discovery
- The saga state document
HappyMealOrderState
is passed in as a method argument. The surrounding saga persistence in Jasper is loading that document for you based on either the incoming envelope metadata or the message as explained in the next section - If the
StatefulSagaOf<T>.MarkCompleted()
method is called while handling the message, the state document will be deleted from storage after the message handler is called
Sagas, Outbox, and Transactions
The stateful saga support heavily uses Jasper's message persistance. Handler actions involving a saga automatically opt into the transaction and outbox support for every saga action. This means an all or nothing, atomic action to:
- Execute the incoming command
- Update the saga state
- Persist the outgoing messages in the "outbox" message storage
If any of those actions fail, the entire transaction is rolled back and the outgoing messages do not go out. The normal Jasper error handling does apply though.
Saga State Identity
One way or another, Jasper has to be able to correlate that the SodaFetched
message coming in to the message handler above
is related to a certain persisted state document by the id of that state document. Jasper has a couple ways to do that:
If the message is being passed to the local system or exchanging messages with an external Jasper system, there's an Envelope.SagaId
property that gets propagated on every message and response that Jasper can use automatically to do the state document to message correlation. For example, if the SodaRequested
message is sent to another system running Jasper that replies to our system with
a corresponding SodaFetched
message in a handler like this:
// This message handler is in another system responsible for
// filling sodas
public class SodaHandler
{
public SodaFetched Handle(SodaRequested requested)
{
// get the soda, then return the update message
return new SodaFetched();
}
}
If you are receiving messages from an external system or don't want to or can't depend on that envelope metadata, you can pass the identity of the saga state document in the message itself. Jasper always checks the Envelope.SagaId
value first, but failing that it falls back to looking for a property named SagaId
on the incoming message type like this:
public class BurgerReady
{
// By default, Jasper is going to look for a property
// called SagaId as the identifier for the stateful
// document
public int SagaId { get; set; }
}
Or if you want to use a different property name, you can override that with an attribute like this:
public class ToyOnTray
{
// There's always *some* reason to deviate,
// so you can use this attribute to tell Jasper
// that this property refers to the Id of the
// Saga state document
[SagaIdentity] public int OrderId { get; set; }
}
To add some context, let's see these two messages in context:
public void Handle(ToyOnTray toyReady)
{
State.ToyReady = true;
if (State.IsOrderComplete()) MarkCompleted();
}
public void Handle(BurgerReady burgerReady)
{
State.MainReady = true;
if (State.IsOrderComplete()) MarkCompleted();
}
If you were using the Marten-backed saga persistence, the code above
would result in the HappyMealOrderState
document being loaded with the value in BurgerReady.SagaId
or ToyOnTray.OrderId
as the document id.
Right now, Jasper supports the following types as valid saga state document identity types:
int
long
System.Guid
string