DocumentDB in .NET Orleans
Microsoft Orleans is excellent at the part that is hard — virtual actors, placement, transparent activation. Persistence is the part it leaves to you, and it leaves it to you five separate times: cluster membership, grain storage, reminders, the grain directory, and streams are five independent provider contracts, historically satisfied by five different packages with five different setup conventions, storage layouts and operational stories.
Shiny.DocumentDb.Orleans implements all five against a single abstraction —
IDocumentStore. One connection string, one backup, one set of tables, and everything Orleans persists
is structured JSON you can query rather than an opaque serialized blob.
dotnet add package Shiny.DocumentDb.OrleansFive contracts, one store
Section titled “Five contracts, one store”siloBuilder .AddDocumentDbGrainStorage("Default", o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbReminders(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbClustering(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbGrainDirectory("Default", o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbStreams("Default", o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs));Because DocumentDb is schema-free, there are no setup scripts — the tables are created on demand at
silo start. Each store has its own default table (orleans_default, orleans_reminders,
orleans_membership, orleans_graindirectory, orleans_streams) and they all share the same
OrleansStoreOptions shape: give it a relational DatabaseProvider and it builds and owns its store, or
give it a StoreFactory and it uses one you built — which is the escape hatch for MongoDB, Cosmos, LiteDB
or anything else.
MongoDB and Cosmos also get first-class companion packages, and it’s worth saying plainly that there is no first-party Orleans MongoDB provider, so this fills a real gap:
siloBuilder.AddMongoDbGrainStorage("Default", connectionString, databaseName: "orleans"); // Shiny.DocumentDb.Orleans.MongoDbsiloBuilder.AddCosmosDbGrainStorage("Default", connectionString, databaseName: "orleans"); // Shiny.DocumentDb.Orleans.CosmosDbGrain state you can query without activating a grain
Section titled “Grain state you can query without activating a grain”Orleans grain storage is a point key/value contract — Read / Write / Clear by grain id — by design.
The grain is the consistency boundary, so state is meant to be reached through the activation that owns it,
and a query surface on the storage provider would both bypass that boundary and require every provider to
understand the shape of the state it persists. Three operations keyed by grain id are what make the contract
implementable over blob storage, a table, ADO.NET, Redis or a file alike.
The consequence is that cross-grain questions are answered by grains. “Which shopping carts are over $1,000?”
means activating every cart — a silo round trip that places the grain, deserializes its state and runs
OnActivateAsync — and because the built-in providers write state as an opaque blob, the database cannot
answer it instead.
This provider keeps the same contract but stores each grain’s state as structured JSON under $.state, in an
ordinary table — so the rows the runtime reads by key are also readable as documents. Point a read-only store
at the same table and ask:
var opts = new DocumentStoreOptions{ DatabaseProvider = new PostgreSqlDatabaseProvider(connectionString), JsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }};DocumentDbGrainStorage.ConfigureGrainState(opts, "orleans_default");var readStore = new DocumentStore(opts);
// Every ShoppingCart grain over 1000 — no grain activated, no silo involved.var bigCarts = await readStore.Query<GrainStateRecord>( "json_extract(Data, '$.state.total') > @min", parameters: new { min = 1000 });Reporting, dashboards, ops tooling, bulk inspection, analytics — everything that is painful-to-impossible when the only door into grain state is activating the grain becomes a plain document query.
A free audit trail
Section titled “A free audit trail”Grain state is a GrainStateRecord document like any other, which means it can opt into DocumentDb’s
temporal history. One line gives you a queryable record of every mutation a grain
has ever persisted — no event sourcing to design, no extra infrastructure:
opts.ConfigureDocument<GrainStateRecord>(cfg => cfg.MapTemporal(t => t.MaxVersions = 100));
var history = await temporalStore.History<GrainStateRecord>("cart|user-42");Streams — the piece that completes the set
Section titled “Streams — the piece that completes the set”Persistent Orleans streams landed in 13.2 and are the newest member of the stack. Orleans’ in-memory stream provider doesn’t survive a restart; every durable alternative means running a queue service. This one is durable on the database your cluster already uses.
siloBuilder.AddDocumentDbStreams("Default", o =>{ o.DatabaseProvider = new PostgreSqlDatabaseProvider(connectionString); // o.TotalQueueCount = 8; // default // o.Retention = TimeSpan.FromHours(1); // default; null keeps everything});Producing and consuming is the ordinary Orleans stream API — nothing about the grain code is provider-specific.
Why there’s a counter row and not a BIGSERIAL
Section titled “Why there’s a counter row and not a BIGSERIAL”The obvious implementation of “monotonic position per queue” is an identity column. It is a correctness trap, and it’s worth understanding why. Sequence values are handed out at insert time; rows become visible at commit time, and those two orders are not the same:
- Transaction A inserts, takes sequence 5.
- Transaction B inserts, takes 6, and commits first.
- The receiver reads up to 6 and advances its cursor past 5.
- Transaction A commits. Event 5 is behind the cursor and is never delivered.
Silently, and only under load. So instead each queue owns a counter row, and an enqueue reserves its position under a row lock inside the same transaction:
BEGIN counter = Get(queueId, LockMode.Update) -- SELECT … FOR UPDATE seq = counter.Next++ INSERT the event row with Seq = seqCOMMITThe lock does double duty: a second producer blocks until the first commits, so assignment order and
commit order are the same order by construction, and the sequence is gap-free. The trade is explicit —
enqueue throughput per queue is bounded by the lock hold time on one row — and TotalQueueCount is the
dial, since each queue has its own counter row.
That row lock is also why the backend list is short: PostgreSQL, SQL Server, MySQL, MariaDB, Oracle and
CockroachDB. It’s a capability check (SupportsPessimisticLocking), not a name list, and it’s enforced
at silo start rather than on the first event — a cluster that boots and then drops events under load is a
much worse failure than one that refuses to boot.
Two things a queue-backed provider can’t do
Section titled “Two things a queue-backed provider can’t do”Rewind past the cache. Behind Azure Queue or SQS the message is gone once it’s handed over, so a
subscriber resuming from an old StreamSequenceToken gets QueueCacheMissException. Here the row is still
in the table, so the cache replays it:
await stream.SubscribeAsync(handler, lastToken); // works across a silo restartThe rewind window is exactly Retention — the sweep that bounds table growth is the same thing that
bounds how far back you can resume. Set it to the replay window you actually want and size the table for
it.
Show you the backlog. Stream events are ordinary documents, so you can look at them.
IStreamAdmin is the in-process view, and ShinyDocDbMyAdmin has a Streams screen
answering the same questions in both the web and terminal front ends:
var admin = services.GetRequiredKeyedService<IStreamAdmin>("Default");
foreach (var stuck in await admin.StuckStreams(TimeSpan.FromMinutes(5))) logger.LogWarning("{Stream}: {Count} undelivered since {Since}", stuck.StreamId, stuck.UndeliveredCount, stuck.OldestUndeliveredAt);Watch oldest undelivered rather than depth — depth alone can’t tell a busy queue from a dead pulling
agent, but age can. IStreamAdmin is deliberately read-only: an outbox message is a unit of work someone
owns, so requeueing it means something, but a stream event is a position in a gap-free sequence that every
subscriber holds a cursor into. Deleting one tears a hole in that sequence. A stuck stream gets fixed on
the consumer side.
Concurrency, properly
Section titled “Concurrency, properly”Orleans’ ETag is the contract that stops two activations clobbering each other during a failover window. It maps onto the document version, and each provider honours it with a genuinely atomic compare-and-swap:
| Orleans | Shiny.DocumentDb |
|---|---|
| document key | Id = "{stateName}|{grainId}" |
| ETag | GrainStateRecord.Version (via cfg.MapVersionProperty) |
| concurrency conflict | ConcurrencyException → InconsistentStateException |
| state blob | nested JsonElement (queryable, not opaque) |
Relational providers fold the version check into UPDATE … WHERE and verify the row count, MongoDB uses
an atomic version-predicate filter, and Cosmos uses a native IfMatchEtag. A stale write loses the race
and surfaces as InconsistentStateException — exactly what Orleans expects. The PostgreSQL and MongoDB
paths, including the stale-write conflict, are covered by integration tests.
Know your backend
Section titled “Know your backend”| Tier | Backends | Notes |
|---|---|---|
| Recommended | PostgreSQL, SQL Server, MySQL, Oracle | Atomic CAS folded into UPDATE … WHERE; ETag honoured across failover windows |
| Supported | MongoDB | Good key distribution; atomic CAS via version-predicate filter |
| Limited / dev | SQLite, LiteDB, IndexedDB, DuckDB | Single-writer / embedded / analytical — fine for dev, single-silo, or edge |
| Use with care | Cosmos DB | CAS is correct, but it partitions by grain type — weigh the 20 GB / hot-partition trade before large-scale use |
Three limits are worth knowing before production:
- Membership needs real multi-document transactions. The per-silo rows and the global table-version row are updated together, each gated on its own version, because that’s how Orleans’ table-version protocol works. Relational or a MongoDB replica set — not Cosmos, whose transactional batches are single-partition. Grain storage, reminders and the grain directory have no such requirement.
- Streams need row-level locking — see above.
- The silo host is not an AOT target. Grain-state and system-store serialization goes reflection-free
when you assign a
JsonSerializerContext, butMicrosoft.Orleans.Runtimeis codegen-heavy, so a fully AOT-published silo isn’t a goal here.
Reflection-free serialization when you want it
Section titled “Reflection-free serialization when you want it”The provider’s own envelope types — grain-state record, reminders, membership, grain-directory rows — are
always source-generated. The one generic piece is your grain state T. Point a JsonSerializerContext
at it and that goes reflection-free too:
[JsonSerializable(typeof(CartState))][JsonSerializable(typeof(UserPrefs))]public partial class GrainStateContext : JsonSerializerContext;
siloBuilder.AddDocumentDbGrainStorage("Default", o =>{ o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs); o.JsonSerializerOptions = new JsonSerializerOptions { TypeInfoResolver = GrainStateContext.Default }; o.UseReflectionFallback = false; // throw on an unregistered state type instead of reflecting});Purely opt-in — leave the defaults and you keep the familiar reflection-based behaviour. The same knobs exist on the reminder, clustering, grain-directory and stream options.
And in Aspire, it’s one line
Section titled “And in Aspire, it’s one line”If your silo runs under .NET Aspire, the AppHost provisions the store and the silo points the whole stack at it:
builder.AddDocumentStore("orleans");
builder.UseOrleans(silo => silo.UseAspireDocumentDb("orleans"));That’s grain storage, reminders, clustering and the grain directory on the Aspire-provisioned store — connection, health check and telemetry included. Streams are one flag away and deliberately opt-in. The next post in this pair covers the Aspire integration properly.
Get started
Section titled “Get started”dotnet add package Shiny.DocumentDb.Orleans
# optional companionsdotnet add package Shiny.DocumentDb.Orleans.MongoDbdotnet add package Shiny.DocumentDb.Orleans.CosmosDb

