id: relay-sync title: A boot tells relays only what changed, and a restore tells them what it lost status: open crates: [didbot-pds, didbot-serve] dependsOn: [] exitCriterion: > A test restores a data directory from an older copy of itself and boots it, and a consumer that applied the lost run's frames and follows the sync spec ends holding each repository's restored records at a newer rev. A boot of a deployment where no account changed puts no frame on subscribeRepos. #
relay-sync #
Everything below is a proposal for the owner to accept, amend or refuse. It adds stored state, so nothing here is built until the owner has answered the questions at the end.
An account's position is the active and status pair its #account
frame carries, as HostedAccount::sync_status answers it.
The problem #
- #153: a
restore moves every repository's head back by up to the snapshot's age.
Relays and appviews keep the lost records, and nothing on
subscribeRepostells them. - #154:
every boot sends one
#accountper account, erased ones included (Provisioner::announce_accounts, called fromassembleincrates/didbot-serve/src/bin/didbot-pds/run.rs). Bluesky's relay counts each against a new host's 50 events a second, 2,600 an hour and 21,000 a day (rate limits).
What the spec and the reference code say #
Read on 2026-09-23: the specs at atproto.com, the reference PDS and the
lexicons at atproto 9c76c34, indigo's relay at dbcca56. Where the spec and
the code differ, the owner's rule is to follow the spec.
#syncresets a repository. "Sync events are broadcast when the account repository state has been reset to a new state" (sync). It carries the signed commit block and itsrev, and a consumer fetches the repository to catch up.- A consumer ignores an old rev. "services should ignore
#commitand#syncevents with arevlower or equal to the most recent successfully processedrevfor that DID" (sync). After a migration the spec asks for "a#syncand/or#commitevent … with a higher commitrev" (account). indigo stores any#sync, whatever its rev (ingest.go:169), and drops one for an account it holds as inactive (ingest.go:159). - A reset stream starts well above its old numbers. "it is recommended to chose a new initial sequence number with a healthy margin above any previous sequence number. For example, after persistent storage loss" (event-stream).
- indigo stops dialing a host that answers
FutureCursor. It marks the host idle and does not reconnect (slurper.go:442). A laterrequestCrawlresubscribes it from the same stored cursor, and is a no-op for a host it is still reading (crawl.go). An#infoframe is logged and passed over (slurper.go:453). - Every frame counts, and a frame over the limit waits. indigo counts
frames of every type against a second, an hour and a day
(
slurper.go:187), and holds one over the limit rather than dropping it (consumer.go:71). Once every worker is waiting, it stops reading the socket (parallel.go:122), and this server closes a subscriber 4,096 frames behind withConsumerTooSlow. #accountstates a position, and a repeat still goes out. "The event describes the current hosting status, not what changed" (sync). indigo forwards every one (ingest.go:271). The reference PDS sends nothing at boot (index.ts).- A relay can say how far it read.
com.atproto.sync.getHostStatus, "implemented by relays", returns itsseqfor a host (lexicon).
When a relay holds more than this server #
| Cause | Heads | Sequence | Positions |
|---|---|---|---|
| A restore | back by up to the snapshot's age | back to the snapshot's floor, so numbers are handed out again | back |
| A power cut | back by up to 250 ms of commits: a record and its commit are announced before the log syncs them (DEFAULT_SYNC_INTERVAL in crates/didbot-pds/src/wal/mod.rs) |
unchanged: a reservation syncs before its numbers are used | unchanged: an account change syncs before its frame |
| A crash between an account change and its frame | unchanged | unchanged | the relay is behind, not ahead |
An account created after the snapshot has no row, no key and no document on the restored server, so nothing this server sends can speak for it.
1. How the server learns it was restored #
The volume cannot tell a restore from a restart: every fact on it came back with the snapshot.
A. An operator step leaves a marker. Before the first start of a restored
volume, the operator creates pds.restored in the data directory. The boot
that finds it sends the restore frames (section 2) and removes it.
- Costs: one line in the restore runbook.
- Breaks: nothing when it is run. An operator who skips it gets today's behaviour.
- Stores: one empty file, from the step to the end of the next boot.
B. A consumer's cursor ahead of the stream. A cursor above the newest
number is evidence the stream went back. Today it gets FutureCursor and a
close.
- Costs: nothing.
- Breaks: anyone can send any cursor, so acting on one hands a stranger the
relay budget. It sees a lost run only once that run reserved a new block of
1,024 numbers; a smaller loss moves heads back with no future cursor. And
indigo stops dialing after one
FutureCursor, so the evidence arrives once, after the damage. - Stores: nothing.
C. Ask the relay. getHostStatus on the configured relay gives its seq
for this host, and its getRepoStatus gives its rev for one account.
- Costs: a call to the relay at boot, which
docs/deployment.mdrules out ("Boot does not dial the relay"), or an operator command instead. One call per account to learn which heads moved. - Breaks: it hears from one relay and from no appview.
- Stores: nothing.
Proposed: A. It is the only one that knows rather than guesses. The
warning crates/didbot-serve/src/subscribe.rs logs on a FutureCursor stays,
and names the step. The Sync 1.1 proposal gives the host the same job:
"It is not the responsibility of a PDS, or a relay, to proactively detect if
downstream consumers have gotten out of sync"
(0006).
A power cut is the one case no marker covers. Question 5 asks whether to close it.
2. What a restore sends #
A. #sync for every live repository, over a fresh commit. First the
sequence floor rises, so a relay's cursor is behind the stream rather than
ahead of it. Then, for each account whose position is active, the server
signs a commit over the tree the account already has: the same data, a new
rev, no record changed. It sends #account, then #sync carrying that
commit. Every other account that is not decommissioned gets #account.
- Costs: one signature, one log append and two frames per live account, once. 1,000 live accounts is about 2,000 frames, which fits a new host's hour on Bluesky's relay only if nothing else is sent.
- Breaks: each live repository gains a commit no client wrote, and
getRepoandlistReposanswer it. - Stores: the new heads, in the commit history that exists.
B. #sync only where the head moved back.
- Costs: it needs 1C to learn which heads moved.
- Breaks: what 1C breaks.
- Stores: nothing.
C. Nothing: the operator resets every consumer, as docs/deployment.md
says today.
- Costs: nothing to build.
- Breaks: an operator cannot reset Bluesky's relay or anybody's appview. The lost records stay up, and the agent cannot delete what the server no longer holds. An account that never writes again stays wrong for good.
- Stores: nothing.
Proposed: A. An account whose head did not move costs a consumer little:
the fresh commit names the data it already holds.
3. What a boot sends #
A. Every account, every boot (today).
- Costs: one frame per account with a position, every boot. At the default cap of 1,000 accounts, three boots in an hour spend more than a new host's 2,600. Past 4,096 accounts, the boot's own frames push each other out of the replay buffer before a relay connects.
- Breaks: the relay budget.
- Stores: nothing.
B. Only accounts whose position changed since it was last sent. Each
account row carries announced: the position of the last #account frame
numbered for it. A boot sends where the row's position and announced differ.
- Costs: one log append per
#accountframe. The first boot after the upgrade sends every account once, because no row hasannouncedyet. - Breaks: a frame numbered and then lost with the process, before any
consumer read it, is not sent again. The consumer gets
#info OutdatedCursorfor that gap, as it does for a lost commit. - Stores: one field per account row, and one new log entry.
C. Nothing.
- Costs: nothing.
- Breaks: it reopens the window
announce_accountscloses. A process killed between a lift and its frame leaves every relay holdingsuspendedfor an account that serves. - Stores: nothing.
Proposed: B. A boot that changed nothing sends nothing, and the crash
window stays closed. A lock and a lift use the same rule, comparing the row
with announced rather than with the row before the change. So a frame lost
to a failed reservation also goes out with the account's next change.
The proposed machine #
Stored facts #
| Fact | Type | Lives in | Written | Read |
|---|---|---|---|---|
announced (new) |
Option<SyncAccountStatus>: the position of the last #account frame numbered for the account |
a field on HostedAccount. A new log entry AccountAnnounced { did, active, status }, Durability::Sync like every account entry. The checkpoint carries it inside AccountInserted |
when an #account frame for the account is numbered |
at every boot, and after every account change |
pds.restored (new) |
an empty file, present or absent | the data directory, beside pds.sequence |
created by the operator's restore step, removed by the boot that finished the restore frames | at every boot, before any frame |
| sequence floor | u64 |
Entry::StreamReserved and pds.sequence, as now |
as now, and raised by 10^12 by a boot that finds pds.restored |
at every boot, as now |
| repository head | history::Head |
the commit history, as now | as now, and a fresh commit per live account by a boot that finds pds.restored |
as now |
The new log entry and the new file name each move the layout stamp, in one layout change.
One rule covers both kinds of boot: the replay buffer holds every frame a boot numbers before the listener binds, on top of its usual 4,096.
Transitions of announced #
Each reads the row and announced under the account's own turn
(Provisioner::lifecycle_of), which a lock, a lift and an erasure already
take.
| # | Trigger | Fires when | Frame | Then |
|---|---|---|---|---|
| A1 | an account change is written: activation, a lock, a lift, an erasure | the row's position differs from announced |
#account with the row's position |
announced becomes that position, if the frame was numbered |
| A2 | a boot, before the listener binds | for each account with a position, it differs from announced |
the same | the same |
| A3 | a frame that could not be numbered, because a reservation did not reach the log | — | none | announced is unchanged, so the next A1 or A2 sends it |
| A4 | the row is removed | — | none | the field goes with the row |
Transitions of pds.restored #
| # | Trigger | Before → after | What happens, in order |
|---|---|---|---|
| R1 | the operator's restore step, before the unit's first start | absent → present | nothing else |
| R2 | a boot that finds it, after the log is replayed and before A2 | present → present | 1. The floor rises by 10^12, in the log and in pds.sequence, before any frame is numbered. 2. For each account that has a position and is not decommissioned, in DID order: #account (A1's write follows it), then, if the position is active, a fresh commit and #sync. 3. A2, for the decommissioned accounts. 4. The log syncs. |
| R3 | R2 finished | present → absent | the file is removed, then the listener binds |
| R4 | the boot dies during R2 | present → present | nothing; the next boot runs R2 from the start, and repeats frames a relay may already hold |
Why 10^12. It is more numbers than this server can hand out in the thirty
days a recovery point is kept, at 385,000 a second. A relay's cursor from the
lost run is then behind the stream. It gets #info OutdatedCursor and the
whole replay buffer, which holds the restore frames. FutureCursor would have
made indigo stop dialing.
Why a fresh commit. The restored head's rev is older than the one a
relay holds, and the spec tells a consumer to ignore it. The minter reads the
stored rev and the clock (Provisioner::commit_write), so the fresh rev is
newer than any the lost run minted, as long as the restored host's clock is
not behind the lost host's.
Why R2 is not paced. indigo holds a frame over its limit rather than dropping it, and every R2 frame is in the replay buffer before a relay connects. Pacing cannot fix the budget: past about 1,300 live accounts, R2 alone spends more than a new host's hour, and the deployment wants its relay limit raised first (capacity).
What R2 leaves. An account locked at the restore gets #account and no
#sync, because indigo drops #sync for an inactive account. Its first
commit after the lift breaks the chain, and a consumer that follows the spec
resyncs then. An account erased after the snapshot comes back active, and R2
says so. R2 sends no #identity: an account's handle and key are fixed when
it is created.
The runbook. docs/operations.md's "Restoring" gains the step before
"Start the unit", and didbot announce after the checks: it redials a relay
that already saw FutureCursor, and is a no-op for one still reading. The
advice in docs/deployment.md to reset every consumer, or to raise
pds.sequence by hand, goes. docs/conformance.md's rows on #sync and
desynchronized change with it.
Tests #
In crates/didbot-pds/tests/account_announce_restart.rs, against a recording
sink:
- A boot after a clean stop sends no
#account. - A boot after a lift that never reached the sink sends that account's
#accountand no other. - A lock whose frame could not be numbered goes out with the account's next change, and otherwise with the next boot.
- The first boot over a log written before
announcedsends every account once, and the boot after it sends none.
In crates/didbot-pds/tests/restore.rs, over a copied data directory:
- With the marker, the first frame's number is above every number the lost run handed out.
- With the marker, each live account gets
#accountand then#sync. The#synccommit verifies with the account's key, names the restoreddata, and carries arevabove the lost run's highest for that account. Adecommissionedaccount gets nothing. - A boot stopped part way through R2 leaves the marker, and the next boot sends every frame again and removes it.
- A boot without the marker signs no commit.
In crates/didbot/tests/, over the wire, with a consumer built on
support::ws and support::mst_invert that applies the sync spec's rules: it
ignores a rev that is not newer, checks since and prevData, and fetches
getRepo when a #sync changes data.
- The exit criterion. The consumer applies a live run; the directory is
copied; the lost run writes more; the copy boots with the marker. The
consumer reconnects with its cursor, gets
OutdatedCursorrather thanFutureCursor, and ends holding each repository's restored records. The next write chains onto the#synccommit. - A restart where nothing changed puts no frame on the wire before the next write.
- A boot that numbers more than 4,096 frames before the listener binds replays all of them to a consumer that connects after it.
crates/didbot-serve/tests/restore_drill.rs restores with the marker.
listRepos then answers a newer head and rev for each account, over the
same records.
If the owner picks the first answer to question 5: with the log's sync held back, a commit's frame is withheld until its append syncs.
Questions for the owner #
- How the server learns of a restore: an operator step that leaves
pds.restored(proposed) / a consumer's future cursor / the relay'sgetHostStatus. - What a boot sends: positions that differ from
announced, a new field per account (proposed) / every account, as now / nothing. - What a restore's
#synccarries: a fresh commit no client wrote (proposed) / the restored commit, whose olderrevconsumers ignore. - How far a restore raises the sequence floor: by 10^12 (proposed) / to the current time in microseconds / by hand, as now.
- The power-cut window: publish a commit only once its log append syncs, up to 250 ms later / keep publishing at once.