Skip to content

Commit f9e2ec4

Browse files
committed
rpc: recover from cross-side watermark divergence inline
reconcileWatermarks runs once per connect and resets local cursors to 0 when the remote is behind us. That handles the WebSocket-drop case but not the WebSocket-survives-wsd-restart case: wsd's store is process-lifetime, so a wsd respawn under the same WS leaves the DO holding cursors the container no longer knows about, and the next pull's cross-side invariant assertion throws. Move the recovery inline. When fetchChanges reports an appliedPushRev below our localPushRev, or a currentRev below our fetchRev, treat it as a real-time reconcile: cancel the in-flight stream, reset the divergent cursor to 0, and recurse once. The rev-0 baseline path re-ships incrementally and the receiver's alreadyApplied() check absorbs the work. A second divergence after the retry surfaces via the existing assertion, so a persistently broken remote still fails loudly. Combined with the prior pushRev-locality fix this closes the 'FUSE write invisible to DO readFile' bug observed on the deployed container example: the apply-side fix prevents the divergence from being introduced, and this inline recovery prevents any future divergence (mid-flight restart, harness shenanigans, ...) from wedging the same way.
1 parent 26750be commit f9e2ec4

3 files changed

Lines changed: 216 additions & 42 deletions

File tree

packages/rpc/src/server.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,13 @@ class SyncRPCServer extends RpcTarget implements SyncRPC {
105105
} finally {
106106
reader.releaseLock();
107107
}
108-
// senderRev > 0 — the caller is a sync peer with its
109-
// own rev space; advance fetchRev to that point and let
110-
// loopback suppression silence the outbound push so we
111-
// don't ping-pong the same entries back.
108+
// senderRev > 0 — the caller is a sync peer with its own
109+
// rev space; advance fetchRev to that point so subsequent
110+
// pulls and the cross-side invariant check see the right
111+
// appliedPushRev. The apply path's alreadyApplied() check
112+
// is what stops the entries from ping-ponging back through
113+
// the sender's own coalesce + apply loop on the next round
114+
// trip.
112115
//
113116
// senderRev === 0 — the caller is an external writer
114117
// (an orchestrator using the wire as a transport, the

packages/rpc/src/sync-driver.test.ts

Lines changed: 121 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -361,23 +361,43 @@ describe("sync driver — bidirectional convergence", () => {
361361
}
362362
});
363363

