Skip to content

perf(cache-utils): use native fs traversal#7104

Open
43081j wants to merge 4 commits into
netlify:mainfrom
43081j:jg/the-purge-cache-files
Open

perf(cache-utils): use native fs traversal#7104
43081j wants to merge 4 commits into
netlify:mainfrom
43081j:jg/the-purge-cache-files

Conversation

@43081j

@43081j 43081j commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Switches the following:

  • locate-path -> use a regular for...of around fs.access
  • move-file -> use fs.rename and fs.cp for cross-device
  • readdirp -> use fs.readdir

For us to review and ship your PR efficiently, please perform the following steps:

  • Open a bug/issue before writing your code 🧑‍💻. This ensures
    we can discuss the changes and get feedback from everyone that should be involved. If you`re fixing a typo or
    something that`s on fire 🔥 (e.g. incident related), you can skip this step.
  • Read the contribution guidelines 📖. This ensures
    your code follows our style guide and passes our tests.
  • Update or add tests (if any source code was changed or added) 🧪
  • Update or add documentation (if features were changed or added) 📝
  • Make sure the status checks below are successful ✅

A picture of a cute animal (not mandatory, but encouraged)

@43081j
43081j requested a review from a team as a code owner June 25, 2026 13:36
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c495412d-d8c8-4104-8967-f4d8a86ce1ae

📥 Commits

Reviewing files that changed from the base of the PR and between f6010b6 and 42d4d52.

📒 Files selected for processing (2)
  • packages/cache-utils/src/hash.ts
  • packages/cache-utils/tests/digests.test.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • netlify/blueprints (manual)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cache-utils/src/hash.ts

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved cache file moves to prevent overwriting existing destinations and to safely handle cross-disk moves.
    • Made digest lookup more reliable by checking candidate digest paths and using the first existing digest file.
    • Updated cached entry enumeration with depth-limited directory traversal and improved handling of missing directories.
    • Cache save/restore now ignores digest directories.
  • Chores
    • Reduced package dependencies used by cache utilities.
  • Documentation
    • Clarified the published digests option type for cached items as string[].
  • Tests
    • Added coverage for ignoring digest directories.

Walkthrough

The cache-utils package removes locate-path, move-file, and readdirp from its dependencies. moveCacheFile now uses fs.rename with an EXDEV fallback to fs.cp and fs.rm. getHash now selects the first file-like digest path with fs.stat before hashing. listBase now traverses directories with readdir({ withFileTypes: true }), handles ENOENT, and uses a basename string for fileFilter.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description lists the code changes, but it omits the required Fixes #issue reference and motivation section from the template. Add a Summary with Fixes #, explain the problem being solved, and keep the checklist items aligned with the template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: replacing external cache-utils filesystem helpers with native fs traversal.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/cache-utils/src/list.ts (1)

54-54: 🩺 Stability & Availability | 🔵 Trivial

Use currentPath instead of child.parentPath

currentPath is the explicit parameter passed to readdir, making it the canonical and safer source for constructing the full path. Relying on child.parentPath couples the code to specific Dirent internals which can vary across TypeScript/node type definitions and simplifies the logic.

♻️ Proposed fix
-      const childPath = join(child.parentPath, child.name)
+      const childPath = join(currentPath, child.name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cache-utils/src/list.ts` at line 54, The path construction in
readdir should use the explicit currentPath parameter instead of
child.parentPath. Update the logic in readdir to build childPath from
currentPath and child.name, keeping the code independent of Dirent internals and
more portable across Node/TypeScript definitions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cache-utils/src/fs.ts`:
- Around line 18-25: The cross-device fallback in fs.ts loses no-overwrite
behavior: the fs.cp path in the move helper can allow an existing destination to
remain while still deleting the source. Update the move logic in the same helper
that wraps fs.rename so the EXDEV fallback preserves overwrite: false semantics
before calling fs.rm, matching the same-device behavior and preventing source
deletion on destination collisions.

---

Nitpick comments:
In `@packages/cache-utils/src/list.ts`:
- Line 54: The path construction in readdir should use the explicit currentPath
parameter instead of child.parentPath. Update the logic in readdir to build
childPath from currentPath and child.name, keeping the code independent of
Dirent internals and more portable across Node/TypeScript definitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 126fde29-4529-4038-a35e-57e8fb08e5e7

📥 Commits

Reviewing files that changed from the base of the PR and between 96b864c and efe1389.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • packages/cache-utils/package.json
  • packages/cache-utils/src/fs.ts
  • packages/cache-utils/src/hash.ts
  • packages/cache-utils/src/list.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • netlify/blueprints (manual)

Comment thread packages/cache-utils/src/fs.ts
Switches the following:

- `locate-path` -> use a regular `for...of` around `fs.access`
- `move-file` -> use `fs.rename` and `fs.cp` for cross-device
- `readdirp` -> use `fs.readdir`
@43081j
43081j force-pushed the jg/the-purge-cache-files branch from efe1389 to 203e3cb Compare June 25, 2026 13:54
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

e18e dependency analysis

No dependency warnings found.

return
}

