Table of Contents

ILattice: Cancellation to Stateful cursors

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 ilattice-1.md, and llms.txt lists every page.

Part of ILattice, in Lattice Public API Reference.

Cancellation

Every method on ILattice - including scans (via the ScanKeysAsync / ScanEntriesAsync extension wrappers), range deletes, counts, fan-out batch operations, bulk load, stateful cursors, and all tree-lifecycle orchestrators - accepts an optional trailing CancellationToken cancellationToken = default parameter. The signatures in the tables below omit the parameter for readability.

  • A pre-cancelled token fails fast before any shard is contacted.
  • Scan iterators apply [EnumeratorCancellation], so await foreach (...).WithCancellation(ct) propagates correctly.
  • Once a long-running coordinator (saga, resize, snapshot, reshard, merge) has accepted a request it drives itself to a terminal state and is not cooperatively cancelled.

The typed extensions in TypedLatticeExtensions and the streaming BulkLoadAsync extension in LatticeExtensions also thread the token.

Runtime operations

These methods are used during normal application flow to read, write, and enumerate data. They are safe to call concurrently and do not affect tree availability.

Single-key

Method Signature Description
GetAsync Task<byte[]?> GetAsync(string key) Returns the value for key, or null when absent or tombstoned. Staleness is bounded by LatticeOptions.CacheTtl (default zero - refresh on every read).
GetWithVersionAsync Task<VersionedValue> GetWithVersionAsync(string key) Returns the value paired with its HybridLogicalClock version; returns a default VersionedValue with null value and zero version when absent or tombstoned. Use the returned version with SetIfVersionAsync for optimistic concurrency. Bypasses the read cache.
ExistsAsync Task<bool> ExistsAsync(string key) Returns true when key is live (not absent and not tombstoned).
SetAsync Task SetAsync(string key, byte[] value) Inserts or updates the value for key. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode; author values through the matching accessor instead (see Replication modes - Single shape per tree).
SetAsync (TTL) Task SetAsync(string key, byte[] value, TimeSpan ttl) Inserts or updates key with a time-to-live. The entry is treated as tombstoned on every read once ttl has elapsed from the server-side write instant; physical reclamation follows LatticeOptions.TombstoneGracePeriod. Throws ArgumentOutOfRangeException when ttl is zero or negative, or so large that the absolute expiry would overflow. A typed SetAsync<T>(this ILattice, string, T, TimeSpan, ILatticeSerializer<T>) overload exists in TypedLatticeExtensions. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
SetIfVersionAsync Task<bool> SetIfVersionAsync(string key, byte[] value, HybridLogicalClock expectedVersion) Atomic compare-and-set: writes only when the entry's current version equals expectedVersion. Returns true on success, false on version mismatch. Pass HybridLogicalClock.Zero for a key that must not exist. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
GetOrSetAsync Task<byte[]?> GetOrSetAsync(string key, byte[] value) Inserts value only when key is absent or tombstoned. Returns the existing value when live, or null when the new value was written. No read-then-write race. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
DeleteAsync Task<bool> DeleteAsync(string key) Tombstones key. Returns true if the key was live. See Tombstone Compaction for retention. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
ApplyCrdtDeltaAsync Task<HybridLogicalClock> ApplyCrdtDeltaAsync(string key, LatticeMergeMode mode, byte[] deltaBytes) Applies a producer-side typed CRDT delta to key under the declared mode. The owning leaf resolves the registered CrdtShape, folds the delta into the current state via the shape's MergeDelta, and appends a single WAL record carrying only the delta bytes. Returns the HybridLogicalClock stamped on the committed entry. CRDT merges are convergent, so this surface deliberately omits the optimistic-CAS guard SetIfVersionAsync carries. LatticeMergeMode.OrMap requires a per-tree shape registered via ISiloBuilder.AddOrMapShape<TKey, TValue>(treeName); the closed-shape modes (OrSet, PnCounter, VersionVector, MvRegister, Sequence, OrFlag, RwFlag, GCounter, GSet, RwSet, MaxRegister, and MinRegister) resolve through the registry's global fallback without per-tree registration. An OR-Map verb against a tree with no registered shape throws LatticeCrdtShapeNotRegisteredException (a subclass of InvalidOperationException), a deterministic host-configuration precondition that the API bindings map to a client-error status rather than a server fault. LatticeMergeMode.LwwRegister is rejected with ArgumentException - use SetAsync for LWW. The typed accessors listed under CRDT value-surface accessors wrap this surface and are the recommended caller-facing seam. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication under a mode other than mode (see Replication modes - Single shape per tree).
ApplyCrdtDeltaAsync (TTL) Task<HybridLogicalClock> ApplyCrdtDeltaAsync(string key, LatticeMergeMode mode, byte[] deltaBytes, TimeSpan ttl) Time-to-live overload of ApplyCrdtDeltaAsync: the entry expires ttl after the server-side write instant, resolved to an absolute UTC instant on the accepting silo. Unlike the last-writer-wins SetAsync TTL path, CRDT expiry converges by an order-independent max-absolute-ticks join, so a later or concurrent TTL'd write extends the entry's life and every replica agrees on the same expiry regardless of merge order; a durable (no-TTL) write leaves any existing expiry unchanged. Throws ArgumentOutOfRangeException when ttl is zero, negative, or overflows. Every typed accessor's primary write carries a matching TimeSpan ttl overload. See TTL - Per-entry TTL on CRDT writes.

