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], soawait 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:
- Raise
LatticeOptions.MaxScanRetries. - Wrap the scan in an application-level retry that resumes from the
last successfully yielded key using
startInclusive. - 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.