How SyncRetryScheduler Persists Retry State for Failed Post‑Command Pulls
The SyncRetryScheduler interface persists retry state using Durable Object KV storage with namespaced keys like retry:${backend}, combined with setAlarm() to schedule wake‑up times for exponential back‑off retries.
When a post‑command pull fails in the Cloudflare Computer framework, the system must remember to retry later—even across worker restarts. The SyncRetryScheduler abstraction provides this durability boundary, separating intent definition from storage mechanics. This article examines the scheduler's design, persistence implementation, and testing patterns based on the cloudflare/computer source code.
SyncRetryIntent: The Retry State Structure
Before exploring persistence, understand what gets stored. The SyncRetryIntent interface—defined in [packages/computer/src/workspace.ts](https://github.com/cloudflare/computer/blob/main/packages/computer/src/workspace.ts#L54-L61)—encapsulates everything needed to resume a failed pull:
interface SyncRetryIntent {
backend: string; // Target backend identifier
runtimeId?: string; // Optional runtime context
attempt: number; // Current retry attempt (0‑based)
notBefore: number; // Epoch milliseconds for next retry
}
The notBefore timestamp is critical: it enables exponential back‑off by computing future wake‑up times based on the attempt count.
The SyncRetryScheduler Interface
The scheduler contract—found at workspace.ts#L69-L73—is intentionally minimal:
interface SyncRetryScheduler {
get(backend: string): Promise<SyncRetryIntent | undefined>;
schedule(intent: SyncRetryIntent): Promise<void>;
clear(backend: string): Promise<void>;
}
Each method serves a distinct lifecycle purpose:
get(backend)– Retrieves any pending intent for a backend, returningundefinedif no retry is scheduled.schedule(intent)– Persists (or overwrites) the intent and triggers the alarm mechanism.clear(backend)– Removes stored state after success or exhaustion, preventing further retries.
Production Persistence: Durable Object Storage
The production implementation uses Durable Object KV storage (ctx.storage) for durability. Here's how SyncRetryScheduler persists retry state in practice:
class DurableObjectRetryScheduler implements SyncRetryScheduler {
constructor(private readonly storage: DurableObjectStorage) {}
async get(backend: string): Promise<SyncRetryIntent | undefined> {
const raw = await this.storage.get(`retry:${backend}`);
return raw
? JSON.parse(raw as string) as SyncRetryIntent
: undefined;
}
async schedule(intent: SyncRetryIntent): Promise<void> {
const key = `retry:${intent.backend}`;
// Persist the retry intent
await this.storage.put(key, JSON.stringify(intent));
// Schedule wake-up for exponential back-off retry
await this.storage.setAlarm(intent.notBefore);
}
async clear(backend: string): Promise<void> {
await this.storage.delete(`retry:${backend}`);
await this.storage.setAlarm(null); // Cancel pending alarm
}
}
Key implementation details:
- Namespaced keys – The
retry:${backend}pattern prevents collisions with other storage usage. - Atomic scheduling – Both the intent and alarm are set in the same
schedule()call, ensuring consistency. - Alarm integration –
setAlarm(notBefore)guarantees the Durable Object wakes exactly when needed to invokeretryPendingSync(backend).
Alarm‑Driven Retry Loop
Persistence alone isn't sufficient—the scheduler coordinates with the Durable Object alarm system. When schedule() calls setAlarm(intent.notBefore), the following sequence occurs:
- The Durable Object sleeps until the alarm fires.
- The runtime invokes
retryPendingSync(backend), which:- Calls
scheduler.get(backend)to read the persistedSyncRetryIntent. - Attempts the post‑command pull again.
- On failure: increments
attempt, recomputesnotBeforewith exponential back‑off, and callsschedule(updatedIntent). - On success: calls
scheduler.clear(backend).
- Calls
This loop continues until success or maxAttempts exhaustion, with all state surviving restarts due to KV storage.
Integrating SyncRetryScheduler with Workspace
To enable retry persistence, pass a scheduler instance when constructing a Workspace:
const ws = new Workspace({
storage: ctx.storage,
backends: [...],
retryScheduler: new DurableObjectRetryScheduler(ctx.storage),
retry: {
initialDelayMs: 2000,
maxDelayMs: 30000,
maxAttempts: 5
},
});
The retry configuration object controls back‑off behavior, while retryScheduler handles the actual state persistence.
Testing: The MemoryRetryScheduler Double
The test suite in [packages/computer/src/retry.test.ts](https://github.com/cloudflare/computer/blob/main/packages/computer/src/retry.test.ts#L14-L30) provides a deterministic, in‑memory implementation:
class MemoryRetryScheduler implements SyncRetryScheduler {
private readonly map = new Map<string, SyncRetryIntent>();
async get(backend: string): Promise<SyncRetryIntent | undefined> {
return this.map.get(backend);
}
async schedule(intent: SyncRetryIntent): Promise<void> {
this.map.set(intent.backend, intent);
}
async clear(backend: string): Promise<void> {
this.map.delete(backend);
}
}
This double allows unit tests to verify retry logic without spinning up Durable Objects or handling actual timer delays.
Key Files Reference
| File | Purpose |
|---|---|
packages/computer/src/workspace.ts |
Defines SyncRetryIntent, SyncRetryScheduler interface, and Workspace configuration |
packages/computer/src/retry.test.ts |
Contains MemoryRetryScheduler test double and retry contract validation |
Summary
SyncRetrySchedulerprovides a minimal, durable interface for recording retry intent after failed post‑command pulls.- Production persistence uses Durable Object KV storage with
retry:${backend}keys andsetAlarm()for wake‑up scheduling. - State structure (
SyncRetryIntent) includesattemptcounters andnotBeforetimestamps to drive exponential back‑off. - Testing relies on
MemoryRetryScheduler, aMap‑backed double that validates behavior without external dependencies.
Frequently Asked Questions
What happens to retry state if the Durable Object restarts?
The retry state survives restarts because SyncRetryScheduler persists intents to ctx.storage, which is backed by Cloudflare's distributed KV storage. When the Durable Object restarts, scheduler.get(backend) returns the same intent that was stored before the restart.
How does exponential back‑off work with the scheduler?
The scheduler itself doesn't compute delays—it stores the result. When retryPendingSync detects a failure, it increments intent.attempt, calculates a new notBefore using Math.min(initialDelay * 2^attempt, maxDelay), and calls schedule(updatedIntent) to persist both the new state and alarm time.
Can I implement a custom SyncRetryScheduler for external storage?
Yes. The interface only requires three async methods. You could implement a Redis‑backed or database‑backed scheduler by adapting get, schedule, and clear to your storage of choice. Ensure schedule also handles alarm semantics if you need wake‑up behavior.
Why separate SyncRetryScheduler from the Workspace class?
This separation follows the dependency inversion principle. Workspace orchestrates sync logic but delegates durability concerns to an injected scheduler. This enables testing with MemoryRetryScheduler and allows alternative persistence strategies without modifying core sync behavior.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →