Skip to content
Document DB v7.1: Temporal Support, Telemetry Collection, & Orleans Storage Providers! Feed The Machine Here

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>.

NuGet package Shiny.DocumentDb.Orleans NuGet package Shiny.DocumentDb.Orleans.MongoDb NuGet package Shiny.DocumentDb.Orleans.CosmosDb
  • 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.

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:

OrleansShiny.DocumentDb
document keyId = "{stateName}|{grainId}"
ETagGrainStateRecord.Version (mapped via MapVersionProperty)
concurrency conflictConcurrencyExceptionInconsistentStateException
state blobnested 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.

Terminal window
# Core provider (relational backends built in)
dotnet add package Shiny.DocumentDb.Orleans
# First-class companion packages (optional)
dotnet add package Shiny.DocumentDb.Orleans.MongoDb
dotnet add package Shiny.DocumentDb.Orleans.CosmosDb

The 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
});

AddDocumentDbGrainStorageAsDefault(...) registers under Orleans’ default provider name, and the same provider backs the streaming PubSubStore when named accordingly.

PropertyTypeDefaultDescription
DatabaseProviderIDatabaseProvider?nullA relational backend. When set, the provider builds its own DocumentStore with GrainStateRecord mapped to TableName and its version mapped. Ignored when StoreFactory is set.
StoreFactoryFunc<IServiceProvider, IDocumentStore>?nullGeneric escape hatch returning a fully-configured store. The factory must map GrainStateRecord (type→table/container + version property).
TableNamestring?"orleans_{providerName}"Table/collection/container name for the grain-state envelope.
DeleteStateOnClearbooltrueWhen true, ClearStateAsync deletes the row. When false, it writes a tombstone (null state) and bumps the version, preserving the ETag chain.
JsonSerializerOptionsJsonSerializerOptions?nullJSON options used to (de)serialize grain state into the envelope’s nested document.
InitStageintApplicationServicesSilo 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).

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");
TierBackendsNotes
RecommendedPostgreSQL ✅, SQL Server, MySQL, OracleAtomic CAS: the version check is folded into UPDATE … WHERE and row-count-verified, so the ETag is honored even during failover duplicate-activation windows.
SupportedMongoDB ✅Good key distribution (_id embeds the grain key). Atomic CAS via the version-predicate update filter.
Limited / devSQLite, LiteDB, IndexedDB, DuckDBSingle-writer / embedded / analytical engines — fine for dev, single-silo, or edge.
Use with careCosmos DBCAS 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));

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.

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.CamelCase on the options for predictable, camelCase $.state.… paths.

  • 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), but Microsoft.Orleans.Runtime is reflection/codegen-heavy, so a fully AOT-published silo is not a goal of this package.
  • Cosmos partitioning — see the compatibility note above.