Skip to content

Commit 6e5e76f

Browse files
authored
feat(cli): allow percentage-based concurrency configuration (e.g., "50%") (#5818)
- Allow concurrency option to accept percentage strings (e.g., "50%") in addition to absolute numbers - Switch from os.cpus() to os.availableParallelism() for more accurate CPU count detection
1 parent 6398fda commit 6e5e76f

9 files changed

Lines changed: 212 additions & 39 deletions

File tree

docs/configuration.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,16 @@ Settings for the `clear-text` reporter.
126126
- `reportScoreTable`: Indicates whether or not to log score table.
127127
- `skipFull`: Indicates whether rows with 100% mutation score are hidden in the score table.
128128

129-
### `concurrency` [`number`]
129+
### `concurrency` [`number` \| `string`]
130130

131131
Default: `cpuCoreCount <= 4? cpuCoreCount : cpuCoreCount - 1`<br />
132-
Command line: `--concurrency 4`<br />
133-
Config file: `"concurrency": 4`
132+
Command line: `--concurrency 4` or `--concurrency 50%`<br />
133+
Config file: `"concurrency": 4` or `"concurrency": "50%"`
134134

135135
Set the concurrency of workers. This defaults to `n-1` where `n` is the number of logical CPU cores available on your machine, unless `n <= 4`, in that case it uses `n`. This is a sane default for most use cases.
136136

137+
You can also specify a percentage string (e.g., `"50%"`) to compute the worker count as a percentage of the available CPU cores. For example, on an 8-core machine, `"50%"` results in 4 workers. The minimum is always 1 worker.
138+
137139
### `commandRunner` [`object`]
138140

139141
Default: `{ command: 'npm test' }`<br />

docs/parallel-workers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Parallel Workers
33
custom_edit_url: https://github.com/stryker-mutator/stryker-js/edit/master/docs/parallel-workers.md
44
---
55

6-
Stryker will always run checkers and test runners in parallel by creating worker processes (note, not `worker_threads`). The number of such processes forked is determined by the configuration option [`--concurrency`](./configuration.md#concurrency-number).
6+
Stryker will always run checkers and test runners in parallel by creating worker processes (note, not `worker_threads`). The number of such processes forked is determined by the configuration option [`--concurrency`](./configuration.md#concurrency-number--string). You can specify a number (e.g., `4`) or a percentage of CPU cores (e.g., `"50%"`).
77

88
However, imagine running these parallel processes on a test suite which uses resources like a database connection, web server or file system. This means these processes can conflict if they write to the same database, file or utilize the same port.
99

packages/api/schema/stryker-core.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,18 @@
292292
}
293293
},
294294
"concurrency": {
295-
"description": "Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (note, not `worker_threads`). This defaults to `n-1` where `n` is the number of logical CPU cores available on your machine, unless `n <= 4`, in that case it uses `n`. This is a sane default for most use cases.",
296-
"type": "number"
295+
"description": "Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (note, not `worker_threads`). This defaults to `n-1` where `n` is the number of logical CPU cores available on your machine, unless `n <= 4`, in that case it uses `n`. This is a sane default for most use cases. You can also specify a percentage string (e.g., \"50%\") to compute the worker count as a percentage of the logical CPU cores available (0%-100%).",
296+
"oneOf": [
297+
{
298+
"type": "number",
299+
"minimum": 1
300+
},
301+
{
302+
"type": "string",
303+
"pattern": "^(100|[1-9]?[0-9])%$"
304+
}
305+
],
306+
"examples": [4, "50%", "100%"]
297307
},
298308
"commandRunner": {
299309
"description": "Options used by the command test runner. Note: these options will only be used when the command test runner is activated (this is the default)",

packages/core/src/concurrent/concurrency-token-provider.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export class ConcurrencyTokenProvider implements Disposable {
2424
options: Pick<StrykerOptions, 'checkers' | 'concurrency'>,
2525
private readonly log: Logger,
2626
) {
27-
const cpuCount = os.cpus().length;
28-
const concurrency =
29-
options.concurrency ?? (cpuCount > 4 ? cpuCount - 1 : cpuCount);
27+
const availableParallelism = os.availableParallelism();
28+
const concurrency = this.computeConcurrency(
29+
options.concurrency,
30+
availableParallelism,
31+
);
3032
if (options.checkers.length > 0) {
3133
this.concurrencyCheckers = Math.max(Math.ceil(concurrency / 2), 1);
3234
this.checkerToken$ = range(this.concurrencyCheckers);
@@ -50,6 +52,36 @@ export class ConcurrencyTokenProvider implements Disposable {
5052
);
5153
}
5254

55+
private computeConcurrency(
56+
concurrencyOption: number | string | undefined,
57+
availableParallelism: number,
58+
): number {
59+
if (typeof concurrencyOption === 'string') {
60+
const percentageMatch = concurrencyOption.match(/^(100|[1-9]?[0-9])%$/);
61+
if (percentageMatch) {
62+
const percentage = parseInt(percentageMatch[1], 10);
63+
const computed = Math.max(
64+
1,
65+
Math.round((availableParallelism * percentage) / 100),
66+
);
67+
this.log.debug(
68+
'Computed concurrency %s from "%s" based on %s available parallelism.',
69+
computed,
70+
concurrencyOption,
71+
availableParallelism,
72+
);
73+
return computed;
74+
}
75+
}
76+
if (typeof concurrencyOption === 'number') {
77+
return concurrencyOption;
78+
}
79+
// Default: n-1 for n > 4, else n
80+
return availableParallelism > 4
81+
? availableParallelism - 1
82+
: availableParallelism;
83+
}
84+
5385
public freeCheckers(): void {
5486
if (this.concurrencyCheckers > 0) {
5587
this.log.debug(

packages/core/src/config/options-validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export class OptionsValidator {
175175
);
176176
if (
177177
!options.concurrency &&
178-
options.maxConcurrentTestRunners < os.cpus().length - 1
178+
options.maxConcurrentTestRunners < os.availableParallelism() - 1
179179
) {
180180
options.concurrency = options.maxConcurrentTestRunners;
181181
}

packages/core/src/stryker-cli.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ function parseCleanDirOption(val: string) {
3131
return v === 'always' ? v : v !== 'false' && v !== '0';
3232
}
3333

34+
function parseConcurrency(val: string): number | string {
35+
// If it's a pure number, parse as integer
36+
if (/^\d+$/.test(val)) {
37+
return parseInt(val, 10);
38+
}
39+
// Otherwise keep as string (for percentage values like "50%")
40+
return val;
41+
}
42+
3443
const configFileArgument = new Argument(
3544
'[configFile]',
3645
'Path to the config file',
@@ -262,7 +271,7 @@ export class StrykerCli {
262271
.option(
263272
'-c, --concurrency <n>',
264273
'Set the concurrency of workers. Stryker will always run checkers and test runners in parallel by creating worker processes (default: cpuCount - 1)',
265-
parseInt,
274+
parseConcurrency,
266275
)
267276
.option(
268277
'--disableBail',

packages/core/test/unit/concurrent/concurrency-token-provider.spec.ts

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { lastValueFrom, toArray } from 'rxjs';
66
import { testInjector } from '@stryker-mutator/test-helpers';
77

88
import { ConcurrencyTokenProvider } from '../../../src/concurrent/index.js';
9-
import { createCpuInfo } from '../../helpers/producers.js';
109

1110
describe(ConcurrencyTokenProvider.name, () => {
1211
function createSut() {
@@ -36,30 +35,15 @@ describe(ConcurrencyTokenProvider.name, () => {
3635
});
3736

3837
describe('testRunnerToken$', () => {
39-
it('should use cpuCount if concurrency is not set and CPU count <= 4', async () => {
40-
sinon
41-
.stub(os, 'cpus')
42-
.returns([
43-
createCpuInfo(),
44-
createCpuInfo(),
45-
createCpuInfo(),
46-
createCpuInfo(),
47-
]);
38+
it('should use availableParallelism if concurrency is not set and availableParallelism <= 4', async () => {
39+
sinon.stub(os, 'availableParallelism').returns(4);
4840
const sut = createSut();
4941
const actualTokens = await actAllTestRunnerTokens(sut);
5042
expect(actualTokens).deep.eq([0, 1, 2, 3]);
5143
});
5244

53-
it('should use cpuCount - 1 if concurrency is not set and CPU count > 4', async () => {
54-
sinon
55-
.stub(os, 'cpus')
56-
.returns([
57-
createCpuInfo(),
58-
createCpuInfo(),
59-
createCpuInfo(),
60-
createCpuInfo(),
61-
createCpuInfo(),
62-
]);
45+
it('should use availableParallelism - 1 if concurrency is not set and availableParallelism > 4', async () => {
46+
sinon.stub(os, 'availableParallelism').returns(5);
6347
const sut = createSut();
6448
const actualTokens = await actAllTestRunnerTokens(sut);
6549
expect(actualTokens).deep.eq([0, 1, 2, 3]);
@@ -130,6 +114,98 @@ describe(ConcurrencyTokenProvider.name, () => {
130114
});
131115
});
132116

117+
describe('concurrency percentage', () => {
118+
it('should convert "50%" to half of available parallelism', () => {
119+
sinon.stub(os, 'availableParallelism').returns(8);
120+
testInjector.options.concurrency = '50%';
121+
const sut = createSut();
122+
expect(testInjector.logger.debug).calledWith(
123+
'Computed concurrency %s from "%s" based on %s available parallelism.',
124+
4,
125+
'50%',
126+
8,
127+
);
128+
sut.dispose();
129+
});
130+
131+
it('should convert "100%" to full available parallelism', () => {
132+
sinon.stub(os, 'availableParallelism').returns(4);
133+
testInjector.options.concurrency = '100%';
134+
const sut = createSut();
135+
expect(testInjector.logger.debug).calledWith(
136+
'Computed concurrency %s from "%s" based on %s available parallelism.',
137+
4,
138+
'100%',
139+
4,
140+
);
141+
sut.dispose();
142+
});
143+
144+
it('should round to nearest integer', () => {
145+
sinon.stub(os, 'availableParallelism').returns(3);
146+
testInjector.options.concurrency = '50%';
147+
const sut = createSut();
148+
// 3 * 0.5 = 1.5, rounded to 2
149+
expect(testInjector.logger.debug).calledWith(
150+
'Computed concurrency %s from "%s" based on %s available parallelism.',
151+
2,
152+
'50%',
153+
3,
154+
);
155+
sut.dispose();
156+
});
157+
158+
it('should enforce minimum of 1 for "0%"', () => {
159+
sinon.stub(os, 'availableParallelism').returns(2);
160+
testInjector.options.concurrency = '0%';
161+
const sut = createSut();
162+
expect(testInjector.logger.debug).calledWith(
163+
'Computed concurrency %s from "%s" based on %s available parallelism.',
164+
1,
165+
'0%',
166+
2,
167+
);
168+
sut.dispose();
169+
});
170+
171+
it('should not convert invalid percentage format like "abc50%" as percentage', () => {
172+
sinon.stub(os, 'availableParallelism').returns(8);
173+
testInjector.options.concurrency = 'abc50%';
174+
const sut = createSut();
175+
// Should use default logic (availableParallelism - 1 since 8 > 4)
176+
expect(testInjector.logger.info).calledWith(
177+
'Creating %s test runner process(es).',
178+
7,
179+
);
180+
sut.dispose();
181+
});
182+
183+
it('should not convert percentages over 100% because they fail schema validation', () => {
184+
sinon.stub(os, 'availableParallelism').returns(8);
185+
// This scenario should be prevented by schema validation, but test defensive code
186+
testInjector.options.concurrency = '150%';
187+
const sut = createSut();
188+
// Should use default logic (availableParallelism - 1 since 8 > 4)
189+
expect(testInjector.logger.info).calledWith(
190+
'Creating %s test runner process(es).',
191+
7,
192+
);
193+
sut.dispose();
194+
});
195+
196+
it('should not convert invalid percentage format like "50%abc" as percentage', () => {
197+
sinon.stub(os, 'availableParallelism').returns(8);
198+
testInjector.options.concurrency = '50%abc' as any;
199+
const sut = createSut();
200+
// Should use default logic (availableParallelism - 1 since 8 > 4)
201+
expect(testInjector.logger.info).calledWith(
202+
'Creating %s test runner process(es).',
203+
7,
204+
);
205+
sut.dispose();
206+
});
207+
});
208+
133209
describe('dispose', () => {
134210
it('should complete the subject(s)', () => {
135211
// Arrange

packages/core/test/unit/config/options-validator.spec.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { expect } from 'chai';
1313

1414
import { OptionsValidator } from '../../../src/config/options-validator.js';
1515
import { coreTokens } from '../../../src/di/index.js';
16-
import { createCpuInfo } from '../../helpers/producers.js';
1716
import { optionsPath } from '../../../src/utils/index.js';
1817

1918
describe(OptionsValidator.name, () => {
@@ -410,25 +409,67 @@ describe(OptionsValidator.name, () => {
410409
);
411410
});
412411

413-
it('should not configure "concurrency" if "maxConcurrentTestRunners" is >= cpus-1', () => {
412+
it('should not configure "concurrency" if "maxConcurrentTestRunners" is >= availableParallelism-1', () => {
414413
testInjector.options.maxConcurrentTestRunners = 2;
415-
sinon
416-
.stub(os, 'cpus')
417-
.returns([createCpuInfo(), createCpuInfo(), createCpuInfo()]);
414+
sinon.stub(os, 'availableParallelism').returns(3);
418415
sut.validate(testInjector.options);
419416
expect(testInjector.options.concurrency).undefined;
420417
});
421418

422419
it('should configure "concurrency" if "maxConcurrentTestRunners" is set with a lower value', () => {
423420
testInjector.options.maxConcurrentTestRunners = 1;
424-
sinon
425-
.stub(os, 'cpus')
426-
.returns([createCpuInfo(), createCpuInfo(), createCpuInfo()]);
421+
sinon.stub(os, 'availableParallelism').returns(3);
427422
sut.validate(testInjector.options);
428423
expect(testInjector.options.concurrency).eq(1);
429424
});
430425
});
431426

427+
describe('concurrency percentage', () => {
428+
it('should accept "50%" as a valid percentage', () => {
429+
testInjector.options.concurrency = '50%';
430+
sut.validate(testInjector.options);
431+
expect(testInjector.options.concurrency).eq('50%');
432+
});
433+
434+
it('should accept "100%" as a valid percentage', () => {
435+
testInjector.options.concurrency = '100%';
436+
sut.validate(testInjector.options);
437+
expect(testInjector.options.concurrency).eq('100%');
438+
});
439+
440+
it('should accept "0%" as a valid percentage', () => {
441+
testInjector.options.concurrency = '0%';
442+
sut.validate(testInjector.options);
443+
expect(testInjector.options.concurrency).eq('0%');
444+
});
445+
446+
it('should reject percentages over 100%', () => {
447+
testInjector.options.concurrency = '101%';
448+
expect(() => sut.validate(testInjector.options)).throws();
449+
});
450+
451+
it('should reject invalid percentage format', () => {
452+
testInjector.options.concurrency = '50';
453+
expect(() => sut.validate(testInjector.options)).throws();
454+
});
455+
456+
it('should reject percentages with prefix like "abc50%"', () => {
457+
testInjector.options.concurrency = 'abc50%';
458+
expect(() => sut.validate(testInjector.options)).throws();
459+
});
460+
461+
it('should reject percentages with suffix like "50%abc"', () => {
462+
testInjector.options.concurrency = '50%abc';
463+
expect(() => sut.validate(testInjector.options)).throws();
464+
});
465+
466+
it('should not modify numeric concurrency values', () => {
467+
testInjector.options.concurrency = 4;
468+
sut.validate(testInjector.options);
469+
expect(testInjector.options.concurrency).eq(4);
470+
});
471+
});
472+
432473
it('should be invalid with non-numeric maxTestRunnerReuse', () => {
433474
breakConfig('maxTestRunnerReuse', 'break');
434475
actValidationErrors(

packages/core/test/unit/stryker-cli.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ describe(StrykerCli.name, () => {
8181
[['--force'], { force: true }],
8282
[['--ignoreStatic'], { ignoreStatic: true }],
8383
[['--concurrency', '5'], { concurrency: 5 }],
84+
[['--concurrency', '50%'], { concurrency: '50%' }],
8485
[['--cleanTempDir', 'false'], { cleanTempDir: false }],
8586
[['--cleanTempDir', 'always'], { cleanTempDir: 'always' }],
8687
[['-c', '6'], { concurrency: 6 }],
88+
[['-c', '12'], { concurrency: 12 }],
89+
[['-c', '100%'], { concurrency: '100%' }],
8790
[['--maxTestRunnerReuse', '3'], { maxTestRunnerReuse: 3 }],
8891
];
8992
testCases.forEach(([args, expected]) => {

0 commit comments

Comments
 (0)