Replication and synced()
Last updated 29 October 2026
Read this when you need to know how commits leave your machine, beyond what When a commit is durable promises: how the extension sends them to the database’s stream, how it retries, and what synced() waits for.
One append at a time
Each database has one replicator in its process. It reads the commits the extension logged and sends them, in order, as appends to the stream, with at most one append in flight:
your host Tonbo, in the database's region
--------- -------------------------------
log [c1][c2][c3][c4][c5]
acknowledged | pending
|
replicator --- POST c3..c5 (one batch) ---> gateway ---> the group's leader
| Raft: 2 of 3
| voters hold it
c3..c5 acknowledged <--- 200, Stream-Offset -------------+
(one append in flight per database)When no append is in flight, it seals the pending records, oldest first, into one batch of at most 4 MiB, or less when the stream advertises a smaller request limit. A commit larger than that goes out as a run of parts, one append each, which readers apply only once the run is complete. An append is an HTTP request of the Durable Streams Protocol with Ursula’s producer headers:
POST <database URL>
Content-Type: application/vnd.ursula.sqlite-vfs.v2
Stream-Incarnation: <the stream's history token>
Producer-Id: sqlite-ursula-vfs/2
Producer-Epoch: <this owner's epoch>
Producer-Seq: <the next sequence number>
Producer-Digest: <BLAKE3-256 of the body>
<one batch of compressed commit records>The stream acknowledges an append once two of the three voters hold it in their Raft logs and it is applied (see Ursula in Tonbo). The answer names the offset where the batch starts, which must continue exactly where the previous acknowledged write ended.
Retries without duplicates
An append without a definite answer is sent again, byte for byte, with no retry budget, however long an outage lasts. The wait between attempts doubles from 25 ms to 5 s with full jitter, honours a Retry-After of up to 60 s, and is 30 s while a condition blocks replication.
A repeat cannot apply twice. The stream keeps, for each producer, its epoch, its highest sequence number and the receipt of its newest write, and every append carries the producer id, the epoch, the sequence number and a digest of its body. A repeat of a write that already applied is answered 204 with the original receipt. So a proxy or the gateway sending an append again does no harm.
| Answer to an append | The replicator |
|---|---|
200, or 204 (a repeat), at the expected offset | Marks the batch acknowledged |
No answer, a timeout, a 5xx, 429 or 408 | Sends the same request again after the backoff |
401 | Rereads the token and sends again; the same token refused twice blocks as unauthorized |
402, or 403 from the gateway | Blocks as payment_required or forbidden, and keeps trying |
403 naming a newer epoch | Stops: another process owns the database (see Ownership and fencing) |
| A redirect | Never follows it; after three, blocks as redirected |
What synced() waits for
synced() takes as its target the last commit logged when it is called. It resolves once both of these hold:
- The stream acknowledged every commit up to the target: two of three voters hold them.
- A round trip to the stream that started after the call found nothing written after this process’s own commits. That proves the process still owns the database.
synced() called target = the last logged commit, c5
|
| writing: the append that carries c5 is answered at the
| expected offset ----------------------------> resolves
|
| idle: a leader read at the stream's tail, made after
| the call, returns no bytes -------------------> resolves
|
| another owner took the database ---------> rejects (fenced)
| the signal fires -------------------------> rejects (signal.reason)While the process writes, its appends are the round trip: an answer at the expected offset shows nobody else wrote in between. An idle owner pays one read at the stream’s tail instead, made with consistency=leader: the group’s leader first confirms with a majority of voters that it still leads, so the read sees every write acknowledged before it. Concurrent calls share one round trip. synced() never throws for a network failure: it waits, and rejects only on a reset or when its signal fires, while the commits keep replicating.
The backlog
Commits that are logged and not yet acknowledged are pending, and db.status().commits.pending_bytes counts their compressed size. Once it reaches maxPendingBytes (64 MiB by default), the next write transaction fails at its first write with backlog_full, having written nothing; reads go on, and the database stays usable. This is the only point where the stream’s state stops a commit: a refused token, a 402 or a 403 blocks replication, not commits, until the backlog fills. The bound also caps what a reset can discard.
Replication conditions
db.status().replication.condition is null while appends succeed, and otherwise says why they are being sent again:
| Condition | Meaning |
|---|---|
retrying | No definite answer yet: the network, the gateway or a leader change |
unauthorized | The token was refused twice |
forbidden | The token may not write this database |
payment_required | The account cannot pay for writes (see When the balance runs out) |
request_too_large | A layer in front of the stream allows less than 4 KiB |
redirected | Something answered with redirects |
wrong_cluster, no_server_identity, protocol_mismatch | The URL leads to another deployment, to something that is not Ursula, or to another release line |
rejected | Any other answer the stream cannot take |
Commits continue under every condition until the backlog fills. Troubleshooting says what to do about each.