Where a Saga Keeps Its Memory
Chronicle's persistence surface is two interfaces - a state repository and an append-only log - and the defaults behind AddChronicle() are two Lists. What the contracts really demand, why the in-memory implementations are sharper than they look, and how to write a provider that holds up.
A saga is only as durable as its memory. Everything the previous parts described — the initializer's gates, the processor's finally, the compensation replay — bottoms out in two small interfaces, and whatever you plug into them decides whether your process survives a deploy. This part reads Chronicle's persistence layer: the contracts, the in-memory defaults hiding behind AddChronicle(), and the guarantees a real provider must supply that the interfaces are too polite to mention.
Two stores, two jobs
Chronicle persists two distinct things, and keeping their jobs separate is the key to the whole layer.
The state repository holds what the saga knows — one record per saga instance:
public interface ISagaStateRepository
{
Task<ISagaState> ReadAsync(SagaId id, Type type);
Task WriteAsync(ISagaState state);
}
An ISagaState is the saga's whole persistent identity: the id, the saga's CLR Type, the current SagaStates value, and the Data object — plus an Update(state, data) mutator the processor calls before writing. Note the composite key: id and type. An OrderSaga and a ShippingSaga correlating on the same order id are two rows, not one.
The saga log holds what the saga has seen — an append-only record of every handled message:
public interface ISagaLog
{
Task<IEnumerable<ISagaLogData>> ReadAsync(SagaId id, Type type);
Task WriteAsync(ISagaLogData message);
}
Each ISagaLogData carries the id, the saga type, a unix-millisecond CreatedAt, and the message object itself. It's tempting to treat the log as an audit nicety. It is not: as part 6 showed, the log is the compensation plan. When a saga rejects, these message objects are re-dispatched, newest first, into your CompensateAsync overloads. A log provider that mangles a message on the round-trip doesn't corrupt your telemetry — it corrupts your undo.
Both stores are written on every dispatch, side by side, in the processor's finally:
var persistenceTasks = new []
{
_repository.WriteAsync(state),
_log.WriteAsync(logData)
};
await Task.WhenAll(persistenceTasks).ConfigureAwait(false);
Two writes, no transaction. If the process dies between them — or one store is up and the other isn't — state and history diverge. It's the textbook dual-write, and Chronicle's interfaces give a provider no way to fix it individually… but they don't forbid fixing it jointly. Nothing stops one class from implementing both interfaces over one database and making the pair effectively atomic — the two writes become two statements against the same store, and if you're willing to tolerate the second WriteAsync committing work eagerly, even one table with the log as rows and the state as an upsert. I've shipped exactly that (more below), and it removes the divergence window almost entirely.
The defaults: honest Lists
Call AddChronicle() with no builder and you get the in-memory pair. The state repository, in full:
internal class InMemorySagaStateRepository : ISagaStateRepository
{
private readonly List<ISagaState> _repository = new List<ISagaState>();
public Task<ISagaState> ReadAsync(SagaId id, Type type)
=> Task.FromResult(_repository.FirstOrDefault(s => s.Id == id && s.Type == type));
public async Task WriteAsync(ISagaState state)
{
var sagaDataToUpdate = await ReadAsync(state.Id, state.Type);
_repository.Remove(sagaDataToUpdate);
_repository.Add(state);
await Task.CompletedTask;
}
}
A List, a FirstOrDefault, a remove-and-add. The log is the same idea with a Where. Three observations that pay rent:
Lifetime is load-bearing. The builder registers these as singletons — necessarily, since a transient in-memory store would forget everything between resolutions. Custom providers, by contrast, are registered transient by UseSagaStateRepository<T>()/UseSagaLog<T>() (the project's own tests pin down both lifetimes). Which means a custom provider must be stateless over an external store — hold a connection factory, not a cache field, because every dispatch may get a fresh instance.
Thread-safety is borrowed, and only partially. List<T> is not thread-safe, and there's no lock in sight here. The pipeline's KeyedLocker serializes dispatches per saga id — but two concurrent dispatches for different ids run in parallel and can hit _repository.Add simultaneously. Concurrent Adds on a List can corrupt it. In development traffic you will likely never see it; under a load test you occasionally will, as an inexplicable IndexOutOfRangeException from inside List.Add. The in-memory pair is a dev/test convenience and the source is refreshingly unpretentious about it — just don't let it near production, which you weren't going to do anyway, because it also forgets everything on restart.
Updates are aliasing in disguise. SagaState.Update mutates the object in place, and the in-memory store holds references — so by the time WriteAsync runs, the stored state has already changed; the remove-and-add shuffles the same reference. Harmless here, but it hides a contract subtlety from provider authors: by the time your WriteAsync sees the state, it is the new state. Don't diff against your own store expecting to see the old one.
What the contracts don't say but mean
Writing a provider — and part 8 reads the two official ones, sharp edges included — you're really signing up for five unwritten guarantees:
- Stable keys.
ReadAsync(id, type)must find whatWriteAsyncstored, across process restarts and deploys. Key onSagaId.Id(a string) and something durable for the type —type.FullName, not anything runtime-derived. (Part 8 shows an official provider getting this wrong, instructively.) - Upsert semantics.
WriteAsyncis called for brand-new sagas and for updates alike; there is no separate insert. Make it a genuine atomic upsert —MERGE,ON CONFLICT DO UPDATE,ReplaceOne(upsert: true)— not a read-then-write or delete-then-insert, which tears under concurrency. - Round-trip fidelity for
Data— and for messages.Datais typedobject; you must persist enough type information to rebuild the real CLR object, or the initializer will hydrate the saga with a bag of JSON tokens. The same applies double to log messages: compensation dispatches by the message's runtime type (reflection overload resolution), so a message that comes back asJObjectinstead ofPaymentCompletedmakes the replay miss its target. - Log ordering that survives ties. The replay sorts by
CreatedAtmilliseconds. Store an insertion sequence too and you can… do nothing with it through this interface, admittedly — but you can return the log already ordered so ties break by insertion, which the LINQ sort (stable) will preserve. - A concurrency answer, even if it's “loudly none”. The pipeline lock is process-local; scaled out, two nodes can write the same saga interleaved. The interface won't let you reject a stale write into the pipeline gracefully — but a provider can carry a version column and throw on lost updates, which turns silent state corruption into a visible failed dispatch. Given the choice, take the exception.
A minimal SQL provider that honours all five is barely a screen of code — a SagaStates table (Id, Type, State, DataType, DataJson, Version) with an ON CONFLICT upsert incrementing Version, and a SagaLog table (Id, Type, Seq identity, CreatedAt, MessageType, MessageJson) read back with ORDER BY CreatedAt DESC, Seq DESC. Wire it in with the builder:
services.AddChronicle(b =>
{
b.UseSagaStateRepository<SqlSagaStateRepository>();
b.UseSagaLog<SqlSagaLog>();
});
One rule from the README worth repeating because the builder won't enforce it: if you replace either store, replace both. Configure only one and the other interface simply has no registration — the first dispatch fails resolving the pipeline. The error is at least immediate; the principle (state and log are a matched pair with matched durability) is the real point. A durable state store with an in-memory log gives you sagas that survive restarts but compensate from amnesia.
Next part puts these criteria to work on the two providers Chronicle ships in its own repository — Redis and MongoDB — where one of them keys its data on a value that changes every time the process restarts.