CRDT value-surface accessors
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 crdt-value-surface-accessors.md, and llms.txt lists every page.Part of Lattice Public API Reference.
The CRDT value-surface methods on ILattice return lightweight,
allocation-free accessors that read and write a single key through the
primitive's natural mutation API instead of forcing callers to hand-roll
byte arrays or CRDT deltas. The underlying state types are CRDTs whose Merge is
commutative, associative, and idempotent, so concurrent updates from
multiple replicas converge without coordination. Each accessor writes
through ApplyCrdtDeltaAsync, so writing through an accessor whose mode
differs from a tree's declared cross-cluster replication mode throws
LatticeReplicationModeMismatchException (see Replication modes - Single shape per tree).
ILattice.Sequence<T>(key) adds a Replicated Growable Array (RGA)
sequence accessor for collaborative ordered lists and text;
concurrent inserts under the same parent converge on a deterministic
order via the standard RGA descending (Counter, ReplicaId)
tie-break, and removes tombstone the targeted node so a later
re-insert against the same parent still resolves correctly.
ILattice.OrFlag(key) adds an observed-remove (enable-wins) flag
accessor - the single-element specialisation of the OR-Set. It
tracks a single presence bit (enabled / disabled) that converges
enable-wins under concurrent active-active enable and disable, and
is the minimal observed-remove primitive for composite-key
membership rows such as a tag/key secondary index.
ILattice.RwFlag(key) adds the remove-wins (disable-wins) inverse
of the OR-Flag. It tracks the same single presence bit but converges
the opposite way under conflict: a concurrent disable that an
enable never observed suppresses the flag, so ties and unobserved
withdrawals resolve to disabled. Reach for it when the safe outcome
of a race is the withdrawn state - a revocation, kill-switch, or
opt-out bit.
See
state-primitives.mdfor the convergence semantics, merge rules, and example use cases of the CRDT primitives - including when to prefer one primitive over another and the recursiveICrdt<TSelf>contract that letsOrMapnest other CRDTs as values.
using Orleans.Lattice;
// Observed-remove set: concurrent adds and removes converge.
await tree.OrSet("tags:42").AddAsync("urgent"u8.ToArray(), replicaId: "siloA");
await tree.OrSet("tags:42").AddAsync("review"u8.ToArray(), replicaId: "siloA");
bool isUrgent = await tree.OrSet("tags:42").ContainsAsync("urgent"u8.ToArray());
// Positive-negative counter: concurrent increments across replicas sum.
await tree.PnCounter("hits:home").IncrementAsync("siloA");
await tree.PnCounter("hits:home").IncrementAsync("siloA", amount: 3);
long hits = await tree.PnCounter("hits:home").ValueAsync();
// Version vector: track per-replica causal history.
await tree.VersionVector("vv:order:1").TickAsync("siloA");
var vv = await tree.VersionVector("vv:order:1").GetAsync();
// Multi-value register: concurrent writes survive as conflict candidates.
await tree.MvRegister<string>("cart:42").SetAsync("siloA", "Alice's cart");
IReadOnlyList<string> candidates = await tree.MvRegister<string>("cart:42").ValuesAsync();
// Observed-remove map of CRDT-typed values: per-key values fold via
// the value CRDT's MergeFrom, so concurrent writes under the same
// map key converge into a single recursively-merged value.
var tagsByUser = tree.OrMap<string, OrSet>("tags-by-user");
var localTags = new OrSet();
localTags.Add("urgent"u8.ToArray(), replicaId: "siloA", counter: 1);
await tagsByUser.SetAsync("alice", "siloA", localTags);
OrSet? aliceTags = await tagsByUser.GetValueAsync("alice");
// Replicated Growable Array (RGA) sequence: collaborative
// ordered list / text. Concurrent inserts under the same
// parent converge on a deterministic order, removes tombstone
// nodes to keep causal stability for re-inserts.
var transcript = tree.Sequence<string>("chat:42");
await transcript.InsertAtAsync(0, "siloA", "Hello");
await transcript.InsertAtAsync(1, "siloA", "World");
IReadOnlyList<string> lines = await transcript.ToListAsync();
// Observed-remove (enable-wins) flag: a single presence bit that
// converges enable-wins under concurrent enable / disable. Ideal
// for composite-key membership rows (a tag/key secondary index).
await tree.OrFlag("tag/urgent/order:42").EnableAsync(replicaId: "siloA");
bool tagged = await tree.OrFlag("tag/urgent/order:42").IsEnabledAsync();
await tree.OrFlag("tag/urgent/order:42").DisableAsync();
// Remove-wins (disable-wins) flag: the inverse of the OR-Flag. A
// concurrent disable beats a concurrent enable, so the flag fails
// closed under conflict. Ideal for revocation / kill-switch bits.
await tree.RwFlag("access/order:42").EnableAsync(replicaId: "siloA");
bool granted = await tree.RwFlag("access/order:42").IsEnabledAsync();
await tree.RwFlag("access/order:42").DisableAsync(replicaId: "siloB");
Each accessor is obtained from a CrdtLatticeExtensions entry point on
ILattice; every entry point rejects a null or empty key:
| Entry point | Returns |
|---|---|
OrSet(string key) |
OrSetAccessor |
PnCounter(string key) |
PnCounterAccessor |
GCounter(string key) |
GCounterAccessor |
GSet(string key) |
GSetAccessor |
VersionVector(string key) |
VersionVectorAccessor |
MvRegister<T>(string key, ILatticeSerializer<T>? serializer = null) |
MvRegisterAccessor<T> |
OrMap<TKey, TValue>(string key) (TValue : ICrdt<TValue>, new()) |
OrMapAccessor<TKey, TValue> |
Sequence<T>(string key, ILatticeSerializer<T>? serializer = null) |
RgaAccessor<T> |
OrFlag(string key) |
OrFlagAccessor |
RwFlag(string key) |
RwFlagAccessor |
RwSet(string key) |
RwSetAccessor |
MaxRegister<T>(string key, Func<T, byte[]> orderKeySelector, ILatticeSerializer<T>? serializer = null) |
MaxRegisterAccessor<T> |
MinRegister<T>(string key, Func<T, byte[]> orderKeySelector, ILatticeSerializer<T>? serializer = null) |
MinRegisterAccessor<T> |
CrdtLatticeExtensions also carries two batched OR-Flag helpers.
EnableManyAsync(IReadOnlyCollection<string> keys, string replicaId, CancellationToken cancellationToken = default)
enables many flags with one batched read plus one ApplyCrdtDeltaManyAsync
call instead of two round trips per key; like the underlying batch it is not
atomic, and a retry converges. StageEnableManyAsync (same parameters) stages
the same enables as LatticeStagedCrdtWrite tokens for
LatticeAtomicWriteBuilder.SetMany, minting every delta from one batched read.
Both require a non-empty replicaId, exactly as the per-key EnableAsync does,
and throw ArgumentException for an empty one.
See OR-Flag - Marking many flags at once.
| Accessor | Method | Description |
|---|---|---|
OrSetAccessor |
Task<OrSet> GetAsync() |
Reads the current set; returns an empty OrSet when absent or tombstoned. |
OrSetAccessor |
Task AddAsync(byte[] element, string replicaId) |
Adds element with a fresh causal dot. Concurrent adds from other replicas survive a later remove that did not observe them. |
OrSetAccessor |
Task RemoveAsync(byte[] element) |
Tombstones every dot currently observed for element. A no-op when the element is absent. |
OrSetAccessor |
Task<bool> ContainsAsync(byte[] element) |
Returns true when element is a member of the set. |
OrSetAccessor |
Task MergeAsync(OrSet other) |
Merges other into the stored state, applied as one typed delta. |
PnCounterAccessor |
Task<PnCounter> GetAsync() |
Reads the current counter state. |
PnCounterAccessor |
Task<long> ValueAsync() |
Reads the current scalar value. |
PnCounterAccessor |
Task IncrementAsync(string replicaId, long amount = 1) |
Advances the positive component for replicaId. amount must be non-negative. Throws OverflowException, and writes nothing, when the advance would take the component past long.MaxValue. |
PnCounterAccessor |
Task DecrementAsync(string replicaId, long amount = 1) |
Advances the negative component for replicaId. amount must be non-negative. Throws OverflowException, and writes nothing, when the advance would take the component past long.MaxValue. |
PnCounterAccessor |
Task MergeAsync(PnCounter other) |
Merges other into the stored state, applied as one typed delta. |
GCounterAccessor |
Task<GCounter> GetAsync() |
Reads the current grow-only counter state. |
GCounterAccessor |
Task<long> ValueAsync() |
Reads the current scalar value: the sum of all replica components. |
GCounterAccessor |
Task IncrementAsync(string replicaId, long amount = 1) |
Advances the grow-only component for replicaId. amount must be non-negative. Throws OverflowException, and writes nothing, when the advance would take the component past long.MaxValue. |
GCounterAccessor |
Task MergeAsync(GCounter other) |
Merges other into the stored state by pointwise-max per replica. |
GSetAccessor |
Task<GSet> GetAsync() |
Reads the current grow-only set state. |
GSetAccessor |
Task AddAsync(byte[] element) |
Adds element; duplicate adds are idempotent. |
GSetAccessor |
Task<bool> ContainsAsync(byte[] element) |
Returns true when element is in the set. |
GSetAccessor |
Task<IReadOnlyList<byte[]>> ToListAsync() |
Reads members in deterministic order. |
GSetAccessor |
Task MergeAsync(GSet other) |
Merges other into the stored state by set union. |
VersionVectorAccessor |
Task<VersionVector> GetAsync() |
Reads the current vector state. |
VersionVectorAccessor |
Task TickAsync(string replicaId) |
Advances the entry for replicaId and persists the result. |
VersionVectorAccessor |
Task MergeAsync(VersionVector other) |
Merges other into the stored state, applied as one typed delta. |
MvRegisterAccessor<T> |
Task<MvRegister> GetAsync() |
Reads the raw register state, including every dot-tagged entry. |
MvRegisterAccessor<T> |
Task<IReadOnlyList<T>> ValuesAsync() |
Returns the live deserialised values. A single-valued register returns one element; a concurrently-written register returns every conflict candidate in deterministic order. |
MvRegisterAccessor<T> |
Task SetAsync(string replicaId, T value) |
Writes value from replicaId. Drops every dot the writer observed and mints a fresh one - concurrent writes from other replicas survive the next merge. |
MvRegisterAccessor<T> |
Task MergeAsync(MvRegister other) |
Merges other into the stored state, applied as one typed delta. Entries observed in only one side are preserved; pointwise-max is applied to the dot context. |
OrMapAccessor<TKey, TValue> |
Task<OrMap<TKey, TValue>> GetAsync() |
Reads the current map state. |
OrMapAccessor<TKey, TValue> |
Task<TValue?> GetValueAsync(TKey mapKey) |
Returns the lattice-merged value at mapKey, or null when the key is absent or every observed dot has been tombstoned. |
OrMapAccessor<TKey, TValue> |
Task<bool> ContainsKeyAsync(TKey mapKey) |
Returns true when mapKey has at least one live (un-tombstoned) dot. |
OrMapAccessor<TKey, TValue> |
Task SetAsync(TKey mapKey, string replicaId, TValue value) |
Writes value at mapKey from replicaId, minting a fresh causal dot. Concurrent writes survive the next merge and are folded into a single per-key value via ICrdt<TValue>.MergeFrom. |
OrMapAccessor<TKey, TValue> |
Task RemoveAsync(TKey mapKey) |
Tombstones every dot currently observed for mapKey. Concurrent writes on other replicas survive the next merge (add-wins). |
OrMapAccessor<TKey, TValue> |
Task MergeAsync(OrMap<TKey, TValue> other) |
Merges other into the stored state, applied as one typed delta. Per-key values are folded recursively through TValue's MergeFrom. |
RgaAccessor<T> |
Task<Rga> GetAsync() |
Reads the raw sequence state, including tombstoned nodes preserved for causal stability. |
RgaAccessor<T> |
Task<IReadOnlyList<T>> ToListAsync() |
Returns the live values in resolved in-order projection (descending (Counter, ReplicaId) sibling tie-break). |
RgaAccessor<T> |
Task<OrSetDot> InsertAtAsync(int index, string replicaId, T value) |
Inserts value at the visible position index in the materialised projection. Index 0 inserts at the head; an index equal to the count appends at the tail. Returns the new node's stable cursor dot. |
RgaAccessor<T> |
Task<OrSetDot> InsertAfterAsync(OrSetDot parentDot, string replicaId, T value) |
Inserts as a child of parentDot (or Rga.Root for a top-level insert). Useful for tooling that captured a stable cursor identity from a previous read. |
RgaAccessor<T> |
Task RemoveAtAsync(int index) |
Tombstones the live node at the visible position index. |
RgaAccessor<T> |
Task RemoveAsync(OrSetDot dot) |
Tombstones the node identified by dot. A no-op when the dot is absent or already tombstoned. |
RgaAccessor<T> |
Task MergeAsync(Rga other) |
Merges other into the stored state, applied as one typed delta. |
OrFlagAccessor |
Task<OrFlag> GetAsync() |
Reads the current flag state; returns a disabled OrFlag when absent or tombstoned. |
OrFlagAccessor |
Task<bool> IsEnabledAsync() |
Returns true when the flag is currently enabled. |
OrFlagAccessor |
Task EnableAsync(string replicaId) |
Enables the flag with a fresh causal dot. A concurrent enable on another replica survives a disable that did not observe it (enable-wins). |
OrFlagAccessor |
Task DisableAsync() |
Tombstones every enable dot currently observed. A no-op when the flag is not enabled. |
OrFlagAccessor |
Task MergeAsync(OrFlag other) |
Merges other into the stored state, applied as one typed delta. |
RwFlagAccessor |
Task<RwFlag> GetAsync() |
Reads the current flag state; returns a disabled RwFlag when absent or tombstoned. |
RwFlagAccessor |
Task<bool> IsEnabledAsync() |
Returns true when at least one enable dot survives and no live disable suppresses it. |
RwFlagAccessor |
Task EnableAsync(string replicaId) |
Mints a fresh enable dot and tombstones every disable dot currently observed. A concurrent disable the enabler never saw still suppresses the flag (remove-wins). |
RwFlagAccessor |
Task DisableAsync(string replicaId) |
Mints a fresh disable dot. Additive - the disable survives until an enable observes and tombstones it. |
RwFlagAccessor |
Task MergeAsync(RwFlag other) |
Merges other into the stored state, applied as one typed delta. |
RwSetAccessor |
Task<RwSet> GetAsync() |
Reads the current remove-wins set state. |
RwSetAccessor |
Task AddAsync(byte[] element, string replicaId) |
Adds element with a fresh causal dot and cancels observed removes. Concurrent unobserved removes still win. |
RwSetAccessor |
Task RemoveAsync(byte[] element, string replicaId) |
Mints a fresh remove dot. A concurrent add that did not observe it is suppressed. |
RwSetAccessor |
Task<bool> ContainsAsync(byte[] element) |
Returns true when element has an add dot and no live remove dot. |
RwSetAccessor |
Task<IReadOnlyList<byte[]>> ToListAsync() |
Reads the current live members. |
RwSetAccessor |
Task MergeAsync(RwSet other) |
Merges add, remove, and tombstone dots from other. |
MaxRegisterAccessor<T> |
Task SetAsync(T value) |
Proposes value; the register advances only if its order key is greater than the current key. |
MaxRegisterAccessor<T> |
Task<T?> GetAsync() |
Reads the greatest value seen, or default when never written. |
MaxRegisterAccessor<T> |
Task<bool> HasValueAsync() |
Returns whether the register has been written at least once. |
MaxRegisterAccessor<T> |
Task<BoundedRegister> GetRegisterAsync() |
Reads the raw bounded-register state. |
MaxRegisterAccessor<T> |
Task MergeAsync(BoundedRegister other) |
Merges other under the max direction. |
MinRegisterAccessor<T> |
Task SetAsync(T value) |
Proposes value; the register advances only if its order key is less than the current key. |
MinRegisterAccessor<T> |
Task<T?> GetAsync() |
Reads the smallest value seen, or default when never written. |
MinRegisterAccessor<T> |
Task<bool> HasValueAsync() |
Returns whether the register has been written at least once. |
MinRegisterAccessor<T> |
Task<BoundedRegister> GetRegisterAsync() |
Reads the raw bounded-register state. |
MinRegisterAccessor<T> |
Task MergeAsync(BoundedRegister other) |
Merges other under the min direction. |
Each accessor's primary write also has a per-entry time-to-live overload that
takes a TimeSpan ttl after the write's own arguments (ahead of the trailing
CancellationToken and maxAttempts): OrSetAccessor.AddAsync,
PnCounterAccessor.IncrementAsync and GCounterAccessor.IncrementAsync (where
amount is then required), GSetAccessor.AddAsync,
VersionVectorAccessor.TickAsync, MvRegisterAccessor<T>.SetAsync,
OrMapAccessor<TKey, TValue>.SetAsync, RgaAccessor<T>.InsertAtAsync,
OrFlagAccessor.EnableAsync, RwFlagAccessor.EnableAsync,
RwSetAccessor.AddAsync, and the bounded registers' SetAsync. The expiry
converges exactly as for the ApplyCrdtDeltaAsync TTL overload; see
TTL - Per-entry TTL on CRDT writes.
Accessors that support cross-tree staged CRDT writes expose a Stage*
counterpart for their live mutators. A Stage* method reads the key's
current snapshot once, mints
the typed CRDT delta once (the same dot-minting logic the live
mutator uses), folds the delta into the snapshot to produce the merged
state, and returns a LatticeStagedCrdtWrite carrying the key, the
serialized merged state, and the serialized typed delta. It performs no
durable write - hand the token to
LatticeAtomicWriteBuilder.Set(LatticeStagedCrdtWrite) under the
matching CRDT-mode tree so the mutation rides a cross-tree atomic write.
See Atomic Writes - Coupling a CRDT mutation into an atomic write.
| Accessor | Staging method |
|---|---|
OrSetAccessor |
Task<LatticeStagedCrdtWrite> StageAddAsync(byte[] element, string replicaId, CancellationToken = default) |
OrSetAccessor |
Task<LatticeStagedCrdtWrite> StageRemoveAsync(byte[] element, CancellationToken = default) |
PnCounterAccessor |
Task<LatticeStagedCrdtWrite> StageIncrementAsync(string replicaId, long amount = 1, CancellationToken = default) |
PnCounterAccessor |
Task<LatticeStagedCrdtWrite> StageDecrementAsync(string replicaId, long amount = 1, CancellationToken = default) |
GCounterAccessor |
Task<LatticeStagedCrdtWrite> StageIncrementAsync(string replicaId, long amount = 1, CancellationToken = default) |
GSetAccessor |
Task<LatticeStagedCrdtWrite> StageAddAsync(byte[] element, CancellationToken = default) |
VersionVectorAccessor |
Task<LatticeStagedCrdtWrite> StageTickAsync(string replicaId, CancellationToken = default) |
MvRegisterAccessor<T> |
Task<LatticeStagedCrdtWrite> StageSetAsync(T value, string replicaId, CancellationToken = default) |
RgaAccessor<T> |
Task<LatticeStagedCrdtWrite> StageInsertAtAsync(int index, string replicaId, T value, CancellationToken = default) |
RgaAccessor<T> |
Task<LatticeStagedCrdtWrite> StageInsertAfterAsync(OrSetDot parentDot, string replicaId, T value, CancellationToken = default) |
RgaAccessor<T> |
Task<LatticeStagedCrdtWrite> StageRemoveAtAsync(int index, CancellationToken = default) |
RgaAccessor<T> |
Task<LatticeStagedCrdtWrite> StageRemoveAsync(OrSetDot dot, CancellationToken = default) |
OrFlagAccessor |
Task<LatticeStagedCrdtWrite> StageEnableAsync(string replicaId, CancellationToken = default) |
OrFlagAccessor |
Task<LatticeStagedCrdtWrite> StageDisableAsync(CancellationToken = default) |
RwFlagAccessor |
Task<LatticeStagedCrdtWrite> StageEnableAsync(string replicaId, CancellationToken = default) |
RwFlagAccessor |
Task<LatticeStagedCrdtWrite> StageDisableAsync(string replicaId, CancellationToken = default) |
LatticeStagedCrdtWrite is a client-side staging token (Key,
Value, Delta) consumed synchronously by the atomic-write builder. It
never crosses the wire and is not an Orleans-serializable type.
No accessor runs a compare-and-swap retry loop. Every write - MergeAsync
included - reads the current state at most once to mint its typed delta and
then applies it with a single ApplyCrdtDeltaAsync call; the owning leaf is
the single writer for the key and folds the delta. The trailing
int maxAttempts parameter every mutator accepts (default
DefaultMaxAttempts = 16) is kept for binary compatibility: it is validated
(a value below 1 throws ArgumentOutOfRangeException) but no longer drives a
retry. Every accessor also exposes the Lattice and Key it is bound to,
plus Serializer on MvRegisterAccessor<T>, RgaAccessor<T>, and the
bounded registers, which also expose OrderKeySelector. Values are
JSON-serialized via JsonLatticeSerializer<T> unless a serializer is
supplied, so the bytes are inspectable through ILattice.GetAsync.