Skip to content

Commit 98b74eb

Browse files
committed
workspace: Add durable sync retries
Let Durable Object hosts persist one pending pull intent per backend and wake Workspace through retryPendingSync. Failed attempts use bounded exponential backoff while exhausted intents remain available for inspection.\n\nCover scheduling, coalescing, cursor resume, convergence, exhaustion, and RPC cleanup with an in-memory scheduler.
1 parent 1baee2c commit 98b74eb

5 files changed

Lines changed: 553 additions & 21 deletions

File tree

packages/workspace/README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,98 @@ A workspace with two backends that both write into
239239
[`docs/05_shell_interface.md`](../../docs/05_shell_interface.md)
240240
for the caveat.
241241

242+
## Durable pending-sync retries
243+
244+
A command can finish after changing backend files while its post-command pull
245+
fails. The command result reports `sync.status: "pending"`. If you configure a
246+
`SyncRetryScheduler`, Workspace also writes one durable retry intent for that
247+
backend. The library does not use an in-memory timer and cannot own the host
248+
Durable Object's alarm.
249+
250+
The scheduler contract contains only values that a host can persist:
251+
252+
```ts
253+
import {
254+
type SyncRetryIntent,
255+
type SyncRetryScheduler,
256+
Workspace,
257+
} from "@cloudflare/workspace";
258+
259+
const RETRY_PREFIX = "workspace:sync-retry:";
260+
261+
class DurableObjectRetryScheduler implements SyncRetryScheduler {
262+
constructor(private readonly state: DurableObjectState) {}
263+
264+
async get(backend: string): Promise<SyncRetryIntent | undefined> {
265+
return this.state.storage.get(`${RETRY_PREFIX}${backend}`);
266+
}
267+
268+
async schedule(intent: SyncRetryIntent): Promise<void> {
269+
// schedule replaces the backend's existing intent, so repeated
270+
// failures coalesce instead of creating an alarm queue.
271+
await this.state.storage.put(`${RETRY_PREFIX}${intent.backend}`, intent);
272+
273+
const intents = await this.state.storage.list<SyncRetryIntent>({
274+
prefix: RETRY_PREFIX,
275+
});
276+
const next = Math.min(...[...intents.values()].map((item) => item.notBefore));
277+
await this.state.storage.setAlarm(next);
278+
}
279+
280+
async clear(backend: string): Promise<void> {
281+
await this.state.storage.delete(`${RETRY_PREFIX}${backend}`);
282+
}
283+
}
284+
```
285+
286+
Pass the scheduler to `Workspace` and invoke `retryPendingSync` from the host's
287+
alarm or another durable scheduler:
288+
289+
```ts
290+
export class WorkspaceHost extends DurableObject<Env> {
291+
readonly scheduler = new DurableObjectRetryScheduler(this.ctx);
292+
readonly workspace = new Workspace({
293+
storage: this.ctx.storage,
294+
backends: [/* ... */],
295+
retryScheduler: this.scheduler,
296+
retry: {
297+
initialDelayMs: 1_000,
298+
maxDelayMs: 60_000,
299+
maxAttempts: 5,
300+
},
301+
});
302+
303+
async alarm(): Promise<void> {
304+
const intents = await this.ctx.storage.list<SyncRetryIntent>({
305+
prefix: RETRY_PREFIX,
306+
});
307+
const now = Date.now();
308+
for (const intent of intents.values()) {
309+
if (intent.notBefore <= now) {
310+
await this.workspace.retryPendingSync(intent.backend);
311+
}
312+
}
313+
314+
const remaining = await this.ctx.storage.list<SyncRetryIntent>({
315+
prefix: RETRY_PREFIX,
316+
});
317+
if (remaining.size > 0) {
318+
await this.ctx.storage.setAlarm(
319+
Math.min(...[...remaining.values()].map((item) => item.notBefore)),
320+
);
321+
}
322+
}
323+
}
324+
```
325+
326+
`retryPendingSync(backend?)` enters the same per-backend FIFO as commands,
327+
`push`, and `pull`. It resumes `pullOnce` from the cursor already persisted in
328+
SQLite. Success clears the intent. Failure replaces it with the next bounded
329+
exponential-backoff attempt. Once `maxAttempts` fails, the final intent stays
330+
in storage and the method returns `status: "exhausted"`; the host can inspect,
331+
alert on, or explicitly clear it. Calling the method with no pending intent
332+
returns `status: "idle"`.
333+
242334
## Worker-side consumption
243335

244336
```ts

packages/workspace/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,11 @@ export {
6868
WorkspaceShellStub,
6969
WorkspaceStub,
7070
} from "./stub.js";
71-
export { Workspace, type WorkspaceOptions } from "./workspace.js";
71+
export {
72+
type SyncRetryIntent,
73+
type SyncRetryOptions,
74+
type SyncRetryScheduler,
75+
Workspace,
76+
type WorkspaceOptions,
77+
type WorkspaceRetryPendingSyncResult,
78+
} from "./workspace.js";
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import type { ChangeEntry } from "@cloudflare/dofs";
2+
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
3+
import { describe, expect, it } from "vitest";
4+
5+
import type { BackendHandle, WorkspaceBackend } from "./backend.js";
6+
import type {
7+
SyncRetryIntent,
8+
SyncRetryScheduler,
9+
WorkspaceRetryPendingSyncResult,
10+
} from "./workspace.js";
11+
import { Workspace } from "./workspace.js";
12+
13+
class MemoryRetryScheduler implements SyncRetryScheduler {
14+
readonly intents = new Map<string, SyncRetryIntent>();
15+
readonly scheduled: SyncRetryIntent[] = [];
16+
readonly cleared: string[] = [];
17+
18+
async get(backend: string): Promise<SyncRetryIntent | undefined> {
19+
return this.intents.get(backend);
20+
}
21+
22+
async schedule(intent: SyncRetryIntent): Promise<void> {
23+
this.intents.set(intent.backend, intent);
24+
this.scheduled.push(intent);
25+
}
26+
27+
async clear(backend: string): Promise<void> {
28+
this.intents.delete(backend);
29+
this.cleared.push(backend);
30+
}
31+
}
32+
33+
function retryBackend(options: {
34+
onExec(): void;
35+
fetchChanges: import("@cloudflare/workspace-rpc").SyncRPC["fetchChanges"];
36+
close?: () => Promise<void>;
37+
}): WorkspaceBackend {
38+
const sync: import("@cloudflare/workspace-rpc").SyncRPC = {
39+
async push(input) {
40+
return { rev: 0, appliedPushCursor: { rev: input.senderRev, path: null } };
41+
},
42+
fetchChanges: options.fetchChanges,
43+
async readEntry() {
44+
return null;
45+
},
46+
async hasObjects(hashes) {
47+
return hashes;
48+
},
49+
fetchObjects() {
50+
return new ReadableStream({ start: (controller) => controller.close() });
51+
},
52+
async watermarks() {
53+
return { currentRev: 0, pushRev: 0, fetchCursor: { rev: 0, path: null } };
54+
},
55+
async pushObjects() {},
56+
};
57+
return {
58+
id: "sandbox",
59+
type: "fake",
60+
async connect(): Promise<BackendHandle> {
61+
return {
62+
rpc: {
63+
sync,
64+
shell: {
65+
async exec() {
66+
options.onExec();
67+
return {
68+
id: "command-1",
69+
events: new ReadableStream({
70+
start(controller) {
71+
controller.enqueue({ id: "command-1", seq: 1, name: "exit", value: 0 });
72+
controller.close();
73+
},
74+
}),
75+
};
76+
},
77+
async getExec() {
78+
throw new Error("not used");
79+
},
80+
async killExec() {},
81+
async disposeExec() {},
82+
},
83+
},
84+
close: options.close ?? (async () => {}),
85+
};
86+
},
87+
};
88+
}
89+
90+
async function runCommand(ws: Workspace): Promise<WorkspaceRetryPendingSyncResult | undefined> {
91+
const handle = await ws.shell.exec("build", { encoding: "utf8" });
92+
const result = await handle.result();
93+
expect(result.sync.status).toBe("pending");
94+
return undefined;
95+
}
96+
97+
describe("Workspace durable pending-sync retries", () => {
98+
it("schedules the exact durable retry intent after a post-command pull failure", async () => {
99+
const scheduler = new MemoryRetryScheduler();
100+
let execs = 0;
101+
const backend = retryBackend({
102+
onExec: () => execs++,
103+
async fetchChanges() {
104+
throw new Error("backend unavailable");
105+
},
106+
});
107+
const ws = new Workspace({
108+
storage: new SQLiteTestStorage(),
109+
backends: [backend],
110+
retryScheduler: scheduler,
111+
retry: { initialDelayMs: 2_000, maxDelayMs: 30_000, maxAttempts: 4 },
112+
now: () => 10_000,
113+
});
114+
115+
await runCommand(ws);
116+
117+
expect(execs).toBe(1);
118+
expect(scheduler.scheduled).toEqual([{ backend: "sandbox", attempt: 1, notBefore: 12_000 }]);
119+
});
120+
121+
it("resumes a partial batch from the persisted cursor and converges without rerunning the command", async () => {
122+
const scheduler = new MemoryRetryScheduler();
123+
const after: Array<{ rev: number; path: string | null } | undefined> = [];
124+
let fetches = 0;
125+
let execs = 0;
126+
const entries = Array.from(
127+
{ length: 257 },
128+
(_, index): ChangeEntry => ({
129+
kind: "delete",
130+
rev: 1,
131+
path: `/generated/${index.toString().padStart(3, "0")}`,
132+
mtime: 1,
133+
}),
134+
);
135+
const backend = retryBackend({
136+
onExec: () => execs++,
137+
async fetchChanges(input) {
138+
after.push(input.after);
139+
fetches++;
140+
const remaining = entries.filter((entry) => {
141+
if (!input.after || input.after.rev < entry.rev) return true;
142+
return (
143+
input.after.rev === entry.rev &&
144+
input.after.path !== null &&
145+
entry.path > input.after.path
146+
);
147+
});
148+
return {
149+
currentCursor: { rev: 1, path: null },
150+
appliedPushCursor: { rev: 0, path: null },
151+
stream:
152+
fetches === 1
153+
? new ReadableStream<ChangeEntry>({
154+
pull(controller) {
155+
const entry = remaining.shift();
156+
if (entry !== undefined) {
157+
controller.enqueue(entry);
158+
return;
159+
}
160+
controller.error(new Error("lost after first batch"));
161+
},
162+
})
163+
: new ReadableStream<ChangeEntry>({
164+
start(controller) {
165+
for (const entry of remaining) controller.enqueue(entry);
166+
controller.close();
167+
},
168+
}),
169+
};
170+
},
171+
});
172+
const ws = new Workspace({
173+
storage: new SQLiteTestStorage(),
174+
backends: [backend],
175+
retryScheduler: scheduler,
176+
retry: { initialDelayMs: 100, maxDelayMs: 1_000, maxAttempts: 3 },
177+
now: () => 5_000,
178+
});
179+
180+
await runCommand(ws);
181+
const retried = await ws.retryPendingSync("sandbox");
182+
183+
expect(retried).toMatchObject({ status: "complete", applied: 1 });
184+
expect(execs).toBe(1);
185+
expect(after).toEqual([
186+
{ rev: 0, path: null },
187+
{ rev: 1, path: "/generated/255" },
188+
]);
189+
expect(scheduler.intents.size).toBe(0);
190+
expect(scheduler.cleared).toEqual(["sandbox"]);
191+
});
192+
193+
it("coalesces repeated command failures into one pending intent per backend", async () => {
194+
const scheduler = new MemoryRetryScheduler();
195+
const backend = retryBackend({
196+
onExec() {},
197+
async fetchChanges() {
198+
throw new Error("still unavailable");
199+
},
200+
});
201+
const ws = new Workspace({
202+
storage: new SQLiteTestStorage(),
203+
backends: [backend],
204+
retryScheduler: scheduler,
205+
now: () => 1_000,
206+
});
207+
208+
await Promise.all([runCommand(ws), runCommand(ws)]);
209+
210+
expect(scheduler.scheduled).toHaveLength(1);
211+
expect(scheduler.intents.size).toBe(1);
212+
});
213+
214+
it("reschedules with bounded exponential backoff and leaves exhaustion visible", async () => {
215+
const scheduler = new MemoryRetryScheduler();
216+
const backend = retryBackend({
217+
onExec() {},
218+
async fetchChanges() {
219+
throw new Error("still unavailable");
220+
},
221+
});
222+
const ws = new Workspace({
223+
storage: new SQLiteTestStorage(),
224+
backends: [backend],
225+
retryScheduler: scheduler,
226+
retry: { initialDelayMs: 100, maxDelayMs: 150, maxAttempts: 3 },
227+
now: () => 1_000,
228+
});
229+
230+
await runCommand(ws);
231+
expect(await ws.retryPendingSync()).toMatchObject({ status: "pending", attempt: 2 });
232+
expect(await ws.retryPendingSync()).toMatchObject({ status: "pending", attempt: 3 });
233+
expect(await ws.retryPendingSync()).toMatchObject({ status: "exhausted", attempt: 3 });
234+
235+
expect(scheduler.scheduled).toEqual([
236+
{ backend: "sandbox", attempt: 1, notBefore: 1_100 },
237+
{ backend: "sandbox", attempt: 2, notBefore: 1_150 },
238+
{ backend: "sandbox", attempt: 3, notBefore: 1_150 },
239+
]);
240+
expect(scheduler.intents.get("sandbox")).toEqual({
241+
backend: "sandbox",
242+
attempt: 3,
243+
notBefore: 1_150,
244+
});
245+
});
246+
247+
it("disposes a failed retry envelope and closes its RPC handle", async () => {
248+
const scheduler = new MemoryRetryScheduler();
249+
let closes = 0;
250+
let disposals = 0;
251+
const backend = retryBackend({
252+
onExec() {},
253+
async fetchChanges() {
254+
return {
255+
currentCursor: { rev: 1, path: null },
256+
appliedPushCursor: { rev: 0, path: null },
257+
stream: new ReadableStream<ChangeEntry>({
258+
start(controller) {
259+
controller.error(new Error("cancel retry stream"));
260+
},
261+
}),
262+
[Symbol.dispose]() {
263+
disposals++;
264+
},
265+
};
266+
},
267+
close: async () => {
268+
closes++;
269+
},
270+
});
271+
const ws = new Workspace({
272+
storage: new SQLiteTestStorage(),
273+
backends: [backend],
274+
retryScheduler: scheduler,
275+
});
276+
scheduler.intents.set("sandbox", { backend: "sandbox", attempt: 1, notBefore: 0 });
277+
278+
await expect(ws.retryPendingSync()).resolves.toMatchObject({ status: "pending" });
279+
await ws.close();
280+
281+
expect(disposals).toBe(1);
282+
expect(closes).toBe(1);
283+
});
284+
});

0 commit comments

Comments
 (0)