Skip to content

Commit ea3997e

Browse files
committed
dofs: Make fetch progress cursor-only
Fetch progress is now a rev/path cursor, but the exported watermark helper still accepted scalar fetchRev writes. That left two public write paths for one logical cursor. Restrict the scalar watermark API to pushRev, move fetch progress callers to readFetchCursor and writeFetchCursor, and normalize equal-rev partial cursors when a peer push proves the full rev was applied.
1 parent a233aeb commit ea3997e

7 files changed

Lines changed: 78 additions & 71 deletions

File tree

docs/11_lifecycle.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,12 @@ Two things have to change for capnweb + hibernation to work:
335335
wake as a fresh session. The peer must retry any in-flight RPC.
336336
This is the same semantics as a transport reset, which the
337337
protocol already handles via the rev cursors.
338-
- **Sync streams: nothing to store.** `pushRev` and the durable
339-
fetch cursor are already written to SQLite alongside the data
340-
they describe. On wake, the next `pushOnce` / `pullOnce` reads
341-
them from durable storage and resumes. No attachment write is
338+
- **Sync streams: nothing to store.** `pushRev` is written to
339+
SQLite with the pushed data it describes. The durable fetch
340+
cursor is written after each committed pull batch, not in the
341+
same transaction as the data apply. On wake, the next `pushOnce`
342+
/ `pullOnce` reads the durable counters and resumes; any overlap
343+
is dropped by the idempotent apply path. No attachment write is
342344
required.
343345
- **Exec streams: store `{ [id]: seq }` per in-flight exec.**
344346
The `WorkspaceShell` driver inside the DO is the only place

packages/dofs/src/sync/watermarks.test.ts

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,22 @@ describe("watermarks", () => {
1515
it("readWatermark returns 0 for a fresh DB", async () => {
1616
await withDB(async (db) => {
1717
expect(readWatermark(db, "pushRev")).toBe(0);
18-
expect(readWatermark(db, "fetchRev")).toBe(0);
1918
});
2019
});
2120

2221
it("writeWatermark persists across reads", async () => {
2322
await withDB(async (db) => {
2423
writeWatermark(db, "pushRev", 42);
2524
expect(readWatermark(db, "pushRev")).toBe(42);
26-
expect(readWatermark(db, "fetchRev")).toBe(0);
27-
writeWatermark(db, "fetchRev", 7);
28-
expect(readWatermark(db, "fetchRev")).toBe(7);
2925
});
3026
});
3127

3228
it("persists the fetch cursor rev and path separately", async () => {
3329
await withDB(async (db) => {
3430
expect(readFetchCursor(db)).toEqual({ rev: 0, path: null });
3531
writeFetchCursor(db, { rev: 12, path: "/dir/file.txt" });
36-
expect(readWatermark(db, "fetchRev")).toBe(12);
3732
expect(readFetchCursor(db)).toEqual({ rev: 12, path: "/dir/file.txt" });
3833
writeFetchCursor(db, { rev: 13, path: null });
39-
expect(readWatermark(db, "fetchRev")).toBe(13);
40-
expect(readFetchCursor(db)).toEqual({ rev: 13, path: null });
41-
});
42-
});
43-
44-
it("clears a stale fetch cursor path when writing fetchRev directly", async () => {
45-
await withDB(async (db) => {
46-
writeFetchCursor(db, { rev: 12, path: "/z.txt" });
47-
48-
writeWatermark(db, "fetchRev", 13);
49-
5034
expect(readFetchCursor(db)).toEqual({ rev: 13, path: null });
5135
});
5236
});
@@ -70,23 +54,6 @@ describe("watermarks", () => {
7054
});
7155
});
7256

73-
it("does not persist an intermediate cursor when a direct fetchRev write fails", async () => {
74-
await withDB(async (db) => {
75-
writeFetchCursor(db, { rev: 12, path: "/old.txt" });
76-
77-
const originalRun = db.run.bind(db);
78-
db.run = ((query: string, ...bindings: unknown[]) => {
79-
if (query.includes("_vfs_fetch_cursor")) {
80-
throw new Error("forced cursor path clear failure");
81-
}
82-
return originalRun(query, ...bindings);
83-
}) as typeof db.run;
84-
85-
expect(() => writeWatermark(db, "fetchRev", 13)).toThrow("forced cursor path clear failure");
86-
expect(readFetchCursor(db)).toEqual({ rev: 12, path: "/old.txt" });
87-
});
88-
});
89-
9057
it("returns a fresh start cursor for a zero fetchRev", async () => {
9158
await withDB(async (db) => {
9259
const cursor = readFetchCursor(db);
@@ -136,9 +103,8 @@ describe("watermarks", () => {
136103
});
137104

138105
it("rejects unknown watermark keys at the type level via the helper signature", () => {
139-
// Compile-time only: writeWatermark only accepts the union
140-
// "pushRev" | "fetchRev". This test is a placeholder that
141-
// documents the contract; the type system catches misuse.
106+
// Compile-time only: writeWatermark only accepts "pushRev".
107+
// Fetch progress must go through readFetchCursor/writeFetchCursor.
142108
expect(true).toBe(true);
143109
});
144110

packages/dofs/src/sync/watermarks.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,24 @@ import type { Database } from "../storage.js";
55
// own sync cursors. The container's appliedPushRev lives in-memory on
66
// the container side; we don't store it here.
77
//
8-
// pushRev — last DO-side rev successfully pushed to the backend.
9-
// fetchRev — last backend-side rev the DO has fetched and applied.
8+
// pushRev — last DO-side rev successfully pushed to the backend.
109
//
11-
// initializeSchema() seeds both at 0 in _vfs_watermark for the
10+
// initializeSchema() seeds pushRev at 0 in _vfs_watermark for the
1211
// default backend. The schema table is the durability surface;
1312
// readers and writers always go through this module so the SQL
1413
// stays in one place.
1514
//
1615
// `backend` defaults to DEFAULT_BACKEND_ID so older callers that
1716
// only ran one backend (or ran the package against a schema before
18-
// per-backend keying landed) keep working unchanged. The v2v3
17+
// per-backend keying landed) keep working unchanged. The v3v4
1918
// schema migration backfills the column on existing rows with the
2019
// same default.
21-
export type WatermarkKey = "pushRev" | "fetchRev";
20+
//
21+
// Fetch progress is a `{ rev, path }` cursor. Its rev component is
22+
// still stored in _vfs_watermark for schema compatibility, but callers
23+
// must use readFetchCursor() / writeFetchCursor() so rev and path stay
24+
// consistent.
25+
export type WatermarkKey = "pushRev";
2226

2327
export const DEFAULT_BACKEND_ID = "default";
2428

@@ -39,9 +43,19 @@ export function readWatermark(
3943
);
4044
}
4145

46+
function readFetchRev(db: Database, backend: string = DEFAULT_BACKEND_ID): number {
47+
return (
48+
db.scalar<number>(
49+
"SELECT v FROM _vfs_watermark WHERE k = ? AND backend = ?",
50+
"fetchRev",
51+
backend,
52+
) ?? 0
53+
);
54+
}
55+
4256
function writeWatermarkValue(
4357
db: Database,
44-
key: WatermarkKey,
58+
key: WatermarkKey | "fetchRev",
4559
value: number,
4660
backend: string = DEFAULT_BACKEND_ID,
4761
): void {
@@ -74,19 +88,11 @@ export function writeWatermark(
7488
value: number,
7589
backend: string = DEFAULT_BACKEND_ID,
7690
): void {
77-
if (key !== "fetchRev") {
78-
writeWatermarkValue(db, key, value, backend);
79-
return;
80-
}
81-
82-
db.transactionSync(() => {
83-
writeWatermarkValue(db, key, value, backend);
84-
writeFetchCursorPath(db, null, backend);
85-
});
91+
writeWatermarkValue(db, key, value, backend);
8692
}
8793

8894
export function readFetchCursor(db: Database, backend: string = DEFAULT_BACKEND_ID): ChangeCursor {
89-
const rev = readWatermark(db, "fetchRev", backend);
95+
const rev = readFetchRev(db, backend);
9096
if (rev === 0) return { rev: 0, path: null };
9197
const path = db.scalar<string | null>(
9298
"SELECT path FROM _vfs_fetch_cursor WHERE k = ? AND backend = ?",

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
currentRev,
1717
Database,
1818
initializeSchema,
19+
readFetchCursor,
1920
readWatermark,
2021
SQLiteWorkspaceProvider,
2122
} from "@cloudflare/dofs";
@@ -84,7 +85,7 @@ describe("sync driver — push throughput", () => {
8485
for (let i = 0; i < bytes.byteLength; i += 4096) {
8586
bytes[i] = (i * 31) & 0xff;
8687
}
87-
provider.writeFileSync("/big.bin", bytes);
88+
provider.writeFileSync("/big.bin", Buffer.from(bytes));
8889
await pushOnce(a.db, b.rpc);
8990
} finally {
9091
a.close();
@@ -105,7 +106,7 @@ describe("sync driver — push throughput", () => {
105106
for (let i = 0; i < bytes.byteLength; i += 4096) {
106107
bytes[i] = (i * 31) & 0xff;
107108
}
108-
provider.writeFileSync("/big.bin", bytes);
109+
provider.writeFileSync("/big.bin", Buffer.from(bytes));
109110
await pushOnce(a.db, b.rpc);
110111
} finally {
111112
a.close();
@@ -199,7 +200,7 @@ describe("sync driver — bidirectional convergence", () => {
199200
// time. Reading watermarks here adds noise we can
200201
// tolerate vs. running a no-op closure for the
201202
// baseline. Sanity assert outside the iteration body:
202-
if (readWatermark(b.db, "fetchRev") <= 0) throw new Error("pull didn't advance");
203+
if (readFetchCursor(b.db).rev <= 0) throw new Error("pull didn't advance");
203204
if (currentRev(b.db) <= 0) throw new Error("apply didn't bump rev");
204205
} finally {
205206
a.close();

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ describe("sync driver — streaming pullOnce", () => {
665665
get(target, prop, receiver) {
666666
if (prop === "hasObjects") {
667667
return async (hashes: Uint8Array[]) => {
668-
sampledRevs.push(readWatermark(b.db, "fetchRev"));
668+
sampledRevs.push(readFetchCursor(b.db).rev);
669669
return Reflect.get(target, prop, receiver).call(target, hashes);
670670
};
671671
}
@@ -684,7 +684,7 @@ describe("sync driver — streaming pullOnce", () => {
684684
expect(sampledRevs[i]).toBeGreaterThan(sampledRevs[i - 1]);
685685
}
686686
// End state still matches the remote.
687-
expect(readWatermark(b.db, "fetchRev")).toBe(remoteFinalRev);
687+
expect(readFetchCursor(b.db).rev).toBe(remoteFinalRev);
688688
} finally {
689689
a.close();
690690
b.close();
@@ -798,6 +798,33 @@ describe("sync driver — streaming pullOnce", () => {
798798
b.close();
799799
}
800800
});
801+
802+
it("lets a peer push supersede a partial pull cursor", async () => {
803+
const a = makePeer();
804+
const b = makePeer();
805+
try {
806+
const providerA = new SQLiteWorkspaceProvider(a.db, { now: () => 1 });
807+
const providerB = new SQLiteWorkspaceProvider(b.db, { now: () => 1 });
808+
providerA.writeFileSync("/before-stale-path.txt", "pushed");
809+
const pushedRev = currentRev(a.db);
810+
811+
writeFetchCursor(b.db, { rev: pushedRev, path: "/zzzz" });
812+
813+
expect(await pushOnce(a.db, b.rpc)).toBeGreaterThan(0);
814+
expect(readFetchCursor(b.db)).toEqual({ rev: pushedRev, path: null });
815+
expect(providerB.readFileSync("/before-stale-path.txt", "utf8")).toBe("pushed");
816+
817+
providerA.writeFileSync("/after-push.txt", "pulled later");
818+
const pulled = await pullOnce(b.db, a.rpc);
819+
820+
expect(pulled.applied).toBe(1);
821+
expect(providerB.readFileSync("/after-push.txt", "utf8")).toBe("pulled later");
822+
expect(readFetchCursor(b.db)).toEqual({ rev: currentRev(a.db), path: null });
823+
} finally {
824+
a.close();
825+
b.close();
826+
}
827+
});
801828
});
802829

803830
describe("sync driver — push atomicity", () => {
@@ -875,11 +902,11 @@ describe("sync driver — reconcileWatermarks", () => {
875902
// (currentRev = 1 from initializeSchema seeding the root).
876903
const local = new Database(new SQLiteTestStorage());
877904
initializeSchema(local, () => 1000);
878-
writeWatermark(local, "fetchRev", 42);
905+
writeFetchCursor(local, { rev: 42, path: null });
879906
writeWatermark(local, "pushRev", 0);
880907

881908
await reconcileWatermarks(local, remote.rpc);
882-
expect(readWatermark(local, "fetchRev")).toBe(0);
909+
expect(readFetchCursor(local)).toEqual({ rev: 0, path: null });
883910
expect(readWatermark(local, "pushRev")).toBe(0);
884911
} finally {
885912
remote.close();
@@ -893,7 +920,7 @@ describe("sync driver — reconcileWatermarks", () => {
893920
// which is echoed as appliedPushCursor on the wire, is 0/null.
894921
const local = new Database(new SQLiteTestStorage());
895922
initializeSchema(local, () => 1000);
896-
writeWatermark(local, "fetchRev", 0);
923+
writeFetchCursor(local, { rev: 0, path: null });
897924
writeWatermark(local, "pushRev", 17);
898925

899926
await reconcileWatermarks(local, remote.rpc);
@@ -913,11 +940,11 @@ describe("sync driver — reconcileWatermarks", () => {
913940
const local = new Database(new SQLiteTestStorage());
914941
initializeSchema(local, () => 1000);
915942
const remoteCurrent = currentRev(remote.db);
916-
writeWatermark(local, "fetchRev", remoteCurrent);
943+
writeFetchCursor(local, { rev: remoteCurrent, path: null });
917944
writeWatermark(local, "pushRev", 0);
918945

919946
await reconcileWatermarks(local, remote.rpc);
920-
expect(readWatermark(local, "fetchRev")).toBe(remoteCurrent);
947+
expect(readFetchCursor(local)).toEqual({ rev: remoteCurrent, path: null });
921948
expect(readWatermark(local, "pushRev")).toBe(0);
922949
} finally {
923950
remote.close();

packages/rpc/tests/wire.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ describe("push semantics — external vs sync peer", () => {
500500
expect(readWatermark(harness.db, "pushRev")).toBe(0);
501501
// The fetch cursor was NOT advanced either — the sender has
502502
// no rev space.
503-
expect(readWatermark(harness.db, "fetchRev")).toBe(0);
503+
expect(readFetchCursor(harness.db)).toEqual({ rev: 0, path: null });
504504
// The entry is in the coalesce stream.
505505
const drained: { path: string }[] = [];
506506
for await (const e of coalesceChanges(harness.db, { rev: 0, path: null })) drained.push(e);
@@ -512,7 +512,9 @@ describe("push semantics — external vs sync peer", () => {
512512

513513
it("push with senderRev>0 (sync peer) advances pushRev to silence loopback", async () => {
514514
harness = await startHarness();
515-
const { currentRev, readWatermark, writeWatermark } = await import("@cloudflare/dofs");
515+
const { currentRev, readFetchCursor, readWatermark, writeWatermark } = await import(
516+
"@cloudflare/dofs"
517+
);
516518
const client = createSyncClient({ url: harness.url });
517519
try {
518520
// Seed pushRev at the current point so the F1 guard

packages/workspace/src/workspace.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,14 +517,17 @@ describe("Workspace backend selection", () => {
517517
};
518518
const storage = makeStorage();
519519
const ws = new Workspace({ storage, backends: [makeBackend("only", sync)] });
520-
const { writeWatermark, readWatermark } = await import("@cloudflare/dofs");
520+
// Pre-seed local watermarks for the "only" backend.
521+
const { readFetchCursor, readWatermark, writeFetchCursor, writeWatermark } = await import(
522+
"@cloudflare/dofs"
523+
);
521524
writeWatermark(ws.db, "pushRev", 17, "only");
522-
writeWatermark(ws.db, "fetchRev", 42, "only");
525+
writeFetchCursor(ws.db, { rev: 42, path: null }, "only");
523526
// ready() alone no longer dials; ready(id) forces the connect.
524527
await ws.ready("only");
525528
expect(watermarksCalls).toBe(1);
526529
expect(readWatermark(ws.db, "pushRev", "only")).toBe(0);
527-
expect(readWatermark(ws.db, "fetchRev", "only")).toBe(0);
530+
expect(readFetchCursor(ws.db, "only")).toEqual({ rev: 0, path: null });
528531
});
529532

530533
it("skips push/pull when the backend declares sync: 'none'", async () => {

0 commit comments

Comments
 (0)