Skip to content

Sync planning scans the full pending change set before yielding; repeated Durable Object CPU-limit failures #225

Closed

Description

@lumpinif

Describe the bug

The sync coalescer performs work for the entire pending change set before yielding its first entry. A downstream block limit therefore does not bound this initial planning work.

We investigated repeated container-exec failures with @cloudflare/computer@0.4.1. Six invocations on the same SQLite-backed Durable Object reported exceededCpu at 32,500 ms and an instance reset. Each invocation included the same large batch of VFS path-resolution queries before failure.

Evidence boundary: the eager planning behavior is confirmed in source. The telemetry makes it a strong candidate contributor to these CPU failures, but we do not have per-stage CPU profiling or a standalone reproducer that proves it accounts for the entire CPU budget. We also cannot establish from these traces whether the shell command had started. A Durable Object reset is not evidence that the container restarted.

Source path

Incident package: @cloudflare/computer@0.4.1. We also checked the following upstream sources while preparing this report:

  • coalesce.ts: coalesceChanges calls db.all for the full touched-inode revision range, resolves all touched inodes through pathsOfMany, builds the candidate map, merges tombstones and sorts the candidates before the first yield.
  • paths.ts: pathsOfMany batches 512 inodes per recursive SQL query, but synchronously processes every batch before returning.

In the inspected 0.4.1 bundle, the exec path pushes changes before spawnShell. Its mode-selection probe and block entry/byte limits consume the coalescer after this eager work. Smaller output blocks therefore do not by themselves bound the work before the first block.

Observed behavior

Sanitized aggregate measurements from six failed invocations:

Measurement Observed value
Invocation active CPU 32,500 ms each
Invocation wall time Approximately 37.7–40.2 seconds
Path-resolution query batches 449 per invocation
Billed rows read for those path batches Approximately 10.34 million per invocation
Earlier touched-range query Approximately 229,500 billed rows read

The SQL templates were matched to coalesceChanges → pathsOfMany → collectBatch. The large enumeration repeated on subsequent attempts. Other lightweight operations on the same object could succeed.

rows_read is a platform billing metric, not a unique-node count or a CPU measurement. We did not recover the incident cursor values and are not claiming cursor corruption. We have omitted workspace contents, paths, object IDs and private trace identifiers.

Expected behavior

Preparing the first sync block should have a bounded work budget that can make durable progress on a large change set under the Durable Object CPU limit. Reconnection should not force unbounded planning to restart before any progress can be acknowledged.

Reducing the wire block size should either bound the preparatory work too, or the API should clearly expose the separate planning cost and a supported way to bound it.

Steps to reproduce / current reproduction limits

The observed trigger was a container-backed workspace with a large pending change set, followed by a minimal shell-command request through workspace.runtime.exec. Repeated attempts encountered the measurements above.

We do not yet have a portable, self-contained reproduction of the exact deployed CPU failure. The source-level eager-work boundary can be isolated as follows:

  1. Use the existing real SQLite test harness to create a large nested tree and retain a cursor preceding those writes.
  2. Instrument database calls, then request only the first entry from coalesceChanges(db, cursor).
  3. Observe that the full touched range and all path-resolution batches are processed before that first entry is returned.
  4. For a platform reproduction, prepare equivalent data across bounded setup requests in a disposable SQLite Durable Object with the container backend, then issue a minimal exec and record platform CPU plus sync-planning/spawn stage markers.

These are suggested isolation steps, not a claim that this exact standalone fixture has already reproduced the CPU reset. Tree depth, pending revision range and peer cursor state should be reported with any reproduction.

Environment

  • Incident SDK and computerd image version: 0.4.1.
  • Cloudflare Workers, SQLite-backed Durable Object, container execution backend.
  • Deployed compatibility date: 2026-07-15.
  • No explicit limits.cpu_ms override.
  • Source inspection during this report: upstream commit 61f5aeae56e8e7ed75dddf235a9e8983508a0b39. We have not reproduced the platform failure on that newer commit.
  • The consumer had local patches for stream decoding, cancellation and read-handle behavior; the incident patch did not alter sync planning or schema.

Possible direction

Could the existing sync planner bound candidate reading, path reconstruction and sorting before the first output block, using its existing cursor/target mechanism? Any change needs to preserve same-revision path ordering, hard links, tombstones, concurrent changes and lost-acknowledgement recovery; simply limiting the initial SQL result could silently skip changes.

