How Backend Cursors Remain Independent When Multiple Execution Backends Share a Single Workspace in Cloudflare Computer
Multiple execution backends maintain independent cursors in Cloudflare Computer by keying every watermark and cursor value with a unique backend identifier in shared SQLite tables.
Cloudflare Computer enables a single Workspace instance to coordinate sync operations across heterogeneous execution environments—such as container runtimes and shell processes—without cursor collisions. This article explains the architectural mechanisms in the @cloudflare/computer and @cloudflare/dofs packages that guarantee isolation, drawing directly from the source implementation.
The Backend Identifier as Isolation Key
Every execution backend registered with a Workspace receives a stable, caller-supplied id. This identifier permeates all sync state operations, ensuring distinct cursor tracks.
When constructing a Workspace, the backends array in WorkspaceOptions defines each backend with a unique string identifier:
const ws = new Workspace({
storage: ctx.storage,
backends: [
{ id: "container", ...MyContainerBackend }, // isolated as "container"
{ id: "shell", ...MyShellBackend }, // isolated as "shell"
],
});
These identifiers are not merely cosmetic—they become part of the primary key in the underlying sync tables.
Per-Backend Watermark Storage in DOFS
The DOFS (Distributed Object File System) sync layer implements all cursor read/write operations in packages/dofs/src/sync/watermarks.ts. Every helper accepts an optional backend parameter defaulting to DEFAULT_BACKEND_ID = "default":
readWatermark(db, which, backend?)writeWatermark(db, which, position, backend?)readFetchCursor(db, backend?)writeFetchCursor(db, position, backend?)writeFetchCursorIfAhead(db, position, backend?)
The SQL schema enforces isolation at the storage level. Both _vfs_watermark and _vfs_fetch_cursor tables include a backend column as part of their composite primary key (k, backend). This schema design guarantees that:
- The same logical key (e.g.,
fetchRev) exists as separate rows for each backend - No backend can overwrite another's cursor accidentally
- Query operations return only the rows matching the specified backend parameter
RPC Driver Forwards Backend Context
The sync driver in packages/rpc/src/sync-driver.ts orchestrates push and pull operations without conflating backend state. Its core methods—pullOnce, pushOnce, and reconcileWatermarks—receive a backend identifier from the caller and propagate it unchanged to the watermark helpers:
// From sync-driver.ts conceptual flow:
async function pullOnce(db, backend, options) {
const cursor = readFetchCursor(db, backend); // reads only this backend's cursor
// ... fetch operations using backend-specific cursor ...
writeFetchCursor(db, newPosition, backend); // writes only this backend's cursor
}
This pass-through design ensures that the RPC layer itself never mixes cursor state between backends. Each pull("container") or push("shell") invocation advances exactly one cursor track.
In-Memory Per-Backend State in Workspace
The Workspace class in packages/computer/src/workspace.ts mirrors the database isolation with in-memory data structures keyed by backend identifier:
#mutationTails: Map<string, MutationTail>— per-backend mutation queues#handles: Map<string, HandleCache>— cached filesystem handles#commandHandles: Map<string, CommandHandleCache>— cached command interfaces#moduleHandles: Map<string, ModuleHandleCache>— cached module references
When ws.push("container") executes, the workspace:
- Resolves the backend ID to its configuration
- Accesses only the
#mutationTailsentry for"container" - Invokes
pushOncewith"container"as the backend parameter - Returns results scoped to that backend's applied mutations
Concurrent operations on different backends proceed without blocking because they operate on disjoint key spaces in both memory and persistent storage.
Practical Usage: Isolated Sync Operations
The following patterns demonstrate correct backend-scoped operations:
Pushing Changes for a Specific Backend
// Advance only the container backend's cursors
await ws.push("container");
Pulling with Backend-Specific Results
// Receive and apply only entries for the shell backend
const result = await ws.pull("shell");
console.log(result.applied); // mutations from "shell" backend only
Debugging Raw Cursor Values
Direct database inspection requires the backend identifier to retrieve the correct row:
import { readFetchCursor, readWatermark } from "@cloudflare/dofs";
const db = ws.db;
// Distinct rows, distinct values
const containerFetch = readFetchCursor(db, "container");
const shellFetch = readFetchCursor(db, "shell");
const containerPushRev = readWatermark(db, "pushRev", "container");
const shellPushRev = readWatermark(db, "pushRev", "shell");
Without the backend parameter, all calls would return or modify the DEFAULT_BACKEND_ID row—likely corrupting sync state in multi-backend configurations.
Summary
- Backend identifiers supplied at
Workspaceconstruction become the isolation boundary for all sync operations - SQLite primary keys in
_vfs_watermarkand_vfs_fetch_cursorinclude the backend column, creating separate row namespaces - DOFS watermark helpers accept and propagate the backend parameter through all read/write paths
- RPC sync driver forwards caller-provided backend IDs without interpretation, preventing cross-backend cursor leakage
- Workspace internal maps maintain per-backend handle caches and mutation queues for memory-level isolation
These layered mechanisms—storage schema, API design, and runtime data structures—collectively guarantee that multiple execution backends share a workspace without interfering in each other's sync progress.
Frequently Asked Questions
What happens if two backends use the same identifier?
Cloudflare Computer treats identical id values as the same backend. Their cursors would collide in the database, with last-write-wins behavior on _vfs_watermark and _vfs_fetch_cursor rows. Always assign unique, stable identifiers when registering multiple backends.
Can a workspace dynamically add or remove backends after creation?
The WorkspaceOptions.backends array is processed at construction time. The source implementation in workspace.ts initializes #mutationTails, #handles, and related structures from this initial list. Dynamic backend reconfiguration would require workspace reconstruction; runtime backend list mutation is not supported in the current implementation.
Does per-backend isolation affect performance or storage overhead?
Each backend adds one row per cursor type (typically 2–3 rows: fetchRev, pushRev, fetchCursor). Storage overhead is negligible—SQLite handles sparse row sets efficiently. Performance remains linear per backend because queries use the composite primary key (k, backend) with index-optimized lookups.
How does backend isolation interact with the sync protocol specification?
The documentation in docs/02_sync_protocol.md formalizes cursor semantics and explicitly includes per-backend isolation as a requirement. The implementation in watermarks.ts and sync-driver.ts directly satisfies this specification, ensuring protocol-compliant behavior regardless of backend count or type.
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 →