Skip to content

Commit 21bb129

Browse files
committed
fs: improve performance of recursive directory read
Signed-off-by: avivkeller <me@aviv.sh>
1 parent 056e2ae commit 21bb129

9 files changed

Lines changed: 658 additions & 193 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const fs = require('fs');
5+
const path = require('path');
6+
const assert = require('assert');
7+
8+
const bench = common.createBenchmark(main, {
9+
n: [10],
10+
dir: ['lib', 'test/parallel'],
11+
mode: ['sync', 'callback', 'promise'],
12+
withFileTypes: ['true', 'false'],
13+
});
14+
15+
async function main({ n, dir, mode, withFileTypes }) {
16+
withFileTypes = withFileTypes === 'true';
17+
const fullPath = path.resolve(__dirname, '../../', dir);
18+
const options = { recursive: true, withFileTypes };
19+
let entries;
20+
21+
bench.start();
22+
switch (mode) {
23+
case 'sync':
24+
for (let i = 0; i < n; i++) {
25+
entries = fs.readdirSync(fullPath, options);
26+
}
27+
break;
28+
case 'callback':
29+
for (let i = 0; i < n; i++) {
30+
entries = await new Promise((resolve, reject) => {
31+
fs.readdir(fullPath, options, (err, result) => {
32+
if (err) reject(err);
33+
else resolve(result);
34+
});
35+
});
36+
}
37+
break;
38+
case 'promise':
39+
for (let i = 0; i < n; i++) {
40+
entries = await fs.promises.readdir(fullPath, options);
41+
}
42+
break;
43+
}
44+
bench.end(n);
45+
46+
assert.ok(entries.length > 0);
47+
}

‎lib/fs.js‎

Lines changed: 18 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const {
2828
ArrayFromAsync,
2929
ArrayPrototypePush,
3030
BigIntPrototypeToString,
31-
Boolean,
3231
FunctionPrototypeCall,
3332
MathMax,
3433
Number,
@@ -57,9 +56,6 @@ const {
5756
F_OK,
5857
O_WRONLY,
5958
O_SYMLINK,
60-
UV_DIRENT_DIR,
61-
UV_DIRENT_LINK,
62-
UV_DIRENT_UNKNOWN,
6359
} = constants;
6460

6561
const pathModule = require('path');
@@ -105,8 +101,8 @@ const {
105101
},
106102
copyObject,
107103
Dirent,
108-
getDirent,
109104
getDirents,
105+
getRecursiveDirents,
110106
getOptions,
111107
getValidatedFd,
112108
getValidatedPath,
@@ -1742,43 +1738,6 @@ function mkdirSync(path, options) {
17421738
}
17431739
}
17441740

