OCC & atomicity
Why mutations in one shard are serialized rather than retried, where optimistic-concurrency conflicts still arise, and what atomicity means across shards.
Last updated:
A mutation runs inside a single Durable Object, and that DO runs one mutation at a time. This is the property most of Lunora's consistency story rests on, so it is worth stating plainly.
Mutations are serialized, not retried
The Worker routes a mutation to the owning shard, which runs the handler inside
blockConcurrencyWhile plus a storage transaction. There is no second writer
racing you, so there is no read-set validation and no OCC retry loop for
ordinary shard-local writes.
The practical consequence: a mutation that does not yield is a transaction. Read, decide, write — the values you read cannot change underneath you, and either every write commits or none does.
The qualifier is load-bearing. The guarantee holds for the handler's own writes;
it does not survive an await that hands control to another mutation, which is
what a before-update trigger or an onDelete cascade does. Those cases are
covered in Where conflicts still happen below —
and the OCC guard means they fail loudly rather than silently clobbering.
import { mutation, v } from "@/lunora/_generated/server";
export const transfer = mutation
.input({ from: v.id("accounts"), to: v.id("accounts"), amount: v.number() })
.mutation(async ({ ctx, args: { from, to, amount } }) => {
const source = await ctx.db.get(from);
const target = await ctx.db.get(to);
if (!source || !target || source.balance < amount) {
throw new Error("insufficient funds");
}
await ctx.db.patch(from, { balance: source.balance - amount });
await ctx.db.patch(to, { balance: target.balance + amount });
});No read-modify-write dance, no version column, no retry wrapper. The check-then-act
above is safe because accounts declares no before-update trigger and no
cascade, so the handler never yields between the balance check and the two
writes. Add a trigger to that table and the same code acquires a window in which
a concurrent mutation can commit — which is the next section.
Where conflicts still happen
Serialization holds for the handler's own writes. It does not hold across an
await that yields to another mutation — which happens when a write triggers a
before-update hook or an onDelete cascade. During that yield another mutation
can commit and change the row you were about to write.
Lunora guards against this rather than letting the write clobber: each write is a
compare-and-set whose WHERE includes the row's read-time snapshot. If it
touches zero rows, the row changed underneath and the mutation fails with a
conflict:
optimistic concurrency conflict on "accounts" — the row changed during this mutation; refetch and retryA unique-index breach raises a conflict too, with a distinct kind, so the two are separable when handling errors.
Handling a conflict on the client
Conflicts surface to the caller as the CONFLICT error code (HTTP 409). There is
no transparent server-side retry — the caller decides, because the right response
depends on what the mutation meant.
import { isConflictError } from "@lunora/client";
import { api } from "@/lunora/_generated/api";
try {
await client.mutation(api.accounts.transfer, { from, to, amount });
} catch (error) {
if (isConflictError(error)) {
// Refetch and retry, or tell the user their view was stale.
}
throw error;
}For an idempotent mutation, retrying is usually right. For one that is not, refetch and let the user re-confirm against the new state.
Diagnosing a persistent conflict
lunora insights ranks write-conflict hot-spots first, precisely because they
are the clearest signal that something is wrong with a write path:
lunora insights
# Write-conflict hot-spots (OCC contention — candidates for sharding):
# messages:send 42/900 calls (4.7%)Read the result carefully before reaching for a fix. Because a DO's mutations are
serialized, a persistent conflict usually means the handler is conflicting
with itself — a trigger or an onDelete cascade touching the same row the
handler is about to write. Splitting that work is the fix; a retry loop only
hides it.
Contention across many callers is the other reading, and there the answer is a shard key that splits writers across DOs — per tenant, per room, per user. DO throughput also tops out around 1 000 requests per second, so genuine contention and saturation tend to appear together.
Atomicity across shards
Everything above describes one shard. Across shards, Lunora deliberately does not offer distributed transactions: cross-shard writes are eventual, not atomic. Two shards cannot commit together, and a cross-shard read is a fan-out, not a snapshot.
Design around it by keeping data that must change together in the same shard — which is the real constraint that should drive your choice of shard key. When work genuinely must span shards, model it as a sequence of per-shard mutations with an explicit state machine, rather than assuming it will be all-or-nothing. See Non-goals for why this trade-off is deliberate.
.global() tables
Tables marked .global() live in D1 rather than in a shard's SQLite, so they do
not participate in a shard's transaction. @lunora/d1 wraps the Sessions API so
that reads following your own writes see them, but a mutation that writes both a
shard-local and a .global() table is not writing both atomically. Treat the
global write as a separate step that can fail independently.