Atomic Action
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 README.md, and llms.txt lists every page.What it shows
IAtomicActionGrain is a generic, all-or-nothing saga / TCC coordinator. It runs
an ordered plan of steps - each a forward effect paired with a compensating effect
- and if a later step faults, every already-committed step is compensated in strict reverse order, so the action leaves no partial effect behind.
This sample puts a Lattice tree write in the same transaction as an external
effect. The built-in .TreeWrite step delegates to the verified atomic-write
machinery (so it inherits the tree's atomicity) and its compensation is
library-synthesized from a captured pre-image; a custom .Step names a
pre-registered handler (here, one that reserves credit in a stand-in external
ledger). The saga plan is literally a Lattice tree write and a custom action
built together, then run all-or-nothing:
var plan = new AtomicActionPlanBuilder()
// Step 1: an atomic Lattice tree update - decrement on-hand stock.
// Auto-compensated from a captured pre-image on rollback.
.TreeWrite("inventory", w => w.Upsert("sku-42/onhand", Encoding.UTF8.GetBytes("40")))
// Step 2: a custom action - reserve credit in an external ledger.
// Its registered compensate effect releases the reservation.
.Step("reserve-credit", Encoding.UTF8.GetBytes("alice:100"))
.Build();
var outcome = await grainFactory
.GetGrain<IAtomicActionGrain>("order-1001")
.ExecuteAsync(plan);
Prefer the built-in .TreeWrite step over doing the tree write inside a custom
handler: a custom handler has no pre-image scratch, so it cannot cleanly restore
a tree key on compensation, whereas .TreeWrite captures and restores the
pre-image for you. Reach for a custom .Step for the non-tree effects.
To touch more than one tree in a single action, add one .TreeWrite step per tree:
the saga makes the whole plan all-or-nothing by compensation (each step's write is
individually atomic and, being a tree write, commits across every cluster the tree
replicates to). See the feature doc for the
precise cross-tree and cross-cluster semantics.
The sample runs:
- A committing plan - decrement stock in the
inventorytree and reserve credit in the ledger, together. - A rolling-back plan - the same shape plus a third step that faults, proving the tree write is restored to its pre-saga value and the reservation is released.
- An idempotent retry - re-issuing a terminal operation id returns the memoized outcome without re-running any effect.
Run it
dotnet run --project samples/AtomicAction
Expected output
== AtomicAction sample ==
1) Seeded inventory 'sku-42/onhand' = 41, ledger reservation = 0.
Outcome: Committed
inventory 'sku-42/onhand' = 40 (was 41)
ledger reservation for order-1001 = 100
-> one saga committed a Lattice tree write and a custom external action together.
2) Seeded inventory 'sku-99/onhand' = 5, ledger reservation = 0.
Outcome: Compensated (faulted at step 2: carrier rejected the shipment)
inventory 'sku-99/onhand' = 5
ledger reservation for order-2002 = 0
-> the tree write was restored and the reservation released: no partial effect.
3) Re-issuing operation 'order-1001' returns the memoized outcome: Committed
-> a client retry after a timeout observes the original result, not a double-apply.
Done.
When to use
- One logical operation must apply several effects - some to Lattice trees, some to external systems (a payment gateway, an email, another grain) - and a partial application is unacceptable.
- You want a durable, idempotent, crash-recoverable place to sequence those effects and roll them back on failure, keyed by an operation id.
When not to use
- Your operation only writes keys, with no external effect. For one tree use
SetManyAtomicAsyncdirectly; for several trees under one isolated commit use the cross-tree atomic-write builder. Both are simpler than a saga and fully two-phase, and both commit atomically across every cluster the tree replicates to. - Remember a custom step is best-effort eventually-consistent: between a forward effect committing and its compensation running, an external observer can see the intermediate effect. Make forward and compensating effects idempotent, and make compensation actually undo the forward effect - that is the caller's contract.