Single-key operations transparently retry when a concurrent topology change invalidates their routing - an adaptive split or reshard that moved the key's virtual slot to another shard, or an online resize (or other shadow-cutover swap) that redirected the tree to a new physical tree. The retry refreshes routing and tries again for up to a 60-second wall-clock budget; only a topology that is still changing when that budget runs out surfaces the last internal stale-routing fault to the caller, as an exception of a non-public type.

Batch

Method Signature Description
GetManyAsync Task<Dictionary<string, byte[]>> GetManyAsync(List<string> keys) Fetches multiple keys in parallel. Missing or tombstoned keys are omitted from the result. A concurrent SetManyAtomicAsync is observed atomically tree-wide: the fan-out is re-run when a saga commits or the shard map moves while it is in flight, bounded by LatticeOptions.MaxScanRetries (default 3), and throws InvalidOperationException when every attempt is invalidated.
GetManyWithGateAccountingAsync Task<GatedMultiReadResult> GetManyWithGateAccountingAsync(List<string> keys) Returns exactly what GetManyAsync returns (GatedMultiReadResult.Values) plus PrunedByAccessGate, the number of requested keys the read-path access gate removed before fan-out. Use it instead of GetManyAsync when you draw a conclusion from a key's absence: absence is sound only when the count is 0, which GatedMultiReadResult.IsComplete reports directly. The count never names the pruned keys. See Reading an empty range read under a gate.
SetManyAsync Task SetManyAsync(List<KeyValuePair<string, byte[]>> entries) Writes multiple entries in parallel. Not atomic - partial failure leaves the batch half-applied with no rollback. Use SetManyAtomicAsync when all-or-nothing semantics are required. Fails fast: the first branch to fault surfaces at once rather than after the slowest branch settles, and sibling branches are deliberately not cancelled, so they may still be in flight when the exception is observed - re-read the affected keys rather than assuming the batch has quiesced. When no branch faults every branch is still awaited, because the per-entry Set events publish only once all shard writes have committed. When LatticeOptions.SetManyFanOutBudget (the multi-shard fan-out) or SetManyEnvelopeBudget (the whole call) is set - both are unbounded by default - and runs out, the call is refused with LatticeSaturatedException (SaturationSource = SetManyFanOut or SetManyEnvelope) and nothing is rolled back (see Saturation back-pressure). Per-leaf batches collapse their per-key WAL grain hops into a single batched dispatch (see WAL - Batched leaf write path). Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
ApplyCrdtDeltaManyAsync Task ApplyCrdtDeltaManyAsync(List<KeyValuePair<string, byte[]>> deltas, LatticeMergeMode mode) Applies multiple typed CRDT deltas, fanning out to shards in parallel and collapsing each leaf's slice into a single batched WAL dispatch, so an N-key batch costs one commit-log round trip per leaf instead of N. The batched counterpart of ApplyCrdtDeltaAsync and the CRDT counterpart of SetManyAsync. The merge mode is declared once for the whole batch rather than per entry, because a tree resolves exactly one CRDT shape - mixing shapes within a tree converges locally but diverges at replication. Not atomic - a partial failure leaves the batch half-applied; because every entry folds a delta rather than overwriting a value, a caller retry converges instead of clobbering a concurrent writer. Use the staged cross-tree atomic path (LatticeAtomicWriteBuilder.SetMany) when all-or-nothing semantics are required. LatticeMergeMode.LwwRegister is rejected with ArgumentException - use SetManyAsync for LWW. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication under a mode other than mode (see Replication modes - Single shape per tree).
SetManyAtomicAsync Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> entries) Atomically writes multiple entries: on success every key holds its new value, on any failure every key holds its pre-saga value. Concurrent readers observe the saga atomically tree-wide and across every cluster the tree replicates to. Throws ArgumentException on duplicate keys or null values; before the saga starts, an entry that violates LatticeOptions.MaxKeyLength or MaxValueSizeBytes throws ArgumentException and an enforcing MaxLiveKeys or MaxEstimatedBytes cap throws LatticeQuotaExceededException; throws InvalidOperationException when compensation completes for a failed write, and LatticeStateWriteFailedException when a saga state write keeps losing an optimistic-concurrency check through the call's bounded re-attach attempts (see Exception reference). After completion, saga state is retained for LatticeOptions.AtomicWriteRetention (default 48 h). See Atomic Writes. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
SetManyAtomicAsync (idempotency key) Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> entries, string operationId) Caller-supplied idempotency-key overload. Re-submitting the same operationId re-attaches to the original saga and inherits its outcome, turning a transport-level failure - or a LatticeStateWriteFailedException whose Conflict is true - into a safe client retry. The write-size and quota checks run before the call re-attaches, so a retry can be refused by a bound in force at retry time before it observes the original outcome. The operationId is bound to the exact sorted key set of the first call; mismatched key sets throw LatticeIdempotencyKeyMismatchException (a subclass of InvalidOperationException). Reordering keys or changing values is allowed. operationId must not be null, empty, or whitespace and must not contain '/'; otherwise throws ArgumentException. See Atomic Writes - Caller-supplied idempotency keys. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
SetManyAtomicAsync (mixed set + delete) Task SetManyAtomicAsync(List<KeyValuePair<string, byte[]>> upserts, IReadOnlyList<string> deletes, string operationId) Mixed atomic batch: applies the upserts and the deletes all-or-nothing in one visibility flip, so no reader observes a partial set/delete. Each delete is staged as a tombstone that becomes visible on commit and is dropped on abort, riding the same saga terminal as the upserts. The defining use is a re-key retraction (move a row from view key A to view key B by upserting B and deleting A atomically). The fingerprinted key set is the union of upsert and delete keys; a key may not appear in both, and either collection may be empty. Same idempotency and retention semantics as the keyed overload. See Atomic Writes. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
DeleteRangeAsync Task<int> DeleteRangeAsync(string startInclusive, string endExclusive) Tombstones every live key in [startInclusive, endExclusive). Returns the total count tombstoned. For resumable or crash-safe range deletes, use OpenDeleteRangeCursorAsync. Throws LatticeReplicationModeMismatchException when the tree is declared for cross-cluster replication as a typed CRDT mode (see Replication modes - Single shape per tree).
CountAsync Task<int> CountAsync() Returns the exact live key count across all shards under the topology snapshot observed during the call. A concurrent SetManyAtomicAsync is observed atomically (included or excluded as a unit). Bounded by LatticeOptions.MaxScanRetries (default 3); throws InvalidOperationException on retry exhaustion.
CountAsync (ranged) Task<int> CountAsync(string? startInclusive, string? endExclusive) Advanced ranged variant (hidden from IntelliSense). Counts only live keys in the half-open range [startInclusive, endExclusive); a null bound is unbounded on that side, so (null, null) matches the unbounded CountAsync(). Reuses the whole-tree count machinery - fully-covered leaves contribute their full count and only boundary leaf(s) are partial-counted, so no keys are materialised across the wire - and carries the identical strong-consistency and concurrent-split guarantees. Used internally by aggregation ILatticeView.CountAsync to count materialised group values above the reserved-row floor.
CountPerShardAsync Task<IReadOnlyList<int>> CountPerShardAsync() Returns the per-shard live-key count, one entry per physical shard in ascending shard-index order. A position is a shard index only while the indices run contiguously from 0, which a shard consolidation that folds a shard away breaks, so read the indices from GetRoutingAsync to address a shard. Same consistency guarantees as CountAsync. Useful for diagnostics and load-balancing analysis.