const digestPath = await locatePath(digests)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It looks like there are two behavioural changes here (see locate-path vs. fsPromises.access:

  1. By default, locate-path only matches on files, not directories.
  2. By default, paths are resolved from process.cwd.

Maybe we can just steal the logic from its source here.

Not sure if these are important (e.g. maybe directories aren't possible? maybe the paths are always absolute? no idea).

Maybe add some test coverage for this too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i feel like im missing something because this is same as my other comment:

By default, paths are resolved from process.cwd

just like all fs functions, no?

Comment thread packages/cache-utils/src/hash.ts Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we add some types here while you're at it?

(For... reasons, our tsconfig is not very strict 😭.)

export const moveCacheFile = async function (src: string, dest: string, move = false) {
// Moving is faster but removes the source files locally
if (move) {
return moveFile(src, dest, { overwrite: false })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

move-file resolves both src and dest from process.cwd() by default: https://npmx.dev/package/move-file#user-content-cwd

Could we add some coverage for relative paths here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this does too, doesn't it? as in all fs functions are relative to the cwd by default

agreed some tests will be a good idea but this behaviour is the same as before afaict

Comment on lines +39 to +63
let queue = [{ path: `${cacheDir}/${name}`, depth: 0 }]
const results: string[] = []
let queueEntry: { path: string; depth: number } | undefined
while ((queueEntry = queue.pop())) {
const { path: currentPath, depth: currentDepth } = queueEntry
let children: Dirent[]
try {
children = await readdir(currentPath, { withFileTypes: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue
}
throw error
}
for (const child of children) {
const childPath = join(child.parentPath, child.name)
if ((child.isDirectory() || child.isFile()) && fileFilter(child.name)) {
results.push(join(base, relative(`${cacheDir}/${name}`, childPath)))
}
if ((depth === undefined || currentDepth < depth) && child.isDirectory()) {
queue.push({ path: childPath, depth: currentDepth + 1 })
}
}
}
return results

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If I'm reading all this right, I think this complexity isn't worthwhile, since the only reason we aren't using a simple readdirp('...', { recursive: true }) is because it doesn't support filtering on the fly, but since the fileFilter is actually just excluding basically one or zero files (no directories), it should be fine to filter after the fact, performance wise. What do you think?

Suggested change
let queue = [{ path: `${cacheDir}/${name}`, depth: 0 }]
const results: string[] = []
let queueEntry: { path: string; depth: number } | undefined
while ((queueEntry = queue.pop())) {
const { path: currentPath, depth: currentDepth } = queueEntry
let children: Dirent[]
try {
children = await readdir(currentPath, { withFileTypes: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue
}
throw error
}
for (const child of children) {
const childPath = join(child.parentPath, child.name)
if ((child.isDirectory() || child.isFile()) && fileFilter(child.name)) {
results.push(join(base, relative(`${cacheDir}/${name}`, childPath)))
}
if ((depth === undefined || currentDepth < depth) && child.isDirectory()) {
queue.push({ path: childPath, depth: currentDepth + 1 })
}
}
}
return results
const results = await readdir(`${cacheDir}/${name}`, { recursive: true })
return results.filter(({ name }) => fileFilter(name))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i agree. will update 👍

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