Skip to content

Tool performance improvements - #115

Open
aron-cf wants to merge 10 commits into
mainfrom
node-modules-operations
Open

Tool performance improvements#115
aron-cf wants to merge 10 commits into
mainfrom
node-modules-operations

Conversation

@aron-cf

@aron-cf aron-cf commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Since #113 node_modules has now become a default part of workspace sync. Performance of routine file operations, grep/find/git etc have degraded. A sync pass looked up paths one at a time, recursive searches visited directories the caller did not care about, the Worker shell asked the durable object for each directory separately, and a focused Git diff still inspected the whole project. These costs add up quickly after a package install.

This change makes those operations focus on the relevant part of the workspace. Sync resolves paths in groups and can find recent deletions without searching the full history. Recursive shell commands reuse one fresh directory listing for the command, while writes clear that listing so later reads stay current. Grep reuses information collected during its directory walk and still reads large files a piece at a time. A Git diff limited to a path now avoids walking unrelated directories.

The user-facing addition is an exclude option for find and grep. It matches a complete directory or file name, so node_modules is skipped while node_modules_extra is not.

const sourceFiles = await workspace.fs.find("/", "**/*.ts", {
  exclude: ["node_modules", ".git"],
});

const todos = await workspace.fs.grep("TODO", "/", {
  include: "**/*.ts",
  exclude: ["node_modules"],
});

On a test workspace with 18,963 entries, excluding the package directory reduced find from about 143 ms to under 1 ms and reduced grep from about 11 seconds to about 4 ms. A scoped Git diff over a project with 300 packages fell from 294 ms to 14 ms. These numbers are workload-specific, but they show that the commands now avoid the unrelated work rather than making the same walk faster by a small amount.

The tests cover grouped path lookup, files with more than one name, upgrades from existing workspaces, excluded directories, matches that cross file chunks, large-file reading, fresh directory listings after writes, and focused Git diffs. The filesystem documentation now describes the new option and its exact-name behavior, and the changeset records it for the next release.


Devin Review

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 736a903

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@aron-cf

aron-cf commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@agent-think can you fix the broken tests

aron-cf pushed a commit that referenced this pull request Aug 21, 2026
ReadableStream's default queue can pull a chunk as soon as the stream is
created. Use a zero high water mark so blob reads begin only when a
consumer requests data across both supported stream runtimes.
@aron-cf
aron-cf marked this pull request as ready for review August 21, 2026 19:16
@pkg-pr-new

pkg-pr-new Bot commented Aug 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@115

commit: ae9166f

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

🔍 Devin Review: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

@aron-cf

aron-cf commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

@agent-think can you fix the merge conflicts and push

dev and others added 10 commits September 11, 2026 16:48
coalesceChanges resolved every touched inode with pathsOf, which walks
vfs_dirents parent-by-parent issuing one statement per ancestor. That is
O(N x depth) round-trips per push tick — ~74k for a 20k-node tree. Every
one of those lookups already hit a covering index; the cost was the
statement count, not the plan.

Add pathsOfMany: one recursive CTE resolves an entire batch of inodes to
all of their hardlink names, seeded from json_each over the inode list
and walking child_inode -> parent_inode upward.

Hardlinks that share a parent directory need the seed's (parent, name)
pair as the grouping key, not the parent inode alone — keying on the
parent collapsed /one.txt and /two.txt into /one.txt/two.txt. Covered by
a regression test.

Unreachable inodes produce no seed row and are absent from the result,
matching pathsOf returning [] for them.
Recursive grep walked every directory even when callers only wanted
source files. Add an `exclude` option and pass its relative globs to
the find walker, which prunes matching directories before reading
their files.

Tests cover searches with and without exclusions, interaction with
the inclusion glob, and confirm that pruned files are not read.
A recursive grep issued three statements per file: the recursive-CTE
path resolve inside readFile, the chunk range read, and the blob fetch.
The traversal had already read the inode and size from vfs_dirents, so
the resolve was pure duplicate work — and it was the most expensive of
the three.

Carry inode and size out of the find walk (internally only; find()'s
public {path, type} shape is unchanged) and add
readCommittedFileByInode, which pulls a committed file's bytes straight
from the chunk store. Open write buffers are still honoured so an
in-flight write is not missed; pending creates have no inode and keep
the resolve path, as does grepping a single file directly.

Line framing is shared with the streaming decoder's semantics: a
trailing fragment without a newline is still a line and an empty file
yields nothing. Verified byte-identical against the streaming path for
empty files, missing trailing newlines, CRLF, unicode, and matches
straddling a 512KiB chunk boundary, with and without context lines.

  statements per file  3.0 -> 2.0 (the resolve CTE: 501 -> 1 for 500 files)
  grep over 18,963 nodes  11412 ms -> 814 ms (14x)
The push tick reads tombstones with
`WHERE rev > ? AND op = 'delete' GROUP BY path`. Neither existing
index serves it: `vfs_changes_by_rev(rev)` can drive the range but
leaves `GROUP BY path` to a sort, so the planner instead scans
`vfs_changes_by_path` in path order and ignores the rev predicate.

Add `vfs_changes_by_op_rev(op, rev)` with the equality column first
and the range column second. Register it as the schema v7 to v8
migration because the sync cursor and operation tables already occupy
versions 6 and 7.

