| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
|
Review requested:
|
Sorry, something went wrong.
The CommonJS loader keeps a per-require-tree `statCache` to avoid re-stat-ing the same path while resolving a module tree, but it only caches successful stats. Negative results (e.g. -ENOENT) fall through and are re-probed every time the same missing path is looked up again within the same top-level require. These misses are extremely common during resolution and recur across sibling and descendant modules: `tryExtensions` probes .js/.json/.node in order (every extension before the real one is a miss), and bare specifiers walk the node_modules chain upward through many non-existent ancestor directories. None of these negatives were cached, so they were re-stat-ed repeatedly within a single resolution pass. Cache negative stat results alongside positive ones. The staleness window is identical and already accepted for positive results: the cache is tree-scoped, created when a top-level require begins (requireDepth === 0) and cleared when it completes, so a stale entry can only survive the duration of one top-level require. Signed-off-by: Maxime David <maxday@amazon.com>
|
force pushing to add the missing : "Signed-off-by:" in the commit message |
Sorry, something went wrong.
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #64682 +/- ##
==========================================
+ Coverage 90.14% 90.26% +0.12%
==========================================
Files 741 762 +21
Lines 242133 247549 +5416
Branches 45568 46685 +1117
==========================================
+ Hits 218265 223452 +5187
- Misses 15371 15530 +159
- Partials 8497 8567 +70
... and 179 files with indirect coverage changes 🚀 New features to boost your workflow:
|
Sorry, something went wrong.
|
This was the behaviour in days of yore (#36638) so this is effectively a reversion of that change, hence the failing tests. One would expect that the impact of the scenario described here is far more common than the need to modify the module tree on-the-fly after a failed require(), which was the motivating case for that change. However, this PR does reintroduce the edge case where the following will fail on both loads, as opposed to the current behaviour where the second require() picks up the new module: const jsonModule = path.resolve('./test.json')
try {
const m = require(jsonModule)
console.debug(m)
} catch (e) {
console.error(e)
}
fs.writeFileSync(jsonModule, '{}\n')
try {
const m = require(jsonModule)
console.debug(m)
} catch (e) {
console.error(e)
}FWIW, there aren't any module benchmarks which really capture the scenario of repeated attempts at resolving the same module specifier(s) and/or having to walk several levels up the directory tree, which would probably help make the case for this PR. The original change was accepted in the context of a neutral impact on the module benchmark suite at the time. |
Sorry, something went wrong.
|
the behavior change here is that a path missing on first probe is now invisible for the rest of the require tree. before this, negative results were never cached, so a file created mid-tree (codegen that writes then requires, some transpile-on-demand setups) would be picked up on a later require in the same tree — now it serves the cached miss. the test actually locks that new behavior in. is that regression considered acceptable, or should negative caching be gated so only the "clearly hot" probes (tryExtensions / node_modules walk) cache, not arbitrary require targets? |
Sorry, something went wrong.
Caching every negative stat reverted the behaviour of nodejs#36642 and broke parallel/test-module-cache: a module missing on a failed require() and then created was no longer picked up by a later require() in the same tree. Narrow the negative caching to speculative probes -- paths resolution guesses at rather than paths the user named: the extension candidates tried by `tryExtensions` and the node_modules ancestors walked for bare specifiers. A cached negative is likewise only read back by a speculative probe, so a stat of a user-named path always re-stats. That restores the nodejs#36642 behaviour while keeping the repeated misses, which are where the win comes from, out of the filesystem. Rewrite test-module-negative-stat-cache to assert both halves of the scoped behaviour, and add a benchmark. The benchmark spawns its workload as a child process's main module because `benchmark/common.js` invokes main() from a process.nextTick callback, by which point statCache is already null. At deps=200 depth=12 it shows ~8% improvement. Signed-off-by: Maxime David <maxday@amazon.com>
|
Thanks both! I've reworked the PR. On the regression: All the CI failures were the same test, parallel/test-module-cache, i.e. exactly the behaviour #36642 locked in. I've scoped the negative caching instead of applying it everywhere, which is close to what @Sanjays2402 suggested:
So create-then-require in the same tree still works, and test-module-cache.js passes unmodified. The negative cache only covers paths the user never asked for, where create-mid-tree isn't a meaningful pattern. On the benchmark: Added benchmark/module/module-resolve-misses.js. A module nested depth levels down requires deps distinct bare specifiers: each walk re-probes the same missing node_modules ancestors.
(higher is better; 10 runs each) Failed statx calls, same workload at depth 12: 3000 → 612 (−80%), and the saving scales with depth, as the mechanism predicts. @Renegade334 on the test.json example you posted: with this version the second require() picks up the new file, since that's a user-named path. Let me know! |
Sorry, something went wrong.
|
Also, now that the previous tests are passing, I'm not sure this is a breaking change anymore. Should we remove the semver-major tag? Thanks! |
Sorry, something went wrong.
Address review feedback: pass the resolution-probe flag as a named option
instead of a positional boolean, so callsites read
tryFile(basePath + exts[i], { isMain, isSpeculativeProbe: true });
_stat(curPath, { isSpeculativeProbe: true });
`isMain` moves into the bag as well rather than staying positional, and
`stat` gets the same treatment since that is where the flag is consumed.
Both default to `kEmptyObject`, matching the existing idiom in this file.
No behaviour change: the negative-caching scope is identical and the
existing tests pass unmodified.
Signed-off-by: Maxime David <maxday@amazon.com>
|
FWIW, I get a modest but significant performance improvement on the new benchmark, albeit tested on a disk that's not under load, but only at the higher depth. confidence improvement accuracy (*) (**) (***) module/module-resolve-misses.js n=30 deps=200 depth=12 *** 3.05 % ±1.33% ±1.77% ±2.30% module/module-resolve-misses.js n=30 deps=200 depth=4 -0.67 % ±1.31% ±1.74% ±2.27% |
Sorry, something went wrong.
|
Thanks @Renegade334 |
Sorry, something went wrong.
|
@jasnell let me know if you have more comments or if you're happy with the changes I've made after your review |
Sorry, something went wrong.
|
cc @nodejs/loaders for review. I'll leave for someone else to make a verdict on semver, dynamic loaders like PnP do some fairly eclectic things on-the-fly, and at this point I'm pretty sure anything that touches module resolution has the potential to xkcd1172 something 😆 |
Sorry, something went wrong.
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
module: cache negative stat results in the CJS loader
Summary
The CommonJS loader keeps a per-require-tree statCache to avoid re-stat-ing the same path while resolving a module tree. Today it only caches successful stats, negative results (e.g. -ENOENT) fall through and are re-probed every time the same missing path is looked up again within the same top-level require.
Failed probes are extremely common during resolution, and the same misses recur across sibling and descendant modules:
Because negatives aren't cached, these misses are re-stat-ed repeatedly within a single resolution pass.
Why this change is safe
The statCache is tree-scoped: it is created when a top-level require begins (requireDepth === 0) and set back to null when it completes. So the staleness window for any cached entry is bounded to the duration of a single top-level require.
That window already exists for positive results: a file cached as "exists" could be deleted mid-traversal and the cache wouldn't notice. Caching a negative result has the exact same bounded window: a file cached as "missing" could be created mid-traversal and the cache wouldn't notice.
Impact
The larger and deeper the dependency tree, the more the same missing paths get re-probed, so real-world node_modules trees are exactly where repeated negative stats add up. The gain scales with how resolution-heavy the tree is and how expensive each syscall is (it is largest when the OS filesystem cache is cold, e.g. the very first run after boot).
It is especially impactful on AWS Lambda. Lambda cold starts are the worst-case for this cost: the filesystem cache is cold, the vCPU share is small so syscalls are relatively expensive, and startup latency is directly user-facing and billed. This is precisely the environment where eliminating repeated negative stats pays off most.
Measured on AWS Lambda (provided:al2023, 128 MB, N=30 cold starts) with a resolution-heavy dependency tree (~1093 modules, each doing bare-specifier node_modules walks plus extensionless/missing tryExtensions probes):
Time spent in the top-level require() call, measured by wrapping it in process.hrtime.bigint():
Test
test/parallel/test-module-negative-stat-cache.js verifies that negative (not-found) stat results are cached. The stat cache is populated and read internally by the loader, so it is not directly observable from user code. The test makes it observable by mutating the filesystem between two probes of the same path within one require tree.
Fixes: #64681