Ownership and fencing
Last updated 29 October 2026
Read this when you need to know how one writer is enforced, beyond what When two instances run promises: how the last process to open a database becomes its only writer, and how the one before it finds out.
Producer epochs
Every append the extension sends carries a producer id, an epoch and a sequence number (see Replication). All owners of a database write under the same producer id, and each owner writes under an epoch of its own. The stream keeps the producer’s current epoch and refuses a write of an older one with 403, naming the current epoch.
That refusal is the fence. The stream checks it in its state machine, in the same order as it applies the appends, on every voter of the stream’s Raft group. Ownership therefore needs nothing outside the stream: no lock service, and no lease for the owner to renew.
Taking a database over
An open() that reaches the stream claims the database. It first writes a claim record to its own log and syncs it, then appends a claim: sequence number 0 under an epoch above every epoch it has seen. The claim’s answer names the epoch it closed and the last sequence number of it that applied:
process A (epoch 7) the stream process B
------------------- ---------- ---------
append seq 41 -------------> applied
applied, epoch 7 closed <-- claim: epoch 8, seq 0
at seq 41 (B's open)
---> answer: closed epoch 7
at seq 41
applied <------------------ append epoch 8, seq 1
append seq 42 -------------> 403 epoch-closed
reset: fenced (stop)After the claim, the new owner reads the stream, with a leader read, from where its own copy was acknowledged up to its claim. That read tells it which of its own pending commits landed. If no other owner wrote since, it sends the rest under the new epoch, in order and unchanged. If another owner did, the rest is discarded and the copy catches up with the stream or is rebuilt from it. When two opens race, each claims again above the epoch it lost to, up to 16 times before the open fails with claim_races.
What the fenced process sees
The old owner’s next append, or the round trip of its next synced(), is refused with 403 and Producer-Outcome: epoch-closed. The database then resets: pending synced() calls reject, the reset listeners run once, and every later statement throws UrsulaResetError, reads included. A read transaction already open keeps its snapshot.
| Kind | Cause | Action | Commits the stream never acknowledged |
|---|---|---|---|
fenced | Another process claimed the database | stop | Kept in the cache. A later open sends them if no other owner wrote in between, and discards them otherwise |
stale | The stream moved on while this process could not see it, such as an owner idle past the producer’s expiry | reopen | Discarded |
Opening the database again takes it back, so a fenced process stops rather than reopening in a loop. Troubleshooting lists the other kinds.
Claims nobody asked for
Only an open() that reaches the stream takes a database over. The extension also claims on its own: after the stream forgot an idle producer (7 days without a write), after a layer in front of the stream lowered its request limit, and when an open that could not reach the stream goes live. Each of these claims exactly one epoch above its own, and loses to any newer owner. An open that could not reach the stream first reads the stream and claims only when nobody else wrote or claimed there. So a process back from a network partition cannot take the database from an owner that opened it meanwhile.
A copy of an owner’s cache directory carries that owner’s claims, and can. Do not copy a live cache root to another host; open the database by name there instead.
A new history
Every append also carries the stream’s incarnation, a token that changes whenever the bytes at an offset the stream already served may differ, as after a restore or an accepted loss (see Ursula in Tonbo). An append with an old token is refused with 412, and the extension takes the new history: when the stream kept every commit this copy saw acknowledged, the copy continues under the new token; otherwise it moves aside into salvage/ (see Backups and salvage) and the database resets with the kind rewound.