Materialised views
This page is part of 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 materialised-views.md, and llms.txt lists every page.A materialised view is an asynchronous, eventually-consistent projection of a source tree, maintained by tailing that tree's write-ahead log (WAL). Two view kinds are supported:
- Filter / re-project - a predicate selects the subset of source keys to keep, an optional value transform reshapes the stored bytes, and an optional injective key re-map relocates the view key.
- Aggregation - each source entry is mapped to a group key and the view materialises one reduced value per group (count, sum, min, max, set-union).
A view lives in its own tree and is read through the ordinary ILattice
surface or, preferably, through the ILatticeView handle.
What you need to register
Materialised views run on top of a WAL-backed lattice; they do not require a replicated cluster. A single-silo deployment registers two things:
AddLattice(...)- the lattice itself, which also registers the commit-log reader and an in-memory WAL baseline.AddLatticeViews(...)- the view catalog, factory, and maintainer. This also folds inAddWalCursorRegistry()(idempotent) so a view can pin the source WAL and entries are not trimmed before the view has consumed them.
siloBuilder
.AddLattice((silo, name) => silo.AddMemoryGrainStorage(name))
.AddLatticeViews(views => views.AddView(
viewName: "adults",
sourceTreeId: "people",
projection: new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
The view declared above keeps exactly the people keys whose JSON value
satisfies Age >= 18, under the same key, in its own view tree.
The maintainer also uses Orleans reminders for a keepalive - a one-minute
reminder that re-activates it after a silo restart - so register a reminder
provider (for example UseInMemoryReminderService() in development, or a durable
provider in production). Orleans initialises its reminder service asynchronously
after the silo starts, so a maintainer that comes online inside that window
retries the keepalive registration with a short bounded backoff (1, 2, 3 and 5
seconds - five attempts in all) instead of failing its start; any other reminder
fault, or a service that is still initialising when the budget runs out, surfaces
unchanged. For a durable commit log, register a WAL
provider such as the Azure Table package (AddAzureTableWalStorage(...)) - that
is a storage concern, independent of views.
AddLatticeViewsand the whole view maintenance implementation are part of the coreOrleans.Latticepackage, so a single-cluster host needs no reference toOrleans.Lattice.Replicationat all.AddLatticeReplicationis only needed when a view ships its tree across clusters (see Replication modes).
Create a view
A view is a LatticeViewDefinition - a view name paired with a projection. The
projection is either a PredicateLatticeViewProjection (filter / re-project, one
source key to at most one view key) or an AggregationLatticeViewProjection (a
grouped reduce; see Aggregation views). When a projection
needs selectors over the source value, build it with the typed Create<T>
factory so the selectors run against the deserialized value type instead of raw
byte[].
There are two ways to create a view.
At startup - declare it on the silo builder so the maintainer comes online
with the host. AddView registers a filter / re-project view;
AddAggregationView registers an aggregation (and AddFoldedView a
folded view). Each also has an overload that takes
a Func<IServiceProvider, ...> factory in place of the projection instance,
resolving the projection from the silo's service provider at startup so it can
take service dependencies. With a projection instance:
siloBuilder.AddLatticeViews(views => views.AddView(
viewName: "adults",
sourceTreeId: "people",
projection: new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
At runtime - resolve ILatticeViewFactory and call CreateAsync with the
source tree, the view name, and the definition. Prefer this when the view shape
is only known at runtime; it returns the same ILatticeView handle used for
reads after the runtime registration is durable. A
filter-only PredicateLatticeViewProjection is captured automatically so the
filter survives restart:
var viewFactory = client.ServiceProvider.GetRequiredService<ILatticeViewFactory>();
var people = grainFactory.GetGrain<ILattice>("people");
ILatticeView adults = await viewFactory.CreateAsync(
people,
"adults",
new LatticeViewDefinition("adults", new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
For application-defined state, register a stable provider key at host startup and persist only the bounded data it needs. The provider runs locally and returns the complete definition, so a remote caller cannot assert projection kind or version:
var viewFactory = client.ServiceProvider.GetRequiredService<ILatticeViewFactory>();
var people = grainFactory.GetGrain<ILattice>("people");
siloBuilder.AddLatticeViews(views => views.AddRuntimeProjectionProvider(
"app.adults.v1",
(_, context) => new LatticeViewDefinition(
context.ViewName,
new PredicateLatticeViewProjection())));
var descriptor = new LatticeRuntimeViewProjectionDescriptor(
"app.adults.v1",
Array.Empty<byte>());
ILatticeView providerBacked = await viewFactory.CreateAsync(people, "provider-adults", descriptor);
Either way the view materialises under its own view-{name} tree and converges
toward the source as the maintainer applies projected writes. The synchronous
Create overload remains for compatibility, but it persists and activates in
the background and therefore cannot report a durable-registration failure to
the caller. Prefer CreateAsync for runtime creation.
View names
A view name is not just a label. It is the view maintainer's grain key, and it is
interpolated into the view tree id, which is itself a grain key and is carried
into the shard-root grain's composite key - a persistent grain. Keyed storage
backends cannot hold some characters there: Azure Table grain storage puts a grain
key into the Partition/Row key columns and the request URL, where the Table
service rejects control characters (an HTTP 400 on the state read or write) and
Orleans rewrites /, \, # and ? to _, so two names differing only in
those characters would share one stored row. A name is therefore validated at
creation and must not contain:
- the control characters
0x00-0x1Fand0x7F-0x9F; /,\,#or?;~, which separates a view tree id from its rebuild generation.
An existing view whose name predates this rule is not stranded: it is restored as before and reported in the log, so it can be re-created under a legal name when convenient.
Rebuild generations
A shadow-swap rebuild materialises the new copy under a suffixed tree id,
view-{name}~g{N}, and swaps the active generation atomically when it completes.
The suffix separator is storage-safe, and because a view name may not contain it,
a (name, generation) pair always maps to exactly one tree id.
A view built by an earlier release used a different separator. Its existing generations keep resolving through the old one - so upgrading strands no data and forces no rebuild - and the next rebuild moves the view onto the current naming on its own. The superseded generation is still reclaimed on the normal grace cadence.
Multi-tenancy
With the tenancy add-on enabled, a view created through the tree-administration
facade is scoped to the tenant that creates it: the facade resolves the caller's
view name (and source tree) to the active tenant, so the view materialises as
t/{tenant}/view-{name}. An in-silo ILatticeViewFactory call uses the view
name it is given as-is. Two tenants can therefore use the same unqualified
view name over their own same-named sources and each reads back only its own view.
Because the tenant segment is outermost, a tenant's view tree is owned, filtered
from enumerations, and deleted with the tenant exactly like its other trees.
Responses echo the name the caller supplied, so the composed name never leaks. Adoption is non-destructive: a view that already exists belongs to the reserved default tenant and keeps its existing name, maintainer, and tree id. With tenancy off nothing is composed and every id is unchanged.
A view's source must be a directly-writable tree, not another view: chaining a
view onto another view's view-* tree is unsupported (it compounds apply lag at
every hop and stacks source-WAL cursor pins), so Create / CreateAsync and the
startup AddView / AddAggregationView / AddFoldedView builders reject a
view-* source with InvalidOperationException.
Reading a view
Reading a view never needs the source tree or the projection - resolve a read
handle by name with ILatticeViewFactory.GetAsync. The factory looks the view up
in the catalog, the startup declarations, or the durable runtime registry and
returns an ILatticeView that follows the maintainer's active generation on every
read, so a rebuild that swaps the live view tree underneath you is handled for
you. GetAsync returns null when no view of that name is registered and never
creates anything; the maintainer comes online lazily on the first read:
public sealed class AdultsByNameReader(ILatticeViewFactory views)
{
public async Task<User?> ReadAsync(string key, CancellationToken cancellationToken)
{
// Open an already-registered view by name - no source tree or projection needed.
ILatticeView? adults = await views.GetAsync("adults", cancellationToken);
if (adults is null)
{
return null; // the view was never created, or was deleted
}
// Typed read: deserialize the view value to User (defaults to JsonLatticeSerializer<User>).
return await adults.GetAsync<User>(key, cancellationToken);
}
}
ILatticeView exposes the usual reads - GetAsync, CountAsync, KeysAsync,
EntriesAsync - over the materialised content. For long-running scans prefer the
ScanKeysAsync / ScanEntriesAsync extensions, which enumerate forward and
recover from EnumerationAbortedException without duplicates or gaps (the raw
KeysAsync / EntriesAsync primitives do not), and use the typed GetAsync<T>,
EntriesAsync<T> and ScanEntriesAsync<T> extensions to deserialize values
(with JsonLatticeSerializer<T> unless you pass an ILatticeSerializer<T>). If you are creating the view in
the same place you read it, the handle returned by Create (see
Create a view) exposes the same reads, so reuse it rather than
re-resolving the view by name.
A view is read-only, and its backing tree is private to the maintainer. Its
contents are derived from the source tree and owned by the maintainer, so the
underlying view-{name} tree rejects both direct writes and direct reads
through the public ILattice surface: binding GetGrain<ILattice>("view-adults")
and calling a mutating method (SetAsync / DeleteAsync / SetManyAtomicAsync,
or any other write) or a content read (GetAsync / GetWithVersionAsync /
ExistsAsync / GetManyAsync / CountAsync / CountPerShardAsync / KeysAsync
/ EntriesAsync) throws InvalidOperationException. A rebuild can swap the active
view-tree generation underneath a raw bind, so a direct read could observe a stale
or empty generation - always read through the ILatticeView handle (resolved via
ILatticeViewFactory.GetAsync or Create), which follows the active generation.
To change a view's contents, write to its source tree and let the view
converge. (For this reason, view- is a reserved tree-name prefix: don't name a
directly-writable data tree view-something.)
Observing lag and forcing a rebuild
Inject ILatticeViewFactory to create a view handle, query its apply lag (the
count of committed-but-unapplied source entries), or force a rebuild:
public sealed class AdultsViewService(ILatticeViewFactory views, IGrainFactory grains)
{
public async Task<long> LagAsync(CancellationToken cancellationToken)
{
var source = grains.GetGrain<ILattice>("people");
var projection = new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18));
ILatticeView view = views.Create(
source,
"adults",
new LatticeViewDefinition("adults", projection));
return await view.GetLagAsync(cancellationToken);
}
}
RebuildAsync re-projects current source state. For a DeriveLocally view (the
default) it never exposes a half-built or empty view to readers - the rebuild
happens off to the side in a new generation and the live tree is swapped in
atomically when it is complete. A ShipView view is the exception: to keep its
replicated view-{name} tree id stable, the producer clears and re-derives that
tree in place, so a reader on the producer can briefly observe a partially
rebuilt view while consumers converge through replication anti-entropy.
ReconcileAsync repairs a drifted ShipView view the same way.
Deleting a view
ILatticeViewFactory.DeleteAsync tears a runtime-created view down completely:
it stops the maintainer, unregisters the keepalive reminder, releases the source
WAL cursor pin, soft-deletes every backing view-tree generation, resets the
durable checkpoint, and removes the catalog entry and runtime registration. After
it returns the view name is free to be re-created from scratch. A deleted tree id
is never reused, so a view re-created under the same name materialises in a fresh
view-{name}~g{N} generation; read it through its ILatticeView handle as
usual.
public sealed class AdultsViewAdmin(ILatticeViewFactory views)
{
public Task RemoveAsync(CancellationToken cancellationToken) =>
views.DeleteAsync("adults", cancellationToken);
}
Deletion is idempotent: deleting a view that was never created, or re-deleting an
already-deleted view, is a no-op. A view declared at startup via
AddLatticeViews(...) cannot be deleted this way - the declaration would
re-create it on the next start - so DeleteAsync rejects it with an
InvalidOperationException. The same rejection applies to a view that a Lattice
add-on declares over one of its reserved system trees, because the add-on
re-creates it on the next start as well.
Deleting a source tree that has views
A materialised view derives its contents from its source tree's write-ahead log,
so the source must outlive every view built on it. A source tree that still has
one or more views therefore cannot be deleted: ILattice.DeleteTreeAsync
throws InvalidOperationException and names the dependent view(s). Tear the
view(s) down first with ILatticeViewFactory.DeleteAsync (which releases the
maintainer's source-WAL cursor pin), then delete the source:
public sealed class SourceTeardownService(ILatticeViewFactory views, IGrainFactory grains)
{
public async Task DropPeopleAndViewsAsync(CancellationToken cancellationToken)
{
// Delete every view derived from 'people' before the source itself.
await views.DeleteAsync("adults", cancellationToken);
await grains.GetGrain<ILattice>("people").DeleteTreeAsync(cancellationToken);
}
}
The guard covers views declared at startup and views created at runtime, and is
authoritative across the whole cluster. A host that never calls AddLatticeViews
has no views and is unaffected.
Durability across restarts
A view created at runtime through ILatticeViewFactory.CreateAsync records a durable
registration alongside its checkpoint. New stateful runtime views use a
LatticeRuntimeViewProjectionDescriptor: a stable host-registered provider key
plus an opaque payload of at most 64 KiB. On creation and every activation, the
server invokes only that configured provider and requires the reconstructed view
name, projection kind, accumulative flag, and ProjectionVersion to match the
persisted registration exactly. Missing providers, malformed state, empty or
mismatched versions, and provider failures leave the maintainer dormant rather
than rebuilding through different logic.
Filter-only PredicateLatticeViewProjection definitions receive the built-in
predicate descriptor automatically. Predicate value/key selectors, aggregation
delegates, folds, and other application state require an explicit provider. The
provider payload is copied defensively and is never returned by status, catalog,
State API, MCP, or Explorer surfaces.
Legacy records without a provider key retain the allow-listed type/DI resolution
path, but they now activate only when the reconstructed version exactly matches
the persisted version. Re-call Create with a descriptor to migrate a legacy
stateful registration. Create rejects a new runtime view before catalog or
durable mutation when restart-faithful reconstruction cannot be proven. Views
declared at startup with AddLatticeViews(...) are always re-registered from the
declaration and carry no runtime-provider constraint.
Source-identity rebind
A view tails its source by the source's logical id, but the maintainer binds to the source's current physical tree id - the effective id its registry alias resolves to. A resize, a shadow-cutover restore, a schema remediation or an administrative alias change can repoint that alias at a new physical tree underneath a live view. When it does, the maintainer rebuilds the view from the new physical source and rebinds its tail: a WAL tail alone can never retract a key the new source never had, so a rebuild is required for correctness. A view that first activates over a source whose alias already resolves to a different physical tree likewise builds from that tree's current state rather than replaying its log.
The rebind is event-driven. The tree registry pushes the alias change to every
view maintainer sourcing that tree the moment the swap commits, and the next drain
re-resolves and heals. A missed push is caught by a coarse backstop
(SourceIdentityBackstopInterval, default 30 s) that re-resolves the source
identity even with no notification. Because the steady-state binding is cached and
re-resolved only on a push or the backstop, an idle view maintainer does not read
the tree registry on every drain.
Changing a projection
PredicateLatticeViewProjection.ProjectionVersion is a structural hash of the
filter plus the caller-declared selector version tags. When you change the
filter (or bump a selector version), the persisted version no longer matches and
the maintainer rebuilds the view from current source state the next time it
activates. Supply a stable version tag whenever you pass a value or key selector
so a logic change is detectable:
var projection = new PredicateLatticeViewProjection(
filter: LatticePredicateTranslator.Translate<User>(u => u.Age >= 18),
keySelector: sourceKey => $"adult:{sourceKey}",
keySelectorVersion: "adult-prefix-v1");
Re-keyed views and deletes
The optional keySelector relocates the view key. It is a pure function of the
source key only (Func<string, string>), never the value. That rule is what
keeps a re-keyed view's deletes correct: a delete carries the source key but not
the value, so the maintainer recomputes the same view key the matching upsert
produced and removes it.
var projection = new PredicateLatticeViewProjection(
keySelector: sourceKey => $"adult:{sourceKey}",
keySelectorVersion: "adult-prefix-v1");
The re-map must be injective: two distinct source keys mapping to one view
key is a configuration error (legitimate many-to-one is the
aggregation view kind). A collision is counted on
orleans.lattice.view.key_collisions, logged, and resolved last-writer-wins so
the view stays well-defined.
Range deletes
A source DeleteRange is lowered to view writes by its matched-key set:
| Source range delete | Key-preserving view | Re-keyed view |
|---|---|---|
Carries MatchedKeys (predicate-filtered deletes do) |
One exact per-key delete per matched key | Each matched source key re-keyed, then deleted |
No MatchedKeys (unconstrained delete) |
One view-side range delete over [start, end) - exact, because the view key equals the source key |
A full rebuild, because the deleted source keys' view keys cannot be recovered without a reverse index |
Supplying MatchedKeys (the default for predicate-filtered deletes) is the way
to get exact per-key retraction on a re-keyed view and avoid the rebuild.
Aggregation views
An aggregation view is a grouped reduce: each source entry is mapped to a
group key (a legitimate many-to-one mapping, unlike the injective filter /
re-project re-key), and the view materialises one reduced value per group. The
built-in reduces are exposed through AggregationKind:
| Kind | Materialised value | Selector required |
|---|---|---|
Count |
Number of live source keys in the group (long) |
group key only |
Sum |
Sum of each member's numeric contribution (double) |
value selector |
Min |
Smallest live contribution (double) |
value selector |
Max |
Largest live contribution (double) |
value selector |
SetUnion |
Distinct-member cardinality (long) |
member selector |
Fold |
A user-defined fold's accumulator (opaque bytes) | group key + Initial / Apply fold |
The first five are commutative-numeric reduces declared with
AggregationLatticeViewProjection.Create<T>; Fold is a custom, non-commutative
reduce declared with LatticeFoldProjection (see
Folded (custom-reducer) views below).
Group keys share the view tree with the maintainer's internal rows, which live
under a reserved NUL (\u0000) prefix. A group-key selector that returns an
empty key or one beginning with \u0000 therefore has its contribution
rejected - dropped and counted on the
orleans.lattice.view.aggregation_rejected metric - rather than materialised
into the reserved region where it would be invisible to reads and could collide
with an internal row. The rejection is deterministic on the key, so every cluster
drops the same members and the view stays convergent; a non-zero counter means a
selector is emitting reserved keys and should be corrected.
Declare one with AggregationLatticeViewProjection.Create<T>: a group-key
selector, a stable selector-version tag (the selectors are delegates and cannot
be structurally hashed, so the tag drives rebuild-on-change), and the value or
member selector the kind needs. The selectors run against the deserialized value
type T (using JsonLatticeSerializer<T> by default, or pass your own
ILatticeSerializer<T>), so you write u => u.Name rather than hand-rolling a
byte[] round-trip.
siloBuilder.AddLatticeViews(views => views.AddAggregationView(
viewName: "age-sum-by-name",
sourceTreeId: "people",
projection: AggregationLatticeViewProjection.Create<User>(
AggregationKind.Sum,
groupKeySelector: u => u.Name,
selectorVersion: "sum-age-v1",
valueSelector: u => u.Age)));
Reading an aggregate
Each group's reduced value is materialised under its bare group key, so
readers are oblivious to the internal accumulator layout. Read it through the
view's ILatticeView handle - the backing view-* tree rejects direct reads
(see Reading a view) - and decode the bytes with
LatticeAggregationValue for the view's kind (a null read means the group has
no live members):
ILatticeView? sums = await client.ServiceProvider
.GetRequiredService<ILatticeViewFactory>()
.GetAsync("age-sum-by-name", cancellationToken);
byte[]? raw = sums is null ? null : await sums.GetAsync("Alice", cancellationToken);
double total = raw is null ? 0 : LatticeAggregationValue.DecodeDouble(raw);
When you hold an ILatticeView handle, GetAggregateDoubleAsync /
GetAggregateInt64Async do the decode for you (returning null for an empty
group), so you never touch the bytes:
ILatticeView ageByName = client.ServiceProvider
.GetRequiredService<ILatticeViewFactory>()
.Create(
grainFactory.GetGrain<ILattice>("people"),
"age-sum-by-name",
new LatticeViewDefinition("age-sum-by-name", AggregationLatticeViewProjection.Create<User>(
AggregationKind.Sum,
groupKeySelector: u => u.Name,
selectorVersion: "sum-age-v1",
valueSelector: u => u.Age)));
double total = await ageByName.GetAggregateDoubleAsync("Alice", cancellationToken) ?? 0;
Count and SetUnion store a long (decode with DecodeInt64); Sum, Min,
and Max store a double (decode with DecodeDouble). Overwrites and deletes
retract a source key's prior contribution automatically, so a group's value
always reflects its current live members.
Sharding a hot group
A group that funnels every member to one accumulator is a write hotspot.
AggregationFanout (default 1) shards each group into sub-accumulators hashed on
the source key and merges them at read time. A fanout of 1 is a single
accumulator and produces an identical result.
siloBuilder.ConfigureLatticeView("age-sum-by-name", options =>
{
options.AggregationFanout = 8;
});
Approximate mode
Min, Max, and SetUnion keep exact per-group state whose size is the
group's cardinality. For unbounded-cardinality groups, set
AggregationMaxGroupEntries to bound each shard: Min / Max keep a top-K and
SetUnion keeps a bounded distinct sample. Leaving the option at its 0 default
keeps every group exact.
siloBuilder.ConfigureLatticeView("age-sum-by-name", options =>
{
options.AggregationMaxGroupEntries = 1024;
});
Folded (custom-reducer) views
Fold maintains a user-defined, non-commutative reduction per group key
instead of one of the built-in commutative reducers. You supply a fold as a seed
(Initial) and a step (Apply(accumulator, sourceKey, value, hlc)); the
maintainer applies the surviving members of each group in ascending source-HLC
order, so the materialised value is a deterministic function of the group's
member set (a state-machine fold, an ordered log, a "latest wins with terminal
states" reduction, and so on).
Because a general fold is not invertible, the maintainer cannot un-apply a
single member on a delete or filter-exit. Instead it keeps each source key's
contributed value and re-folds the whole group over its surviving members
whenever a member is added, retracted, or re-grouped. The cost is bounded by the
group's member count, so folds are best suited to groups with a bounded number of
members each. The materialised value is the accumulator's opaque bytes, stored
under the bare group key, so ReconcileAsync anti-entropy, rebuilds, shadow-swap,
and both replication modes work exactly as for the built-in reducers.
Declare one with LatticeFoldProjection.Create<TValue, TAccumulator> (or the raw
byte[] constructor) and register it with AddFoldedView. As with the built-in
projections, the delegates cannot be structurally hashed, so a stable
foldVersion tag drives rebuild-on-change - bump it whenever the fold's logic
changes:
siloBuilder.AddLatticeViews(views => views.AddFoldedView(
viewName: "name-trail-by-age",
sourceTreeId: "people",
projection: LatticeFoldProjection.Create<User, string>(
groupKeySelector: u => u.Age.ToString(),
initial: () => string.Empty,
apply: (trail, sourceKey, u, hlc) => trail.Length == 0 ? u.Name : trail + "," + u.Name,
foldVersion: "name-trail-v1")));
The accumulator is stored under the bare group key, so read it with the typed
GetAsync<T> (or the raw GetAsync) - there is no dedicated aggregate decoder
because the accumulator shape is yours:
ILatticeView trail = client.ServiceProvider
.GetRequiredService<ILatticeViewFactory>()
.Create(
grainFactory.GetGrain<ILattice>("people"),
"name-trail-by-age",
new LatticeViewDefinition("name-trail-by-age", LatticeFoldProjection.Create<User, string>(
groupKeySelector: u => u.Age.ToString(),
initial: () => string.Empty,
apply: (t, sourceKey, u, hlc) => t.Length == 0 ? u.Name : t + "," + u.Name,
foldVersion: "name-trail-v1")));
string? names = await trail.GetAsync<string>("30", cancellationToken);
Worked example: a folded view plus a read-time join
A folded view can only carry state that is a deterministic function of one source tree's member set. When a read model needs a field that is not derivable from that tree - because it is maintained independently, in a different order, or by a different backend - pair the folded view with a per-key join at read time: the view supplies the fact-derived half, and the caller joins the rest.
The MultiSiteManufacturing sample uses exactly this shape for its dashboard
summary. A folded view over the mfg-facts tree (mfg-compliance) folds each
part's facts in business-HLC order into an accumulator carrying the lattice
compliance state, the latest process stage, and the fact count. The dashboard
snapshot scans that view and then joins each part's baseline compliance state -
which the baseline backend folds in arrival order, deliberately diverging
from the HLC-ordered lattice fold - from the baseline grain. The divergence
between the two halves is the point of the demo, and because it cannot be
reproduced by any fold over mfg-facts, it is joined per part rather than
materialised. The sample owns no summary read model of its own; the library
maintains the folded half off the write-ahead log.
Reconcile and drift detection
ReconcileAsync is the view's anti-entropy: it re-derives the expected view from
current source state and compares it against the live view through a content
digest. If they diverge it repairs the view and returns true; if they already
agree it returns false.
public sealed class ViewRepairService(ILatticeViewFactory views, IGrainFactory grains)
{
public async Task<bool> RepairAsync(CancellationToken cancellationToken)
{
var source = grains.GetGrain<ILattice>("people");
ILatticeView adults = views.Create(
source,
"adults",
new LatticeViewDefinition("adults", new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
// Returns true if drift was detected and repaired, false if already in sync.
return await adults.ReconcileAsync(cancellationToken);
}
}
ComputeDigestAsync exposes the same content fingerprint reconcile uses, so
divergence is observable and testable:
public sealed class ViewDigestService(ILatticeViewFactory views, IGrainFactory grains)
{
public async Task<long> EntryCountAsync(CancellationToken cancellationToken)
{
var source = grains.GetGrain<ILattice>("people");
ILatticeView adults = views.Create(
source,
"adults",
new LatticeViewDefinition("adults", new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
ViewDigest digest = await adults.ComputeDigestAsync(cancellationToken);
return digest.EntryCount;
}
}
The digest is an order-independent fingerprint over the materialised
(key, value) content - two trees that hold the same logical content compare
equal regardless of scan order. It deliberately excludes replication metadata and
TTL, so a digest over the live tree matches a digest over a fresh source
re-projection when, and only when, their materialised content agrees.
ReconcileAsync re-derives from the local source, so it is a no-op (returns
false) on a ShipView consumer that has no local source; consumer drift is
healed by replication anti-entropy instead.
Reading your own writes
The default contract is best-effort lag. A caller that needs to observe its own
write can opt into a barrier: WaitForSourceHeadAsync captures the current
source head and blocks until the view has applied up to it.
public sealed class WriteThenReadService(ILatticeViewFactory views, IGrainFactory grains)
{
public async Task<byte[]?> WriteThenReadAsync(string key, byte[] value, CancellationToken cancellationToken)
{
var source = grains.GetGrain<ILattice>("people");
ILatticeView view = views.Create(
source,
"adults",
new LatticeViewDefinition("adults", new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(u => u.Age >= 18))));
await source.SetAsync(key, value, cancellationToken);
// Block until the view has caught up to the source head, then read.
await view.WaitForSourceHeadAsync(TimeSpan.FromSeconds(5), cancellationToken);
return await view.GetAsync(key, cancellationToken);
}
}
WaitForSourceHlcAsync(target, timeout) is the lower-level form, completing once
the view's highest applied source HLC reaches target (or throwing
TimeoutException).
Atomic-write visibility
A source ILattice.SetManyAtomicAsync batch is all-or-nothing. The view
preserves that guarantee: a prepared-but-uncommitted batch never appears, a
committed batch appears atomically (no partial batch is ever observable), and an
aborted batch is never surfaced.
A view buffers in-flight atomic writes while it waits for them to commit. Two
caps bound that buffer; if either is exceeded the view falls back to a rebuild
from current committed source state. The same fallback fires when the source
tree sets LatticeOptions.WalRetention and the oldest prepared entry of a
still-unterminated batch is older than that window, because the batch could no
longer complete before the log trims under it. Each backstop trip increments
orleans.lattice.view.atomic_staging_backstop.
When a completed batch flushes to the view tree, the maintainer carries the
projection's upserts and its retraction deletes inside a single mixed atomic
op (ILattice.SetManyAtomicAsync(upserts, deletes, operationId)). This closes
the re-key window: when a source mutation moves a row from view key A to view key
B, the upsert at B and the delete at A flip in the same visibility change, so a
reader never observes both view keys (or neither) at once. The same mixed flush
is used on the cross-tree joint path below and on the per-tree degrade path.
siloBuilder.ConfigureLatticeView("adults", options =>
{
options.MaxStagedTransactions = 2048;
options.MaxStagedBytes = 128L * 1024 * 1024;
});
Cross-tree atomic visibility
A source-side cross-tree atomic write (IGrainFactory.SetManyAtomicAsync /
BeginAtomicWrite, see atomic writes) commits across several
source trees all-or-nothing. The views derived from those trees preserve that
coupling: when the cross-tree batch commits, the participating views flip
jointly - a reader never observes one participating view committed while
another is still pre-commit.
If a participant view is permanently unavailable, the present views degrade to
per-tree atomicity after a bounded wait (CrossTreeReadinessTimeout, default
5 s): each flips its own slice, increments
orleans.lattice.view.cross_tree_joint_violation, and schedules a reconcile that
heals any divergence - choosing liveness over an indefinite stall.
Replication modes
A view declares, through LatticeViewOptions.ReplicationMode, how its tree is
made available across replicating clusters. Three topologies are supported:
| Topology | Source tree replicated? | View tree replicated? | Producer selection |
|---|---|---|---|
DeriveLocally (default) |
Yes | No | Every cluster maintains its own view. |
ShipView, source-less consumers |
No | Yes | Inferred from local source-WAL ownership. |
ShipView, replicated source |
Yes | Yes | Exactly one stable replication cluster id is explicit. |
A startup guard validates startup declarations, and runtime creation runs the
same checks before publishing a view. It rejects a replicated DeriveLocally
view tree, an unreplicated ShipView tree, an explicit producer on a
non-replicated source, and source-plus-view replication without an explicit
producer. Cluster ids are case-sensitive and must be globally unique.
Replication topology is fixed for the lifetime of a view name. Do not change an
existing view between DeriveLocally and ShipView, or change its designated
producer in place: the existing view-tree WAL may contain writes authored under
the old topology. Create a new view name, let it converge, and then retire the
old view. The maintainer enforces part of this rule: a view configured as
ShipView whose active generation is no longer its original tree (because a
DeriveLocally shadow-swap rebuild has already run) throws
InvalidOperationException whenever its maintainer activates or drains.
Source-relative GetLagAsync and read barriers are producer-only for
ShipView. Consumers receive view rows through replication, so their local
source WAL and maintainer checkpoint do not describe view progress; these APIs
throw InvalidOperationException on a consumer.
Derive independently on every cluster
Use DeriveLocally when every cluster replicates the source and deploys the
same deterministic projection version. Replicate people, but never
view-adults; each cluster owns and maintains its local view tree.
var deriveTrees = new Dictionary<string, LatticeMergeMode>(StringComparer.Ordinal)
{
["people"] = LatticeMergeMode.LwwRegister,
};
siloBuilder.AddLatticeReplication(options =>
{
options.ClusterId = "site-a"; // Use this cluster's stable id.
options.ReplicationPeers = new[] { "site-b" };
options.ReplicatedTrees = deriveTrees;
});
siloBuilder.AddLatticeViews(views => views.AddView(
"adults",
"people",
new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(user => user.Age >= 18))));
siloBuilder.ConfigureLatticeView("adults", options =>
options.ReplicationMode = LatticeViewReplicationMode.DeriveLocally);
Ship a view to source-less consumers
Use inferred ShipView when only the producer holds people. Replicate
view-adults, but not people. A cluster with a locally readable source WAL
maintains the view; a source-less consumer suppresses its maintainer and receives
the view through replication. Deploy the same tree map to every participant,
using each cluster's own ClusterId and outbound peers.
var thinConsumerTrees = new Dictionary<string, LatticeMergeMode>(StringComparer.Ordinal)
{
["view-adults"] = LatticeMergeMode.LwwRegister,
};
siloBuilder.AddLatticeReplication(options =>
{
options.ClusterId = "site-a"; // Use "site-b" on the consumer.
options.ReplicationPeers = new[] { "site-b" };
options.ReplicatedTrees = thinConsumerTrees;
});
siloBuilder.AddLatticeViews(views => views.AddView(
"adults",
"people",
new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(user => user.Age >= 18))));
siloBuilder.ConfigureLatticeView("adults", options =>
options.ReplicationMode = LatticeViewReplicationMode.ShipView);
Ship a view while also replicating its source
When consumers need both people and view-adults, source-WAL readability can
no longer identify one writer. Replicate both trees and set
ShipViewProducerClusterId to the same producer id on every cluster. Only the
cluster whose local replication ClusterId matches that value maintains the
view; all others suppress their maintainers even though they can read the source.
A runtime-created view in this topology must have its named options configured
at host startup before it is created.
var activeSourceTrees = new Dictionary<string, LatticeMergeMode>(StringComparer.Ordinal)
{
["people"] = LatticeMergeMode.LwwRegister,
["view-adults"] = LatticeMergeMode.LwwRegister,
};
siloBuilder.AddLatticeReplication(options =>
{
options.ClusterId = "site-a"; // Use "site-b" on the consumer.
options.ReplicationPeers = new[] { "site-b" };
options.ReplicatedTrees = activeSourceTrees;
});
siloBuilder.AddLatticeViews(views => views.AddView(
"adults",
"people",
new PredicateLatticeViewProjection(
LatticePredicateTranslator.Translate<User>(user => user.Age >= 18))));
siloBuilder.ConfigureLatticeView("adults", options =>
{
options.ReplicationMode = LatticeViewReplicationMode.ShipView;
options.ShipViewProducerClusterId = "site-a";
});
Lag budget and dead-view eviction
A view pins the source WAL only up to a per-view MaxLagBudget (default 0
disables it). A view that exceeds the budget - chronically slow, or a crashed
maintainer - is force-evicted: the WAL pin is released and the view is rebuilt
from current source state. Eviction is emitted on
orleans.lattice.view.lag_budget_eviction, and a LagEvictionCooldown
(default 30 s) prevents repeated evictions from thrashing a view that is kept
chronically over budget. Size LatticeOptions.WalRetention at or above the
expected steady-state view lag so the budget is a backstop rather than a routine
trigger.
Source back-pressure
The maintainer is asynchronous, but on a busy silo its drain still competes with
the foreground writer for client-side concurrency (storage connections, threads,
the grain scheduler). To keep the view from slowing the source it derives from,
the maintainer obeys the source tree's WAL saturation signal. While the source is
Throttled it drains a reduced batch (ThrottledBatchRatio of BatchSize) and
defers its next background tick by ThrottledPauseMs; while the source is
Saturated it drips a small SaturatedBatchSize batch and defers by
SaturatedPauseMs. The view therefore lags more while the source is hot and
catches up once it recovers - foreground write throughput is preserved over view
freshness. Only background drains are deferred; an explicit read-your-writes
barrier (WaitForSourceHlcAsync / WaitForSourceHeadAsync) still makes progress, just
with a smaller batch. Each self-throttled pass is emitted on
orleans.lattice.view.source_backpressure (tagged with the observed source
regime). Set ObeySourceBackpressure to false to opt out and always drain at
full rate. The throttle engages only while the source is actually saturated, so
leaving it on costs nothing on a healthy source. The maintainer reads the signal
under the source's logical id, while the signal is sampled under the id of the
write-ahead log the source's writes land in, so while the source resolves to a
different physical copy - after a resize, a shadow-cutover restore or a schema
remediation (see Source-identity rebind) - the
maintainer does not see that copy's saturation and drains at full rate.
Separately from this client-side self-throttle, the maintainer's catch-up reads
no longer contend with foreground writes at the WAL grain itself. Each
(tree, partition) is served by a single grain activation, and the maintainer
tails it by paging through the write-ahead log. Those read pages interleave with
concurrent foreground appends to the same activation rather than serialising
ahead of them, so a maintainer that is paging over a large backlog cannot
head-of-line-block the source's foreground write path while it reads. Append
ordering is unaffected - appends remain serialised among themselves; only the
read no longer holds the activation turn for the duration of its storage
round-trip.
Relative cost by view shape
Runtime creation and provider-backed reconstruction do not add a separate steady-state data-path cost: after activation, runtime-created and startup-declared views use the same maintainer. Projection shape determines the cost. A two-run, single-silo reference-architecture cohort using the Azure Storage emulator observed the following relative costs after 256 seed writes and 256 rewrites (32 groups for aggregates):
| Shape | Source write rate vs no view | View bytes vs source bytes | Additional peak silo memory | Stored view rows |
|---|---|---|---|---|
| Pass-through | 0.23-0.43x | 1.00x | 6-12 MiB | 256 visible |
| Approximately 10% selective filter | 0.43-0.72x | 0.11x | 8-9 MiB | 20-23 visible |
| Count, 32 groups | 0.04-0.06x | 4.12x | 34-42 MiB | 32 visible + 288 internal |
| Sum, 32 groups | 0.05-0.07x | 4.12x | 33-41 MiB | 32 visible + 288 internal |
| Exact set-union, 32 groups | 0.11-0.16x | 3.59x | 11-16 MiB | 32 visible + 288 internal |
These are relative observations, not production capacity limits: local Docker, the emulator, entry size, group cardinality, storage latency, and concurrent work all affect the absolute rates. Measure the intended projection and data shape in the target environment.
Selective filters reduce stored bytes in proportion to the surviving rows, but
the maintainer still reads and evaluates every source mutation. Aggregates retain
one membership row per source key plus accumulator or inverse rows and visible
group rows. Count and sum also perform crash-idempotent atomic
membership/accumulator updates for each contribution; in this cohort that
serial read-before-write path cost more than exact set-union despite set-union's
larger exact per-group state. AggregationMaxGroupEntries can bound set-union,
min, and max state by accepting approximate results. Increasing
AggregationFanout spreads hot-group writes but also makes each materialisation
read more accumulator shards, so treat it as a contention control rather than a
free throughput improvement.
Initial backfill uses the same path. A large aggregate backfill can therefore hold a maintainer turn long enough for an immediate create/status request to hit a transport timeout even though the durable registration succeeded and the view continues converging. Treat creation as asynchronous: poll view status and apply lag, and do not infer rollback from a create-time timeout.
Configuration
LatticeViewOptions is resolved per view name via
IOptionsMonitor<LatticeViewOptions>.Get(viewName). A registered validator
rejects an out-of-range value when a view's options are first resolved; each
row notes its rule:
| Option | Default | Meaning |
|---|---|---|
BatchSize |
256 | Maximum WAL entries read from each source partition per drain pass. Must be positive. |
CoalesceWindow |
50 ms | Period of the background drain timer. Must be greater than zero and at most 0xFFFFFFFE milliseconds (about 49.7 days), the longest period a grain timer accepts. |
SourceIdentityBackstopInterval |
30 s | Safety-net interval after which the maintainer re-resolves its source tree's physical identity from the registry when no alias-change notification has arrived. In steady state the source binding is event-driven (rebound the moment an alias swap commits), so this backstop only covers a missed push. See Source-identity rebind. Must be greater than zero. |
AggregationFanout |
1 | Aggregation views only: shards each group's accumulator into this many sub-accumulators hashed on the source key, merged at read. 1 is a single accumulator. Must be at least 1. |
AggregationMaxGroupEntries |
0 | Aggregation views only: when greater than zero, bounds each Min / Max / SetUnion group shard (approximate mode). 0 keeps every group exact. Must not be negative. |
MaxStagedTransactions |
1024 | Maximum in-flight atomic-write transactions buffered before the backstop forces a rebuild. Must be at least 1. |
MaxStagedBytes |
64 MiB | Maximum buffered prepared-entry payload (key + value) before the backstop forces a rebuild. Must be at least 1. |
ReadHandleCacheTtl |
1 s | How long an ILatticeView handle caches the resolved live view tree id before re-resolving it. Bounds the post-swap read-staleness window. Must be greater than zero. |
OldGenerationReclaimGrace |
5 s | How long a swapped-out view tree is retained before reclamation. Must exceed ReadHandleCacheTtl so a reader holding a stale cached id still resolves a live tree. |
CrossTreeReadinessTimeout |
5 s | Cross-tree atomic visibility only: how long a completed cross-tree batch waits for every present participant view before degrading to per-tree atomicity. Must be greater than zero. |
ReplicationMode |
DeriveLocally |
How the view tree is made available across clusters. See Replication modes. |
ShipViewProducerClusterId |
null |
Required only when ShipView replicates both source and view trees. The stable, case-sensitive replication cluster id of the single producer. When set it must not be empty or whitespace, and ReplicationMode must be ShipView. |
MaxLagBudget |
0 | Upper bound, in committed-but-unapplied source entries, on how far the view may fall behind before it is force-evicted (WAL unpinned and rebuilt). 0 disables eviction. Must not be negative. |
LagEvictionCooldown |
30 s | Minimum interval between two lag-budget evictions of the same view. A non-positive value falls back to the default. Has no effect when MaxLagBudget is 0. |
ObeySourceBackpressure |
true |
Whether the maintainer throttles its own drain when the source tree's WAL is under saturation back-pressure (smaller batch + deferred ticks). Set to false to always drain at full rate. Only engages while the source is actually saturated, and does not see the saturation of a source that resolves to a different physical copy (see Source back-pressure). |
ThrottledBatchRatio |
0.5 | Fraction of BatchSize drained per pass while the source is Throttled. Must be within [0, 1]; the effective batch (rounded up) is clamped to [1, BatchSize]. |
ThrottledPauseMs |
50 | Milliseconds background drain ticks are skipped after a pass that saw a Throttled source. <= 0 disables the deferral. |
SaturatedBatchSize |
16 | Drip-feed batch drained per pass while the source is Saturated. Must be at least 1; the effective batch is capped at BatchSize. |
SaturatedPauseMs |
500 | Milliseconds background drain ticks are skipped after a pass that saw a Saturated source. <= 0 disables the deferral. |
HistoryHybridFullValueWindow |
5 min | Durable per-key history views under Hybrid retention only: the maximum apply-time age of a revision that keeps its full value bytes; an older revision is stored as metadata only. A non-positive value degrades Hybrid to metadata-only. The unnamed (default) instance's value also shapes Hybrid reads from the write-ahead-log history fallback of a tree that has no history view. |
Configure a single view with ConfigureLatticeView:
siloBuilder.ConfigureLatticeView("adults", options =>
{
options.BatchSize = 512;
options.CoalesceWindow = TimeSpan.FromMilliseconds(100);
});
Metrics
The maintainer publishes the following instruments on the orleans.lattice
meter, each tagged view (the view name) plus the derived tenant label:
| Instrument | Kind (unit) | Meaning |
|---|---|---|
orleans.lattice.view.apply_lag |
Histogram ({entry}) |
Apply lag (committed-but-unapplied source entries) sampled at the end of each drain pass. |
orleans.lattice.view.backlog_depth |
Histogram ({entry}) |
WAL entries read in the drain pass. |
orleans.lattice.view.applied |
Counter ({write}) |
View writes applied to the view tree. |
orleans.lattice.view.key_collisions |
Counter ({collision}) |
View keys that two or more distinct source keys re-mapped to in a drain batch (injectivity violation), counted once per view key per batch. |
orleans.lattice.view.aggregation_applied |
Counter ({contribution}) |
Aggregation contributions folded into the view. |
orleans.lattice.view.aggregation_rejected |
Counter ({contribution}) |
Aggregation contributions dropped for producing a reserved (empty or NUL-prefixed) group key. |
orleans.lattice.view.atomic_staging_backstop |
Counter ({rebuild}) |
Times the bounded-buffer / retention backstop abandoned atomic staging and forced a rebuild. |
orleans.lattice.view.cross_tree_joint_violation |
Counter ({degradation}) |
Cross-tree view batches that degraded to per-tree atomicity because a participant view did not become ready in time. |
orleans.lattice.view.lag_budget_eviction |
Counter ({eviction}) |
Views force-evicted (WAL unpinned and rebuilt) for exceeding their MaxLagBudget. |
orleans.lattice.view.source_backpressure |
Counter ({pass}) |
Drain passes of any trigger - read-your-writes barrier drains included, not only background ticks - that shrank their batch because the source tree was under WAL saturation back-pressure. Also tagged state with the observed source regime (throttled / saturated). |
Durable per-key history
A history view is an opt-in, append-only variant maintained on this same
subsystem: an accumulative view whose projection re-keys every source mutation
into a durable revision row at {sourceKey}/{encodedHlc}, so a key's full
timeline survives independently of source WAL garbage collection. The accumulative
flag suppresses the automatic version-change and range-delete rebuilds that would
otherwise collapse the timeline, and per-tree retention modes bound the storage
cost. See Durable per-key history views.
Limitations
- WAL provider required. Views tail the commit log, so a WAL-backed lattice
must be registered (
AddLatticesupplies an in-memory baseline; a durable WAL provider such as the Azure Table package can replace it). - Unconstrained range delete on a re-keyed view rebuilds. A
DeleteRangewithoutMatchedKeysagainst a re-keyed view escalates to a full rebuild. Predicate-filtered range deletes (which carryMatchedKeys) retract exactly and do not rebuild. - Single-projection filter and aggregation views. Each view is maintained by one filter / re-project or aggregation projection.
- Atomic apply does not carry TTL. A committed atomic batch's view entries are written without a TTL even when the source prepared entries had one.
- An atomic batch and a concurrent non-atomic write to the same source key resolve by source HLC. Within a drain pass a committed atomic batch is applied after the ordinary survivors, but a staged entry whose source key took a higher-HLC ordinary write while its batch was staged is skipped when the batch flushes, so the view keeps the source's last-writer-wins outcome.
- Approximate set-union cardinality is a bounded sample, not HyperLogLog.
AggregationMaxGroupEntriesboundsSetUnionwith a distinct sample; a true HyperLogLog estimator is a later phase. - A runtime view must be restart-faithful. Filter-only predicate state is persisted automatically. Other stateful or delegate-backed projections need a host-registered provider and a payload no larger than 64 KiB. Creation and activation fail closed unless the reconstructed shape and version match exactly. Startup-declared views are exempt - they are rebuilt from their declaration.
- Startup-declared views cannot be deleted at runtime.
DeleteAsyncrejects a view that was declared throughAddLatticeViews(...), because the declaration would re-create it on the next start. Remove the declaration instead.