Enumeration

Long-running scans use the resilient extension wrappers on ILattice. Each iterator survives mid-scan reconnects (silo failover, idle expiry, cold start) and resumes deterministically - no duplicates, no gaps, original ordering preserved.

Method Signature Description
ScanKeysAsync IAsyncEnumerable<string> ScanKeysAsync(this ILattice, string? startInclusive, string? endExclusive, bool reverse, bool? prefetch, int? maxAttempts) Streams live keys in strict lexicographic order. prefetch=true (or null with LatticeOptions.PrefetchKeysScan = true) overlaps the next page fetch with the current page consumption. maxAttempts overrides the wrapper's reconnect budget (default LatticeExtensions.DefaultScanReconnectAttempts = 8) and, capped at LatticeExtensions.DefaultScanStallResumeAttempts = 2, its budget for resuming a ScanPageStalledException; maxAttempts: 0 disables both. The stall budget counts consecutive stalls and is replenished whenever the walk yields a record, and a single enumeration takes at most LatticeExtensions.DefaultScanStallResumeCeiling = 64 stall resumptions in total. A stalled scan resumes from its last yielded key after a backoff derived from the stall's reported ceiling, and rethrows the stall once either bound is spent, so it either yields the full range or rethrows the stall - it never returns a short prefix as though the range were complete. A concurrent SetManyAtomicAsync is observed atomically across every page of each uninterrupted underlying enumeration; a transparent reconnect or stall resume reopens the scan under a freshly captured saga-decision view, so a saga that commits in between can be visible in only the part of the range yielded after the resume. Use a point-in-time cursor when one view must cover the whole range.
ScanEntriesAsync IAsyncEnumerable<KeyValuePair<string, byte[]>> ScanEntriesAsync(this ILattice, string? startInclusive, string? endExclusive, bool reverse, bool? prefetch, int? maxAttempts) Streams live key-value entries in strict lexicographic key order. prefetch is gated by LatticeOptions.PrefetchEntriesScan (separate flag from keys because entry pages also carry byte[] values). Same atomic-visibility, reconnect, and stall-resume guarantees as ScanKeysAsync.
DeleteRangeAsync Task<int> DeleteRangeAsync(this ILattice, string startInclusive, string endExclusive, int stepSize, int? maxAttempts = null, CancellationToken cancellationToken = default) Resiliently drains a delete-range cursor over the half-open range [startInclusive, endExclusive) to completion, returning the total number of keys tombstoned. Deletes in batches of stepSize (required, and it must be positive - a call without it binds to the single-call ILattice.DeleteRangeAsync instead) and, if the durable enumerator is lost mid-drain (EnumerationAbortedException), transparently reopens a fresh cursor over the same range and continues; already-tombstoned keys are skipped on reopen so the count never double-counts. maxAttempts overrides the reconnect budget (default LatticeExtensions.DefaultScanReconnectAttempts = 8). A ScanPageStalledException is not retried: it propagates to the caller. Both bounds are required. Authorization is all-or-nothing across the span (LatticeOperation.RangeDelete): a caller who may not delete the whole range is denied and nothing is removed. Prefer this one-shot helper over hand-rolling an OpenDeleteRangeCursorAsync / DeleteRangeStepAsync loop when you simply need a range gone.

