You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Large directory renames can produce thousands of entries at one
revision. A scalar fetch watermark can only resume at rev boundaries,
so a crash in the middle of one of those streams forces the next pull
to replay the whole rev.
Store fetch progress as a rev/path cursor and checkpoint committed
batches inside a rev. fetchChanges now advertises a snapshot cursor
and streams only entries at or before that cursor, which keeps retry
behavior deterministic while materialized entries read current data.
This changes the RPC fetch shape from scalar revs to cursors. The
durable object and wsd are deployed as a matched pair, so the protocol
is updated in lockstep rather than negotiated across mixed versions.
Copy file name to clipboardExpand all lines: docs/02_sync_protocol.md
+86-31Lines changed: 86 additions & 31 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -54,10 +54,10 @@ A typical `exec()` round-trip:
54
54
[06. Mount Interface](./06_mount_interface.md).
55
55
3.**Exec.** The command runs. FUSE writes are captured by the
56
56
in-container VFS as they happen, each stamped with a fresh revision.
57
-
4.**Fetch.** The DO calls `fetchChanges({ sinceRev: fetchRev })`. The
58
-
container streams `ChangeEntry` records — one per touched path,
59
-
per-file entries carrying `chunks: (hash, size)[]`. No bytes
60
-
inline.
57
+
4.**Fetch.** The DO calls `fetchChanges({ after: fetchCursor })`.
58
+
The container streams `ChangeEntry` records after that `(rev, path)`
59
+
cursor — one per touched path, per-file entries carrying
60
+
`chunks: (hash, size)[]`. No bytes inline.
61
61
5.**Diff.** The DO reads up to `PULL_BATCH_SIZE` (256) entries from the
62
62
stream, unions the chunk hashes referenced by that batch, probes
63
63
its own `vfs_blobs` for which it already has, and calls
@@ -69,13 +69,13 @@ A typical `exec()` round-trip:
69
69
`transactionSync` inside `writeFile`/`mkdir`/`rm`/`symlink` is the
70
70
real durability boundary. The driver then loops back to step 5 for
71
71
the next batch.
72
-
`fetchRev`is advanced **per committed batch** to the max `rev`
73
-
any entry in that batch carried. `coalesceChanges` emits entries
74
-
in ascending rev order so this checkpoint is safe — everything
75
-
below `batchMaxRev` has been applied. A crash mid-pull resumes
76
-
from the last per-batch advance, so re-fetched work is bounded
77
-
by `PULL_BATCH_SIZE` (256) entries, not the whole stream. The
78
-
receiver's `alreadyApplied` check inside `applyChanges` still
72
+
The fetch cursor is advanced **per committed batch** to the last
73
+
streamed entry's `(rev, path)`. `coalesceChanges` emits entries in
74
+
ascending `rev`, then ascending `path`, so this checkpoint is safe
75
+
even when one rev contains more than one batch. A crash mid-pull
76
+
resumes from the last per-batch advance, so re-fetched work is
77
+
bounded by `PULL_BATCH_SIZE` (256) entries, not the whole stream.
78
+
The receiver's `alreadyApplied` check inside `applyChanges` still
79
79
drops already-applied entries on the floor so re-apply is
80
80
idempotent and cheap.
81
81
@@ -105,6 +105,56 @@ applies the upstream entry. This is last-writer-wins conflict handling:
105
105
it converges the tree, but local-only children under the conflicting
106
106
path are discarded without separate tombstones.
107
107
108
+
## Alternatives considered
109
+
110
+
Representing a rename as a full-subtree restamp produces one wire entry
111
+
per subtree item at a single revision. Two cheaper encodings were
112
+
considered and rejected.
113
+
114
+
### A rename opcode
115
+
116
+
A dedicated `rename` entry carrying `{ fromPath, toPath, inode }` would
117
+
collapse a directory move to one wire row. It was rejected because it is
118
+
an operation, while the rest of the protocol is state-based:
119
+
`materialiseChange` resolves each entry to the path's current state at
120
+
fetch time, the receiver reconciles against its own live state, and
121
+
`alreadyApplied` makes re-apply idempotent without ordered replay.
122
+
123
+
An opcode breaks that model in three ways. It is relative to the
124
+
receiver's prior state: `relink(from -> to)` is meaningless to a peer
125
+
that never held `from`, so a cold-start peer pulling from rev 0 has
126
+
nothing to relink. The pull path is deliberately receiver-history
127
+
agnostic: the producer answers "changes after cursor X" by
128
+
materialising current state and knows nothing about what a given
129
+
receiver has seen, so it cannot decide when an opcode is safe to emit.
130
+
And making the opcode idempotent against final state requires
131
+
re-deriving the same state reconciliation the opcode was meant to avoid,
132
+
while still not solving cold start. The per-subtree cost is the price of
133
+
keeping one state-based representation that bootstraps, converges, and
134
+
replays under a single rule.
135
+
136
+
### Chunking a rename across revisions
137
+
138
+
A scalar fetch watermark can only resume at revision boundaries, so a
139
+
large rename at one rev forces a crash to replay the whole rev. One way
140
+
to bound that without a path cursor is to split a single rename across
141
+
many revisions, so a scalar watermark resumes at a chunk boundary.
142
+
143
+
This was rejected because it weakens an invariant the protocol relies
144
+
on: `rev` is bumped atomically once per mutation (see
145
+
[03. Filesystem Schema](./03_filesystem_schema.md)), so every `rev`
146
+
value names one committed, point-in-time snapshot of the tree.
147
+
`currentCursor` is built on that meaning, and the same meaning keeps
148
+
room for snapshot reads at an arbitrary `rev`. Chunking would mint
149
+
intermediate revisions that never committed as a whole, leaving most
150
+
`rev` values describing tree states that never existed.
151
+
152
+
The `(rev, path)` fetch cursor avoids that. `path` is an orthogonal
153
+
second coordinate: "within committed snapshot `rev`, consumed up to
154
+
`path`." A rename still stamps exactly one revision across its subtree,
155
+
while a crash can resume mid-rev. Resumability is bought without
156
+
weakening what a revision means.
157
+
108
158
### Chunking
109
159
110
160
Files are split at a fixed `CHUNK_SIZE` (512 KiB). Chunk boundaries are
@@ -127,10 +177,10 @@ one name.
127
177
| Watermark | Owner | Meaning |
128
178
| --- | --- | --- |
129
179
|`pushRev`| DO | Last DO-side `rev` successfully pushed to the container. |
130
-
|`fetchRev`| DO | Last container-side `rev` the DO has fetched. |
180
+
|`fetchCursor`| DO | Last container-side cursor the DO has fetched; `path = null` means the whole rev is complete. |
131
181
|`currentRev`| DO | Latest `rev` stamped on a DO-side mutation. |
132
182
|`currentRev`| Container | Latest `rev` stamped on a container-side mutation. |
133
-
|`appliedPushRev`| Container |Largest DO `rev` the container has fully applied. Echoed on every **push** response. |
183
+
|`appliedPushCursor`| Container |DO-side cursor the container has applied. Echoed on every **push** and **fetchChanges** response. |
134
184
135
185
The DO watermarks live in the `_vfs_watermark` table so they survive DO
136
186
restarts. The container's watermarks live in the same `Database`
@@ -144,15 +194,14 @@ fresh receiver).
144
194
### Cross-side invariant
145
195
146
196
After every successful `push`**and** every `fetchChanges`, the
147
-
response carries the receiver's current `appliedPushRev` (the
148
-
largest `senderRev` it has fully applied). The DO asserts
149
-
`appliedPushRev >= pushRev` before continuing. The two sides never
150
-
share a single clock, but echoing the largest applied rev makes the
151
-
"receiver is caught up with our pushes" invariant inspectable on
152
-
the wire instead of load-bearing in-process state. A regression in
153
-
the post-apply `pushRev` advancement path (see step 1 above) trips
154
-
the assertion on the next push or pull rather than corrupting data
155
-
silently.
197
+
response carries the receiver's current `appliedPushCursor`. The DO
198
+
asserts that cursor covers its local `{ rev: pushRev, path: null }`
199
+
before continuing. The two sides never share a single clock, but
200
+
echoing the applied cursor makes the "receiver is caught up with our
201
+
pushes" invariant inspectable on the wire instead of load-bearing
202
+
in-process state. A regression in the post-apply cursor advancement
203
+
path trips the assertion on the next push or pull rather than
204
+
corrupting data silently.
156
205
157
206
## Wire shape
158
207
@@ -161,10 +210,14 @@ records, both probe with `hasObjects`, both transfer bytes by hash.
161
210
Naming follows git's vocabulary — the DO *pushes* entries and
162
211
objects to the container, and *fetches* entries and objects back.
163
212
213
+
The DO and `wsd` are deployed as a matched pair. The protocol has no
214
+
version negotiation, so changes to request or response shapes are hard
215
+
wire breaks and require lockstep rollout.
216
+
164
217
| RPC | Direction | Returns | Notes |
165
218
| --- | --- | --- | --- |
166
-
|`push({ senderRev, changes })`| DO → container |`{ rev, appliedPushRev }`| Streams a coalesced batch of `ChangeEntry` via the `changes``ReadableStream`. The sender then calls `hasObjects` on the referenced hashes and follows up with `pushObjects` for the missing subset. See the `senderRev` branches below. |
167
-
|`fetchChanges({ sinceRev?, ignore? })`| container → DO |`Promise<{ currentRev, appliedPushRev, stream: ReadableStream<ChangeEntry> }>`| Streams one entry per touched path. For files, `chunks: (hash, size)[]` (no bytes inline); for dirs, metadata; for deletes, a tombstone. `currentRev` is the receiver's revat stream open; the puller advances `fetchRev` no further than this. `appliedPushRev` carries the cross-side invariant check on the pull path. |
219
+
|`push({ senderRev, changes })`| DO → container |`{ rev, appliedPushCursor }`| Streams a coalesced batch of `ChangeEntry` via the `changes``ReadableStream`. The sender then calls `hasObjects` on the referenced hashes and follows up with `pushObjects` for the missing subset. See the `senderRev` branches below. |
220
+
|`fetchChanges({ after?, ignore? })`| container → DO |`Promise<{ currentCursor, appliedPushCursor, stream: ReadableStream<ChangeEntry> }>`| Streams one entry per touched path after `after`, ordered by `rev` then `path`. For files, `chunks: (hash, size)[]` (no bytes inline); for dirs, metadata; for deletes, a tombstone. `currentCursor` is `{ rev: currentRev, path: null }`at stream open; the puller writes it after a clean drain. `appliedPushCursor` carries the cross-side invariant check on the pull path. |
168
221
|`hasObjects(hashes[])`| sender probes receiver |`Uint8Array[]`| Returns the subset of the input the receiver already holds. The git `have` line, batched. |
169
222
|`fetchObjects(hashes[])`| container → DO |`ReadableStream<{ hash, bytes }>`| Streams chunk bytes by hash. The git `want`/pack response on the fetch path. |
170
223
|`pushObjects(objects)`| DO → container |`void`| Streams chunk bytes by hash. The push-direction mirror of `fetchObjects`. |
@@ -177,7 +230,8 @@ load-test rationale):
177
230
178
231
-**`senderRev > 0` — sync peer.** A DO calling its container counterpart
179
232
(or vice versa). The receiver applies the batch as `upstream`,
180
-
advances its own `fetchRev` to `senderRev`, and on the *sender's*
233
+
advances its own fetch cursor to `{ rev: senderRev, path: null }`,
234
+
and on the *sender's*
181
235
side `pushRev` is advanced past the rev just shipped (gated on no
182
236
interleaved local writes — see step 1 above).
183
237
-**`senderRev === 0` — external writer / fresh receiver.** Used by
@@ -197,7 +251,7 @@ edited file) shows up exactly once on the wire. See
197
251
-**Container restart mid-exec.** The DO's connection detects the
198
252
closed WebSocket and self-destructs. The next call transparently
199
253
rebuilds against the still-running `wsd` (or restarts it if needed).
200
-
`pushRev` and `fetchRev` mean the catch-up is incremental, modulo
254
+
`pushRev` and the fetch cursor mean the catch-up is incremental, modulo
201
255
whatever the container's deployment chose for its DB lifetime.
202
256
-**Container crash mid-apply.**`push` is atomic from the DO's
203
257
perspective on the receiver: the server wraps the whole batch in a
@@ -209,11 +263,12 @@ edited file) shows up exactly once on the wire. See
209
263
applied so far; the receiver never sees a partial push. The pull
210
264
path keeps the per-mutation model because the streaming batches
211
265
can't hold a synchronous transaction across network I/O.
212
-
-**DO restart mid-pull.**`fetchRev` advances per committed batch
213
-
to the max `rev` the batch carried, so a restart mid-pull resumes
214
-
from the last per-batch checkpoint. Wasted work is bounded by
215
-
`PULL_BATCH_SIZE` entries (256), not the whole stream. End state is
216
-
correct either way — apply is idempotent.
266
+
-**DO restart mid-pull.** The fetch cursor advances per committed
267
+
batch to the last entry's `(rev, path)`, so a restart mid-pull
268
+
resumes from the last per-batch checkpoint, including within a
269
+
single large rev. Wasted work is bounded by `PULL_BATCH_SIZE`
270
+
entries (256), not the whole stream. End state is correct either
271
+
way — apply is idempotent.
217
272
-**DO restart.** Watermarks are persisted, so the new DO instance
218
273
picks up where the old one left off. The container keeps `wsd`
0 commit comments