Skip to content

dofs: perf/bench and quickwins - #14

Merged
aron-cf merged 7 commits into
cloudflare:mainfrom
ndisidore:dofs-perf/bench-and-quickwins
Jul 6, 2026
Merged

dofs: perf/bench and quickwins#14
aron-cf merged 7 commits into
cloudflare:mainfrom
ndisidore:dofs-perf/bench-and-quickwins

Conversation

@ndisidore

Copy link
Copy Markdown
Member

Overview

Makes @cloudflare/dofs (the Durable Object SQLite virtual filesystem) fast on the path-heavy operations that dominate agent workloads — stat, exists, read, directory listing, delete, and rename — while preserving every design goal: content-addressed dedup, chunked buffered writes, hardlinks, small on-disk footprint, and sync convergence. A reproducible micro-benchmark harness (against real DO SqlStorage) lands first, so every change is measured, not asserted.

Net effect: operations that previously cost O(path-depth) SQL round-trips per call are now a single statement cold and ~O(1) warm; whole-subtree operations (delete, directory rename) are set-based; and reads no longer emit writes.

Motivation

dofs is used by agents to work on git repos, so it is overwhelmingly filesystem-op heavy: constant stat/exists/read against deep paths (node_modules, nested src/…), plus bursts of edits, deletes, and renames. The hot path was structurally expensive:

  • Path resolution walked one component at a time — ~1 + 2·depth SQL statements per lookup, no single-query resolve, no cache. A stat 20 levels deep cost 41 statements; provider.statSync doubled that to 83 (it resolved twice and added a link-count query).
  • Recursive delete re-resolved each node's parent from the root.
  • Directory rename bumped state per descendant (2N round-trips).
  • Reads issued a DB write per chunk (a last_seen touch), so grep -r wrote proportional to bytes scanned.
  • ls <dir> scanned the entire filesystem, and the sync object-probe ran one query per hash.

On Durable Objects each SQL statement is a billed row op and they run serially, so statement count is the cost that matters — and it was scaling with tree depth and size.

Commit-by-commit

  1. 72fc614 add filesystem micro-benchmark harness — depth-parameterized bench over real DO SqlStorage; deterministic statement counts + wall-clock, with a signature gate so future changes must consciously update the fingerprint.
  2. 72a9ffe drop redundant path resolves in statSync, rm, and writeSyncstatSync resolves once and folds in link-count (3+4·depth → 2+2·depth); recursive delete reuses the parent inode the walk already knows; non-append writeSync skips an unused stat.
  3. 3fa0566 store vfs_dirents and vfs_chunks WITHOUT ROWID — clusters those tables on their real PKs, so child_inode lives in the dirents leaf and a path-segment resolve is a single covering read. Includes the v4→v5 migration (recreating both secondary indexes the rebuild drops) with a lossless-migration test; vfs_blob_bytes intentionally untouched.
  4. 1e52048 resolve paths in one statement and cache path lookups — single recursive-CTE resolve (→ 1 statement) plus a per-DO positive/negative path→inode cache; falls back to the component walk on symlinks. Invalidated on every local mutation and on the sync-apply path.
  5. 49a48c0 batch directory-rename subtree writes and encode manifests once — directory rename applies the whole subtree with set-based SQL (2N → ~3 statements), producing a byte-identical change-log; manifest encodes once.
  6. a3a73f2 resolve reads without restamping last_seen — removes the per-chunk write on the read path; last_seen stays as the crash-window timer for staged-but-unlinked blobs.
  7. 60ab69c scope ls to its subtree and batch hasObjects and range readsls seeds its walk at the target inode instead of scanning the store; the sync object-probe matches raw hash blobs through an index-backed IN (…) list (bounded windows) instead of a query per hash; positional reads fetch chunks in one indexed range scan.

Real-world benchmarks

How we measure. The harness (packages/dofs/src/bench/) runs against a real Durable Object SqlStorage under workerd via @cloudflare/vitest-pool-workers — not a node SQLite stand-in, whose statement caching would understate cost. Two signals per operation, across path depths 1–20:

  • SQL statement count — deterministic run-to-run, and on DO this is the billed row-op count (the cost that scales).
  • wall-clock ns/op under workerd — secondary; ~1 ms-quantized, so small values are grain-limited.

Reproduce: cd packages/dofs && npm run bench.

Before → after (real DO SqlStorage; stmts = SQL statements = billed row ops):

Operation (real-world trigger) Before After
stat a file 20 dirs deep — fs.stat (editor open, git status) 41 stmts / 240µs 1 stmt / 15µs
stat a file 20 dirs deep — provider.statSync 83 stmts / 498µs 2 stmts / 18µs
repeat-stat the same deep path (build hot loop) 104µs (cold walk) 11.5µs (warm cache)
exists a missing file 16 deep (module resolution) 32 stmts / 174µs 1 stmt / 7µs
read a 4 KiB file 8 deep (cat, file open) 19 stmts / 104µs 3 stmts / 27µs
grep -r scanning N chunks (read amplification) 1 write per chunk 0 writes
rm -rf a 2000-entry tree 16 011 stmts 10 010 stmts
mv a directory with 500 descendants 1 017 stmts / 10µs/item 14 stmts / 8µs/item
ls <dir> scans whole FS subtree only

Dedup and footprint are preserved: 100 × 1 MiB identical files → 0.64 MiB on disk (content-addressed sharing intact), and WITHOUT ROWID made the DB marginally smaller (667,648 → 659,456 bytes).

Risks

  • Path cache correctness is the main surface. Mitigated by invalidating on every dirent mutation and the sync-apply path (all funnel through shared primitives); positive hits re-read the node row so metadata is never stale; the cache is never populated inside a transaction (rollback-safe) and is LRU-bounded. Covered by tests for negative-cache-then-create, subtree rename, hardlink, sync-applied change, and rolled-back write.
  • Directory-rename change-log must stay byte-identical for sync convergence. The set-based path is proven to emit the same tombstone rows and per-inode rev stamps as the old loop (including the interior-hardlink case), with insertion order shown inert to all readers.
  • CTE resolve falls back to the per-component walk on any symlink, so ENOENT/ENOTDIR/ELOOP and symlink-follow behavior is unchanged.
  • WITHOUT ROWID migration rebuilds vfs_dirents/vfs_chunks in one atomic transactionSync, recreating both secondary indexes and converging with the fresh-install DDL; covered by a lossless-migration test (data byte-identical, fresh == migrated). Caveat for large existing tenants: this is the first migration whose cost scales with filesystem size, and the consumer runs schema init synchronously on DO construction — so a very large store pays a one-time whole-table copy on first wake post-deploy. Worth measuring worst-case copy time / peak storage before GA; fresh installs get WITHOUT ROWID directly with no migration.
  • last_seen: reads no longer refresh it, so a read-then-deleted blob is reclaimable up to ~1 h sooner — strictly better for footprint; the staging crash-window guarantee is unchanged. (gc() is not yet wired to a scheduler, so this is latent regardless.)

All changes are behavior-preserving; no public API or wire-format change.