1745-
/**
1746-
* Appends one directory's entries to `context.results` and the subdirectories
1747-
* still to visit to `context.dirs` (with the prefix their entries get in
1748-
* string results in `context.prefixes`). `result` is a `binding.readdir()`
1749-
* result with file types, so only symbolic links and entries of unknown type
1750-
* need a stat() to find out whether they lead to a directory.
1751-
* @param {string} dir
1752-
* @param {string} prefix
1753-
* @param {[string[], number[]]} result
1754-
* @param {{ withFileTypes: boolean, results: (string | Dirent)[], dirs: string[], prefixes: string[] }} context
1755-
*/
1756-
function collectRecursiveReaddirResult(dir, prefix, { 0: names, 1: types }, context) {
1757-
const { length } = names;
1758-
for (let i = 0; i < length; i++) {
1759-
const name = names[i];
1760-
const relative = prefix === '' ? name : `${prefix}${pathModule.sep}${name}`;
1761-
let isDirectory;
1762-
if (context.withFileTypes) {
1763-
const dirent = getDirent(dir, name, types[i]);
1764-
ArrayPrototypePush(context.results, dirent);
1765-
// Follow symbolic links to directories, see https://github.com/nodejs/node/issues/52663
1766-
isDirectory = dirent.isDirectory() ||
1767-
(dirent.isSymbolicLink() && binding.internalModuleStat(pathModule.join(dir, name)) === 1);
1768-
} else {
1769-
ArrayPrototypePush(context.results, relative);
1770-
const type = types[i];
1771-
isDirectory = type === UV_DIRENT_DIR ||
1772-
((type === UV_DIRENT_LINK || type === UV_DIRENT_UNKNOWN) &&
1773-
binding.internalModuleStat(pathModule.join(dir, name)) === 1);
1774-
}
1775-
if (isDirectory) {
1776-
ArrayPrototypePush(context.dirs, pathModule.join(dir, name));
1777-
ArrayPrototypePush(context.prefixes, relative);
1778-
}
1779-
}
1780-
}
1781-
17821741
/*
17831742
* An recursive algorithm for reading the entire contents of the `basePath` directory.
17841743
* This function does not validate `basePath` as a directory. It is passed directly to
@@ -1792,79 +1751,30 @@ function collectRecursiveReaddirResult(dir, prefix, { 0: names, 1: types }, cont
17921751
* @returns {void}
17931752
*/
17941753
function readdirRecursive(basePath, options, callback) {
1795-
const context = {
1796-
withFileTypes: Boolean(options.withFileTypes),
1797-
results: [],
1798-
dirs: [basePath],
1799-
prefixes: [''],
1754+
const withFileTypes = !!options.withFileTypes;
1755+
const req = new FSReqCallback();
1756+
req.oncomplete = (err, result) => {
1757+
if (err) {
1758+
callback(err);
1759+
return;
1760+
}
1761+
callback(null, withFileTypes ? getRecursiveDirents(basePath, result) : result);
18001762
};
1801-
1802-
let i = 0;
1803-
1804-
/**
1805-
* Reads one directory from `context.dirs` and then moves on to the next
1806-
* one, or calls back once none are left.
1807-
* @param {string} path
1808-
* @param {string} prefix path of this directory relative to `basePath`
1809-
*/
1810-
function read(path, prefix) {
1811-
const req = new FSReqCallback();
1812-
req.oncomplete = (err, result) => {
1813-
if (err) {
1814-
callback(err);
1815-
return;
1816-
}
1817-
1818-
if (result === undefined) {
1819-
callback(null, context.results);
1820-
return;
1821-
}
1822-
1823-
try {
1824-
collectRecursiveReaddirResult(path, prefix, result, context);
1825-
} catch (err) {
1826-
callback(err);
1827-
return;
1828-
}
1829-
1830-
if (i < context.dirs.length) {
1831-
read(context.dirs[i], context.prefixes[i++]);
1832-
} else {
1833-
callback(null, context.results);
1834-
}
1835-
};
1836-
1837-
binding.readdir(path, options.encoding, true, req);
1838-
}
1839-
1840-
read(context.dirs[i], context.prefixes[i++]);
1763+
binding.readdirRecursive(basePath, options.encoding, withFileTypes, req);
18411764
}
18421765

18431766
/**
1844-
* An iterative algorithm for reading the entire contents of the `basePath` directory.
1845-
* This function does not validate `basePath` as a directory. It is passed directly to
1846-
* `binding.readdir`.
1847-
* @param {string} basePath
1767+
* Synchronously reads the entire contents of the `basePath` directory.
1768+
* This function does not validate `basePath` as a directory. It is passed
1769+
* directly to `binding.readdirRecursive`.
1770+
* @param {string | Buffer} basePath
18481771
* @param {{ encoding: string, withFileTypes: boolean }} options
1849-
* @returns {string[] | Dirent[]}
1772+
* @returns {string[] | Buffer[] | Dirent[]}
18501773
*/
18511774
function readdirSyncRecursive(basePath, options) {
1852-
const context = {
1853-
withFileTypes: Boolean(options.withFileTypes),
1854-
results: [],
1855-
dirs: [basePath],
1856-
prefixes: [''],
1857-
};
1858-
1859-
for (let i = 0; i < context.dirs.length; i++) {
1860-
const dir = context.dirs[i];
1861-
const result = binding.readdir(dir, options.encoding, true);
1862-
if (result !== undefined) {
1863-
collectRecursiveReaddirResult(dir, context.prefixes[i], result, context);
1864-
}
1865-
}
1866-
1867-
return context.results;
1775+
const withFileTypes = !!options.withFileTypes;
1776+
const result = binding.readdirRecursive(basePath, options.encoding, withFileTypes);
1777+
return result !== undefined && withFileTypes ? getRecursiveDirents(basePath, result) : result;
18681778
}
18691779

18701780
/**
@@ -1898,9 +1808,6 @@ function readdir(path, options, callback) {
18981808
}
18991809

19001810
if (options.recursive) {
1901-
// Make shallow copy to prevent mutating options from affecting results
1902-
options = copyObject(options);
1903-
19041811
readdirRecursive(path, options, callback);
19051812
return;
19061813
}

‎lib/internal/fs/promises.js‎

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

33
const {
4-
ArrayPrototypePop,
54
ArrayPrototypePush,
65
Error,
76
ErrorCaptureStackTrace,
@@ -32,9 +31,6 @@ const {
3231
O_WRONLY,
3332
S_IFMT,
3433
S_IFREG,
35-
UV_DIRENT_DIR,
36-
UV_DIRENT_LINK,
37-
UV_DIRENT_UNKNOWN,
3834
} = constants;
3935

4036
const binding = internalBinding('fs');
@@ -66,8 +62,8 @@ const {
6662
kWriteFileMaxChunkSize,
6763
},
6864
copyObject,
69-
getDirent,
7065
getDirents,
66+
getRecursiveDirents,
7167
getOptions,
7268
getStatFsFromBinding,
7369
getStatsFromBinding,
@@ -1643,41 +1639,17 @@ async function mkdir(path, options) {
16431639

16441640
async function readdirRecursive(originalPath, options) {
16451641
const withFileTypes = !!options.withFileTypes;
1646-
const readdirWithTypes = (path) => PromisePrototypeThen(
1647-
binding.readdir(path, options.encoding, true, kUsePromises),
1642+
const result = await PromisePrototypeThen(
1643+
binding.readdirRecursive(
1644+
originalPath,
1645+
options.encoding,
1646+
withFileTypes,
1647+
kUsePromises,
1648+
),
16481649
undefined,
16491650
handleErrorFromBinding,
16501651
);
1651-
const result = [];
1652-
const queue = [[originalPath, '', await readdirWithTypes(originalPath)]];
1653-
1654-
while (queue.length > 0) {
1655-
// If we want to implement BFS make this a `shift` call instead of `pop`
1656-
const { 0: path, 1: prefix, 2: { 0: names, 1: types } } = ArrayPrototypePop(queue);
1657-
for (let i = 0; i < names.length; i++) {
1658-
const name = names[i];
1659-
const relative = prefix === '' ? name : `${prefix}${pathModule.sep}${name}`;
1660-
let isDirectory;
1661-
if (withFileTypes) {
1662-
const dirent = getDirent(path, name, types[i]);
1663-
ArrayPrototypePush(result, dirent);
1664-
isDirectory = dirent.isDirectory();
1665-
} else {
1666-
ArrayPrototypePush(result, relative);
1667-
// Entries that are, or may be, symbolic links to directories are followed.
1668-
const type = types[i];
1669-
isDirectory = type === UV_DIRENT_DIR ||
1670-
((type === UV_DIRENT_LINK || type === UV_DIRENT_UNKNOWN) &&
1671-
binding.internalModuleStat(pathModule.join(path, name)) === 1);
1672-
}
1673-
if (isDirectory) {
1674-
const direntPath = pathModule.join(path, name);
1675-
ArrayPrototypePush(queue, [direntPath, relative, await readdirWithTypes(direntPath)]);
1676-
}
1677-
}
1678-
}
1679-
1680-
return result;
1652+
return withFileTypes ? getRecursiveDirents(originalPath, result) : result;
16811653
}
16821654

16831655
async function readdir(path, options) {

‎lib/internal/fs/utils.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const {
44
ArrayIsArray,
5+
ArrayPrototypePush,
56
BigInt,
67
Date,
78
DateNow,
@@ -323,6 +324,26 @@ function getDirent(path, name, type, callback) {
323324
}
324325
}
325326

327+
/**
328+
* Builds the Dirent objects for a recursive readdir
329+
* @param {string | Buffer} basePath
330+
* @param {[string[] | Buffer[], number[], number[], string[]]} result
331+
* @returns {Dirent[]}
332+
*/
333+
function getRecursiveDirents(basePath, { 0: names, 1: types, 2: dirIndices, 3: dirs }) {
334+
const parentPaths = [basePath];
335+
if (dirs.length > 1) {
336+
const base = typeof basePath === 'string' ? basePath : `${basePath}`;
337+
for (let i = 1; i < dirs.length; i++) {
338+
ArrayPrototypePush(parentPaths, pathModule.join(base, dirs[i]));
339+
}
340+
}
341+
for (let i = 0; i < names.length; i++) {
342+
names[i] = new Dirent(names[i], types[i], parentPaths[dirIndices[i]]);
343+
}
344+
return names;
345+
}
346+
326347
function getOptions(options, defaultOptions = kEmptyObject) {
327348
if (options == null || typeof options === 'function') {
328349
return defaultOptions;
@@ -1128,6 +1149,7 @@ module.exports = {
11281149
getDirent,
11291150
getDirents,
11301151
getOptions,
1152+
getRecursiveDirents,
11311153
getValidatedFd,
11321154
getValidatedPath,
11331155
handleErrorFromBinding,

0 commit comments

Comments
 (0)