The migration only adds an index, so existing tombstones remain
unchanged. Tests cover fresh and upgraded databases and confirm that
SQLite uses the index for the tombstone query.
just-bash's find and grep are filesystem-agnostic: they walk through
IFileSystem, one readdirWithFileTypes per directory. Against the
workspace stub every one is an RPC to the DO, so a recursive command
over a tree containing node_modules costs hundreds to thousands of
round-trips before any matching work happens.

Give the adapter an opt-in prefetch scope. On the first listing inside
a scope it reads the whole subtree with one server-side find() and
answers later readdirWithFileTypes calls from that snapshot. The shell
entrypoint opens a scope only for commands that actually traverse
(find, grep -r), picked by prefetch-policy.

Correctness constraints, each covered by a test:
  * the snapshot lives only for the command that opened it, so no
    listing is ever reused across commands;
  * every mutating adapter method drops it, so a walk that writes
    re-reads instead of trusting the snapshot;
  * paths outside the prefetched root fall through to a direct listing;
  * a failed prefetch degrades to direct listings rather than failing
    the command;
  * dofs's find reports "symlink" at runtime even though the published
    type is narrower, so links keep isSymbolicLink rather than being
    mislabelled as files.

The policy declines mutating traversals (-delete, -exec) where a
snapshot taken before the walk could describe entries the command
removes, and declines non-recursive greps that would not benefit.

  find /       412 -> 9 stub calls (45.8x)
  grep -rl /   832 -> 429 (1.9x; the traversal is gone, per-file reads
               remain)
Output is byte-identical with the scope on and off in every case.

Content prefetch was prototyped and rejected: file bodies can only be
reconstructed from grep matches lossily (a file with no trailing
newline comes back with one), and silently altering file content is
not worth the remaining round-trips.
diffWith computed the full worktree status matrix and then discarded
everything outside opts.paths with makePathFilter. isomorphic-git can
prune the walk itself, so the traversal was visiting — and stat'ing and
hashing — the entire tree to produce a result the caller had already
narrowed.

Pass the caller's paths through as filepaths. The library's matching
rule (exact path or directory prefix) is the same one makePathFilter
implements, so the filter stays the authority on what is emitted and
this is purely a traversal hint. Callers that name no paths keep
isomorphic-git's default of walking everything.

This only became expensive once node_modules was synced into the
worktree: a repo whose .gitignore does not cover it makes every scoped
diff pay for the vendor tree.

  300-package vendor tree, :
    unscoped  294 ms, 4,865 fs calls
    scoped     14 ms,    64 fs calls   (21x, 76x fewer calls)
  Scoped rows verified identical to the full run's src subset.
Prevent an invalidated in-flight subtree load from publishing a stale
directory snapshot after a concurrent mutation. A generation check now
limits cache publication to the active prefetch scope.
Keep the path-resolution savings from inode-based grep reads without
materializing whole files. Chunk bytes are loaded only as the line
scanner pulls them, preserving bounded memory use for large files.
Document the shared relative-glob exclusion behavior for find and grep, and add release metadata for the large-tree performance changes.
@aron-cf
aron-cf force-pushed the node-modules-operations branch from ae9166f to 736a903 Compare September 11, 2026 16:01

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

6 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

if (this.#withinPrefetch(normalized)) {
const cache = await this.#ensurePrefetch();
const hit = cache?.get(normalized);
if (hit !== undefined) return hit.map((entry) => ({ ...entry }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Successful moves leave traversal stale

After mv succeeds during a prefetched command, readdirWithFileTypes returns the old tree. The successful mv rename bypasses #invalidatePrefetch. Later traversals report the source present and destination absent.

Learn more

A prefetch scope spans the complete shell command, including command lists and pipelines. The first recursive traversal loads one directory snapshot. A later mv normally succeeds through the direct rename path, which never calls #invalidatePrefetch. Any subsequent traversal in the same shell command therefore reads the original snapshot.

Example: find .; mv old.txt new.txt; find . loads the cache during the first find. The move succeeds, but the second find still lists old.txt and omits new.txt.

Recommended fix: Call #invalidatePrefetch() before the first rename attempt in mv, matching every other mutating adapter method. Add a test covering a successful move between two cached listings.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +200 to +204
} catch {
// A failed prefetch must not fail the command: fall back to
// direct listings for the rest of the scope.
if (this.#prefetchLoad === load) this.#prefetchLoad = undefined;
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Failed prefetch retries every directory

When #fs.find fails but direct listings work, each readdirWithFileTypes retries the full subtree. Resetting #prefetchLoad forgets the failure. Recursive commands can issue one failed subtree RPC per directory.

Learn more

The fallback continues the walk through direct readdir calls. Each child directory re-enters #ensurePrefetch, sees no load or cache, and launches the same find(root) again. This is especially costly when the one-shot subtree response exceeds an RPC limit while smaller directory listings remain usable.

Example: A tree has 10,000 directories. If find("/") rejects while readdir succeeds, a recursive find can attempt that failing whole-tree request roughly 10,000 times instead of once.

Recommended fix: Record a failed-prefetch sentinel for the current generation. Return undefined without retrying until invalidation or endPrefetch resets the scope. Add a test where find always rejects and verify a multi-directory walk calls it once.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant