Skip to content

Commit 8bdfe75

Browse files
authored
feat(fmt): support custom parallel worker count (#133)
1 parent 38ad68e commit 8bdfe75

8 files changed

Lines changed: 124 additions & 19 deletions

File tree

packages/rstack/src/fmt/cli.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface ParsedFmtCLIArgs {
1111
mode: FmtMode;
1212
patterns: string[];
1313
parallel: boolean;
14+
maxWorkers?: number;
1415
help: boolean;
1516
}
1617

@@ -26,8 +27,26 @@ ${color.cyan('Options')}:
2627
--check Check whether files are formatted
2728
--list-different Print paths of unformatted files
2829
--no-parallel Disable worker parallelism
30+
--parallel-workers <count> Number of parallel workers
2931
-h, --help Display this help message`;
3032

33+
const parseMaxWorkers = (
34+
kebabValue: string | undefined,
35+
camelValue: string | undefined,
36+
): number | undefined => {
37+
const value = kebabValue ?? camelValue;
38+
if (value === undefined) {
39+
return undefined;
40+
}
41+
42+
const maxWorkers = Number(value);
43+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(maxWorkers) || maxWorkers < 1) {
44+
throw new Error('The --parallel-workers option must be a positive integer.');
45+
}
46+
47+
return maxWorkers;
48+
};
49+
3150
const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
3251
const { values, positionals } = parseArgs({
3352
args,
@@ -38,6 +57,8 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
3857
listDifferent: { type: 'boolean' },
3958
'no-parallel': { type: 'boolean' },
4059
noParallel: { type: 'boolean' },
60+
'parallel-workers': { type: 'string' },
61+
parallelWorkers: { type: 'string' },
4162
help: { type: 'boolean', short: 'h' },
4263
},
4364
allowPositionals: true,
@@ -51,11 +72,18 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
5172
}
5273

5374
const mode = values.check ? 'check' : listDifferent ? 'list-different' : 'write';
75+
const noParallel = values['no-parallel'] || values.noParallel;
76+
const maxWorkers = parseMaxWorkers(values['parallel-workers'], values.parallelWorkers);
77+
78+
if (noParallel && maxWorkers !== undefined) {
79+
throw new Error('The --parallel-workers and --no-parallel options cannot be used together.');
80+
}
5481

5582
return {
5683
mode,
5784
patterns: positionals,
58-
parallel: !(values['no-parallel'] || values.noParallel),
85+
parallel: !noParallel,
86+
maxWorkers,
5987
help: values.help ?? false,
6088
};
6189
};
@@ -98,7 +126,7 @@ const logFmtResult = (result: FmtRunResult, mode: FmtMode, cwd: string): void =>
98126
};
99127

100128
const runFmtCLI = async (args: string[]): Promise<void> => {
101-
const { help, mode, parallel, patterns } = parseFmtCLIArgs(args);
129+
const { help, maxWorkers, mode, parallel, patterns } = parseFmtCLIArgs(args);
102130
if (help) {
103131
console.log(fmtHelpMessage);
104132
return;
@@ -124,6 +152,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
124152
mode,
125153
cache: false,
126154
parallel,
155+
maxWorkers,
127156
});
128157

129158
logFmtResult(result, mode, cwd);

packages/rstack/src/fmt/parallel.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ interface FmtWorker {
1010
terminate: () => void;
1111
}
1212

13-
const getFmtWorkerCount = (fileCount: number): number =>
14-
Math.min(fileCount, Math.max(1, availableParallelism() - 1));
13+
const getFmtWorkerCount = (fileCount: number, maxWorkers?: number): number =>
14+
Math.min(fileCount, maxWorkers ?? Math.max(1, availableParallelism() - 1));
1515

1616
const getFmtWorkerUrl = (): URL => {
1717
// Source tests run after build and exercise the same worker artifact as the CLI.
@@ -22,8 +22,8 @@ const getFmtWorkerUrl = (): URL => {
2222
};
2323

2424
/** Creates and starts every worker before formatting can begin. */
25-
const createFmtWorker = async (fileCount: number): Promise<FmtWorker> => {
26-
const workerCount = getFmtWorkerCount(fileCount);
25+
const createFmtWorker = async (fileCount: number, maxWorkers?: number): Promise<FmtWorker> => {
26+
const workerCount = getFmtWorkerCount(fileCount, maxWorkers);
2727
const pool = new WorkTank<FmtWorkerMethods>({
2828
pool: {
2929
name: 'rstack-fmt',
@@ -51,4 +51,4 @@ const createFmtWorker = async (fileCount: number): Promise<FmtWorker> => {
5151
};
5252
};
5353

54-
export { createFmtWorker };
54+
export { createFmtWorker, getFmtWorkerCount };

packages/rstack/src/fmt/runner.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ const runFmtFilesSerial = async (
5454
const runFmtFilesParallel = async (
5555
files: FmtFileRequest[],
5656
shouldWrite: boolean,
57+
maxWorkers?: number,
5758
): Promise<FmtFileResult[]> => {
5859
const { createFmtWorker } = await import('./parallel.ts');
59-
const worker = await createFmtWorker(files.length);
60+
const worker = await createFmtWorker(files.length, maxWorkers);
6061

6162
try {
6263
return await Promise.all(files.map((file) => runFmtFile(file, shouldWrite, worker.formatFile)));
@@ -96,12 +97,13 @@ const runFmtFiles = async ({
9697
files,
9798
mode,
9899
parallel,
100+
maxWorkers,
99101
}: RunFmtFilesOptions): Promise<FmtRunResult> => {
100102
const startTime = performance.now();
101103
const shouldWrite = mode === 'write';
102104
const results =
103105
parallel && files.length > 1 && canRunFmtFilesParallel(files)
104-
? await runFmtFilesParallel(files, shouldWrite)
106+
? await runFmtFilesParallel(files, shouldWrite, maxWorkers)
105107
: await runFmtFilesSerial(files, shouldWrite);
106108

107109
return {

packages/rstack/src/fmt/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ interface RunFmtFilesOptions {
5656
cache: false;
5757
/** Whether cloneable file requests should run in worker threads. */
5858
parallel: boolean;
59+
/** Maximum worker count when parallel execution is enabled. */
60+
maxWorkers?: number;
5961
}
6062

6163
interface SuccessfulFmtFileResult {

packages/rstack/tests/cli/fmt/index.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,14 @@ test('formats the current directory with Prettier defaults', () => {
8484
expect(readProjectFile('index.ts')).toBe('const message = "hello";\n');
8585
});
8686

87-
test('supports disabling parallel execution', () => {
87+
test.each([
88+
['disabling parallel execution', ['--no-parallel']],
89+
['configuring parallel worker count', ['--parallel-workers', '1']],
90+
] as const)('supports %s', (_, options) => {
8891
writeProjectFile('first.ts', 'const first="first"');
8992
writeProjectFile('second.ts', 'const second="second"');
9093

91-
const result = runFmt(['--no-parallel', 'first.ts', 'second.ts']);
94+
const result = runFmt([...options, 'first.ts', 'second.ts']);
9295

9396
expect(result.status).toBe(0);
9497
expect(result.stdout).toBe('first.ts\nsecond.ts\n');

packages/rstack/tests/fmt/cli.test.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ test('uses write mode by default', () => {
66
mode: 'write',
77
patterns: [],
88
parallel: true,
9+
maxWorkers: undefined,
910
help: false,
1011
});
1112
});
@@ -20,6 +21,7 @@ test.each([
2021
mode,
2122
patterns: [],
2223
parallel: true,
24+
maxWorkers: undefined,
2325
help: false,
2426
});
2527
});
@@ -29,17 +31,54 @@ test.each(['--no-parallel', '--noParallel'])('disables parallel execution with %
2931
mode: 'write',
3032
patterns: [],
3133
parallel: false,
34+
maxWorkers: undefined,
3235
help: false,
3336
});
3437
});
3538

39+
test.each(['--parallel-workers', '--parallelWorkers'])(
40+
'configures parallel worker count with %s',
41+
(option) => {
42+
expect(parseFmtCLIArgs([option, '3'])).toEqual({
43+
mode: 'write',
44+
patterns: [],
45+
parallel: true,
46+
maxWorkers: 3,
47+
help: false,
48+
});
49+
},
50+
);
51+
52+
test.each(['0', '-1', '1.5', 'invalid', '9007199254740992'])(
53+
'rejects invalid parallel worker count %s',
54+
(count) => {
55+
expect(() => parseFmtCLIArgs([`--parallel-workers=${count}`])).toThrow(
56+
'The --parallel-workers option must be a positive integer.',
57+
);
58+
},
59+
);
60+
61+
test('prefers the kebab-case parallel worker option', () => {
62+
expect(parseFmtCLIArgs(['--parallel-workers', '2', '--parallelWorkers', '3']).maxWorkers).toBe(2);
63+
});
64+
65+
test.each([
66+
['--no-parallel', '--parallel-workers'],
67+
['--noParallel', '--parallelWorkers'],
68+
])('rejects conflicting parallel options: %s and %s', (noParallel, maxWorkersOption) => {
69+
expect(() => parseFmtCLIArgs([noParallel, maxWorkersOption, '2'])).toThrow(
70+
'The --parallel-workers and --no-parallel options cannot be used together.',
71+
);
72+
});
73+
3674
test('preserves file paths and globs', () => {
3775
const patterns = ['src/file with spaces.ts', 'src/**/*.{js,ts}', '!src/generated/**'];
3876

3977
expect(parseFmtCLIArgs([patterns[0], '--check', ...patterns.slice(1)])).toEqual({
4078
mode: 'check',
4179
patterns,
4280
parallel: true,
81+
maxWorkers: undefined,
4382
help: false,
4483
});
4584
});
@@ -49,6 +88,7 @@ test('treats arguments after the terminator as paths', () => {
4988
mode: 'check',
5089
patterns: ['--write', '--help'],
5190
parallel: true,
91+
maxWorkers: undefined,
5292
help: false,
5393
});
5494
});
@@ -63,6 +103,7 @@ test('provides command help', () => {
63103
expect(fmtHelpMessage).toContain('--check');
64104
expect(fmtHelpMessage).toContain('--list-different');
65105
expect(fmtHelpMessage).toContain('--no-parallel');
106+
expect(fmtHelpMessage).toContain('--parallel-workers <count>');
66107
expect(fmtHelpMessage).toContain('-h, --help');
67108
});
68109

@@ -78,9 +119,6 @@ test.each([
78119
);
79120
});
80121

81-
test.each(['--unknown', '--no-cache', '--parallel-workers'])(
82-
'rejects unsupported option %s',
83-
(option) => {
84-
expect(() => parseFmtCLIArgs([option])).toThrow();
85-
},
86-
);
122+
test.each(['--unknown', '--no-cache'])('rejects unsupported option %s', (option) => {
123+
expect(() => parseFmtCLIArgs([option])).toThrow();
124+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { availableParallelism } from 'node:os';
2+
import { expect, test } from 'rstack/test';
3+
import { getFmtWorkerCount } from '../../src/fmt/parallel.ts';
4+
5+
test('uses one fewer worker than the available parallelism by default', () => {
6+
const defaultWorkerCount = Math.max(1, availableParallelism() - 1);
7+
8+
expect(getFmtWorkerCount(defaultWorkerCount + 1)).toBe(defaultWorkerCount);
9+
});
10+
11+
test.each([
12+
[4, 1, 1],
13+
[4, 2, 2],
14+
[2, 4, 2],
15+
])('uses %s files and %s configured workers as %s workers', (files, workers, expected) => {
16+
expect(getFmtWorkerCount(files, workers)).toBe(expected);
17+
});

packages/rstack/tests/fmt/runnerParallelPreflight.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { readFileSync } from 'node:fs';
2-
import { expect, rs, test } from 'rstack/test';
2+
import { beforeEach, expect, rs, test } from 'rstack/test';
33
import { runFmtFiles } from '../../src/fmt/runner.ts';
44
import type { FmtFileRequest } from '../../src/fmt/types.ts';
55
import { withTempProject, writeProjectFile } from './helpers.ts';
66

7+
const mocks = rs.hoisted(() => ({
8+
createFmtWorkerCalls: [] as [number, number | undefined][],
9+
}));
10+
711
rs.mock('../../src/fmt/parallel.ts', () => ({
8-
createFmtWorker: () => Promise.reject(new Error('worker startup failed')),
12+
createFmtWorker: (fileCount: number, maxWorkers?: number) => {
13+
mocks.createFmtWorkerCalls.push([fileCount, maxWorkers]);
14+
return Promise.reject(new Error('worker startup failed'));
15+
},
916
}));
1017

18+
beforeEach(() => {
19+
mocks.createFmtWorkerCalls.length = 0;
20+
});
21+
1122
const createRequest = (
1223
filePath: string,
1324
plugins?: FmtFileRequest['options']['plugins'],
@@ -32,9 +43,12 @@ test('does not write files when worker startup fails', async () => {
3243
mode: 'write',
3344
cache: false,
3445
parallel: true,
46+
maxWorkers: 3,
3547
}),
3648
).rejects.toThrow('worker startup failed');
3749

50+
expect(mocks.createFmtWorkerCalls).toEqual([[2, 3]]);
51+
3852
for (const filePath of filePaths) {
3953
expect(readFileSync(filePath, 'utf8')).toBe('const value=1');
4054
}

0 commit comments

Comments
 (0)