Prefer the resilient DeleteRangeAsync drain helper over an OpenDeleteRangeCursorAsync / DeleteRangeStepAsync loop when you simply need a whole range removed; it reopens the cursor across a transient enumerator loss so a large purge completes rather than aborting part-way.

int removed = await tree.DeleteRangeAsync("repo/r1/", "repo/r1/\uffff", stepSize: 256);
Console.WriteLine($"tombstoned {removed} keys");

Scan reliability

CountAsync, CountPerShardAsync, GetManyAsync, ScanKeysAsync, and ScanEntriesAsync use a bounded retry budget (LatticeOptions.MaxScanRetries, default 3) to reconcile against concurrent topology changes. If the topology continues to mutate beyond the budget, the call throws InvalidOperationException rather than returning a silently incomplete result. Under default settings (MaxConcurrentAutoSplits = 2, HotShardSplitCooldown = 2 min) exhaustion is not a realistic operational concern.

Three options for multi-minute exports in aggressively split-prone workloads:

  1. Raise LatticeOptions.MaxScanRetries.
  2. Wrap the scan in an application-level retry that resumes from the last successfully yielded key using startInclusive.
  3. Use a stateful cursor (below). A cursor checkpoints server-side after every page and survives silo failover, client restart, and topology changes without caller retry code.

Stateful cursors

ILattice exposes a stateful cursor API for long-running scans and resumable range deletes that survive silo failovers, client restarts, and topology changes. Each cursor is a server-side, checkpointed iterator identified by an opaque GUID returned at open time. See Durable Cursors for the full design and cost model.

Method reference

Method Signature Description
OpenKeyCursorAsync Task<string> OpenKeyCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false) Opens a key-enumeration cursor and returns a server-assigned opaque cursor ID. null bounds are unbounded; reverse=true walks descending. pointInTime=true freezes the saga-decision view at open time so every page sees the same in-flight-saga view (see Point-in-time cursors).
OpenEntryCursorAsync Task<string> OpenEntryCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false) Opens an entry-enumeration cursor (key + value pairs). Same bounds, direction, and point-in-time semantics as OpenKeyCursorAsync.
OpenSnapshotKeyCursorAsync Task<string> OpenSnapshotKeyCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false) Opens a zero-observable-writes snapshot key-enumeration cursor. At open time it captures a frozen baseline of every shard's projection, plus a tree-wide LatticeSnapshotCoordinate (tree map version, per-shard WAL heads, registry HLC, and the per-open baseline token), and serves every page from those frozen baselines rather than replaying the WAL, so a later WAL trim cannot perturb the view. Subsequent foreground writes, saga commits, range deletes, and replication applies are invisible to this cursor. Open-time cost is gated by LatticeOptions.MaxSnapshotReplayEntries, compared against the deepest shard's baseline row count; failure throws LatticeSnapshotReplayBudgetExceededException. A tree whose saturation signal reads Saturated sheds the open with LatticeSaturatedException (see Saturation back-pressure); the shed never fires on an aliased tree, because it looks the signal up under the logical tree id (see Resolution and scope). The cursor registers with IWalCursorRegistry for its lifetime, but that registration does not hold back WAL trimming (see WAL retention). See Snapshot cursors.
OpenSnapshotEntryCursorAsync Task<string> OpenSnapshotEntryCursorAsync(string? startInclusive = null, string? endExclusive = null, bool reverse = false) Opens a zero-observable-writes snapshot entry cursor. Same isolation guarantees and open-time cost gate as OpenSnapshotKeyCursorAsync.
OpenDeleteRangeCursorAsync Task<string> OpenDeleteRangeCursorAsync(string startInclusive, string endExclusive) Opens a resumable range-delete cursor. Both bounds are required (non-null). Reverse mode is not supported. Throws ArgumentException on null bounds or a reverse spec.
NextKeysAsync Task<LatticeCursorKeysPage> NextKeysAsync(string cursorId, int pageSize) Returns up to pageSize keys and advances the cursor. HasMore=false signals exhaustion. Throws ArgumentOutOfRangeException for non-positive pageSize; throws InvalidOperationException if the cursor was opened for a different kind or has been closed.
NextEntriesAsync Task<LatticeCursorEntriesPage> NextEntriesAsync(string cursorId, int pageSize) Returns up to pageSize entries and advances the cursor. Same error surface as NextKeysAsync with expected kind Entries.
DeleteRangeStepAsync Task<LatticeCursorDeleteProgress> DeleteRangeStepAsync(string cursorId, int maxToDelete) Deletes up to maxToDelete keys in a single step. IsComplete=true when the full range has been drained; subsequent calls return DeletedThisStep=0. Throws InvalidOperationException if the cursor is not of kind DeleteRange.
CloseCursorAsync Task CloseCursorAsync(string cursorId) Closes the cursor, clears its state, and deactivates the grain. Idempotent.

Return types

Type Members Description
LatticeCursorKeysPage IReadOnlyList<string> Keys, bool HasMore A page returned by NextKeysAsync. Keys are in the cursor's scan order.
LatticeCursorEntriesPage IReadOnlyList<KeyValuePair<string, byte[]>> Entries, bool HasMore A page returned by NextEntriesAsync.
LatticeCursorDeleteProgress int DeletedThisStep, int DeletedTotal, bool IsComplete Returned by DeleteRangeStepAsync. DeletedTotal accumulates across every step.
LatticeCursorKind Keys, Entries, DeleteRange The kind of scan a cursor performs.
LatticeCursorSpec Kind, StartInclusive, EndExclusive, Reverse, PointInTime, ZeroObservableWrites, Predicate Immutable cursor specification, frozen at open time. ZeroObservableWrites=true selects the frozen-baseline snapshot path; PointInTime=true selects the registry-snapshot path; both false is the live path. Predicate is the optional server-side predicate IR a predicate cursor applies to every page it yields (or, for a DeleteRange cursor, to every key it tombstones); null means unfiltered.
LatticeSnapshotCoordinate long TreeMapVersion, IReadOnlyDictionary<int, long> PerShardWalOffsets, HybridLogicalClock RegistrySnapshotHlc, IReadOnlyDictionary<int, IReadOnlyList<long>>? PerShardPerPartitionWalOffsets, ShardMap? PinnedShardMap, Guid SnapshotBaselineToken, string? PhysicalTreeId Tree-wide snapshot coordinate captured at OpenSnapshot*CursorAsync time. Records the routing map (PinnedShardMap, at TreeMapVersion), every shard's WAL head (as a per-shard scalar and per WAL partition - diagnostic bounds that hold back no WAL trimming), the registry HLC (a diagnostic anchor the current build always records as HybridLogicalClock.Zero), the per-open token that identifies the frozen baselines the cursor reads, and the physical tree id they were captured against, so paging is deterministic across silo failovers. Two public constructors take the tree map version, either the per-shard scalar or the per-partition WAL heads, and the registry HLC.
LatticeScopedCursor LatticeScopedCursor(ILattice lattice, string cursorId), string Id, ValueTask DisposeAsync(), implicit conversion to string IAsyncDisposable wrapper returned by the LatticeExtensions.Open*CursorScopeAsync family. Disposing the scope calls CloseCursorAsync exactly once; idempotent. The implicit string conversion lets a scope be passed directly to NextKeysAsync / NextEntriesAsync / DeleteRangeStepAsync. Does not change the durability contract of the underlying cursor; for cursors whose ID must survive a process boundary, keep using the raw Open*CursorAsync / CloseCursorAsync shape.

Scoped cursor extensions

LatticeExtensions exposes an IAsyncDisposable-returning overload for every Open*CursorAsync method. They are pure conveniences - the underlying cursor grain semantics are unchanged - but they let callers whose cursor lifetime fits in one stack frame avoid the try / finally boilerplate.

