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, returning undefined if 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 invoke retryPendingSync(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:

  1. The Durable Object sleeps until the alarm fires.
  2. The runtime invokes retryPendingSync(backend), which:
    • Calls scheduler.get(backend) to read the persisted SyncRetryIntent.
    • Attempts the post‑command pull again.
    • On failure: increments attempt, recomputes notBefore with exponential back‑off, and calls schedule(updatedIntent).
    • On success: calls scheduler.clear(backend).

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

  • SyncRetryScheduler provides a minimal, durable interface for recording retry intent after failed post‑command pulls.
  • Production persistence uses Durable Object KV storage with retry:${backend} keys and setAlarm() for wake‑up scheduling.
  • State structure (SyncRetryIntent) includes attempt counters and notBefore timestamps to drive exponential back‑off.
  • Testing relies on MemoryRetryScheduler, a Map‑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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →