Skip to content

Commit a233aeb

Browse files
committed
rpc,dofs: Track fetch cursors by rev and path
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.
1 parent cb1d8a0 commit a233aeb

33 files changed

Lines changed: 1006 additions & 324 deletions

docs/02_sync_protocol.md

Lines changed: 86 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ A typical `exec()` round-trip:
5454
[06. Mount Interface](./06_mount_interface.md).
5555
3. **Exec.** The command runs. FUSE writes are captured by the
5656
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.
6161
5. **Diff.** The DO reads up to `PULL_BATCH_SIZE` (256) entries from the
6262
stream, unions the chunk hashes referenced by that batch, probes
6363
its own `vfs_blobs` for which it already has, and calls
@@ -69,13 +69,13 @@ A typical `exec()` round-trip:
6969
`transactionSync` inside `writeFile`/`mkdir`/`rm`/`symlink` is the
7070
real durability boundary. The driver then loops back to step 5 for
7171
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
7979
drops already-applied entries on the floor so re-apply is
8080
idempotent and cheap.
8181

@@ -105,6 +105,56 @@ applies the upstream entry. This is last-writer-wins conflict handling:
105105
it converges the tree, but local-only children under the conflicting
106106
path are discarded without separate tombstones.
107107

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+
108158
### Chunking
109159

110160
Files are split at a fixed `CHUNK_SIZE` (512 KiB). Chunk boundaries are
@@ -127,10 +177,10 @@ one name.
127177
| Watermark | Owner | Meaning |
128178
| --- | --- | --- |
129179
| `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. |
131181
| `currentRev` | DO | Latest `rev` stamped on a DO-side mutation. |
132182
| `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. |
134184

135185
The DO watermarks live in the `_vfs_watermark` table so they survive DO
136186
restarts. The container's watermarks live in the same `Database`
@@ -144,15 +194,14 @@ fresh receiver).
144194
### Cross-side invariant
145195

146196
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.
156205

157206
## Wire shape
158207

@@ -161,10 +210,14 @@ records, both probe with `hasObjects`, both transfer bytes by hash.
161210
Naming follows git's vocabulary — the DO *pushes* entries and
162211
objects to the container, and *fetches* entries and objects back.
163212

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+
164217
| RPC | Direction | Returns | Notes |
165218
| --- | --- | --- | --- |
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 rev at 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. |
168221
| `hasObjects(hashes[])` | sender probes receiver | `Uint8Array[]` | Returns the subset of the input the receiver already holds. The git `have` line, batched. |
169222
| `fetchObjects(hashes[])` | container → DO | `ReadableStream<{ hash, bytes }>` | Streams chunk bytes by hash. The git `want`/pack response on the fetch path. |
170223
| `pushObjects(objects)` | DO → container | `void` | Streams chunk bytes by hash. The push-direction mirror of `fetchObjects`. |
@@ -177,7 +230,8 @@ load-test rationale):
177230

178231
- **`senderRev > 0` — sync peer.** A DO calling its container counterpart
179232
(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*
181235
side `pushRev` is advanced past the rev just shipped (gated on no
182236
interleaved local writes — see step 1 above).
183237
- **`senderRev === 0` — external writer / fresh receiver.** Used by
@@ -197,7 +251,7 @@ edited file) shows up exactly once on the wire. See
197251
- **Container restart mid-exec.** The DO's connection detects the
198252
closed WebSocket and self-destructs. The next call transparently
199253
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
201255
whatever the container's deployment chose for its DB lifetime.
202256
- **Container crash mid-apply.** `push` is atomic from the DO's
203257
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
209263
applied so far; the receiver never sees a partial push. The pull
210264
path keeps the per-mutation model because the streaming batches
211265
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.
217272
- **DO restart.** Watermarks are persisted, so the new DO instance
218273
picks up where the old one left off. The container keeps `wsd`
219274
alive across the gap.

docs/03_filesystem_schema.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,9 @@ positional read primitive can read it directly instead of running
7070
`SUM(size) FROM vfs_chunks` on every call. Every write path stamps
7171
it alongside `mode`/`mtime`/`rev`.
7272

73-
The `vfs_nodes_by_rev` index supports `coalesceChanges`'s
74-
`WHERE rev > sinceRev` scan over live inodes, which the sync protocol
75-
calls once per pull to enumerate everything modified since the last
76-
fetch watermark.
73+
The `vfs_nodes_by_rev` index supports `coalesceChanges`'s cursor scan
74+
over live inodes, which the sync protocol calls once per pull to
75+
enumerate everything modified after the last fetch cursor.
7776

