GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — module: cache negative stat results in the CJS loader by maxday · Pull Request #64682 · nodejs/node Conversation
|
| stat | current | with the fix | delta |
|---|---|---|---|
| mean | 1036.3 ms | 905.1 ms | −12.7% |
| median | 986.8 ms | 916.4 ms | −7.1% |
| p90 | 1305.8 ms | 983.7 ms | −24.7% |
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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Review requested:
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
ea93d9e to
7849455
Compare
|
force pushing to add the missing : "Signed-off-by:" in the commit message |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@ ## main #64682 +/- ## ========================================== - Coverage 90.06% 90.05% -0.02% ========================================== Files 769 769 Lines 261316 261341 +25 Branches 49625 49627 +2 ========================================== - Hits 235367 235344 -23 - Misses 16996 17035 +39 - Partials 8953 8962 +9
🚀 New features to boost your workflow:
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
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 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.
There was an error while loading. Please reload this page.
|
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.
There was an error while loading. Please reload this page.
|
Thanks both! I've reworked the PR. On the regression: All the CI failures were the same test,
So create-then-require in the same tree still works, and On the benchmark: Added
(higher is better; 10 runs each) Failed @Renegade334 on the Let me know! |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Also, now that the previous tests are passing, I'm not sure this is a breaking change anymore. Should we remove the |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
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. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Thanks @Renegade334 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
@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.
There was an error while loading. Please reload this page.
|
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.
There was an error while loading. Please reload this page.
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
9903df5 to
4d39a40
Compare
|
Benchmarking CI https://ci.nodejs.org/view/Node.js%20benchmark/job/benchmark-node-micro-benchmarks/1895/ |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| // A candidate lookup directory (typically a `node_modules` ancestor) is a | ||
| // speculative probe: most of the chain does not exist, and every bare | ||
| // specifier in the tree re-walks the same missing ancestors. | ||
| if (insidePath && curPath && _stat(curPath, { isSpeculativeProbe: true }) < 1) { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This negatively caches missing node_modules lookup directories for the entire require tree. If application code first fails to require a package, creates that package under node_modules, and then requires it again, the second lookup incorrectly reuses the cached miss.
Can we keep this as _stat(curPath) so newly created packages remain observable?
| if (insidePath && curPath && _stat(curPath, { isSpeculativeProbe: true }) < 1) { | |
| if (insidePath && curPath && _stat(curPath) < 1) { |
A regression test could attempt require('late-package') and assert MODULE_NOT_FOUND, then create node_modules/late-package/index.js during the same top-level require tree and require it again. The second call should successfully load the newly created package.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Requested changes must be addressed to merge this pull request.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
module: CJS loader re-stats the same missing paths within one require tree
There was an error while loading. Please reload this page.