Skip to content
Kumar Chandrachooda
.NET

Reading the Redis and Mongo Providers

Chronicle ships two persistence integrations, and reading them against the contract is a masterclass in provider sharp edges: a Redis key built from Type.GetHashCode() that changes on every restart, a Mongo delete-then-insert, and messages that may not survive the round trip.

By Kumar Chandrachooda 20 Mar 2026 6 min read
Two stores, two key schemes, one contract they answer to

Part 7 ended with five guarantees a saga persistence provider must supply: stable keys, atomic upserts, round-trip fidelity, tie-proof log ordering, and some answer to concurrency. Chronicle's repository ships two providers of its own — Chronicle.Integrations.Redis and Chronicle.Integrations.MongoDB — and reading them against that list is the most instructive exercise in the whole codebase. Not because they're bad; because each one gets different guarantees right, and the ones they miss are exactly the ones that are invisible until production. Everything below is from the 3.2.1-era source, quoted where the implementation is the point.

The packaging is the good part

Both integrations extend IChronicleBuilder and wire both stores in one call, which neatly enforces the matched-pair rule from part 7:

services.AddChronicle(b =>
    b.UseRedisPersistence(new ChronicleRedisSettings
    {
        Configuration = "redis:6379",
        InstanceName = "sagas:"
    }));

Redis goes through IDistributedCache (the StackExchangeRedisCache package) rather than a raw Redis client — a pragmatic choice that costs some Redis-native power (no transactions, no Lua) but keeps the provider tiny. Mongo registers an IMongoDatabase factory and two collection-backed stores, SagaData and SagaLog. Both also offer an appsettings-section overload; note it deserialises the section's value as a JSON string via JSON.NET, not through the standard options binder — a small oddity you'll hit if you try to bind a normal nested configuration section.

Redis: the key that changes on restart

The state repository stores JSON under a computed key, and this one method is the sharpest edge in either provider:

private string StateId(string id, Type type) => $"_state_{id}_{type.GetHashCode()}";

Type.GetHashCode(). In .NET Core, that's a per-process runtime identity hash — it is not stable across process restarts, let alone across machines or versions. Which means: your service writes saga state under _state_4711_38562904, gets redeployed, comes back up, and computes _state_4711_51249873 for the very same saga type. Every in-flight saga is still in Redis — perfectly preserved, permanently unreachable. The saga doesn't fail; it does something worse. The initializer finds no state, so start messages cheerfully begin fresh sagas, and non-start messages are silently dropped. Your process amnesia looks exactly like a correlation bug, which is why it took me an embarrassingly long time to diagnose the first time.