Testing

  • node: 430 passing · workers / real DO SqlStorage: 406 passing · tsc --noEmit and biome clean.
  • New tests: migration losslessness (fresh == migrated, data byte-identical), the path-cache invalidation matrix (including negative-under-symlink and CTE-bail-doesn't-poison), rename change-log equivalence, ls subtree-independence + symlink prefix, hasObjects order/duplicates, readRange multi-chunk/sparse/EIO parity, and the "reads don't restamp last_seen" invariant.
  • The pre-existing workers exit=1 ("2 errors") is a known vitest-pool-workers teardown race in two node-only files — reproduced on the unchanged base, not introduced here.

Notes & follow-ups

  • The package is preview; APIs are unstable, so these are internal-only changes.

ndisidore added 7 commits July 3, 2026 23:14
Measure the filesystem operations against a real Durable Object
SqlStorage via vitest-pool-workers, not the node SQLiteTestStorage
fixture (which caches prepared statements and would understate
per-statement cost).

Each scenario reports wall-clock ns/op and a deterministic statement
and row count from a CountingStorage decorator over the real backend.
Scenarios cover both stat surfaces across a path-depth sweep, present
and missing exists, small and positional reads, readdir versus ls,
recursive delete, a write-heavy create/edit/delete burst, single and
subtree renames, and a dedup guard on database size. A signature gate
asserts the deterministic counts so drift fails the run.

Runs only through vitest.config.bench.ts, so it stays out of the
regular suite; src/bench is excluded from the build as it imports the
workers-only test module. Invoke with npm run bench.
statSync resolved the path twice — once through statImpl for
mode/size/mtime and again for the inode behind the link count. stat now
returns the resolved inode, so the provider reuses it for both. nlink
for hardlinked files and the inode 0 / nlink 1 of a pending-create file
are unchanged.

Recursive delete threads the parent inode and name the post-order walk
already knows into unlinkDirent instead of re-resolving each parent
from the root; the single-entry path reuses the parent it just
resolved. Deleted dirents, reap-on-last-link, and tombstones are
unchanged.

writeSync stats only when appending, the only case that needs the
offset. A non-append write is validated by writeRangeSyncImpl
(ENOENT/EISDIR); a zero-length write keeps an explicit existence check
since it short-circuits before that resolve.
Cluster the two composite-key tables on their primary keys so a lookup
reads from the b-tree leaf with no rowid indirection: child_inode lives
in the vfs_dirents PK leaf, so a path segment resolves with a single
covering read, and a file's chunks sit in (inode, idx) order. Both are
eligible — composite PK, no AUTOINCREMENT, not part of any foreign key.
vfs_blob_bytes stays a rowid table; it holds the large blob payloads
and the schema's only foreign key.

Fresh databases get the shape from the baseline DDL; a v4 to v5
migration rebuilds existing ones, copying each table into a WITHOUT
ROWID replacement and recreating its secondary index (the rename and
drop destroy it, and the baseline CREATE INDEX does not re-run). The
rebuild runs inside initializeSchema's transaction.

A migration test stages a v4 database with nested dirs, a hardlink,
multi-chunk files and dedup, and asserts the version bump, both tables
WITHOUT ROWID, both indexes present, byte-for-byte data survival, and
that a fresh install matches a migrated one. The direct-range-write
test watches vfs_blobs.last_seen instead of a chunk rowid, which no
longer exists.
Resolve the read path with a single recursive-CTE statement and add a
per-Database path-to-inode cache, so repeat stat/exists/read of a path
cost one statement, and one node re-read once warm, independent of
depth.

Both fast paths serve only follow-symlinks resolutions outside a
transaction. A cache hit re-reads just the node row, so mode/size/mtime
stay current — only the path-to-inode mapping is cached. A miss runs
one recursive walk of vfs_dirents joined to vfs_nodes; it descends only
through directories, so a missing segment or non-directory intermediate
yields null exactly as the loop, and any symlink on the path falls back
to the per-component loop (which follows links and enforces ELOOP).
Symlink resolutions are not cached.

lstat/readlink and every resolve inside a mutation keep the loop: those
are shallow and hot, and the cache must not be populated
mid-transaction so a rollback leaves no stale entry. The cache is
invalidated at every dirent move — create, unlink, delete, rename, and
the sync-apply structural cleanup — dropping one entry for a leaf and
the whole subtree for a directory or symlink change.
A directory rename stamps the shared rev across the moved inode subtree
and records an old-path tombstone per edge with two set-based statements
over one recursive-CTE walk: an UPDATE keyed on the subtree inodes and an
INSERT ... SELECT of the old paths. The self-move guard reuses the same
walk. The tombstone rows and rev stamps form the set the sync log
expects, coalesced by rev and path independently of insertion order.
File and symlink renames take the single-row path.

buildManifest encodes the chunk list once and hashes those bytes, so the
manifest hash and the stored encoding stay in step by construction.
Reads resolve chunk bytes by hash and leave vfs_blobs.last_seen
untouched. A chunk being read is already linked to a node, so gc's
orphan gate retains its blob independent of the timestamp and a read
never needs to stamp it. last_seen remains the timer that guards blobs
staged but not yet linked — the sync receiver, the streaming write, and
manifests — and those write paths still stamp it. The read clock
argument that fed the removed touch is dropped.
Directory listing resolves the prefix to an inode and seeds its
recursive walk there, so it visits only the listed subtree rather than
the whole store. The prefix walk follows dirents structurally and does
not resolve symlinks, so a symlink prefix lists nothing. The output is
unchanged: the same absolute file paths in path order.

The sync object probe matches the requested hashes against vfs_blobs
through an IN (…) list on the raw hash blobs, so the lookup rides the
primary-key index instead of scanning; a probe larger than the window
splits into a few bounded index lookups. The present subset comes back
in input order with duplicates preserved.

A positional read fetches its overlapping chunks in one indexed range
scan ordered by index instead of a lookup per chunk. Missing indices
are absent from the result, so the assembly compacts around them just
as the per-chunk walk did, and the blob byte cache is consulted in the
same order.

@aron-cf aron-cf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thank you so much. This is rad. Great improvements all round and thanks for presenting it in a way that was super easy to review 🙇

@aron-cf
aron-cf merged commit 45a5719 into cloudflare:main Jul 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants