How the Sync Protocol Works Between Cloudflare Durable Objects and computerd Containers
The sync protocol uses a bidirectional replication layer built on the SyncRPC interface, where the container pulls changes via fetchChanges and pushes via push, while the Durable Object serves as the authoritative source, reconciling watermarks to maintain consistency.
The sync protocol enables eventual consistency between Cloudflare Durable Objects and computerd containers by implementing a bidirectional RPC contract. This protocol powers the replication layer in the cloudflare/computer repository, ensuring that SQLite database states remain synchronized across edge and containerized environments.
Sync Protocol Architecture and Data Flow
The protocol operates through two primary directions: the Durable Object (DO) acts as the server exposing push and pushObjects methods, while the container acts as the client calling fetchChanges and fetchObjects. Both sides implement the SyncRPC interface defined in packages/rpc/src/interface.ts, creating a symmetric contract where data flows in opposite directions depending on which side initiates the sync.
- DO → Container: The Durable Object streams change entries and missing blob bytes via
pushandpushObjects. - Container → DO: The container pulls changes via
fetchChangesand retrieves missing blobs viafetchObjects. - Bidirectional probing: Both sides use
hasObjectsto check which chunk hashes the remote already possesses.
Core Implementation Files
Three files define the protocol:
packages/rpc/src/interface.ts: Defines theSyncRPCwire contract including method signatures forpush,fetchChanges,hasObjects, and watermarks.packages/rpc/src/server.ts: Implements the server-side logic that turns aDatabaseinto aSyncRPCendpoint, handling incoming push streams and change entry application.packages/rpc/src/sync-driver.ts: Contains the client-side driver used by computerd containers to orchestrate pull/push loops, watermark reconciliation, and divergence detection.
Establishing the RPC Connection
The Durable Object initializes the sync server by wrapping its database instance with createWorkspaceServer. This function combines the sync server with a shell server to create a WorkspaceRPC object that handles both synchronization and command execution.
// In the Durable Object (packages/rpc/src/server.ts)
export function createWorkspaceServer(
db: Database,
runner: RunnerLike,
options: ServerOptions = {}
): WorkspaceRPC {
return new WorkspaceRPCServer(
createSyncServer(db, options), // SyncRPC bound to the DO's database
createShellServer(runner) // ShellRPC for command execution
);
}
The DO attaches this server to a WebSocket session using acceptWebSocketSession, while the computerd container connects to the same WebSocket and receives a proxy implementing SyncRPC. The container-side driver in sync-driver.ts uses this proxy to drive the synchronization loop.
Reconciliation on (Re)connect
When a connection is established or re-established, the container must reconcile its local watermarks against the Durable Object's state. The reconcileWatermarks function in sync-driver.ts handles this baseline synchronization.
// packages/rpc/src/sync-driver.ts
export async function reconcileWatermarks(
db: Database,
remote: SyncRPC,
backend?: string,
): Promise<{ fetchRevReset: boolean; pushRevReset: boolean }> {
const remoteWatermarks = await remote.watermarks();
const localFetchCursor = readFetchCursor(db, backend);
const localPushRev = readWatermark(db, "pushRev", backend);
// Reset cursors if the remote's log is shorter or it missed pushes
}
If the Durable Object's revision log is shorter than the container remembers, the fetch cursor resets to revision 0. If the DO has not seen pushes the container previously made, the pushRev watermark resets. This "baseline-from-zero" approach ensures that subsequent pushOnce or pullOnce calls will safely resend all necessary data.
The Pull Path via fetchChanges
The container drives the pull side through pullOnce, which internally calls pullOnceImpl. This function performs a single round-trip synchronization in batches of PULL_BATCH_SIZE (256 entries).
The process follows these steps:
- Read local fetch cursor: The driver queries the local database for the last fetched revision using
readFetchCursor. - Request changes: The container calls
remote.fetchChanges({ after }), passing its current cursor. The Durable Object returns a stream containing:currentCursor: The DO's current revision snapshotappliedPushCursor: The DO's view of what the container has already pushedstream: AReadableStream<ChangeEntry>of batched change entries
- Detect divergence: If the DO's
appliedPushCursorlags behind the container'spushRev, or ifcurrentCursoris behind the fetch cursor, the driver cancels the stream, resets local watermarks, and retries once. - Batch processing: For each batch, the driver:
- Collects needed chunk hashes from file entries
- Probes the DO with
hasObjectsto identify missing chunks - Fetches missing blobs via
fetchObjectsand stages them withstageBlob - Applies change entries via
applyChanges - Advances the fetch cursor using
writeFetchCursorIfAhead
After the stream drains, the driver writes the final currentCursor as the new fetch cursor, ensuring the next pull starts after the last seen revision.
The Push Path via push
After pulling, the container pushes local changes via pushOnce. This function collects entries since the last pushRev, deduplicates chunk hashes, and streams both blobs and metadata to the Durable Object.
// packages/rpc/src/sync-driver.ts
export async function pushOnce(
db: Database,
remote: SyncRPC,
backend?: string
): Promise<number> {
const sincePush = readWatermark(db, "pushRev", backend);
const localRev = currentRev(db);
if (localRev <= sincePush) return 0; // Nothing new to push
// Collect entries, probe DO with hasObjects()
// Stream missing blobs via pushObjects()
// Stream change entries via push()
}
The push workflow:
- Collect entries: Gather all change entries since the last
pushRevwatermark. - Deduplicate chunks: Probe the Durable Object with
hasObjectsto determine which chunks need uploading. - Upload blobs: Stream missing chunks via
remote.pushObjects. - Stream changes: Send change entries via
remote.push. The Durable Object applies these transactionally usingapplyChangesSyncwithin atransactionSyncblock (lines 38-41 ofserver.ts). - Verify progress: The DO returns an
appliedPushCursorthat must cover the sender'ssenderRev. The driver asserts this viaassertAppliedPushCursor. - Update watermark: Finally, the driver updates the local
pushRevto the revision used for the push.
Full Sync Tick and Loop Suppression
A complete synchronization cycle consists of a tick: pulling first, then pushing. The tick function in sync-driver.ts implements this pattern:
export async function tick(
db: Database,
remote: SyncRPC,
): Promise<{ pulled: ApplyResult; pushed: number }> {
const pulled = await pullOnce(db, remote);
const pushed = await pushOnce(db, remote);
return { pulled, pushed };
}
Pulling before pushing prevents loopback—changes the DO just pushed to the container won't be immediately re-pushed in the same cycle. The container typically runs this tick on a timer or event-driven basis to maintain eventual consistency.
Fault Tolerance and Invariants
The protocol implements several safety mechanisms to handle network failures, restarts, and state divergence:
- Cross-side watermark divergence: Detected in
pullOnceImpl(lines 30-34 ofsync-driver.ts). The driver resets cursors and retries once; a second divergence throws an assertion error, indicating a protocol break. - Idempotent application: Both
applyChangesandapplyChangesSyncdrop entries whose live state already matches the incoming entry, making retries safe. - Chunk deduplication: Both pull and push sides send only chunks the peer lacks, limiting network traffic to O(batch size).
- Atomic commits: The server's
pushhandler wraps the entire batch intransactionSync, ensuring that change entries and blob references commit atomically.
Summary
- The sync protocol uses the
SyncRPCinterface to enable bidirectional replication between Cloudflare Durable Objects and computerd containers. - Three core files implement the protocol:
interface.tsdefines the contract,server.tsprovides the DO-side implementation, andsync-driver.tsdrives the container-side logic. - Watermark reconciliation via
reconcileWatermarksensures consistent baselines after reconnections or restarts. - The pull path uses
fetchChangesto stream batched entries andfetchObjectsto retrieve missing blobs, processing in batches of 256. - The push path uses
hasObjectsto deduplicate chunks, then streams viapushObjectsandpush, with the DO applying changes transactionally. - The
tickfunction sequences pull-then-push to prevent loopback and maintain eventual consistency.
Frequently Asked Questions
What happens when a computerd container reconnects after a crash?
When a container reconnects, it calls reconcileWatermarks to compare its local cursors against the Durable Object's state. If the DO's revision log is shorter than the container remembers, or if the DO missed previous pushes, the container resets its fetch or push cursors to zero. This "baseline-from-zero" approach ensures the next sync cycle safely retransmits all necessary data without corruption.
How does the protocol prevent sending duplicate data?
Both sides implement chunk-level deduplication using the hasObjects method. Before streaming blobs, the sender probes the receiver with a list of chunk hashes. Only the hashes the receiver lacks are sent via fetchObjects (during pull) or pushObjects (during push). This mechanism ensures network traffic remains proportional to the actual data differences rather than the total dataset size.
Why does the sync driver pull before pushing in each tick?
The tick function deliberately calls pullOnce before pushOnce to prevent "loopback." If the container pushed first, it might re-send changes that the Durable Object just propagated to it. By pulling first, the container absorbs the DO's latest state, ensuring that only truly local changes—those created since the last pull—are pushed upstream. This ordering is documented in the comment at line 66 of sync-driver.ts.
How does the Durable Object ensure data integrity during a push?
The server-side push handler in packages/rpc/src/server.ts wraps the entire change application in a transactionSync block (lines 38-41). This guarantees that change entries and their associated blob references commit atomically. If the transaction fails midway, the database rolls back to its previous state, preventing partial writes that could corrupt the SQLite VFS state.
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 →