Skip to content

Commit 7d9cb3b

Browse files
committed
module: limit negative stat caching to resolution probes
Caching every negative stat reverted the behaviour of #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 #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>
1 parent 7849455 commit 7d9cb3b

3 files changed

Lines changed: 149 additions & 40 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const { spawnSync } = require('child_process');
6+
const common = require('../common.js');
7+
8+
const tmpdir = require('../../test/common/tmpdir');
9+
const benchmarkDirectory = tmpdir.resolve('nodejs-benchmark-module');
10+
11+
const bench = common.createBenchmark(main, {
12+
depth: [4, 12],
13+
deps: [200],
14+
n: [30],
15+
});
16+
17+
function main({ depth, deps, n }) {
18+
tmpdir.refresh();
19+
20+
// The only `node_modules` that exists: at the root, above the requirer.
21+
const nodeModules = path.join(benchmarkDirectory, 'node_modules');
22+
for (let i = 0; i < deps; i++) {
23+
const dep = path.join(nodeModules, `dep${i}`);
24+
fs.mkdirSync(dep, { recursive: true });
25+
fs.writeFileSync(
26+
path.join(dep, 'package.json'),
27+
`{"name":"dep${i}","main":"index.js"}`,
28+
);
29+
fs.writeFileSync(path.join(dep, 'index.js'), 'module.exports = {};');
30+
}
31+
32+
// The requirer, nested `depth` levels down. Resolution walks up from here and
33+
// finds no `node_modules` until the root.
34+
const nested = path.join(benchmarkDirectory, ...'x'.repeat(depth).split(''));
35+
fs.mkdirSync(nested, { recursive: true });
36+
37+
let entrySource = '';
38+
for (let i = 0; i < deps; i++) {
39+
entrySource += `require('dep${i}');\n`;
40+
}
41+
const entry = path.join(nested, 'entry.js');
42+
fs.writeFileSync(entry, entrySource);
43+
44+
const cmd = process.execPath || process.argv[0];
45+
const warmup = 3;
46+
for (let i = -warmup; i < n; i++) {
47+
if (i === 0) {
48+
bench.start();
49+
}
50+
const child = spawnSync(cmd, [entry]);
51+
if (child.status !== 0) {
52+
throw new Error(`Child process stopped with exit code ${child.status}`);
53+
}
54+
}
55+
bench.end(n);
56+
57+
tmpdir.refresh();
58+
}

