Table of Contents

Durable per-key history 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 history-views.md, and llms.txt lists every page.

A history view is an opt-in, append-only materialised view that records every revision of every key in a source tree. Rather than a bespoke per-leaf revision store, it reuses the materialised view subsystem: the view tails the source tree's write-ahead log and re-keys each mutation into a durable revision row, so the full timeline survives independently of source WAL garbage collection.

History starts from what the source still holds when the view is created. If no source write-ahead-log entry has been garbage-collected yet, the maintainer's first drain replays the log from its beginning, so earlier revisions are recorded with their original clocks; once garbage collection has trimmed the start of the log, the maintainer instead seeds one revision per live key from current source state and tails forward from there. The same seed is taken when the source's logical id already resolves to a different physical tree (after a resize or a restore, for example): the view then starts from current state whether or not the log has been trimmed. Revisions trimmed from the log before the view existed are never reconstructed.

How it works

The history projection re-keys each source mutation to {sourceKey}/{encodedHlc}, where the HLC suffix is a fixed-width, chronologically sortable encoding of the mutation's hybrid logical clock. Because distinct mutations carry distinct HLCs, distinct revisions map to distinct view keys, so nothing folds and the complete per-key timeline is retained.

Each revision is stored as a HistoryRow carrying the kind of mutation (a set, a delete, a CRDT delta, or a range-tombstone marker), the originating cluster, and - for last-writer-wins values - a content hash and length plus, depending on the retention mode, the value bytes themselves. A CRDT mutation is recorded as its author delta (the compact, doubling-free history); only one that carries no delta - an anti-entropy or bootstrap resync that ships the full state - is recorded as a set of that state, which the retention mode shapes like any other set.

Enabling history on a tree

A history view is created the runtime way - through ILatticeViewFactory - rather than declared at startup, because only a runtime-created view can be torn down again (the enable/disable contract). LatticeHistoryView.Definition builds the accumulative definition; resolve the factory and the silo service provider from the cluster. Create it with CreateAsync, which returns only once the view's runtime registration is durable; the synchronous Create overload persists the registration in the background and cannot report a failure to the caller.

using Microsoft.Extensions.DependencyInjection;

var factory = client.ServiceProvider.GetRequiredService<ILatticeViewFactory>();
var source = grainFactory.GetGrain<ILattice>("orders");

// Enable history: a runtime view named "orders-history" tailing "orders".
var history = await factory.CreateAsync(
    source,
    "orders-history",
    LatticeHistoryView.Definition("orders-history", client.ServiceProvider),
    cancellationToken);

// Disable history later: deleting the runtime view stops recording and
// releases the source WAL pin.
await factory.DeleteAsync("orders-history", cancellationToken);

Retention modes

Storage cost is bounded and configurable per source tree. The retention policy is read by the maintainer at drain time and applied around the (pure) projection, so a change takes effect for the revisions the maintainer applies after it - including any backlog written before the change but not yet drained - and never rewrites or rebuilds existing rows.

Mode LWW value bytes Use when
MetadataOnly (default) Stripped to a content hash and length. The timeline and change detection matter, not past values: history reads never return the stripped bytes (the write-ahead-log fallback read applies the same rule).
FullValue Stored verbatim per revision. Point-in-time values must be served directly from the history view.
Hybrid Stored verbatim when the maintainer applies the revision within a short window of its write; stripped to metadata when it applies it later (a backlog or a catch-up replay). Point-in-time values are wanted for promptly applied revisions without paying for them on a backlog. A stored row keeps its shape, so this does not confine full values to a recent tail.

CRDT revisions are always stored as their delta regardless of mode - the delta is the compact history - except a full-state resync, which is stored as a set (see How it works).

Under Hybrid the "short window" is set by LatticeViewOptions.HistoryHybridFullValueWindow (default 5 minutes): a revision keeps its full value bytes when its age at the moment the maintainer applies it is within this window, and one applied later is shaped to metadata. The decision is made once, when the row is written - a stored row is never re-shaped as it ages - so under promptly drained traffic almost every revision keeps its bytes, and only an age bound limits them. The write-ahead-log fallback read applies the same rule at read time instead, so there a revision older than the window reads as metadata.

An optional age bound (a positive retention window) stamps each revision row with an absolute expiry of now + window; the normal entry-expiry path reaps old rows, so no separate reaper is needed. Without an age bound revisions do not expire: pass null as the window to clear it (SetHistoryRetentionAsync rejects a zero or negative window), and GetHistoryRetentionAsync reports that state as a window of TimeSpan.Zero.

Both the mode and the window are live-tunable per tree:

// Keep the last 30 days of full-value revisions for this tree.
await tree.SetHistoryRetentionAsync(
    HistoryRetentionMode.FullValue,
    TimeSpan.FromDays(30),
    cancellationToken);

HistoryRetentionSettings policy = await tree.GetHistoryRetentionAsync(cancellationToken);
// policy.Mode == HistoryRetentionMode.FullValue, policy.Window == 30 days.

A tree with no override resolves to MetadataOnly with no age bound.

Reading a key's history

ILattice.ScanEntryHistoryAsync returns one key's revision timeline as a page of EntryRevision records. When a history view is enabled for the tree it is the primary read: a prefix scan over the view tree's {sourceKey}/{encodedHlc} rows, ordered by encoded clock and paged with a continuation token, reusing the same range-scan machinery as an ordinary entry scan. The read is side-effect-free and never perturbs the maintainer or its source WAL pin.

// Read the first page of a key's revision timeline (oldest first).
EntryHistoryPage page = await tree.ScanEntryHistoryAsync(
    "order-42",
    fromHlc: null,
    toHlc: null,
    limit: 100,
    continuation: null,
    cancellationToken);

foreach (EntryRevision revision in page.Revisions)
{
    // revision.Hlc           - the hybrid-logical-clock stamp of the revision
    // revision.Kind          - Set / Delete / CrdtDelta / RangeTombstone
    // revision.OriginClusterId - authoring cluster, or null for a local write
    // revision.ValueHash     - content fingerprint (all retention modes)
    // revision.ValuePreview  - size-bounded value bytes (FullValue / Hybrid)
    // revision.Delta         - size-bounded CRDT author delta (CrdtDelta rows)
}

// Page through the rest of the timeline with the continuation token.
if (page.Continuation is not null)
{
    EntryHistoryPage next = await tree.ScanEntryHistoryAsync(
        "order-42", null, null, 100, page.Continuation, cancellationToken);
}

The optional fromHlc / toHlc arguments clamp the scan to an inclusive hybrid-logical-clock window. The returned EntryHistoryPage describes where the data came from and whether it is complete:

Field Meaning
Source View when read from the durable history view, WalWindow for the best-effort write-ahead-log fallback, or None when neither is available - and also when the access gate denies a point read of the key, which returns an empty page rather than throwing.
Truncated Always false on the View path - the timeline is never cut off below by WAL garbage collection; it is bounded by the configured retention age and by any rebuild that collapsed it (see the accumulative guard). true on the WalWindow fallback when garbage collection has trimmed older entries.
EarliestAvailable On a truncated WalWindow read, the oldest hybrid-logical-clock still readable; HybridLogicalClock.Zero otherwise.

Fallback without a history view

For a tree that has not opted into a history view, the same method falls back, best-effort, to the retained source write-ahead-log window for the key: it enumerates surviving mutations above the current per-partition garbage-collection trim point in offset order and reports Source == EntryHistorySource.WalWindow. This window is bounded by WAL garbage collection, so it sets Truncated and EarliestAvailable honestly when older revisions have already been trimmed - a partial window is never presented as a full history. Enable a history view when a durable, retention-bounded timeline is required.

The fallback also reads the retained log raw, which the history view does not. The view holds an atomic batch's staged writes back until the batch commits, discards them if it aborts, and skips the records compaction writes. The fallback lists a staged write as a revision whether its batch later commits, aborts or is still in flight, so an aborted batch's writes appear as revisions that never took effect.

The log can also hold one revision several times. A resize or snapshot copy, a reshard migration, a leaf split's redistribution and a replication apply each append the entry they copy again, under the clock its author stamped, and the reap mark compaction writes when it removes a deleted or expired entry past its grace period carries that entry's own clock too. The fallback identifies a revision by its clock, as the history view keys its rows, and reports each one once, across pages too, so none of these internal records shows as a change to the key. A write at a clock of its own is always reported, whatever its record is marked.

The accumulative guard

An ordinary materialised view is rebuilt from current source state when its projection version changes or when an unconstrained range delete is observed on a re-keyed projection (which the history projection is) - both of which would collapse a history timeline. A history view's registration carries an accumulative flag that changes exactly those two behaviours:

  • Projection-version change: the view adopts the new version forward and keeps its existing rows, resuming the drain from the durable checkpoint. The worst case is a row-shape discontinuity at the version boundary, never data loss.
  • Unconstrained range delete: the maintainer records a range-tombstone marker revision rather than rebuilding, because in an append-only log a range delete does not erase the fact that prior values existed. A predicate-filtered range delete already carries its matched keys and yields exact per-key delete revisions.

An explicit, operator-triggered rebuild remains the only intentional clear: it knowingly re-derives the view from current source state (collapsing prior revisions) and is the escape hatch for genuine view-tree corruption. The flag does not suppress the maintainer's other rebuild triggers, and each of them collapses the timeline the same way: falling off the source write-ahead log (the view lagged past garbage collection), the atomic-staging backstop, a source-identity rebind after an alias change (a resize or its undo, a shadow-cutover restore or its revert, a schema remediation, or an administrative alias change) repoints the source, lag-budget eviction, and ReconcileAsync, whose re-derivation from current source state differs from any timeline that still holds earlier revisions. Retention mode and window are deliberately kept out of the projection version: they encode live-tunable policy, not code identity, so changing them never trips a rebuild.

Limitations

  • No reconstruction of trimmed history. The timeline begins with whatever the source still holds at creation - its untrimmed write-ahead log, or a one-revision-per-key seed from current state once garbage collection has trimmed that log or when the source is already aliased to another physical tree; revisions trimmed before the view existed cannot be recovered.
  • Count-based retention ("keep last N per key") is not expressible in a pure per-mutation projection and is out of scope for this substrate.
  • The read path is built in. ILattice.ScanEntryHistoryAsync queries a key's timeline directly off this substrate (see "Reading a key's history" above); decoding element-level CRDT provenance is layered on top of the stored deltas.
  • Do not enable lag-budget eviction on a history view. Lag-budget eviction rebuilds from current source state, which collapses the timeline; leave MaxLagBudget at its default of zero for accumulative views.