Skip to main content

All or Nothing: Multi-Key Transactions in Zaris

· 9 min read
Clustron Team
Distributed Systems Engineering

Multi-key transactions in Zaris

A companion post, Race-Free Coordination on Optimistic CAS, made the case that a single primitive — a store-owned version plus a version-guarded conditional write — carries a remarkable amount of coordination. It also drew a hard line: CAS is atomic on one key. Move money from account A to account B and a single compare-and-swap can't cover both; the honest advice was to model the transfer as a state machine on one key, or reach for a saga.

That line is real, but it isn't the whole story. Zaris has a genuine multi-key transaction — BeginTransactionAsync, a staged read/write set, CommitAsync — and it commits across keys and across nodes with an all-or-nothing guarantee. This post is about what that transaction actually is under the hood (a client-coordinated two-phase commit over optimistic versions), precisely what it guarantees, and the one subtlety that decides whether your cross-key invariant actually holds.

The shape of the API​

A transaction is a context you open, stage work into, and commit. It's IAsyncDisposable, so await using guarantees it's released — and an un-committed transaction that falls out of scope rolls back.

await using var tx = await client.BeginTransactionAsync();

KvResult<int> a = await tx.GetAsync<int>("account:A");
KvResult<int> b = await tx.GetAsync<int>("account:B");

await tx.PutAsync("account:A", a.Value - 50);
await tx.PutAsync("account:B", b.Value + 50);

TransactionResult result = await tx.CommitAsync();
// result.IsSuccess, result.Status (Committed | Aborted | Conflict | Timeout | InternalError)

Two behaviours matter from the first line. Reads inside the transaction record the version of each key they touch. Writes are staged — buffered on the client, invisible to the store and to every other client until the commit succeeds. Your own later reads in the same transaction see your staged writes (read-your-writes): put a key and read it back and you get what you staged; delete a key and read it back and you get NotFound — all before commit.

CommitAsync is the only thing that touches the store, and it either applies every staged operation or none of them. RollbackAsync (or simply disposing) discards the whole set.

What actually happens on commit​

The interesting part is CommitAsync, because this is where "all or nothing across nodes" is earned. The keys in your write set may be owned by different partitions on different nodes, so Zaris runs a two-phase commit with the client as coordinator:

  1. Group the staged writes by the node that owns each key.
  2. Prepare — send each node its slice as a conditional batch. For every key you also read, the batch carries the version you read it at. Each node checks that the key is still at that version and takes a short-lived lock on it. If any key's version has moved, or another transaction already holds its lock, that node votes no.
  3. Commit or roll back — if every node voted yes, the coordinator tells them all to apply their writes and release their locks. If any node voted no, the coordinator rolls back the nodes that had already prepared, and the commit returns TransactionStatus.Conflict.

If node B had voted no — because someone advanced account:B between your read and your prepare — the coordinator would have sent node A a rollback, A's lock would release with no write applied, and you'd get a Conflict. Neither account changes. That is the guarantee: the two keys never diverge, even though they live on different machines.

The guarantees, precisely​

Strip away the marketing and here is what the transaction gives you, and nothing it doesn't:

  • Atomicity across keys and nodes. Every staged put and delete applies together or not at all. A commit that returns Conflict has written nothing — there is no partial state to clean up.
  • Optimistic concurrency, checked at prepare. No key is locked while you think. The version check happens at commit time; a concurrent writer that moved a key you depend on turns into a detected conflict, never a silent overwrite.
  • Serializable isolation during the commit window. Prepare takes per-key locks that are held until commit or rollback, so two transactions that touch an overlapping key can't both prepare — the second one is told the key is locked and votes no. IsolationLevel.Serializable is the only level today; the enum is deliberately a one-element set, not an aspiration with hidden weaker modes.
  • Staged writes with read-your-writes. Nothing you stage is observable outside the transaction until commit succeeds, and everything you stage is observable to you immediately.

A conflict is not an error — it's the protection doing its job. Treat a failed commit the way the CAS post treats a failed conditional write: re-open the transaction, re-read, recompute against current state, and retry, with a bounded attempt count so a hot key can't loop forever.

The one edge most people miss​

Here is the subtlety that decides whether your invariant actually holds, and it's worth saying plainly because it's easy to get wrong: the version check rides on the keys you write, not the keys you merely read.