‎lib/internal/modules/cjs/loader.js‎

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -266,29 +266,37 @@ function wrapModuleLoad(request, parent, isMain, options) {
266266

267267
/**
268268
* Get a path's properties, using an in-memory cache to minimize lookups.
269+
*
270+
* Successful results (0 = file, 1 = directory) are always cached. Negative
271+
* results (libuv error codes, e.g. -ENOENT) are only cached for speculative
272+
* probes, i.e. paths that resolution guesses at rather than paths the user
273+
* actually asked for: the extension candidates tried by `tryExtensions` and
274+
* the `node_modules` ancestor directories walked for bare specifiers. Those
275+
* misses are numerous and repeat across sibling and descendant modules, so
276+
* caching them is where the win is.
277+
*
278+
* A cached negative is also only *read back* by a speculative probe. A stat of
279+
* a path the user named directly ignores it and re-stats. That keeps the
280+
* behaviour of https://github.com/nodejs/node/pull/36642 intact: a module that
281+
* is missing on a failed `require()` and then created can still be picked up by
282+
* a later `require()` in the same tree.
269283
* @param {string} filename Absolute path to the file
284+
* @param {boolean} [isSpeculativeProbe] Whether this is a resolution guess
285+
* rather than a path the user named, which makes the negative result
286+
* cacheable and lets a previously cached negative be reused.
270287
* @returns {number}
271288
*/
272-
function stat(filename) {
289+
function stat(filename, isSpeculativeProbe = false) {
273290
// Guard against internal bugs where a non-string filename is passed in by mistake.
274291
assert(typeof filename === 'string');
275292

276293
filename = path.toNamespacedPath(filename);
277294
if (statCache !== null) {
278295
const result = statCache.get(filename);
279-
if (result !== undefined) { return result; }
296+
if (result !== undefined && (result >= 0 || isSpeculativeProbe)) { return result; }
280297
}
281298
const result = internalFsBinding.internalModuleStat(filename);
282-
if (statCache !== null) {
283-
// Cache both successful results (0 = file, 1 = directory) and negative
284-
// results (libuv error codes, e.g. -ENOENT). Negative results are common
285-
// and repeated during resolution: `tryExtensions` probes several
286-
// non-existent extensions, and bare specifiers walk the `node_modules`
287-
// chain upward stat-ing many parent directories that do not exist. The
288-
// cache is scoped to a single top-level `require` tree (created and torn
289-
// down around `requireDepth === 0`), so caching a negative result carries
290-
// the same bounded staleness window that caching a positive one already
291-
// does.
299+
if (statCache !== null && (result >= 0 || isSpeculativeProbe)) {
292300
statCache.set(filename, result);
293301
}
294302
return result;
@@ -598,10 +606,12 @@ function tryPackage(requestPath, exts, isMain, originalPath) {
598606
* `--preserve-symlinks-main` and `isMain` is true , keep symlinks intact, otherwise resolve to the absolute realpath.
599607
* @param {string} requestPath The path to the file to load.
600608
* @param {boolean} isMain Whether the file is the main module.
609+
* @param {boolean} [isSpeculativeProbe] Whether `requestPath` is a resolution
610+
* guess rather than a path the user named. See {@link stat}.
601611
* @returns {string|undefined}
602612
*/
603-
function tryFile(requestPath, isMain) {
604-
const rc = _stat(requestPath);
613+
function tryFile(requestPath, isMain, isSpeculativeProbe = false) {
614+
const rc = _stat(requestPath, isSpeculativeProbe);
605615
if (rc !== 0) { return; }
606616
if (getOptionValue(isMain ? '--preserve-symlinks-main' : '--preserve-symlinks')) {
607617
return path.resolve(requestPath);
@@ -611,14 +621,16 @@ function tryFile(requestPath, isMain) {
611621

612622
/**
613623
* Given a path, check if the file exists with any of the set extensions.
624+
* Each candidate is a speculative probe: the extension is appended by
625+
* resolution, not named by the user, so its misses are cacheable.
614626
* @param {string} basePath The path and filename without extension
615627
* @param {string[]} exts The extensions to try
616628
* @param {boolean} isMain Whether the module is the main module
617629
* @returns {string|false}
618630
*/
619631
function tryExtensions(basePath, exts, isMain) {
620632
for (let i = 0; i < exts.length; i++) {
621-
const filename = tryFile(basePath + exts[i], isMain);
633+
const filename = tryFile(basePath + exts[i], isMain, true);
622634

623635
if (filename) {
624636
return filename;
@@ -829,7 +841,10 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition
829841
if (typeof curPath !== 'string') {
830842
throw new ERR_INVALID_ARG_TYPE('paths', 'array of strings', paths);
831843
}
832-
if (insidePath && curPath && _stat(curPath) < 1) {
844+
// A candidate lookup directory (typically a `node_modules` ancestor) is a
845+
// speculative probe: most of the chain does not exist, and every bare
846+
// specifier in the tree re-walks the same missing ancestors.
847+
if (insidePath && curPath && _stat(curPath, true) < 1) {
833848
continue;
834849
}
835850

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,70 @@
11
'use strict';
22
require('../common');
33

4-
// This tests that the CommonJS loader's per-require-tree stat cache also caches
5-
// negative (not-found) results, so a path that is missing when first probed is
6-
// not re-stat-ed for the rest of the require tree.
4+
// This tests that the CommonJS loader's per-require-tree stat cache caches
5+
// negative (not-found) results for *speculative* probes: the extension
6+
// candidates tried by `tryExtensions` and the `node_modules` ancestor
7+
// directories walked for bare specifiers.
8+
//
9+
// A path the user named directly is not negatively cached, so the behaviour of
10+
// https://github.com/nodejs/node/pull/36642 is preserved: a module that is
11+
// missing on a failed `require()` and then created is still picked up by a
12+
// later `require()` in the same tree. That case is covered by
13+
// test-module-cache.js and asserted again at the end of this file.
14+
//
15+
// The stat cache is populated and read internally by the loader, so it is not
16+
// directly observable from user code. These tests make it observable by
17+
// mutating the filesystem between two probes of the same path within one
18+
// require tree.
719

820
const assert = require('assert');
921
const fs = require('fs');
22+
const path = require('path');
1023
const tmpdir = require('../common/tmpdir');
1124

1225
tmpdir.refresh();
1326

14-
// A module path that does not exist yet.
15-
const generated = tmpdir.resolve('generated.js');
16-
17-
// First probe: the file does not exist -> negative stat, cached.
18-
assert.throws(
19-
() => require(generated),
20-
{ code: 'MODULE_NOT_FOUND' },
21-
'expected the module to be missing before it is created',
22-
);
23-
24-
// Create the file mid-traversal, in the same require tree.
25-
fs.writeFileSync(generated, 'module.exports = 1;');
26-
27-
// Second probe, still in the same tree: the negative result is cached, so the
28-
// loader must serve the cached miss instead of re-stat-ing and observing the
29-
// freshly-created file.
30-
assert.throws(
31-
() => require(generated),
32-
{ code: 'MODULE_NOT_FOUND' },
33-
'a negative stat result must be cached for the rest of the require tree',
34-
);
27+
// An extensionless specifier is resolved by probing `<name>.js`, `<name>.json`,
28+
// `<name>.node`, ... in order. Those candidate paths are appended by resolution
29+
// rather than named by the user, so their misses are cached for the rest of the
30+
// tree.
31+
{
32+
const dir = tmpdir.resolve('speculative');
33+
fs.mkdirSync(dir);
34+
35+
const specifier = path.join(dir, 'mod');
36+
37+
// Nothing exists yet: every extension candidate misses and is cached.
38+
assert.throws(
39+
() => require(specifier),
40+
{ code: 'MODULE_NOT_FOUND' },
41+
'expected the module to be missing before it is created',
42+
);
43+
44+
// Create one of the candidates that was just probed and missed.
45+
fs.writeFileSync(`${specifier}.js`, 'module.exports = "late";');
46+
47+
// Resolving the same extensionless specifier probes `mod.js` again, but that
48+
// negative result is cached, so the freshly-created file is not observed.
49+
assert.throws(
50+
() => require(specifier),
51+
{ code: 'MODULE_NOT_FOUND' },
52+
'a negative result for a speculative extension probe must be cached',
53+
);
54+
}
55+
56+
// A path the user named directly is not negatively cached, so creating the file
57+
// mid-tree makes a later require in the same tree resolve it.
58+
{
59+
const explicit = tmpdir.resolve('explicit.js');
60+
61+
assert.throws(
62+
() => require(explicit),
63+
{ code: 'MODULE_NOT_FOUND' },
64+
);
65+
66+
fs.writeFileSync(explicit, 'module.exports = "created";');
67+
68+
// A negative result for a user-named path must not be cached.
69+
assert.strictEqual(require(explicit), 'created');
70+
}

0 commit comments

Comments
 (0)