DocumentDB in .NET Orleans
I like Orleans a great deal. The runtime is the hard part and it’s genuinely good at it — virtual actors, placement, transparent activation, the whole thing. My complaint has never been with the runtime.
It’s that Orleans asks you to solve persistence five separate times. Cluster membership is one provider contract. Grain storage is another. Reminders a third, the grain directory a fourth, and streams a fifth. Historically each of those was a different NuGet package with its own setup conventions, its own table layout, its own idea of what a connection string is, and its own operational story. You end up with five things to configure, five things to migrate, five things to back up, and — my actual pet peeve — grain state sitting in your database as a blob you cannot read.
So Shiny.DocumentDb.Orleans implements all five against one abstraction: IDocumentStore.
dotnet add package Shiny.DocumentDb.Orleans
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));
One connection string. One backup. And because DocumentDb is schema-free, no setup scripts — the tables get created at silo start.
Relational backends are built in. MongoDB and Cosmos get companion packages, and it’s worth pointing out that there is no first-party Orleans MongoDB provider at all, so that one fills a real hole:
siloBuilder.AddMongoDbGrainStorage("Default", connectionString, databaseName: "orleans");
siloBuilder.AddCosmosDbGrainStorage("Default", connectionString, databaseName: "orleans");
Reporting over grain state, without activating grains
Orleans grain storage is Read / Write / Clear by grain id, and that’s a deliberate design, not a gap
somebody forgot to fill. The grain is the consistency boundary: one activation owns its state at a time,
and every reader goes through it. A query surface on the storage provider would hand out state that walked
around that boundary, and it would require every provider to understand the shape of what you stored. Keeping
the contract to three point operations by key is exactly what lets it be implemented over blob storage, a
table, ADO.NET, Redis or a flat file — which is what you want from a runtime that has to run anywhere.
The trade is that questions spanning many grains have to be answered by grains. “Show me every shopping cart
over $1,000” means activating every cart — a silo round trip each, placing the grain, deserializing state,
running OnActivateAsync — and since the built-in providers serialize state into an opaque blob, the
database can’t answer it on your behalf either.
DocumentDb doesn’t change that contract; the runtime still reads and writes by key. It just stores what it
writes as structured JSON under $.state in an ordinary table, so those same rows are also readable as
documents. That gives you a second, out-of-band read path alongside the grain path — point a read-only store
at the 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 cart 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, admin tooling and bulk inspection — the jobs that don’t need a grain’s guarantees and shouldn’t pay for them — become plain document queries.
One honest caveat, and I’d rather say it than have you find it: this is the last persisted state. An
activated grain may be holding newer state it hasn’t flushed, since Orleans only writes when the grain calls
WriteStateAsync, and these queries take no grain locks. It’s an eventually-consistent read model. Great for
reporting and ops; not a substitute for calling the grain when you need the real answer.
And since grain state is now just a document, it can opt into temporal history — which is a full audit trail of every mutation for one line of configuration:
opts.ConfigureDocument<GrainStateRecord>(cfg => cfg.MapTemporal(t => t.MaxVersions = 100));
var history = await temporalStore.History<GrainStateRecord>("cart|user-42");
No event sourcing to design. No extra infrastructure.
Streams, and the bug I nearly shipped
Streams landed in 13.2 and finished the set. Orleans’ in-memory provider doesn’t survive a restart, and every durable option means running a queue service, which is a whole extra thing to deploy, pay for and secure. So: durable streams on the database the 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 normal Orleans stream API — no grain code knows this exists.
Let me be blunt about scope before anyone benchmarks me: this is not an Event Hub replacement. It’s a database-backed queue. Thousands of events per second on PostgreSQL, not hundreds of thousands. It exists so a team already running PostgreSQL doesn’t have to add a second piece of infrastructure to get durability.
Now, the interesting part. Orleans needs a monotonic long position per queue, and the receiver reads by
watermark. The obvious answer is BIGSERIAL / IDENTITY / AUTO_INCREMENT. I wrote that first. It’s wrong,
and it’s wrong in the worst possible way — silently, and only under load.
Sequence values are handed out at insert time. Rows become visible at commit time. Those orders are not the same:
- Transaction A inserts, takes sequence 5.
- Transaction B inserts, takes 6, commits first.
- The receiver reads up to 6, advances its cursor past 5.
- Transaction A commits. Event 5 is behind the cursor and is never delivered.
Nobody gets an error. The event just doesn’t arrive. So each queue has a counter row instead, 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 = seq
COMMIT
The lock does two jobs at once: the second producer blocks until the first commits, so assignment order and
commit order become the same order by construction, and the sequence comes out gap-free. The cost is explicit
— per-queue enqueue throughput is bounded by how long that one row is held — and TotalQueueCount is the
dial, since every queue gets its own counter row.
That row lock is also why the supported backend list is short: PostgreSQL, SQL Server, MySQL, MariaDB, Oracle,
CockroachDB. It’s a capability check (SupportsPessimisticLocking), not a hard-coded name list, and it’s
enforced at silo start. A cluster that boots and then quietly drops events under load is a far worse
failure than one that refuses to boot, so I made it refuse to boot.
Two things you don’t get behind a queue service
Rewind. Behind Azure Queue or SQS the message is gone the moment it’s handed over, so resuming from an old
StreamSequenceToken gets you QueueCacheMissException. Here the row is still in the table, so the cache
replays it:
await stream.SubscribeAsync(handler, lastToken); // survives a silo restart
The rewind window is exactly Retention — the sweep that stops the table growing forever is the same thing
that bounds how far back you can resume. Set it to the replay window you actually want.
Looking at the backlog. Stream events are ordinary documents, so you can open them. There’s an
IStreamAdmin for health checks, and the admin tool has a Streams screen 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, not depth — depth can’t tell a busy queue from a dead pulling agent, but age can.
IStreamAdmin is read-only on purpose. An outbox message is a unit of work someone owns, so requeueing it
means something. 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 and re-dating one reorders delivery. A stuck stream gets
fixed on the consumer side.
Concurrency, because this part has to be right
Orleans’ ETag is what stops two activations clobbering each other during a failover window. It maps to the document version, and every provider honours it with a real atomic compare-and-swap:
| Orleans | Shiny.DocumentDb |
|---|---|
| document key | Id = "{stateName}|{grainId}" |
| ETag | GrainStateRecord.Version |
| concurrency conflict | ConcurrencyException → InconsistentStateException |
| state blob | nested JsonElement (queryable, not opaque) |
Relational folds the version check into UPDATE … WHERE and verifies the row count, MongoDB uses an atomic
version-predicate filter, Cosmos uses native IfMatchEtag. A stale write loses the race and surfaces exactly
as Orleans expects. PostgreSQL and MongoDB, including the stale-write conflict, are covered by integration
tests.
Know your backend
| Tier | Backends | Notes |
|---|---|---|
| Recommended | PostgreSQL, SQL Server, MySQL, Oracle | Atomic CAS in UPDATE … WHERE; ETag honoured across failover |
| Supported | MongoDB | Good key distribution; atomic CAS via version predicate |
| Limited / dev | SQLite, LiteDB, IndexedDB, DuckDB | Single-writer / embedded / analytical — dev, single-silo, edge |
| Use with care | Cosmos DB | CAS is correct, but it partitions by grain type — weigh the 20 GB / hot-partition trade first |
Three limits, stated plainly:
- Membership needs real multi-document transactions. The per-silo rows and the global table-version row have to move together, each gated on its own version, because that’s Orleans’ protocol. Relational or a MongoDB replica set — not Cosmos, whose transactional batches are single-partition. Grain storage, reminders and the directory don’t care.
- Streams need row-level locking, as above.
- The silo host isn’t an AOT target. Serialization can go fully reflection-free — point a
JsonSerializerContextat your grain state and setUseReflectionFallback = false— butMicrosoft.Orleans.Runtimeis codegen-heavy, so an AOT-published silo isn’t a goal I’m chasing.
And if you’re on Aspire
If your silo runs under .NET Aspire, all of the above collapses to this:
builder.AddDocumentStore("orleans");
builder.UseOrleans(silo => silo.UseAspireDocumentDb("orleans"));
Grain storage, reminders, clustering and the directory, all on the Aspire-provisioned store — connection, health check and telemetry included. Streams are one flag away and deliberately opt-in. That’s a post of its own, coming shortly; I’ll leave it there.
Go have a look
dotnet add package Shiny.DocumentDb.Orleans
If you’re already on DocumentDb, your grains are one siloBuilder call away from a store you can actually
read. Point it at a dev silo and go look at what’s in there — that first query is the moment it clicks.