When you both read and write a key — the money-transfer example reads account:A and writes account:A — the version you read is carried into prepare and validated. Good. But if your transaction reads a key and bases a decision on it without writing that key, the key is not part of the prepare, so its version is never revalidated. It can change underneath you between your read and your commit, and the commit will still succeed.

await using var tx = await client.BeginTransactionAsync();

// Decision input: we only READ this.
var limit = await tx.GetAsync<int>("policy:max-transfer");

// We WRITE these. Only these are version-checked at commit.
var from = await tx.GetAsync<int>("account:A");
if (from.Value <= limit.Value)
await tx.PutAsync("account:A", from.Value - limit.Value);

await tx.CommitAsync();
// If an admin lowered policy:max-transfer after our read, we did NOT catch it:
// that key was read but never written, so its version wasn't part of prepare.

The fix is mechanical: include in the write set every key your commit's correctness depends on. If a key is a pure decision input, stage a write that re-puts its current value unchanged — that pins its version into the prepare, and any concurrent change to it now turns into a conflict. The shipped Transactions guide phrases the happy path as "read the values you need before you write" — this is why that advice works, and what to do when a value you need is one you weren't otherwise going to write.

Transaction, CAS, or batch?​

Zaris gives you three ways to touch more than you could with a bare PutAsync, and they are not interchangeable. Reach for the smallest one that holds your invariant:

Single-key CASBatch (ExecuteBatchAsync)Transaction
ScopeOne keyMany keysMany keys
Atomic?Yes, one keyNo — per-item results, partial successYes, all-or-nothing
Conflict detectionIfMatchVersionNoneVersion check at prepare
Locks heldNoneNonePer-key, prepare → commit
CostOne round trip, lock-freeOne round trip per owner nodePrepare + commit round trips, 2PC
Use whenA single key's update is self-containedYou want fewer round trips and partial success is fineAn invariant genuinely spans keys

The batch is the easy one to misread. ExecuteBatchAsync groups mixed GET/PUT/DELETE operations into one round trip per owning node and returns a result per item, in order. It is a throughput tool, not a consistency tool: items succeed or fail independently, and a batch can land half-applied. If you need the operations to stand or fall together, that's a transaction, not a batch.

And if a single key covers your invariant, prefer plain CAS — it holds no locks, blocks no one, and costs one round trip. A transaction is the right tool precisely when, and only when, the invariant you're protecting can't be squeezed onto one key.

Where this stops — and what to do about it​

The transaction is real, but it is a client-coordinated two-phase commit, and 2PC has well-known costs worth stating honestly:

The coordinator is your process. Prepare takes locks on each participating node and holds them until the coordinator says commit or rollback. That window should be short: do your thinking and your reads, then commit promptly. Keep long-running or unrelated work outside the transaction, and lean on await using so a dropped transaction rolls back rather than leaving prepared work dangling. TransactionOptions.Timeout lets you bound a transaction's lifetime.

It's a blocking protocol. Two transactions contending for the same key serialise through its prepare lock — the second votes no and retries. As with CAS, the cure for contention is to not funnel everything through one key: shard, and keep the set of keys in any one transaction small and disjoint from what else is hot.

It composes declared reads and writes, not arbitrary server-side logic. The branching — if the balance is sufficient, debit — runs in your client, exactly as it does with CAS; the server executes a declared read/write set guarded by versions. There's no stored procedure that runs your logic atomically on the node. For most cross-key invariants that's all you need. For a workflow that spans services, external calls, or minutes of wall-clock time, a two-phase commit is the wrong shape — that's still saga-and-compensation territory, and making the steps idempotent (the IfAbsent dedupe trick from the CAS post works unchanged) matters more than any single atomic commit.

Pick the smallest tool that holds the invariant​

The CAS post argued that one sharp primitive goes a long way, and it does. The transaction is what you add when an invariant genuinely refuses to fit on one key — and it's a real multi-key, multi-node, all-or-nothing commit, not a veneer. The discipline it asks is small and familiar: keep it short, retry on conflict, shard before a key gets hot, and — the part that's specific to transactions — make sure every key your correctness leans on is in the write set, not just the read set. Get that right and "move money from A to B" stops being the cautionary example and becomes four honest lines of code.