Skip to content

Commit

Permalink
Limit parallel file reads to prevent "EMFILE: too many open files" er…
Browse files Browse the repository at this point in the history
…ror (#4170)

* Added maxParallelFileReads.

* Added some test for maxParallelFileReads setting.

* Added tests for queue.

* Updated tests

* Added docs.

* Fix test on Windows

Co-authored-by: Lukas Taegert-Atkinson <lukas.taegert-atkinson@tngtech.com>
  • Loading branch information
schummar and lukastaegert committed Jul 9, 2021
1 parent b3d5f7d commit ce95197
Show file tree
Hide file tree
Showing 32 changed files with 207 additions and 4 deletions.
7 changes: 7 additions & 0 deletions docs/999-big-list-of-options.md
Expand Up @@ -336,6 +336,13 @@ For this case, choosing `"ifRelativeSource"` will check if the original import w

Note that when a relative path is directly marked as "external" using the [`external`](guide/en/#external) option, then it will be the same relative path in the output. When it is resolved first via a plugin or Rollup core and then marked as external, the above logic will apply.

#### maxParallelFileReads
Type: `number`<br>
CLI: `--maxParallelFileReads <number>`<br>
Default: 20

Limits the number of files rollup will open in parallel when reading modules. Without a limit or with a high enough value, builds can fail with an "EMFILE: too many open files". This dependes on how many open file handles the os allows.

#### onwarn
Type: `(warning: RollupWarning, defaultHandler: (warning: string | RollupWarning) => void) => void;`

Expand Down
7 changes: 6 additions & 1 deletion src/ModuleLoader.ts
Expand Up @@ -30,6 +30,7 @@ import {
} from './utils/error';
import { readFile } from './utils/fs';
import { isAbsolute, isRelative, resolve } from './utils/path';
import { Queue } from './utils/queue';
import relativeId from './utils/relativeId';
import { resolveId } from './utils/resolveId';
import { timeEnd, timeStart } from './utils/timers';
Expand All @@ -53,6 +54,7 @@ export class ModuleLoader {
private readonly indexedEntryModules: { index: number; module: Module }[] = [];
private latestLoadModulesPromise: Promise<unknown> = Promise.resolve();
private nextEntryModuleIndex = 0;
private readQueue = new Queue();

constructor(
private readonly graph: Graph,
Expand All @@ -63,6 +65,7 @@ export class ModuleLoader {
this.hasModuleSideEffects = options.treeshake
? options.treeshake.moduleSideEffects
: () => true;
this.readQueue.maxParallel = options.maxParallelFileReads;
}

async addAdditionalModules(unresolvedModules: string[]): Promise<Module[]> {
Expand Down Expand Up @@ -217,7 +220,9 @@ export class ModuleLoader {
timeStart('load modules', 3);
let source: string | SourceDescription;
try {
source = (await this.pluginDriver.hookFirst('load', [id])) ?? (await readFile(id));
source =
(await this.pluginDriver.hookFirst('load', [id])) ??
(await this.readQueue.run(async () => readFile(id)));
} catch (err) {
timeEnd('load modules', 3);
let msg = `Could not load ${id}`;
Expand Down
2 changes: 2 additions & 0 deletions src/rollup/types.d.ts
Expand Up @@ -537,6 +537,7 @@ export interface InputOptions {
makeAbsoluteExternalsRelative?: boolean | 'ifRelativeSource';
/** @deprecated Use the "manualChunks" output option instead. */
manualChunks?: ManualChunksOption;
maxParallelFileReads?: number;
moduleContext?: ((id: string) => string | null | undefined) | { [id: string]: string };
onwarn?: WarningHandlerWithDefault;
perf?: boolean;
Expand Down Expand Up @@ -564,6 +565,7 @@ export interface NormalizedInputOptions {
makeAbsoluteExternalsRelative: boolean | 'ifRelativeSource';
/** @deprecated Use the "manualChunks" output option instead. */
manualChunks: ManualChunksOption | undefined;
maxParallelFileReads: number;
moduleContext: (id: string) => string;
onwarn: WarningHandler;
perf: boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/fs.ts
@@ -1,4 +1,4 @@
import * as fs from 'fs';
import fs from 'fs';
import { dirname } from './path';

export * from 'fs';
Expand Down
1 change: 1 addition & 0 deletions src/utils/options/mergeOptions.ts
Expand Up @@ -120,6 +120,7 @@ function mergeInputOptions(
input: getOption('input') || [],
makeAbsoluteExternalsRelative: getOption('makeAbsoluteExternalsRelative'),
manualChunks: getOption('manualChunks'),
maxParallelFileReads: getOption('maxParallelFileReads'),
moduleContext: getOption('moduleContext'),
onwarn: getOnWarn(config, defaultOnWarnHandler),
perf: getOption('perf'),
Expand Down
12 changes: 12 additions & 0 deletions src/utils/options/normalizeInputOptions.ts
Expand Up @@ -50,6 +50,7 @@ export function normalizeInputOptions(config: InputOptions): {
input: getInput(config),
makeAbsoluteExternalsRelative: config.makeAbsoluteExternalsRelative ?? true,
manualChunks: getManualChunks(config, onwarn, strictDeprecations),
maxParallelFileReads: getMaxParallelFileReads(config),
moduleContext: getModuleContext(config, context),
onwarn,
perf: config.perf || false,
Expand Down Expand Up @@ -175,6 +176,17 @@ const getManualChunks = (
return configManualChunks;
};

const getMaxParallelFileReads = (
config: InputOptions
): NormalizedInputOptions['maxParallelFileReads'] => {
const maxParallelFileReads = config.maxParallelFileReads as unknown;
if (typeof maxParallelFileReads === 'number') {
if (maxParallelFileReads <= 0) return Infinity;
return maxParallelFileReads;
}
return 20;
};

const getModuleContext = (
config: InputOptions,
context: string
Expand Down
36 changes: 36 additions & 0 deletions src/utils/queue.ts
@@ -0,0 +1,36 @@
export class Queue {
private queue = new Array<{
reject: (reason?: any) => void;
resolve: (value: any) => void;
task: () => any;
}>();
private workerCount = 0;

constructor(public maxParallel = 1) {}

run<T>(task: () => T | Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ reject, resolve, task });
this.work();
});
}

private async work() {
if (this.workerCount >= this.maxParallel) return;
this.workerCount++;

let entry;
while ((entry = this.queue.shift())) {
const { reject, resolve, task } = entry;

try {
const result = await task();
resolve(result);
} catch (err) {
reject(err);
}
}

this.workerCount--;
}
}
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-default/1.js
@@ -0,0 +1 @@
export const x1 = 1;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-default/2.js
@@ -0,0 +1 @@
export const x2 = 2;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-default/3.js
@@ -0,0 +1 @@
export const x3 = 3;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-default/4.js
@@ -0,0 +1 @@
export const x4 = 4;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-default/5.js
@@ -0,0 +1 @@
export const x5 = 5;
24 changes: 24 additions & 0 deletions test/function/samples/max-parallel-file-reads-default/_config.js
@@ -0,0 +1,24 @@
const assert = require('assert');
const fs = require('fs');

const fsReadFile = fs.readFile;
let currentReads = 0;
let maxReads = 0;

module.exports = {
description: 'maxParallelFileReads not set',
before() {
fs.readFile = (path, options, callback) => {
currentReads++;
maxReads = Math.max(maxReads, currentReads);
fsReadFile(path, options, (err, data) => {
currentReads--;
callback(err, data);
});
};
},
after() {
fs.readFile = fsReadFile;
assert.strictEqual(maxReads, 5, 'Wrong number of parallel file reads: ' + maxReads);
}
};
5 changes: 5 additions & 0 deletions test/function/samples/max-parallel-file-reads-default/main.js
@@ -0,0 +1,5 @@
export * from './1';
export * from './2';
export * from './3';
export * from './4';
export * from './5';
31 changes: 31 additions & 0 deletions test/function/samples/max-parallel-file-reads-error/_config.js
@@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
const { loader } = require('../../../utils.js');

const fsReadFile = fs.readFile;

module.exports = {
description: 'maxParallelFileReads: fileRead error is forwarded',
options: {
input: 'main',
plugins: loader({
main: `import {foo} from './dep';`
})
},
before() {
fs.readFile = (path, options, callback) => {
if (path.endsWith('dep.js')) {
return callback(new Error('broken'));
}

fsReadFile(path, options, callback);
};
},
after() {
fs.readFile = fsReadFile;
},
error: {
message: `Could not load ${path.join(__dirname, 'dep.js')} (imported by main): broken`,
watchFiles: ['main', path.join(__dirname, 'dep.js')]
}
};
Empty file.
@@ -0,0 +1 @@
export const x1 = 1;
@@ -0,0 +1 @@
export const x2 = 2;
@@ -0,0 +1 @@
export const x3 = 3;
@@ -0,0 +1 @@
export const x4 = 4;
@@ -0,0 +1 @@
export const x5 = 5;
27 changes: 27 additions & 0 deletions test/function/samples/max-parallel-file-reads-infinity/_config.js
@@ -0,0 +1,27 @@
const assert = require('assert');
const fs = require('fs');

const fsReadFile = fs.readFile;
let currentReads = 0;
let maxReads = 0;

module.exports = {
description: 'maxParallelFileReads set to infinity',
options: {
maxParallelFileReads: 0
},
before() {
fs.readFile = (path, options, callback) => {
currentReads++;
maxReads = Math.max(maxReads, currentReads);
fsReadFile(path, options, (err, data) => {
currentReads--;
callback(err, data);
});
};
},
after() {
fs.readFile = fsReadFile;
assert.strictEqual(maxReads, 5, 'Wrong number of parallel file reads: ' + maxReads);
}
};
@@ -0,0 +1,5 @@
export * from './1';
export * from './2';
export * from './3';
export * from './4';
export * from './5';
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-set/1.js
@@ -0,0 +1 @@
export const x1 = 1;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-set/2.js
@@ -0,0 +1 @@
export const x2 = 2;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-set/3.js
@@ -0,0 +1 @@
export const x3 = 3;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-set/4.js
@@ -0,0 +1 @@
export const x4 = 4;
1 change: 1 addition & 0 deletions test/function/samples/max-parallel-file-reads-set/5.js
@@ -0,0 +1 @@
export const x5 = 5;
27 changes: 27 additions & 0 deletions test/function/samples/max-parallel-file-reads-set/_config.js
@@ -0,0 +1,27 @@
const assert = require('assert');
const fs = require('fs');

const fsReadFile = fs.readFile;
let currentReads = 0;
let maxReads = 0;

module.exports = {
description: 'maxParallelFileReads set to 3',
options: {
maxParallelFileReads: 3
},
before() {
fs.readFile = (path, options, callback) => {
currentReads++;
maxReads = Math.max(maxReads, currentReads);
fsReadFile(path, options, (err, data) => {
currentReads--;
callback(err, data);
});
};
},
after() {
fs.readFile = fsReadFile;
assert.strictEqual(maxReads, 3, 'Wrong number of parallel file reads: ' + maxReads);
}
};
5 changes: 5 additions & 0 deletions test/function/samples/max-parallel-file-reads-set/main.js
@@ -0,0 +1,5 @@
export * from './1';
export * from './2';
export * from './3';
export * from './4';
export * from './5';
1 change: 1 addition & 0 deletions test/function/samples/options-hook/_config.js
Expand Up @@ -20,6 +20,7 @@ module.exports = {
experimentalCacheExpiry: 10,
input: ['used'],
makeAbsoluteExternalsRelative: true,
maxParallelFileReads: 20,
perf: false,
plugins: [
{
Expand Down
4 changes: 2 additions & 2 deletions test/misc/optionList.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ce95197

Please sign in to comment.