Skip to content

Host event-sourced state machines

An event-sourced state machine only works if Chronicle delivers the right events to the right grain, in the right namespace, after the model has caught up. Wiring that by hand means a projection registration, a reactor per state machine and a wait for the read model, and forgetting any of them leaves a grain that silently never refreshes. AddEventSourcedStateMachines() does all three from the state machine types alone. This page follows defining event-sourced state machines.

Call AddEventSourcedStateMachines() on the silo builder or the service collection, next to AddCratisChronicle():

using Cratis.Choreography.Chronicle.Hosting;
builder.AddCratisChronicle();
builder.Host.UseOrleans(silo => silo.AddEventSourcedStateMachines());

The registration hooks into the Chronicle client options, so it must be in place before the client is first resolved. Every concrete EventSourcedStateMachine<TModel> that discovery finds is inspected when the client is created, and its artifacts are registered with Chronicle’s explicit registration API, so they reach every event store and namespace the client connects to:

The model’s projectionWhat is registered
The state machine overrides DefineProjectionThat projection
The model carries model-bound projection attributes Chronicle has not discoveredThe model-bound read model
Chronicle already discovered a projection or model-bound read model for the modelNothing - it is not registered twice

Then one reactor per state machine is registered, identified by the state machine’s full type name. It subscribes to exactly the event types the state machine is interested in: every event type its projection refers to, together with the events its transitions are declared for, with interfaces and base types expanded to the event types implementing them. It never subscribes to all events. The reactor addresses the grain by its class, so a state machine needs no grain interface of its own for delivery; the interface exists for callers.

By default every concrete state machine is discovered. To register only some, turn discovery off and list them:

silo.AddEventSourcedStateMachines(options =>
{
options.Discover = false;
options.StateMachines.Add(typeof(OrderProcess));
});

EventSourcedStateMachinesOptions is set only through that callback:

OptionDefaultMeaning
DiscovertrueRegister every concrete EventSourcedStateMachine<TModel> that type discovery finds
StateMachinesemptyState machine types to register in addition to the discovered ones; a type listed twice is registered once
ModelCatchUpTimeout30 secondsHow long a delivered event waits for a materialized model to catch up before the delivery fails
ModelCatchUpPollInterval50 millisecondsHow often a materialized model is checked while waiting

A passive model is computed from the event log when it is read, so it always includes the event Chronicle most recently delivered. That is why passive models are recommended for state machines.

A materialized model is written by its projection’s own observer, which may not have handled the delivered event yet. Before refreshing the grain for an event the projection consumes, the reactor polls the projection every ModelCatchUpPollInterval until it has handled the event’s sequence number. If it has not within ModelCatchUpTimeout, the delivery fails with ModelDidNotCatchUp, which Chronicle treats like any other failing reactor delivery for that event source. A stale model therefore never decides a transition.

An event only the transitions use is not waited for: the projection never handles it, and events reach the reactor in order per event source, so every projected event before it has already been waited for. See eventual consistency in projections for why a materialized model trails the log.

Failing at startup is deliberate: a state machine that silently never refreshes is far harder to find. Each of these surfaces when the Chronicle client is created:

FailureCauseFix
StateMachineModelAlreadyHasProjectionThe state machine overrides DefineProjection for a model Chronicle already has a projection forRemove DefineProjection to use the existing one
MissingProjectionForStateMachineModelNothing defines a projection for the modelOverride DefineProjection, or add model-bound attributes to the model
StateMachineHasNoEventInterestNeither the projection nor the transitions refer to a known event typeProject or declare a transition for at least one event
TransitionEventIsNotAnEventTypeOn<TEvent> names a type that is neither an [EventType] nor an interface or abstract typeMark it with [EventType] or name an interface that event types implement
TypeIsNotAnEventSourcedStateMachineA type in StateMachines is abstract, generic or does not derive from EventSourcedStateMachine<TModel>List only concrete state machines
SchemasAreNotAvailableWhileInspectingProjectionsDefineProjection needed an event type’s schema, which is not available during inspectionDescribe the projection without it

These appear while the system runs:

FailureCause
ModelDidNotCatchUpA materialized model stayed behind the delivered event for ModelCatchUpTimeout; prefer a passive model
InvalidStateMachineKeyA grain key is not {eventStore}/{namespace}/{eventSourceId}, or the event store or namespace contains /
ReadModelStorageIsNotBoundThe model storage was read before the grain activated, which binds it; create grains through Orleans, not new

The seam is IStateMachineEventStores. Give an Orleans TestKit silo (the OrleansTestKit10 package) a substitute that returns an event store whose read model you control, activate the grain with its key, and deliver an event through Refresh:

using Cratis.Choreography.Chronicle.StateMachines;
using Cratis.Chronicle;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.ReadModels;
using Cratis.Execution;
using NSubstitute;
using Orleans.TestKit;
using Xunit;
public class OrderProcessSpec
{
[Fact]
public async Task ApprovalMovesTheOrderToApproved()
{
var eventStore = Substitute.For<IEventStore>();
eventStore.ReadModels.GetInstanceById<Order>(Arg.Any<ReadModelKey>())
.Returns(new Order("Jane", OrderStatus.Placed));
var eventStores = Substitute.For<IStateMachineEventStores>();
eventStores.Get(Arg.Any<EventStoreName>(), Arg.Any<EventStoreNamespaceName>()).Returns(eventStore);
var silo = new TestKitSilo();
silo.AddService(eventStores);
var process = await silo.CreateGrainAsync<OrderProcess>("orders/tenant-a/order-1");
eventStore.ReadModels.GetInstanceById<Order>(Arg.Any<ReadModelKey>())
.Returns(new Order("Jane", OrderStatus.Approved));
var context = EventContext.From(
"orders", "tenant-a", typeof(OrderApproved).GetEventType(), EventSourceType.Default,
"order-1", EventStreamType.All, EventStreamId.Default, 7UL, CorrelationId.New());
await process.Refresh(StateMachineRefresh.For(typeof(OrderApproved), context));
Assert.IsType<Approved>(await process.GetCurrentState());
}
}

The second Returns stands in for the projection having handled the event. Testing covers cluster fixtures for the full path through the reactor.

  • A process that decides, rather than follows. Transitions here react to facts that already happened. When the grain must accept or reject a command, validate it where the event is appended - a command or a constraint - and let the state machine follow the outcome.
  • State that is not derived from events. Use a state machine with its own storage.
  • Work that must happen exactly once per event. OnModelChanged and OnEnter can run again when Chronicle redelivers an event after a failure or a replay, and OnEnter runs on every activation. Make side effects idempotent, or use a reactor built for them.