7877
There is no `ignored` column: ignored paths are entirely invisible to
7978
the DO-side filesystem API (see
@@ -218,6 +217,20 @@ Stores `pushRev` and `fetchRev` (see
218217
[02. Sync Protocol](./02_sync_protocol.md#watermarks)). Survives DO
219218
restarts so reconnects resume cleanly.
220219

220+
### `_vfs_fetch_cursor` — fetch path tie-breaker
221+
222+
```sql
223+
CREATE TABLE _vfs_fetch_cursor (
224+
k TEXT PRIMARY KEY CHECK(k = 'fetch'),
225+
path TEXT
226+
);
227+
```
228+
229+
Stores the path component for the fetch cursor. The numeric rev remains
230+
in `_vfs_watermark.fetchRev`; `path = NULL` means the whole rev has
231+
been applied. When `path` is not null, only revs before
232+
`_vfs_watermark.fetchRev` are fully complete.
233+
221234
### `_vfs_mounts` — mount index state
222235

223236
```sql

docs/06_mount_interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ This is deliberate, not an oversight:
284284
- R2 and GitHub have no monotonic rev clock. Treating them as peers
285285
would force per-tick polling and a diff against a remembered
286286
snapshot.
287-
- The protocol's invariants — `appliedPushRev`, watermark
287+
- The protocol's invariants — `appliedPushCursor`, watermark
288288
reconciliation, tombstones — assume one peer. They don't generalize
289289
to N peers without a real CRDT / LWW story.
290290
- The "container always wins" conflict policy is a deliberate

docs/08_capnweb_interface.md

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -58,34 +58,39 @@ interface SyncRPC {
5858
// not inline: the DO sends ChangeEntry records with chunk hashes,
5959
// the receiver calls back via hasObjects / pushObjects for the
6060
// missing subset. Returns the receiver's new rev plus the
61-
// appliedPushRev it stamped for this batch.
61+
// appliedPushCursor it stamped for this batch.
6262
push(input: {
6363
senderRev: number;
6464
changes: ReadableStream<ChangeEntry>;
65-
}): Promise<{ rev: number; appliedPushRev: number }>;
65+
}): Promise<{
66+
rev: number;
67+
appliedPushCursor: { rev: number; path: string | null };
68+
}>;
6669

67-
// Container ← DO. Stream every ChangeEntry with rev > sinceRev,
68-
// alongside the receiver's currentRev (cursor the puller advances
69-
// fetchRev to) and appliedPushRev (cross-side invariant check on
70-
// the pull path, mirroring the push response). Per-file entries
71-
// carry (hash, size) chunk lists; no bytes inline.
70+
// Container ← DO. Stream every ChangeEntry after `after`, ordered
71+
// by rev then path. A cursor with path=null means the whole rev is
72+
// complete; a cursor with path set resumes after that path inside
73+
// the same rev. `currentCursor` is the receiver's currentRev at
74+
// stream open with path=null, and `appliedPushCursor` is the
75+
// receiver's cursor for sender changes it has applied. Per-file
76+
// entries carry (hash, size) chunk lists; no bytes inline.
7277
fetchChanges(input: {
73-
sinceRev?: number;
74-
ignore?: string[];
78+
after?: { rev: number; path: string | null };
79+
ignore?: string[];
7580
}): Promise<{
76-
currentRev: number;
77-
appliedPushRev: number;
81+
currentCursor: { rev: number; path: string | null };
82+
appliedPushCursor: { rev: number; path: string | null };
7883
stream: ReadableStream<ChangeEntry>;
7984
}>;
8085

8186
// Diagnostic surface for soak tests, dashboards, and the agent
8287
// when it wants to wait for the wire to drain. pushRev /
83-
// fetchRev only move when the receiver is acting as a sync
84-
// peer; otherwise they sit at 0.
88+
// fetchCursor only move when the receiver is acting as a sync
89+
// peer; otherwise they sit at 0 / { rev: 0, path: null }.
8590
watermarks(): Promise<{
8691
currentRev: number;
8792
pushRev: number;
88-
fetchRev: number;
93+
fetchCursor: { rev: number; path: string | null };
8994
}>;
9095

9196
// Materialise a single path as a ChangeEntry without driving
@@ -116,26 +121,32 @@ interface SyncRPC {
116121
}
117122
```
118123

124+
The durable object and `wsd` are deployed as a matched pair. This
125+
interface has no version negotiation, so request and response shape
126+
changes are hard wire breaks and require lockstep rollout.
127+
119128
`ChangeEntry` is defined in `packages/dofs/src/sync/changes.ts`. Schema
120129
column references match [03. Filesystem Schema](./03_filesystem_schema.md).
121130

122131
#### Rev-0 baseline (no separate snapshot)
123132

124133
There is no dedicated `snapshot()` RPC. A fresh DO with no watermark
125-
calls `fetchChanges({ sinceRev: 0 })`, which streams every live entry
126-
plus any tombstones the receiver has retained. Treating the baseline as
127-
a degenerate fetch keeps the wire shape minimal: the same pull path
128-
covers both cold-start replication and incremental catch-up.
134+
calls `fetchChanges({ after: { rev: 0, path: null } })`, which streams
135+
every live entry plus any tombstones the receiver has retained.
136+
Treating the baseline as a degenerate fetch keeps the wire shape
137+
minimal: the same pull path covers both cold-start replication and
138+
incremental catch-up.
129139

130140
#### Push semantics: peer vs external
131141

132142
`push` distinguishes two callers via `senderRev`:
133143

134144
- **`senderRev > 0` — sync peer.** The sender is replicating its own
135-
log forward. The receiver advances `fetchRev` to `senderRev` once
136-
the batch settles, echoes it back as `appliedPushRev`, and uses
137-
that value to silence the loopback (the next `fetchChanges` from
138-
the peer won't replay these entries back at it).
145+
log forward. The receiver advances its fetch cursor to
146+
`{ rev: senderRev, path: null }` once the batch settles, echoes it
147+
back as `appliedPushCursor`, and uses that value to silence the
148+
loopback (the next `fetchChanges` from the peer won't replay these
149+
entries back at it).
139150
- **`senderRev === 0` — external orchestrator.** The sender doesn't
140151
have a rev space of its own (an agent, a CI script, a one-shot
141152
writer). The receiver applies the batch as ordinary local writes,
@@ -199,19 +210,19 @@ ends:
199210
streaming `ChangeEntry` records, the container calls `hasObjects` on
200211
the chunk hashes referenced, the DO follows up with `pushObjects`
201212
(itself a stream) for the missing subset, the container applies the
202-
batch and returns `{ rev, appliedPushRev }`.
203-
- **Fetch (container → DO).** The DO calls `fetchChanges({ sinceRev })`,
204-
which returns `{ currentRev, appliedPushRev, stream }` in one round-
205-
trip. `currentRev` is the target watermark; `appliedPushRev`
213+
batch and returns `{ rev, appliedPushCursor }`.
214+
- **Fetch (container → DO).** The DO calls `fetchChanges({ after })`,
215+
which returns `{ currentCursor, appliedPushCursor, stream }` in one round-
216+
trip. `currentCursor` is the target cursor; `appliedPushCursor`
206217
carries the cross-side invariant (the puller asserts it covers
207-
the local `pushRev` before draining). The DO then streams
218+
`{ rev: pushRev, path: null }` before draining). The DO then streams
208219
`ChangeEntry` records, accumulates chunk hashes, calls `hasObjects`
209220
on itself (cheap, local) to find what it already has, then calls
210221
`fetchObjects` for the rest.
211222

212223
| Aspect | Value |
213224
| --- | --- |
214-
| Round-trips per fetch | 1 streaming `fetchChanges` (carries `currentRev` + `appliedPushRev` + entry stream) + 1 `hasObjects` per batch + 1 streaming `fetchObjects` per batch (only if any hashes are missing) |
225+
| Round-trips per fetch | 1 streaming `fetchChanges` (carries `currentCursor` + `appliedPushCursor` + entry stream) + 1 `hasObjects` per batch + 1 streaming `fetchObjects` per batch (only if any hashes are missing) |
215226
| Round-trips per push | 1 streaming `push` (carries `senderRev`) + 1 `hasObjects` (server-driven) + 1 streaming `pushObjects` (only if any hashes are missing) |
216227
| Bytes inline in `ChangeEntry` | None — entries carry chunk hashes only |
217228
| Object transfer shape | `ReadableStream<{ hash, bytes }>` in both directions |

0 commit comments

Comments
 (0)