Method Signature Description
OpenKeyCursorScopeAsync Task<LatticeScopedCursor> OpenKeyCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false, CancellationToken cancellationToken = default) Scoped variant of OpenKeyCursorAsync.
OpenEntryCursorScopeAsync Task<LatticeScopedCursor> OpenEntryCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, bool pointInTime = false, CancellationToken cancellationToken = default) Scoped variant of OpenEntryCursorAsync.
OpenSnapshotKeyCursorScopeAsync Task<LatticeScopedCursor> OpenSnapshotKeyCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, CancellationToken cancellationToken = default) Scoped variant of OpenSnapshotKeyCursorAsync.
OpenSnapshotEntryCursorScopeAsync Task<LatticeScopedCursor> OpenSnapshotEntryCursorScopeAsync(this ILattice, string? startInclusive = null, string? endExclusive = null, bool reverse = false, CancellationToken cancellationToken = default) Scoped variant of OpenSnapshotEntryCursorAsync.
OpenDeleteRangeCursorScopeAsync Task<LatticeScopedCursor> OpenDeleteRangeCursorScopeAsync(this ILattice, string startInclusive, string endExclusive, CancellationToken cancellationToken = default) Scoped variant of OpenDeleteRangeCursorAsync.
await using var scope = await tree.OpenEntryCursorScopeAsync();
while (true)
{
    var page = await tree.NextEntriesAsync(scope, pageSize: 500);
    foreach (var (k, v) in page.Entries)
        Console.WriteLine($"{k}={v.Length} bytes");
    if (!page.HasMore) break;
}
// scope.DisposeAsync() runs here.

Ordering and visibility

  • Each step yields keys in strict lexicographic order. Once a key has been yielded by a cursor it is never re-yielded by the same cursor.
  • In live mode (pointInTime: false, the default), values reflect the latest committed state at the moment each key is yielded. A saga that commits between two pages may have its keys split across the pre-commit and post-commit pages.
  • In point-in-time mode (pointInTime: true), every page reads against the saga-decision view captured when the cursor was opened. A saga that commits between two pages is observed identically on every page - either every key the saga touched is visible, or none.

Idle TTL and self-cleanup

Cursors auto-clean if CloseCursorAsync is never called. Each successful call slides an idle-TTL reminder (LatticeOptions.CursorIdleTtl, default 48 h). When the reminder fires without intervening activity the cursor grain clears its state and deactivates.

siloBuilder.ConfigureLattice(o => o.CursorIdleTtl = TimeSpan.FromHours(6));

Minimum effective interval is 1 minute (Orleans reminder granularity); smaller values are clamped. Set CursorIdleTtl = Timeout.InfiniteTimeSpan to disable auto-cleanup.

Example - resumable export across a silo failover

var cursorId = await tree.OpenEntryCursorAsync();
while (true)
{
    var page = await tree.NextEntriesAsync(cursorId, pageSize: 500);
    foreach (var (k, v) in page.Entries)
        Console.WriteLine($"{k}={v.Length} bytes");
    if (!page.HasMore) break;
}
await tree.CloseCursorAsync(cursorId);

If the client crashes mid-export it can persist the cursorId and resume on restart - the cursor grain reactivates on demand.

Example - bounded, resumable range delete

var cursorId = await tree.OpenDeleteRangeCursorAsync("2024/", "2025/");
int total = 0;
while (true)
{
    var progress = await tree.DeleteRangeStepAsync(cursorId, maxToDelete: 1000);
    total = progress.DeletedTotal;
    if (progress.IsComplete) break;
}
await tree.CloseCursorAsync(cursorId);
Console.WriteLine($"Deleted {total} keys.");

Error surface

