Configuration
This page documents Orleans.Lattice.GrainIndex 9.9.0, in the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at configuration.md, and llms.txt lists every page.How to declare a grain index, tune it, and understand the guardrails that stop a declaration change from silently invalidating the entries already written.
Declaring an index
An index is declared once, in silo setup, with AddGrainIndex<TGrain, TState>:
using Orleans.Lattice.GrainIndex;
public interface IUserGrain : IGrainWithStringKey
{
}
[GenerateSerializer]
public sealed class UserState
{
[Id(0)] public int Age { get; set; }
[Id(1)] public string Country { get; set; } = string.Empty;
}
public static void Configure(ISiloBuilder siloBuilder) =>
siloBuilder
.AddLattice((silo, storageName) => silo.AddMemoryGrainStorage(storageName))
.AddGrainIndex<IUserGrain, UserState>(index => index
.WithName("users")
.Include(u => u.Age)
.Include(u => u.Country));
TGrain is the grain interface the index hands back; TState is the persistent
state type it projects from. Both are part of the index's identity, so changing
either is a breaking change (see Drift detection).
Builder members
| Member | Default | What it does |
|---|---|---|
WithName(string) |
the TGrain interface name |
The index's name, used to resolve it, to name its tree, and to tag its metrics. Must be unique within the silo and must not contain /. |
WithTreeName(string) |
__grainindex/<name> |
The lattice tree that backs the index. An override must stay inside the reserved prefix. |
WithKeyCodec(IGrainKeyCodec<TGrain>) |
codec for the grain's key type | How a grain identity is encoded into, and decoded out of, an index entry. |
AllowReplication(bool) |
false |
Whether the index's tree may be replicated across clusters. See Grain indexes are cluster-local. |
WithBackfillBatchSize(int) |
256 |
How many grains one backfill pass visits. Must be at least 1. |
WithBackfillInterval(TimeSpan) |
1 second | The pause between backfill passes. Must be greater than zero and at most 0xFFFFFFFE milliseconds (about 49.7 days); either violation throws ArgumentOutOfRangeException. |
Include<TProperty>(Expression<Func<TState, TProperty>>) |
none | Adds one property to the projection. At least one is required. |
Include is the only way a property enters the index. There is no
index-everything mode: every indexed property costs write amplification on the
grain's write path, so each one is a deliberate choice.
Enrolling the grain
Declaring the index is half of the opt-in. The grain must also annotate its
persistent state with [Indexed], which is what installs the projection on its
activation and write path:
using Orleans.Lattice.GrainIndex;
using Orleans.Runtime;
public interface IUserGrain : IGrainWithStringKey
{
Task SetAgeAsync(int age);
}
[GenerateSerializer]
public sealed class UserState
{
[Id(0)] public int Age { get; set; }
}
public sealed class UserGrain(
[Indexed("user")] IPersistentState<UserState> state)
: IndexedGrain<UserState>(state), IUserGrain
{
public async Task SetAgeAsync(int age)
{
State.Age = age;
await WriteStateAsync();
}
}
[Indexed] is an Orleans facet attribute that stands in for
[PersistentState], so it takes the same state name and optional storage name.
Deriving from IndexedGrain<TState> is the convenience route: it exposes
State, WriteStateAsync, ReadStateAsync, and ClearStateAsync and forwards
them to the [Indexed] state object, which publishes the grain's entries on a
write, reconciles them on a re-read, and withdraws them on a clear. The base
class holds no enrolment logic of its own.
A grain with [Indexed] but no matching declaration is not indexed, and a
declaration whose grain is not annotated is never populated at all - not even by
backfill, which onboards a dormant grain only by activating it so
that its [Indexed] state enrols it. Both halves are required.
Tuning an index after declaration
ConfigureGrainIndex overrides the options of an already-declared index by
name, which is how configuration binding and per-environment overrides reach an
index. The overload without an index name, ConfigureGrainIndex(Action<GrainIndexOptions>),
applies to every declared index instead. Option delegates run in registration
order, so a later registration wins; the declaration seeds TreeName and
AllowReplication always, and the backfill knobs only when it set them:
using Orleans.Lattice.GrainIndex;
public static class IndexTuning
{
public static void Configure(ISiloBuilder siloBuilder) =>
siloBuilder.ConfigureGrainIndex("users", options =>
{
options.BackfillBatchSize = 1024;
options.BackfillInterval = TimeSpan.FromSeconds(5);
options.DriftPolicy = GrainIndexDriftPolicy.Rebuild;
});
}
GrainIndexOptions
Resolved per index through IOptionsMonitor<GrainIndexOptions>.Get(indexName).
| Option | Default | What it controls |
|---|---|---|
TreeName |
__grainindex/<name> |
The lattice tree backing the index. Validated to stay inside the reserved prefix. |
AllowReplication |
false |
Whether the index's tree may replicate across clusters. |
BackfillBatchSize |
256 (DefaultBackfillBatchSize) |
Grains visited per backfill pass. Must be at least 1. |
BackfillInterval |
1 second (DefaultBackfillInterval) |
Pause between backfill passes. Must be greater than zero and at most 0xFFFFFFFE milliseconds (about 49.7 days), the longest period the backfill's pass timer accepts; both the per-index validator and WithBackfillInterval reject a longer value. |
BackfillEnabled |
true |
Whether this host schedules the crawl. Switching it off leaves the checkpoint durable and the control primitives working; it only stops this host driving passes. |
DriftPolicy |
Reject |
What silo start does when the declaration has drifted on a breaking field. |
ProjectionMode |
Synchronous |
When entries are published relative to the grain's own state write. |
ProjectionMode is read once, when the index's enrolment path is built, because
it changes the shape of a grain's write path rather than tuning it. Changing
it at run time would leave already-activated grains on the old path. It takes
one of two GrainIndexProjectionMode values:
| Mode | Behaviour |
|---|---|
Synchronous (default) |
The entries are written as part of the grain's write path. The grain's own state is committed first, and a failed index write on WriteStateAsync or ClearStateAsync is thrown to the caller (on activation or a state re-read it is logged instead); the outbox entry recorded beforehand is retried until it lands either way. |
Eventual |
The index write is recorded durably in the outbox during the write path but applied afterwards by the outbox drain, so the caller neither waits for it nor sees its failures. A query issued straight after the write may not see the new entries until the next drain pass. ClearStateAsync is the exception: it applies its removal on the write path under either mode and throws a failure to the caller. The activation keeps diffing later writes against the entries it last confirmed itself, which a drained write does not update, so a value the drain published and a later write or ClearStateAsync from the same activation replaced can leave its entry behind; see Consistency. |
GrainIndexOutboxOptions
The outbox is the durable retry path for an index write that failed, or that was
deferred in Eventual mode. It is configured for the whole silo with
ConfigureGrainIndexOutbox:
| Option | Default | What it controls |
|---|---|---|
Enabled |
true |
Whether this silo drains pending projections in the background. Switching it off still records them; it only stops this silo retrying them. |
RetryInterval |
5 seconds (DefaultRetryInterval) |
The pause between drain passes, which bounds how long an index lags a failed or deferred write. A non-positive value falls back to the 5-second default, and a value above the timer ceiling (about 49.7 days) is clamped to it. |
MaxBatchSize |
256 (DefaultMaxBatchSize) |
The most pending entries one drain pass visits before yielding to the next pass. A value below 1 is treated as 1. |
See The outbox for what writes a marker and what clears it.
GrainIndexDeclarationOptions
Every AddGrainIndex call appends its definition to
GrainIndexDeclarationOptions.Definitions (IList<IGrainIndexDefinition>, in
registration order), so the silo's whole declaration set is resolvable as
IOptions<GrainIndexDeclarationOptions>. It is populated by AddGrainIndex
rather than configured by hand, and it is validated as a set when the host starts:
an index name declared twice, an index name containing /, or an index that
Includes no property, fails start-up with a message naming the index. A / is
refused because the registry scans each index's bookkeeping by the prefix
{name}/, so an index named users/archive would fall inside the range of one
named users.
Grain indexes are cluster-local
An index entry points at a grain identity in this cluster. Replicating that
tree to another cluster would publish grain references that the receiving
cluster cannot meaningfully activate, so AllowReplication defaults to false
and startup audits the resolved replication configuration of every index
tree.
If a tree owned by an index is configured to replicate while its index has
AllowReplication set to false, silo start is rejected. The audit never
rewrites the replication resolver: overriding a host's explicit replication
configuration silently would be a worse failure than refusing to start.
Opt in only when the deployment genuinely wants the index tree replicated:
using Orleans.Lattice.GrainIndex;
public interface IUserGrain : IGrainWithStringKey
{
}
[GenerateSerializer]
public sealed class UserState
{
[Id(0)] public int Age { get; set; }
}
public static void Configure(ISiloBuilder siloBuilder) =>
siloBuilder.AddGrainIndex<IUserGrain, UserState>(index => index
.WithName("users")
.Include(u => u.Age)
.AllowReplication());
The reserved tree namespace
Every index tree lives under the reserved prefix __grainindex/, exposed as
GrainIndexTreeNames.ReservedPrefix. GrainIndexTreeNames.ForIndex(name)
builds the default name and GrainIndexTreeNames.IsIndexOwned(treeName) reports
whether a tree belongs to the index subsystem.
The prefix exists so that index storage is identifiable at a glance in the
explorer, in backups, and in replication configuration, and so an index can
never collide with an application tree. WithTreeName may rename a tree within
the namespace; the validator rejects a name outside it. Silo start also rejects
an index whose tree resolves to the package's own registry tree,
__grainindex/.registry - which an index named .registry does by default.
Drift detection
Each index's effective declaration is fingerprinted and stored in an internal registry tree. At silo start the new declaration is compared field by field against the stored record.
A field is drift-breaking when changing it invalidates entries already written, because the entry's key encoding, value encoding, ordering, or location is a function of that field:
| Field | Classification |
|---|---|
Name |
breaking |
TreeName |
breaking |
GrainInterfaceType |
breaking |
StateType |
breaking |
KeyCodec |
breaking |
Properties |
breaking |
AllowReplication |
safe |
The classification is deliberately conservative: a field is drift-safe only when it demonstrably cannot appear in an entry's encoding, ordering, or location, because getting this wrong yields a silently incorrect query result rather than an error.
A drift-safe change refreshes the stored record and logs at Information under
either policy. A drift-breaking change branches on DriftPolicy:
| Policy | Behaviour |
|---|---|
Reject (default) |
Silo start fails with GrainIndexConfigurationDriftException, naming the index and the fields that drifted. |
Rebuild |
The stored record is updated and its needs-backfill flag raised, and start proceeds. Until the rebuild completes the index is incomplete, so queries can under-report. |
Reject is the default because the alternative to failing loudly is serving
queries from an index whose stored entries no longer match the declaration
reading them.
See also
- Queries - the predicate dialect and how a predicate is routed.
- Backfill - onboarding grains that are not currently active.
- Observability - metrics and the admin surface.
- Architecture - key encoding, the registry tree, and the consistency contract.