How Bidirectional Synchronization Works Between Durable Objects and Containers
The sync protocol maintains consistency between Durable Objects and containers using revision cursors and watermarks, executing a pull-then-push tick that batches changes, validates cross-side invariants, and recovers from divergence by resetting cursors to re-establish a consistent baseline.
The Computer project implements a robust sync protocol to keep the SQLite-backed VFS inside a Durable Object (DO) synchronized with the FUSE-exposed VFS running in a container (computerd). When both sides mutate the filesystem independently, the protocol ensures eventual consistency through atomic resume points and watermark tracking defined in packages/rpc/src/interface.ts and implemented in packages/rpc/src/sync-driver.ts.
Core Concepts: Revision Cursors and Watermarks
The protocol tracks progress through watermarks persisted in the _vfs_watermark table on the DO side and the local Database abstraction on the container side.
pushRev(DO side): The highest DO revision successfully pushed to the container.fetchCursor(DO side): The resume point for the next pull, formatted as{ rev, path }wherepath=nullindicates all changes up torevhave been offered.currentRev: A monotonic revision counter stamped on each mutation by the respective side.appliedPushCursor: The cursor reported by the receiver after applying a batch, guaranteeing the receiver has caught up to the sender’spushRev.ChangeCursor: The atomic resume type{ rev: number; path: string | null }used by both sides to resume interrupted transfers.
These values enable idempotent transfers and bounded-memory processing through batching.
The Pull Phase: Container to Durable Object
The pullOnce() function in packages/rpc/src/sync-driver.ts (around lines 91-130) handles ingestion of container changes into the DO:
- The DO reads its stored fetch cursor via
readFetchCursor. - It calls
remote.fetchChanges({ after })on the container RPC interface. The response includes:currentCursor: The container’s latest revision at stream open.appliedPushCursor: The container’s progress on previous pushes.stream: AReadableStream<ChangeEntry>of entries after theaftercursor.
- The DO validates the cross-side invariant through
assertAppliedPushCursor, ensuring the container has applied everything up to the DO’spushRev. If this invariant fails, the driver resets cursors and retries once (see lines 110-170). - Entries are consumed in batches of
PULL_BATCH_SIZE = 256to bound memory usage. For each batch:- Collect required chunk hashes.
- Call
remote.hasObjectsto identify missing chunks. - Stream missing chunks via
remote.fetchObjectsand stage them withstageBlob. - Apply the batch using
applyChanges. - Advance the fetch cursor with
writeFetchCursorIfAhead.
- After stream exhaustion, the final fetch cursor is persisted.
The Push Phase: Durable Object to Container
The pushOnce() function (around lines 92-64 in packages/rpc/src/sync-driver.ts) propagates DO changes to the container:
- Read the local
pushRevwatermark usingreadWatermark. - Coalesce all local changes since
pushRevviacoalesceChanges. - Probe the container with
remote.hasObjectsto determine missing chunks, then stream those chunks viaremote.pushObjects. - Stream the
ChangeEntrybatch viaremote.push({ senderRev, changes }). - The container returns
appliedPushCursor; the DO asserts this covers its localpushRev(lines 54-58). - On successful validation, the DO advances its
pushRevto the local revision observed at batch start usingwriteWatermark.
This phase ensures the container receives all mutations since the last successful push.
Watermark Reconciliation and Divergence Recovery
When a connection is (re)established, the DO calls reconcileWatermarks() (lines 95-133 in the driver) to establish a consistent baseline. This function compares the remote’s watermarks (retrieved via remote.watermarks()) with local values. If cursors diverge—indicating a split-brain or lost update scenario—the driver resets the diverging cursor to revision 0. This forces the next pull or push to re-ship the full state from scratch, guaranteeing consistency over availability during recovery.
Revision Cursor Semantics and Protocol Guarantees
Resume points, not snapshots: A cursor { rev, path } marks a resume position, not a snapshot. When path is null, all changes up to rev are confirmed delivered. A non-null path allows mid-revision resumption if a batch interrupts (see cursorComplete in the driver).
Bidirectional flow: Each side acts as both sender and receiver:
- The DO pushes changes via
push→ the container receives them and updates its fetch cursor. - The container exposes changes via
fetchChanges→ the DO pulls them and updates its fetch cursor.
Safety guarantees:
- Idempotence: Already-applied entries are silently dropped.
- Bounded memory: Fixed batch sizes prevent unbounded buffer growth.
- Echo prevention: The cross-side invariant ensures a side never re-pushes data the peer has already applied, avoiding infinite loops.
Implementation Examples
Run a complete synchronization tick using tick(), which sequences pull before push:
import { Database } from "@cloudflare/dofs";
import { createSyncClient } from "@cloudflare/computer/rpc";
import { tick } from "@cloudflare/computer/rpc/src/sync-driver";
// Initialize the local SQLite DB (process-lifetime DB for the container)
const db: Database = /* ... */;
// Create the RPC stub that talks to the Durable Object
const remote = await createSyncClient({ /* DO address, credentials */ });
// Run a full sync tick – typically called after an exec or on a timer
async function syncLoop() {
const { pulled, pushed } = await tick(db, remote);
console.log(`Pulled ${pulled.applied} entries, pushed ${pushed} entries`);
}
// Periodic sync (e.g., every 5 seconds)
setInterval(syncLoop, 5_000);
Execute pull-only synchronization when you only need to ingest remote changes:
import { pullOnce } from "@cloudflare/computer/rpc/src/sync-driver";
async function justPull() {
const result = await pullOnce(db, remote);
console.log(`Applied ${result.applied} entries`);
}
Execute push-only synchronization for external orchestrators writing directly to the DO:
import { pushOnce } from "@cloudflare/computer/rpc/src/sync-driver";
async function justPush() {
const sent = await pushOnce(db, remote);
console.log(`Pushed ${sent} entries`);
}
Summary
- The sync protocol uses revision cursors (
{ rev, path }) as atomic resume points to track synchronization progress between Durable Objects and containers. - A full synchronization tick consists of pull (container → DO) followed by push (DO → container), implemented in
packages/rpc/src/sync-driver.ts. - Watermarks (
pushRev,fetchCursor,appliedPushCursor) persist in the_vfs_watermarktable and enable idempotent, bounded-memory transfers usingPULL_BATCH_SIZE = 256. - Divergence recovery via
reconcileWatermarks()resets diverging cursors to revision 0, forcing a full state re-ship to re-establish consistency. - The protocol guarantees idempotence and prevents echo loops through cross-side invariant checks in
assertAppliedPushCursor.
Frequently Asked Questions
What happens when the container and Durable Object have divergent revision histories?
When reconnecting, reconcileWatermarks() compares local and remote watermarks retrieved via remote.watermarks(). If cursors diverge, the driver resets them to revision 0, causing the next sync to re-ship the full state and establish a consistent baseline from scratch.
How does the sync protocol handle intermittent network failures during batch transfers?
The cursor format { rev, path } acts as a resume point. If path is non-null, the protocol resumes mid-revision using writeFetchCursorIfAhead; if null, all changes up to rev are confirmed delivered. This allows the driver to retry from the last successful cursor rather than restarting the entire sync.
What prevents infinite loops where changes echo back and forth between sides?
The protocol validates cross-side invariants via assertAppliedPushCursor, ensuring a side never re-pushes data the peer has already applied. Each side tracks appliedPushCursor to confirm the receiver has caught up to the sender's pushRev before proceeding.
Why does the implementation use a pull-then-push ordering instead of simultaneous bidirectional streaming?
The tick() function in packages/rpc/src/sync-driver.ts sequences pull before push to ensure the container has ingested all remote changes before attempting to push local mutations. This ordering simplifies conflict resolution and maintains the invariant that each side acts as both sender and receiver in distinct phases.
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 →