Call Condition Exception
OpenDeleteRangeCursorAsync startInclusive or endExclusive is null ArgumentException
OpenDeleteRangeCursorAsync reverse=true passed through the internal spec ArgumentException
Open* (point-in-time) pointInTime=true passed for a DeleteRange cursor ArgumentException
Open* (point-in-time) A saga decision registry shard the snapshot touches would exceed LatticeOptions.MaxPinnedSagaDecisions LatticeCursorRegistryPinExhaustedException
OpenSnapshot*CursorAsync The deepest shard's frozen baseline would exceed LatticeOptions.MaxSnapshotReplayEntries rows LatticeSnapshotReplayBudgetExceededException
OpenSnapshot*CursorAsync The tree's saturation signal reads Saturated and LatticeOptions.ShedSnapshotOpensWhenSaturated is on (the default); never on an aliased tree (see Resolution and scope) LatticeSaturatedException (SaturationSource = SnapshotCursorOpen)
Next* (snapshot) A shard's frozen baseline can no longer be loaded - it was lost before it became durable, or reclaimed after LatticeOptions.SnapshotBaselineTtl of inactivity; open a fresh snapshot cursor LatticeSnapshotExpiredException
DeleteRangeAsync (extension) startInclusive or endExclusive is null ArgumentNullException
DeleteRangeAsync (extension) stepSize <= 0 ArgumentOutOfRangeException
DeleteRangeAsync (extension) Enumerator lost more than maxAttempts times EnumerationAbortedException (rethrown after the budget is exhausted)
Any Open* Re-open with a different spec on the same cursor ID InvalidOperationException
Next* / DeleteRangeStep* Cursor kind mismatch InvalidOperationException
Next* / DeleteRangeStep* Cursor was closed InvalidOperationException
Next* / DeleteRangeStep* pageSize / maxToDelete <= 0 ArgumentOutOfRangeException
Next* (point-in-time) Pin lifetime exceeded LatticeOptions.MaxCursorSnapshotPinTtl LatticeCursorSnapshotExpiredException

Point-in-time cursors

OpenKeyCursorAsync and OpenEntryCursorAsync accept a pointInTime flag. When set, every page issued by the cursor reads against the saga-decision view captured at open time, so a SetManyAtomicAsync batch that commits between two pages is observed identically on every page (either all of its keys, or none). The all-or-nothing guarantee that single-call reads (GetManyAsync, CountAsync, CountPerShardAsync) already provide is extended to a multi-page enumeration.

Streaming ScanKeysAsync / ScanEntriesAsync enumerations (no cursor) provide the same all-or-nothing view automatically - no opt-in required - but only for each uninterrupted underlying enumeration: a transparent reconnect or stall resume reopens the scan under a freshly captured saga-decision view, so the guarantee does not span it. A point-in-time cursor holds its one view across every page.

DeleteRange cursors cannot be opened in point-in-time mode (range deletes are mutations, not snapshot reads).

Three independent caps bound the registry footprint of point-in-time cursors:

Cap Default Effect
LatticeOptions.CursorIdleTtl 48 h Cursor idle-TTL reminder releases the pin on inactivity.
LatticeOptions.MaxCursorSnapshotPinTtl 7 d Hard cap on a single pin's lifetime. A live cursor slides this on every step; a stalled cursor surfaces LatticeCursorSnapshotExpiredException on its next call.
LatticeOptions.MaxPinnedSagaDecisions 100 000 Footprint cap on the saga decisions pinned across all live point-in-time cursors, enforced by each saga decision registry shard of the tree: one cap for the whole tree with the default single shard (TxRegistryShardCount = 1), and one for each shard and for the legacy registry with more. OpenKeyCursorAsync / OpenEntryCursorAsync, or their WherePredicate variants, opened with pointInTime: true throw LatticeCursorRegistryPinExhaustedException if accepting the snapshot would breach the cap on any shard it touches. With a single shard no pin is installed; when the snapshot spans several, a shard that accepted its part of the pin keeps it until the pin expires after MaxCursorSnapshotPinTtl - never, when that cap is disabled.
var cursorId = await tree.OpenEntryCursorAsync(
    startInclusive: null,
    endExclusive: null,
    reverse: false,
    pointInTime: true);
try
{
    while (true)
    {
        var page = await tree.NextEntriesAsync(cursorId, pageSize: 500);
        foreach (var (k, v) in page.Entries)
            Console.WriteLine($"{k}={v.Length} bytes");
        if (!page.HasMore) break;
    }
}
finally
{
    await tree.CloseCursorAsync(cursorId);
}

See Durable Cursors - Point-in-time cursors for the full design.