Orleans Provider
A full Microsoft Orleans persistence stack — grain storage, reminders, cluster membership (clustering), and grain directory — built entirely on the backend-agnostic IDocumentStore abstraction. One set of implementations runs on every Shiny.DocumentDb backend, and because everything is persisted as structured, queryable JSON you get two things Orleans’ built-in providers can’t offer: you can query grain state directly — without activating the grains — and you can keep a free audit trail of every state mutation via MapTemporal<T>.
When to Use
Section titled “When to Use”- You already use Shiny.DocumentDb (or one of its backends) and want grain persistence on the same store.
- You want to query grain state without activating grains — reporting, dashboards, admin/ops tooling, and analytics over live grain state, straight from the store. See Query grain state without activating grains.
- You want a free audit trail of grain-state history via
MapTemporal<T>. - You want the whole Orleans persistence stack on one store — grain storage plus reminders, clustering, and grain directory — instead of stitching together separate provider packages.
- You want one storage abstraction that can move between PostgreSQL, SQL Server, MongoDB, Cosmos, and the rest without rewriting the provider.
There is no first-party Orleans MongoDB provider, so this fills a real gap there. For Cosmos, weigh the partitioning note in Provider Compatibility before large-scale production.
How it maps
Section titled “How it maps”Orleans grain storage is a versioned key/value contract — Read/Write/Clear keyed by (stateName, grainId) with an ETag for optimistic concurrency. That maps cleanly onto IDocumentStore:
| Orleans | Shiny.DocumentDb |
|---|---|
| document key | Id = "{stateName}|{grainId}" |
| ETag | GrainStateRecord.Version (mapped via MapVersionProperty) |
| concurrency conflict | ConcurrencyException → InconsistentStateException |
| state blob | nested JsonElement (stays queryable, not opaque) |
Because the runtime binds only to IDocumentStore, the same code path serves all backends. The ETag is honored by each provider’s atomic compare-and-swap: the relational providers fold the version check into UPDATE … WHERE, MongoDB uses an atomic version-predicate filter, and Cosmos uses a native IfMatchEtag — so a stale write loses the race and surfaces as an InconsistentStateException, even during a failover duplicate-activation window.
Installation
Section titled “Installation”# Core provider (relational backends built in)dotnet add package Shiny.DocumentDb.Orleans
# First-class companion packages (optional)dotnet add package Shiny.DocumentDb.Orleans.MongoDbdotnet add package Shiny.DocumentDb.Orleans.CosmosDbThe built-in path — the provider builds and owns its DocumentStore, wiring the grain-state type/table and version mapping for you. Covers SQLite, SQL Server, PostgreSQL, MySQL, Oracle, and DuckDB.
siloBuilder.AddDocumentDbGrainStorage("Default", o =>{ o.DatabaseProvider = new PostgreSqlDatabaseProvider(connectionString); // o.TableName = "orleans_default"; // default: "orleans_{providerName}" // o.DeleteStateOnClear = true; // default});The companion packages wire the store, the grain-state mapping, and the version property for you:
siloBuilder.AddMongoDbGrainStorage("Default", connectionString, databaseName: "orleans");
// Shiny.DocumentDb.Orleans.CosmosDbsiloBuilder.AddCosmosDbGrainStorage("Default", connectionString, databaseName: "orleans");Both also have an overload taking a Func<…DocumentStoreOptions> when you need to set a pooled client, JsonSerializerOptions, DefaultThroughput, etc.:
siloBuilder.AddMongoDbGrainStorage( "Default", () => new MongoDbDocumentStoreOptions { MongoClient = sharedClient, DatabaseName = "orleans" });The generic escape hatch — you build a fully-configured IDocumentStore with GrainStateRecord mapped (type→table/collection/container and MapVersionProperty). Use this for LiteDB, IndexedDB, or any other backend.
siloBuilder.AddDocumentDbGrainStorage("Default", o =>{ o.StoreFactory = sp => { var opts = new LiteDbDocumentStoreOptions { ConnectionString = "Filename=grains.db" }; opts.MapVersionProperty<GrainStateRecord>(x => x.Version); return new LiteDbDocumentStore(opts); };});For the relational options shape, DocumentDbGrainStorage.ConfigureGrainState(options, tableName) applies both mappings in one call.
AddDocumentDbGrainStorageAsDefault(...) registers under Orleans’ default provider name, and the same provider backs the streaming PubSubStore when named accordingly.
Options Reference
Section titled “Options Reference”| Property | Type | Default | Description |
|---|---|---|---|
DatabaseProvider | IDatabaseProvider? | null | A relational backend. When set, the provider builds its own DocumentStore with GrainStateRecord mapped to TableName and its version mapped. Ignored when StoreFactory is set. |
StoreFactory | Func<IServiceProvider, IDocumentStore>? | null | Generic escape hatch returning a fully-configured store. The factory must map GrainStateRecord (type→table/container + version property). |
TableName | string? | "orleans_{providerName}" | Table/collection/container name for the grain-state envelope. |
DeleteStateOnClear | bool | true | When true, ClearStateAsync deletes the row. When false, it writes a tombstone (null state) and bumps the version, preserving the ETag chain. |
JsonSerializerOptions | JsonSerializerOptions? | null | JSON options used to (de)serialize grain state into the envelope’s nested document. |
InitStage | int | ApplicationServices | Silo lifecycle stage at which the store is initialized (fail-fast on a misconfigured store). |
Query grain state without activating grains
Section titled “Query grain state without activating grains”This is the headline feature. Orleans grain storage is a point key/value contract — Read/Write/Clear by grain id, with no query surface. To inspect grain state you normally have to activate the grain: a silo round-trip that places the grain, deserializes its state, and runs OnActivateAsync. There is no built-in way to ask “which grains have state matching X?” — and the first-party providers persist state as an opaque serialized blob, so you can’t query the database directly either.
Because this provider stores each grain’s state as structured JSON in an ordinary table/collection (Data column, under $.state), you can run the normal document query API straight against the grain-state table — no grains are activated, no silo is involved. Point a read-only IDocumentStore at the same database and table (DocumentDbGrainStorage.ConfigureGrainState applies the type→table + version mapping), then query:
// A read-only store pointed at the same database + grain-state table.// Set JsonSerializerOptions so the stored JSON path casing is predictable.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 a single grain.var bigCarts = await readStore.Query<GrainStateRecord>( "json_extract(Data, '$.state.total') > @min", // SQLite/MySQL/Oracle; PG/SQL Server use their JSON funcs parameters: new { min = 1000 });
foreach (var rec in bigCarts){ // rec.Id == "cart|{grainId}", rec.State is the nested JSON state element var total = rec.State.GetProperty("total").GetDecimal();}Grain state lives under the $.state path; the inner field names follow the JsonSerializerOptions you configure on the grain-storage options (default is the .NET property name — set PropertyNamingPolicy = JsonNamingPolicy.CamelCase, as above, to make the paths predictable and match the envelope). Filtering by the Id prefix ("{stateName}|…") scopes a query to one grain type. Use the LINQ Query<T>() overload on backends without raw SQL (MongoDB, LiteDB, IndexedDB).
State-history audit trail
Section titled “State-history audit trail”Opt the envelope into temporal history for a full, queryable audit trail of every grain-state mutation (who/when/what), on top of the same store:
o.StoreFactory = sp =>{ var opts = new DocumentStoreOptions { DatabaseProvider = new PostgreSqlDatabaseProvider(cs) }; DocumentDbGrainStorage.ConfigureGrainState(opts, "orleans_default"); opts.MapTemporal<GrainStateRecord>(t => t.MaxVersions = 100); // audit trail return new DocumentStore(opts);};
// later, against a store mapped to the same table:var history = await temporalStore.History<GrainStateRecord>("cart|user-42");Provider Compatibility
Section titled “Provider Compatibility”| Tier | Backends | Notes |
|---|---|---|
| Recommended | PostgreSQL ✅, SQL Server, MySQL, Oracle | Atomic CAS: the version check is folded into UPDATE … WHERE and row-count-verified, so the ETag is honored even during failover duplicate-activation windows. |
| Supported | MongoDB ✅ | Good key distribution (_id embeds the grain key). Atomic CAS via the version-predicate update filter. |
| Limited / dev | SQLite, LiteDB, IndexedDB, DuckDB | Single-writer / embedded / analytical engines — fine for dev, single-silo, or edge. |
| Use with care | Cosmos DB | CAS is correct (native IfMatchEtag), but the provider partitions by typeName, putting all state of a grain type in one logical partition (20 GB cap + hot-partition). Fine for modest grain populations; weigh partitioning before large-scale production. |
✅ = covered by automated integration tests (tests/Shiny.DocumentDb.Orleans.Tests, PostgreSQL + MongoDB Read/Write/Clear + stale-write CAS conflict).
System stores (reminders, clustering, grain directory)
Section titled “System stores (reminders, clustering, grain directory)”Beyond grain storage, the same IDocumentStore foundation backs the rest of the Orleans persistence stack. Each store has its own silo-builder extension and its own default table, and all share the same OrleansStoreOptions shape — supply a relational DatabaseProvider (the document type + version mappings are wired for you) or a StoreFactory that returns a fully-configured store (the escape hatch for MongoDB / Cosmos / others). Per-row optimistic concurrency rides on the same version-property CAS as grain storage.
siloBuilder .AddDocumentDbReminders(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbClustering(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs)) .AddDocumentDbGrainDirectory("Default", o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs));Reminders (IReminderTable)
Section titled “Reminders (IReminderTable)”AddDocumentDbReminders(...) registers a reminder table (and calls Orleans’ AddReminders() for you). Default table orleans_reminders. Each reminder is a queryable ReminderDocument keyed by {serviceId}|{grainId}|{reminderName}; the hash-ring range reads Orleans needs are served by a fluent query on the stored GrainHash (exclusive begin / inclusive end, with ring-wrap handled), and each row’s ETag is the document version (per-row CAS). No multi-document transaction is required, so reminders work on any backend — relational built-in, or MongoDB / Cosmos / others via StoreFactory.
Cluster membership / clustering (IMembershipTable)
Section titled “Cluster membership / clustering (IMembershipTable)”AddDocumentDbClustering(...) registers cluster membership. Default table orleans_membership. The per-silo rows and a single global table-version row are updated together inside RunInTransaction, each gated on its own version (CAS) — this is how Orleans’ table-version protocol is honored.
Grain directory (IGrainDirectory)
Section titled “Grain directory (IGrainDirectory)”AddDocumentDbGrainDirectory("Default", ...) registers a named grain directory (a distributed grain-activation registry). Default table orleans_graindirectory. Each registration is a GrainDirectoryDocument with per-row version CAS for register/unregister races; no multi-document transaction is required, so it runs on any backend.
System-store compatibility: reminders ✅ and grain directory ✅ run on any backend (relational built-in, or NoSQL via
StoreFactory); membership requires transactions (relational / Mongo replica set, not Cosmos). All three are covered by PostgreSQL integration tests (ReminderTableTests,MembershipTableTests,GrainDirectoryTests).
Source-generated (reflection-free) serialization
Section titled “Source-generated (reflection-free) serialization”The provider’s own envelope/document types (grain-state record, reminders, membership, grain-directory rows) are always source-generated — the store serializes them without reflection. The one piece that can fall back to reflection is your grain state T, because the provider is generic over it. Assign a JsonSerializerContext covering your grain-state types as the TypeInfoResolver, and grain-state (de)serialization becomes source-generated 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});With UseReflectionFallback = false, an unregistered grain-state type throws a clear exception at write/read time rather than silently using reflection. The same JsonSerializerOptions / UseReflectionFallback knobs exist on the reminder, clustering, and grain-directory options. Leaving the defaults (UseReflectionFallback = true, no context) keeps the previous reflection-based behavior, so this is purely opt-in.
The grain-state JSON path used by queries follows your context’s naming policy — set
PropertyNamingPolicy = JsonNamingPolicy.CamelCaseon the options for predictable, camelCase$.state.…paths.
Limitations
Section titled “Limitations”- Membership requires multi-document transactions — relational or MongoDB replica set only; not Cosmos (see the clustering note). Grain storage, reminders, and grain directory have no such requirement.
- The silo host itself is not an AOT target — grain-state and system-store serialization is reflection-free when you configure a
JsonSerializerContext(above), butMicrosoft.Orleans.Runtimeis reflection/codegen-heavy, so a fully AOT-published silo is not a goal of this package. - Cosmos partitioning — see the compatibility note above.