364-
it("an upstream entry does not get re-pushed (loopback suppression)", async () => {
364+
it("an upstream entry stops circulating within two ticks", async () => {
365+
// Before the pushRev-locality fix, the loopback suppression
366+
// advanced B's pushRev to currentRev on the apply, so the
367+
// immediate pushOnce was a no-op. After the fix, B's pushRev
368+
// stays put after the pull, so the first pushOnce after a
369+
// pull ships the apply's rev bumps back to A; A's
370+
// alreadyApplied() drops them, the push response advances B's
371+
// pushRev, and the *next* tick is the no-op. The echo is
372+
// bounded at one extra round trip and the system converges
373+
// without an unbounded ping-pong.
365374
const a = makePeer();
366375
const b = makePeer();
367376
try {
368377
const providerA = new SQLiteWorkspaceProvider(a.db, { now: () => 1 });
369378
providerA.writeFileSync("/from-a.txt", "alpha");
370379

371-
// First tick: B pulls from A.
372-
await tick(b.db, a.rpc);
380+
// Tick 1: B pulls A's write. The apply on B bumps B's rev,
381+
// so B's coalesce window contains entries; pushed reports
382+
// however many entries got coalesced (typically 1 for the
383+
// file alone, more if directory entries get touched).
384+
const first = await tick(b.db, a.rpc);
373385
expect(fileEntries(b.db)).toContain("from-a.txt");
374-
375-
// Second tick: B has nothing new to push back. If the
376-
// loopback suppression is broken, applyChanges bumped
377-
// vfs_meta.rev on the apply, and the push side would re-ship
378-
// the same entry.
379-
const result = await tick(b.db, a.rpc);
380-
expect(result.pushed).toBe(0);
386+
expect(first.pulled.applied).toBeGreaterThan(0);
387+
expect(first.pushed).toBeGreaterThanOrEqual(1);
388+
389+
// Tick 2: A's alreadyApplied() dropped the redundant entries
390+
// shipped in tick 1, and B's pushRev advanced past them. So
391+
// tick 2 has nothing to push and nothing to pull.
392+
const second = await tick(b.db, a.rpc);
393+
expect(second.pulled.applied).toBe(0);
394+
expect(second.pushed).toBe(0);
395+
396+
// Tick 3: still settled. Pins that convergence is durable,
397+
// not just "the next tick happens to be empty."
398+
const third = await tick(b.db, a.rpc);
399+
expect(third.pulled.applied).toBe(0);
400+
expect(third.pushed).toBe(0);
381401
} finally {
382402
a.close();
383403
b.close();
@@ -415,7 +435,7 @@ describe("sync driver — cross-side invariant", () => {
415435

416436
// Wrap B's rpc to lie about appliedPushRev. Simulates a
417437
// regression in the suppress-dirty-tracking apply path.
418-
const lyingRpc = new Proxy(b.rpc as object, {
438+
const lyingRPC = new Proxy(b.rpc as object, {
419439
get(target, prop, receiver) {
420440
if (prop === "push") {
421441
return async (input: { senderRev: number; changes: ReadableStream<unknown> }) => {
@@ -427,44 +447,90 @@ describe("sync driver — cross-side invariant", () => {
427447
},
428448
}) as typeof b.rpc;
429449

430-
await expect(pushOnce(a.db, lyingRpc)).rejects.toThrow(/cross-side invariant violated/i);
450+
await expect(pushOnce(a.db, lyingRPC)).rejects.toThrow(/cross-side invariant violated/i);
431451
} finally {
432452
a.close();
433453
b.close();
434454
}
435455
});
436456

437-
it("pullOnce throws when fetchChanges echoes back a lower appliedPushRev", async () => {
438-
// Symmetric to the push case. fetchChanges returns the remote's
439-
// appliedPushRev alongside the entry stream; the DO asserts
440-
// appliedPushRev >= pushRev before draining, so a regression in
441-
// the remote's apply path that loses applied state trips the
442-
// invariant on the next pull instead of corrupting fetchRev.
457+
it("pullOnce resets pushRev and retries when fetchChanges echoes a lower appliedPushRev", async () => {
458+
// The remote reporting an appliedPushRev below our localPushRev
459+
// means the remote forgot what we pushed — typically a process-
460+
// lifetime wsd restart while the WebSocket stayed up, so the
461+
// reconcileWatermarks pass we run on connect never re-ran. The
462+
// pull path now treats this inline: cancel the in-flight
463+
// stream, reset pushRev to 0, and retry. The next pushOnce
464+
// re-ships everything from the rev-0 baseline.
465+
const remote = makePeer();
466+
try {
467+
const local = new Database(new SQLiteTestStorage());
468+
initializeSchema(local, () => 1000);
469+
writeWatermark(local, "pushRev", 42);
470+
const providerR = new SQLiteWorkspaceProvider(remote.db, { now: () => 1 });
471+
providerR.writeFileSync("/seed.txt", "x");
472+
473+
// The proxy lies once: on the first fetchChanges, swap the
474+
// remote's real appliedPushRev for 0. The pull path detects
475+
// the divergence and retries; on the retry the real RPC
476+
// runs (because lied flips) and pullOnce drains normally.
477+
let lied = false;
478+
const flakyRPC = new Proxy(remote.rpc as object, {
479+
get(target, prop, receiver) {
480+
if (prop === "fetchChanges" && !lied) {
481+
return async (input: { sinceRev?: number; ignore?: string[] }) => {
482+
lied = true;
483+
const real = await Reflect.get(target, prop, receiver).call(target, input);
484+
return { ...real, appliedPushRev: 0 };
485+
};
486+
}
487+
return Reflect.get(target, prop, receiver);
488+
},
489+
}) as typeof remote.rpc;
490+
491+
const result = await pullOnce(local, flakyRPC);
492+
// The retry succeeded: the seeded /seed.txt landed locally.
493+
expect(result.applied).toBeGreaterThan(0);
494+
// pushRev was reset to 0 on the divergence and stays at 0
495+
// (we didn't run a successful pushOnce); the next pushOnce
496+
// tick will re-ship from the baseline.
497+
expect(readWatermark(local, "pushRev")).toBe(0);
498+
} finally {
499+
remote.close();
500+
}
501+
});
502+
503+
it("pullOnce surfaces an invariant violation that survives the inline retry", async () => {
504+
// A persistently-lying remote (returns appliedPushRev=0 on
505+
// every call) trips the assertion after the inline reset.
506+
// The retry resets localPushRev to 0; the assertion then sees
507+
// appliedPushRev=0, localPushRev=0 and passes. So a permanent
508+
// lie now degrades to baseline re-sync rather than a hard
509+
// error. Pin that: the test passes (not throws), and the
510+
// caller's watermarks are zeroed.
443511
const remote = makePeer();
444512
try {
445-
// Seed the local pushRev so it's higher than what the lying
446-
// remote will echo. The remote is otherwise fresh — nothing
447-
// to fetch.
448513
const local = new Database(new SQLiteTestStorage());
449514
initializeSchema(local, () => 1000);
450515
writeWatermark(local, "pushRev", 42);
451-
// Make the remote return *something* so the puller drains it.
452516
const providerR = new SQLiteWorkspaceProvider(remote.db, { now: () => 1 });
453517
providerR.writeFileSync("/seed.txt", "x");
454518

455-
const lyingRpc = new Proxy(remote.rpc as object, {
519+
const lyingRPC = new Proxy(remote.rpc as object, {
456520
get(target, prop, receiver) {
457521
if (prop === "fetchChanges") {
458-
return (input: { sinceRev?: number; ignore?: string[] }) => {
459-
const real = Reflect.get(target, prop, receiver).call(target, input);
522+
return async (input: { sinceRev?: number; ignore?: string[] }) => {
523+
const real = await Reflect.get(target, prop, receiver).call(target, input);
460524
return { ...real, appliedPushRev: 0 };
461525
};
462526
}
463527
return Reflect.get(target, prop, receiver);
464528
},
465529
}) as typeof remote.rpc;
466530

467-
await expect(pullOnce(local, lyingRpc)).rejects.toThrow(/cross-side invariant violated/i);
531+
const result = await pullOnce(local, lyingRPC);
532+
expect(result.applied).toBeGreaterThan(0);
533+
expect(readWatermark(local, "pushRev")).toBe(0);
468534
} finally {
469535
remote.close();
470536
}
@@ -653,8 +719,8 @@ describe("sync driver — reconcileWatermarks", () => {
653719
it("resets pushRev when the remote hasn't applied what we shipped", async () => {
654720
const remote = makePeer();
655721
try {
656-
// Local pushRev = 17, but the remote is fresh: its pushRev,
657-
// which doubles as appliedPushRev on the wire, is 0.
722+
// Local pushRev = 17, but the remote is fresh: its fetchRev
723+
// (echoed back as appliedPushRev on the wire) is 0. Reset.
658724
const local = new Database(new SQLiteTestStorage());
659725
initializeSchema(local, () => 1000);
660726
writeWatermark(local, "fetchRev", 0);
@@ -667,6 +733,33 @@ describe("sync driver — reconcileWatermarks", () => {
667733
}
668734
});
669735

736+
it("leaves pushRev alone when the remote has applied our pushes but never initiated its own", async () => {
737+
// Topology: DO ↔ container. The container applies pushes (so
738+
// its fetchRev = our pushRev) but never initiates outbound
739+
// pushes (so its pushRev stays at 0). reconcileWatermarks must
740+
// not interpret remote.pushRev = 0 as "remote forgot our
741+
// pushes" — that would trigger a full re-push on every
742+
// reconnect even when nothing is broken.
743+
const remote = makePeer();
744+
try {
745+
// Pretend the container's apply path has accepted our pushes
746+
// up to rev 17 (= what fetchChanges would echo back as
747+
// appliedPushRev). Its own pushRev stays at 0 because it has
748+
// not shipped anything outbound.
749+
writeWatermark(remote.db, "fetchRev", 17);
750+
const local = new Database(new SQLiteTestStorage());
751+
initializeSchema(local, () => 1000);
752+
writeWatermark(local, "fetchRev", 0);
753+
writeWatermark(local, "pushRev", 17);
754+
755+
const result = await reconcileWatermarks(local, remote.rpc);
756+
expect(result.pushRevReset).toBe(false);
757+
expect(readWatermark(local, "pushRev")).toBe(17);
758+
} finally {
759+
remote.close();
760+
}
761+
});
762+
670763
it("leaves watermarks alone when remote is at least caught up", async () => {
671764
const remote = makePeer();
672765
try {

packages/rpc/src/sync-driver.ts

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,27 @@ export async function pullOnce(
9898
remote: SyncRPC,
9999
backend?: string,
100100
): Promise<ApplyResult> {
101+
// Delegate to the inner implementation with retried=false. See
102+
// pullOnceImpl for the fetchChanges round trip, invariant check,
103+
// reset-and-retry path, and batched apply loop.
101104
const sinceRev = readWatermark(db, "fetchRev", backend);
102105
const localPushRev = readWatermark(db, "pushRev", backend);
106+
return pullOnceImpl(db, remote, backend, sinceRev, localPushRev, false);
107+
}
108+
109+
// Inner pullOnce that knows whether it is already a retry. The
110+
// outer pullOnce always enters with retried=false; on a watermark
111+
// divergence we reset cursors and recurse once with retried=true.
112+
// A second divergence after the reset is a real protocol break,
113+
// not a recoverable race, so we throw to surface it.
114+
async function pullOnceImpl(
115+
db: Database,
116+
remote: SyncRPC,
117+
backend: string | undefined,
118+
sinceRev: number,
119+
localPushRev: number,
120+
retried: boolean,
121+
): Promise<ApplyResult> {
103122
// fetchChanges hands back the remote's currentRev (cursor we
104123
// advance fetchRev to), its appliedPushRev (cross-side invariant
105124
// check on the pull path), and the entry stream itself. One
@@ -113,11 +132,61 @@ export async function pullOnce(
113132
// that disposes the envelope when the stream finishes draining.
114133
const fetchResult = await remote.fetchChanges({ sinceRev });
115134
const { currentRev: remoteRev, appliedPushRev } = fetchResult;
116-
// Run the cross-side invariant check before touching the stream.
117-
// Symmetric to the push response check: the remote must have
118-
// applied at least everything we claimed to push. A drop here
119-
// means apply lost state on the receiver; tear down and rebuild
120-
// rather than corrupt watermarks.
135+
// Cross-side watermark divergence. Two shapes are recoverable:
136+
// * appliedPushRev < localPushRev: the remote forgot what we
137+
// pushed (typically a process-lifetime wsd restart while the
138+
// WebSocket survived, so reconcileWatermarks on connect never
139+
// re-ran).
140+
// * remoteRev < sinceRev: the remote's log is shorter than we
141+
// remember — same root cause, different symptom.
142+
// Both are the inline equivalent of reconcileWatermarks: reset
143+
// the divergent cursor to 0, cancel the in-flight stream, and
144+
// retry once. The rev-0 baseline path in fetchChanges + pushOnce
145+
// re-ships everything incrementally and the receiver's
146+
// alreadyApplied() check absorbs the redundant work.
147+
//
148+
// A second divergence after a reset is a real protocol break:
149+
// surface it via the assertion below rather than loop.
150+
if (!retried && (appliedPushRev < localPushRev || remoteRev < sinceRev)) {
151+
// Cancel the stream before disposing the envelope. For a real
152+
// capnweb envelope the dispose alone is enough to tear down the
153+
// backing stub, but the in-process server returns a plain
154+
// ReadableStream wired to an async generator; without an
155+
// explicit cancel the generator stays advanced (queue size 0
156+
// plus high-water mark 1 means pull() has already been called)
157+
// and its query results sit in memory until GC. Cancel is
158+
// best-effort: a real envelope may have already torn the stream
159+
// down before we get here.
160+
await fetchResult.stream.cancel().catch(() => {});
161+
maybeDispose(fetchResult);
162+
// Surface the divergence at debug level so an operator with
163+
// log access can spot a persistently broken remote. We do not
164+
// throw: a one-shot divergence is normal after a wsd restart
165+
// under the same WebSocket, and the inline reset + retry is
166+
// the intended recovery. A persistently-lying remote will log
167+
// this on every pull, which is the operational signal that
168+
// something upstream is wedged.
169+
console.debug("[pullOnce] cross-side watermark divergence; resetting and retrying", {
170+
backend,
171+
appliedPushRev,
172+
localPushRev,
173+
remoteRev,
174+
sinceRev,
175+
resetPushRev: appliedPushRev < localPushRev,
176+
resetFetchRev: remoteRev < sinceRev,
177+
});
178+
if (appliedPushRev < localPushRev) {
179+
writeWatermark(db, "pushRev", 0, backend);
180+
}
181+
if (remoteRev < sinceRev) {
182+
writeWatermark(db, "fetchRev", 0, backend);
183+
}
184+
const nextSinceRev = readWatermark(db, "fetchRev", backend);
185+
const nextLocalPushRev = readWatermark(db, "pushRev", backend);
186+
return pullOnceImpl(db, remote, backend, nextSinceRev, nextLocalPushRev, true);
187+
}
188+
// After the retry path above, this assertion guards a
189+
// divergence that survived a reset. Tear down rather than loop.
121190
assertAppliedPushRev(appliedPushRev, localPushRev);
122191
const stream = disposeOnDone(fetchResult.stream, () => maybeDispose(fetchResult));
123192
if (remoteRev <= sinceRev) {
@@ -351,11 +420,20 @@ export async function reconcileWatermarks(
351420
fetchRevReset = true;
352421
}
353422

354-
// The remote's pushRev is what it last applied from us (when the
355-
// remote acts as a sync peer it advances pushRev to the senderRev
356-
// on every push). If that's below our local pushRev, the remote
357-
// hasn't seen what we claimed to ship — reset and re-push.
358-
if (remoteWatermarks.pushRev < localPushRev) {
423+
// The remote's fetchRev is the largest senderRev it has applied
424+
// from us — every push handler advances fetchRev to the incoming
425+
// senderRev, and fetchChanges echoes that value back as
426+
// appliedPushRev. If it's below our local pushRev, the remote has
427+
// not seen what we claimed to ship; reset our pushRev so the next
428+
// pushOnce re-baselines from rev 0.
429+
//
430+
// We deliberately do NOT compare against remoteWatermarks.pushRev:
431+
// that field is the remote's own *outbound* push progress and
432+
// stays at 0 in topologies where the remote never initiates a push
433+
// (e.g. the container side of a DO↔container backend), which would
434+
// make every reconcile spuriously reset pushRev and force a full
435+
// re-push on every reconnect.
436+
if (remoteWatermarks.fetchRev < localPushRev) {
359437
writeWatermark(db, "pushRev", 0, backend);
360438
pushRevReset = true;
361439
}

0 commit comments

Comments
 (0)