The fix a fork or a custom provider makes is one line — key on type.FullName (or better, a short logical name so refactoring namespaces doesn't orphan state either). The Mongo provider next door does exactly that, which makes the Redis scheme read like an oversight rather than a decision. Either way, the lesson generalises beyond Chronicle: any persistence key derived from runtime type identity is a restart away from data loss. Check your own serializers and cache-key helpers; this pattern hides in more codebases than you'd think.

Two smaller Redis findings while we're in the file. WriteAsync builds its envelope with state.Data.GetType() — no null guard — so a data-less saga (plain Saga, no TData) throws NullReferenceException on first persist; in practice the provider requires Saga<TData>. And both Redis classes expose a DeleteAsync that exists on neither core interface — the pipeline never calls it. It reads like the beginning of a state-cleanup story that never landed, and it means completed sagas accumulate in Redis forever unless you set a TTL policy or call the concrete class yourself.

The Redis log has a subtler structural issue: IDistributedCache only does whole-value get/set, so appending means read-the-entire-list, add one, write-the-entire-list:

var sagaLogDatas = (await ReadAsync(logData.Id, logData.Type)).ToList();
sagaLogDatas.Add(sagaLogData);
var serializedSagaLogDatas = JsonConvert.SerializeObject(sagaLogDatas);
await cache.SetStringAsync(LogId(logData.Id, logData.Type), serializedSagaLogDatas);

Per-message cost grows with saga history (quadratic over a saga's lifetime — fine for five-step sagas, unpleasant for hundred-event ones), and between two nodes it's a textbook lost update: both read the list, both append, one append vanishes. A vanished log entry is a compensation that will never run. A Redis-native implementation would use RPUSH on a list key; IDistributedCache simply can't express it.

What Redis gets right is round-trip fidelity, and it's worth copying: both the state and each log entry persist their CLR type alongside the payload (DataType, MessageType), and reads re-materialise through JObject.ToObject(type). Compensation receives real PaymentCompleted objects, so the reflection dispatch from part 6 finds its overloads. The cost is type names in your data — rename or move a message class and old sagas can't rehydrate — which is the standard versioning tax on type-name serialization. Deploy message-class renames only when no affected saga is in flight, or keep the old type as a shim.

Mongo: right keys, racy writes

The Mongo provider keys on type.FullName — stable, readable, greppable in the shell. Score one. Its state write, though:

public async Task WriteAsync(ISagaState sagaState)
{
    await _collection.DeleteOneAsync(sld =>
        sld.MongoId == sagaState.Id.Id && sld.SagaType == sagaState.Type.FullName);
    await _collection.InsertOneAsync(new MongoSagaState { ... });
}

Delete, then insert — two operations where Mongo offers one (ReplaceOneAsync with IsUpsert = true). Between the two, the saga briefly doesn't exist; interleave two writers (remember: the pipeline lock is process-local, so two nodes can be here together) and you can get doubled documents or a lost state depending on the weave. On one node it's merely inelegant; scaled out, it's the upsert guarantee from part 7, violated in the one store where honouring it is a single method call.

The type round-trip is where Mongo gets interesting. To turn the stored type name back into a CLR Type, the state document scans every loaded assembly:

Type ISagaState.Type => _type ??= AppDomain.CurrentDomain.GetAssemblies()
        .Select(a => a.GetType(SagaType))
        .FirstOrDefault(t => t is {});

Slow but thorough. The log document, meanwhile, uses Assembly.GetEntryAssembly()?.GetType(SagaType) — entry assembly only. Keep your sagas in a class library (where everyone keeps them) and the log's Type property resolves to null. The log's filtering still works — queries compare the stored string — but anything downstream touching the deserialised entry's Type gets nothing. Same problem, two files apart, two different answers; the state document's answer is the right one.

And the fidelity question: log messages are stored as BSON objects with no message-type field at all (unlike Redis). What the driver re-materialises on read is not guaranteed to be your original message class — and part 6 showed that compensation resolves CompensateAsync overloads by the runtime type of exactly this object. My honest advice: before trusting compensation on the Mongo provider, integration-test the full reject-and-replay path with your real message types and your real driver version. It's the kind of behaviour that depends on serializer registration and driver defaults, and “the compensation silently didn't dispatch” is not a failure you want to discover from a customer refund queue.

The scorecard

Guarantee (part 7) Redis Mongo
Stable keys NoType.GetHashCode() Yes — FullName
Atomic upsert Yes (single SET) No — delete-then-insert
Data fidelity Yes — persisted DataType Partial — assembly-scan rehydration
Message fidelity (compensation) Yes — persisted MessageType Test it — untyped BSON
Log ordering under ties Same-ms ties possible Same-ms ties possible
Concurrency answer None (last write wins) None (last write wins)

Neither provider passes clean, and that's the honest takeaway — not that Chronicle is careless, but that saga persistence is where all the hard distributed-systems requirements concentrate, in a library whose core can afford to be twenty simple files precisely because the contracts push the hard parts out here. If you run Chronicle in production, the single highest-leverage thing you can do is write your own provider against a store you understand — part 7's SQL sketch is a real afternoon's work — and steal the good ideas from these two: Redis's self-describing payloads, Mongo's stable names, and from both of them, the humility to integration-test the compensation round trip.

One part left: stepping back from the source to ask the only question that matters — when is all of this the right tool, and when is a saga library, any saga library, more machinery than your process deserves?