Idempotent writes and retries
Make a mutation safely retryable with idempotencyKey, and what happens when you don't.
A write’s ack can go missing for reasons that have nothing to do with whether the write landed: a dropped connection, a timeout, a node that died after committing but before replying. Retrying blind then has two ways to be wrong:
- Don’t retry: a write that never landed is silently lost.
- Retry unconditionally: a write that did land gets applied twice.
idempotencyKey is how you tell the system which case you’re in.
Unkeyed mutations are never retried automatically
Neither the engine’s client nor the router will retry a bare mutation. A transport failure on mutate.update might mean the request never reached a node, or it might mean the node applied it and only the reply was lost. Without a key there is nothing to tell those apart.
Retrying risks a double-apply; not retrying risks silently dropping the write. The client reports TransportFailed and leaves the decision to you.
Reads always retry
Reads and other side-effect-free calls (query.execute, changes.read, node.stats) don’t have this problem. They retry automatically, key or no key.
Passing a key
await engine.update("acme_roadmap", "task-1", { status: "done" }, {
idempotencyKey: crypto.randomUUID(),
});await db.update("task-1", { status: "done" }, {
idempotencyKey: crypto.randomUUID(),
});engine.transaction accepts the same option, and keys the whole multi-board unit as one: a retried transaction dedupes as a single thing, not per statement.
Keys require writePath: "wal". There is nowhere to record a key under writePath: "lake", and silently not deduplicating would turn a caller’s retry into a double-apply, which is worse than refusing:
// on an engine running writePath: "lake"
const refused = await engine.update("b", "i1", { n: 1 }, { idempotencyKey: "k" });
refused.isErr() && refused.error.message; // '...requires writePath "wal"...'
Exactly-once semantics
A key is deduplicated in the WAL itself: the first append with a given key is applied and its watermark recorded against that key; every later append carrying the same key is answered with that original watermark and applies nothing.
A replay also fires no change events. onChange listeners and watch saw the write once, when it first landed, and a retry that applied nothing has nothing new to report. See the watch guide for what does fire.
const first = (await engine.update("b", "i1", { n: 2 }, { idempotencyKey: "k1" })).unwrap();
const retry = (await engine.update("b", "i1", { n: 2 }, { idempotencyKey: "k1" })).unwrap();
retry === first; // true: the same watermark, nothing applied twice
Keys expire after 24 hours
Idempotency keys are retained for a bounded window, expired by the same maintenance tick that runs the WAL flusher. A retry that arrives after its key has expired is treated as new and re-applies: deduplication applies only within the retention window. Retry soon after the original attempt, not days later.
Through the router: retry and failover
A keyed mutation is safe not just to retry but to send to a different node: since a replay is answered with the original watermark regardless of which node’s WAL the request reaches, the router is free to fail a request over after a lost reply.
The client marks this by setting the x-lakefront-idempotent header exactly when the request body carries a key, so the router can decide without parsing bodies.
A bare mutation gets one attempt. If the reply is lost, the router returns 502 without retrying. The mutation may already have committed. Header and envelope details are in the wire protocol reference.
Choosing keys
Generate keys client-side. A node has no way to invent one that survives past the request that would need it. A key should be stable per logical operation: the same user action, retried, reuses the same key; a genuinely new action gets a new one.
// generated once, when the user clicks: reused across every retry of this click
const idempotencyKey = crypto.randomUUID();
async function submit() {
const result = await db.update("task-1", { status: "done" }, { idempotencyKey });
if (result.isErr()) {
// retry with the same key: a resend of this click, not a new one
}
}
Keys are capped at 200 characters.