Related: #67 concerns retained tombstones. This report concerns eager planning over the current pending live-node set as well, so pruning old tombstones alone would not address the boundary described here.

Submitting an issue as requested by CONTRIBUTING.md. Maintainer guidance on a supported bounded-planning approach would be appreciated.

Activity

  1. aron-cf commented on Oct 12, 2026

    @aron-cf
    Collaborator

    Hey, thanks for the bug report. The current implementation has been running on a wide variety of workloads for the past six months without hitting CPU limits. I'm not saying that there aren't potential issues here. But I'm going to need a more concrete reproduction in order to action this ticket. I'll close it for now, please re-open if you can provide more details.

  2. ian-pascoe commented on Oct 22, 2026

    @ian-pascoe

    We hit what looks like this in production, and have the telemetry you asked for. Happy to provide more if it helps.

    Setup: @cloudflare/computer 0.5.1, computerd 0.5.1 (ghcr.io/cloudflare/computer-computerd-linux-x64:0.5.1), ContainerBackend (scheduling_policy: "durable_object") alongside WorkerShellBackend, ignore set (**/node_modules, **/.venv, caches), computerd's default in-memory store.

    Trigger: the Workspace's SQLite filesystem held 96,320 files (1.5 GB) in only 406 directories. 95,729 of those files were a pnpm content-addressed store at /workspace/.pnpm-store/v11/files/{00..ff}, about 374 files per directory. pnpm put the store at the root of the FUSE mount because the home directory is on a different filesystem, and our ignore list didn't cover it. With the in-memory store, every new container comes up empty, reconcileWatermarks resets the push cursor to 0, and the first command waits for a push of the whole tree.

    What Workers Observability showed (one Durable Object, Workers traces):

    • First container: about 104,000 durable_object_storage_exec statements in roughly three minutes, under the computerd /api WebSocket invocation. That invocation reported 164 s wall and 46.9 s CPU, and the container was then replaced.
    • Second container: 48,140 statements in about three minutes, all under one parent span that had not closed when we looked. Top statement templates in that window:
      • 20,922 × SELECT inode, type, mode, mtime, size, link_target FROM vfs_nodes WHERE inode = ?
      • 17,799 × SELECT child_inode FROM vfs_dirents WHERE parent_inode = ? AND name = ?
      • 2,956 × SELECT rev FROM vfs_nodes WHERE inode = ?
      • 2,909 × SELECT hash, size FROM vfs_chunks WHERE inode = ? ORDER BY idx
      • 2,566 × the recursive targets(inode) / walk(target, link, depth, name, parent_inode) CTE
      • 794 × SELECT bytes FROM vfs_blob_bytes WHERE hash = ?
    • While the push ran, unrelated cheap RPCs on the same object (a settings read, __getWorkspaceStub) waited 26–29 s with about 0 ms CPU of their own. One RPC of ours that walks a few directories through workspace.fs (normally 2 ms) reported 103 s wall and 79.5 s CPU. We can't attribute that CPU between our call and the concurrent push.
    • Earlier the same day: an /api WebSocket invocation held for 4.76 h (47 s CPU) ended with Durable Object connection closed because the object was reset. The object's alarms in the half hour before took 600 s and 39 s of wall time (normally about 2 ms).

    To users, simple commands "hang": they queue behind the push.

    What fixed it for us:

    1. **/.pnpm-store in ignore, so pnpm's store stays on the container's disk. pnpm can still hard-link it into an ignored node_modules; we checked that on a deployed container.
    2. COMPUTERD_DB=/var/lib/computerd/state.db, so a container restored from a snapshot keeps its sync positions. Measured on a deployed standard-2: a 3,001-file Workspace restored from its idle snapshot was pushed 1 entry in 1.1 s; launched from the image it was pushed 3,087 entries in 7.2 s.

    Two things that would have helped, in case they're useful:

    • A path added to ignore after files were synced there is still pushed to every new container, into computerd's store where commands never see it. coalesceChanges already takes an ignore option, but Workspace doesn't pass one for pushes.
    • The way out is rm -r of the tree in the Durable Object, which records one vfs_changes tombstone per path. With Prune acknowledged tombstones from vfs_changes so the table stops growing without bound #67 open, a fresh container would then be sent 96k deletes on every launch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions