Orleans on DocumentDb — Query Your Grains Without Waking Them Up


I love Microsoft Orleans. The runtime is brilliant — virtual actors, automatic placement, transparent activation. But the persistence story has always annoyed me: a separate provider package per backend, grain state serialized into an opaque blob you can’t query, and for some databases (MongoDB, I’m looking at you) no first-party option at all.

So I built Shiny.DocumentDb.Orleans around a single idea — put the whole Orleans persistence stack on top of Shiny.DocumentDb’s backend-agnostic IDocumentStore. One set of implementations runs on every DocumentDb backend, grain state is persisted as structured, queryable JSON, and you get a couple of things the built-in providers simply can’t offer.

dotnet add package Shiny.DocumentDb.Orleans

The headline win: query grain state without activating grains

Orleans grain storage is a point key/value contract — Read/Write/Clear by grain id, with no query surface. Want to know “which shopping carts have a total over $1,000?” Normally you can’t ask the store that at all. You’d have to activate every grain — a silo round-trip that places the grain, deserializes its state, and runs OnActivateAsync — and because the first-party providers persist state as an opaque blob, querying the database directly is off the table too.

This provider stores each grain’s state as structured JSON in an ordinary table (under $.state), so you can run the normal document query API straight against the grain-state table — no grains activated, no silo involved:

// A read-only store pointed at the same database + grain-state table.
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 whose persisted total exceeds 1000 — without activating one.
var bigCarts = await readStore.Query<GrainStateRecord>(
    "json_extract(Data, '$.state.total') > @min",
    parameters: new { min = 1000 });

That’s the feature I’m most excited about. Reporting, dashboards, admin/ops tooling, analytics, bulk inspection — everything that’s painful-to-impossible when your only door into grain state is activating the grain — becomes a plain document query.

One honest caveat: these queries see the last persisted state. An activated grain may hold newer in-memory state it hasn’t flushed yet (Orleans only persists when the grain calls WriteStateAsync), and the queries take no grain locks. Treat it as an eventually-consistent read model — ideal for reporting and ops, not a substitute for calling the grain when you need authoritative live state.


A free audit trail for every grain

Grain state is a GrainStateRecord document like any other, so it can opt into DocumentDb’s temporal history. One line gives you a full, queryable audit trail of every mutation — who changed what, and when:

opts.MapTemporal<GrainStateRecord>(t => t.MaxVersions = 100);

// later:
var history = await temporalStore.History<GrainStateRecord>("cart|user-42");

No event sourcing to design, no extra infrastructure to stand up. The version history rides along on the same store.


The whole stack, not just grain storage

Grain storage is the marquee feature, but it’s only a quarter of the package. The same IDocumentStore foundation backs the entire Orleans persistence stack, each with its own silo-builder extension:

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));
  • Reminders (IReminderTable) — each reminder is a queryable document; the hash-ring range reads Orleans needs become a fluent query on the stored GrainHash. No multi-document transaction required, so it runs on any backend.
  • Clustering (IMembershipTable) — the per-silo rows and the single global table-version row are updated together inside a RunInTransaction, each gated on its own version, honoring Orleans’ table-version protocol.
  • Grain directory (IGrainDirectory) — a distributed activation registry with per-row version CAS for register/unregister races; again, no cross-document transaction needed.

Instead of stitching together separate provider packages with different conventions, it’s one storage abstraction for the lot.


Backend-agnostic — and it fills a real gap

Because the runtime binds only to IDocumentStore, the same code path serves every backend. Relational providers are built in; MongoDB and Cosmos get first-class companion packages:

// Shiny.DocumentDb.Orleans.MongoDb
siloBuilder.AddMongoDbGrainStorage("Default", connectionString, databaseName: "orleans");

// Shiny.DocumentDb.Orleans.CosmosDb
siloBuilder.AddCosmosDbGrainStorage("Default", connectionString, databaseName: "orleans");

There is no first-party Orleans MongoDB provider, so this genuinely fills a gap. And moving from PostgreSQL to SQL Server to MongoDB doesn’t mean rewriting your persistence layer — it means swapping a provider line.


Concurrency that’s actually correct

Orleans’ ETag is the contract that stops two activations from clobbering each other during a failover window. This provider maps the ETag to the document version and honors it with each backend’s atomic compare-and-swap:

OrleansShiny.DocumentDb
document keyId = "{stateName}|{grainId}"
ETagGrainStateRecord.Version (via MapVersionProperty)
concurrency conflictConcurrencyExceptionInconsistentStateException
state blobnested JsonElement (stays queryable, not opaque)

The 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 an InconsistentStateException — exactly what Orleans expects, even during a duplicate-activation window. The PostgreSQL and MongoDB paths, including the stale-write conflict, are covered by automated integration tests.


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 grain-state serialization 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
});

It’s purely opt-in — leave the defaults and you keep the familiar reflection-based behavior.


Know your backend

Not every database is equal for every job. The compatibility tiers are worth a glance before you go to production:

TierBackendsNotes
RecommendedPostgreSQL, SQL Server, MySQL, OracleAtomic CAS folded into UPDATE … WHERE; ETag honored across failover windows.
SupportedMongoDBGood key distribution; atomic CAS via version-predicate filter.
Limited / devSQLite, LiteDB, IndexedDB, DuckDBSingle-writer / embedded / analytical — fine for dev, single-silo, or edge.
Use with careCosmos DBCAS is correct, but it partitions by grain type — weigh the 20 GB / hot-partition tradeoff before large-scale use.

Two limits to keep in mind. Membership/clustering needs real multi-document transactions, so it runs on the relational providers or a MongoDB replica set — not Cosmos (grain storage, reminders, and grain directory have no such requirement). And the silo host itself isn’t an AOT target — serialization can be reflection-free, but Microsoft.Orleans.Runtime is codegen-heavy, so a fully AOT-published silo isn’t a goal here.


Get started

dotnet add package Shiny.DocumentDb.Orleans

# optional companions
dotnet add package Shiny.DocumentDb.Orleans.MongoDb
dotnet add package Shiny.DocumentDb.Orleans.CosmosDb

If you’re already on Shiny.DocumentDb, your grains are one siloBuilder call away from a store you can actually query. Pull it down, point it at your silo, and tell me what you find in there.


comments powered by Disqus