Skip to content

Commit 02dea2d

Browse files
committed
feature: Support percentage-based weights for operationSettings in rush-project.json
1 parent f8a668d commit 02dea2d

6 files changed

Lines changed: 254 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Support percentage weight in operationSettings in rush-project.json file.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

common/reviews/api/rush-lib.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ export interface IOperationSettings {
676676
outputFolderNames?: string[];
677677
parameterNamesToIgnore?: string[];
678678
sharding?: IRushPhaseSharding;
679-
weight?: number;
679+
weight?: number | `${number}%`;
680680
}
681681

682682
// @internal (undocumented)

libraries/rush-lib/src/api/RushProjectConfiguration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export interface IOperationSettings {
160160
* How many concurrency units this operation should take up during execution. The maximum concurrent units is
161161
* determined by the -p flag.
162162
*/
163-
weight?: number;
163+
weight?: number | `${number}%`;
164164

165165
/**
166166
* If true, this operation can use cobuilds for orchestration without restoring build cache entries.

libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import os from 'node:os';
5+
46
import { Async } from '@rushstack/node-core-library';
57

68
import type { Operation } from './Operation';
@@ -31,6 +33,20 @@ function weightOperations(
3133
context: ICreateOperationsContext
3234
): Map<Operation, IOperationExecutionResult> {
3335
const { projectConfigurations } = context;
36+
const availableParallelism: number = os.availableParallelism();
37+
38+
const percentageRegExp: RegExp = /^[1-9][0-9]*(\.\d+)?%$/;
39+
40+
function _tryConvertPercentWeight(weight: `${number}%`): number {
41+
if (!percentageRegExp.test(weight)) {
42+
throw new Error(`Expected a percentage string like "100%".`);
43+
}
44+
45+
const percentValue: number = parseFloat(weight.slice(0, -1));
46+
47+
// Use as much CPU as possible, so we round down the weight here
48+
return Math.floor((percentValue / 100) * availableParallelism);
49+
}
3450

3551
for (const [operation, record] of operations) {
3652
const { runner } = record as OperationExecutionRecord;
@@ -41,8 +57,18 @@ function weightOperations(
4157
const projectConfiguration: RushProjectConfiguration | undefined = projectConfigurations.get(project);
4258
const operationSettings: IOperationSettings | undefined =
4359
operation.settings ?? projectConfiguration?.operationSettingsByOperationName.get(phase.name);
44-
if (operationSettings?.weight) {
45-
operation.weight = operationSettings.weight;
60+
if (operationSettings?.weight !== undefined) {
61+
if (typeof operationSettings.weight === 'number') {
62+
operation.weight = operationSettings.weight;
63+
} else if (typeof operationSettings.weight === 'string') {
64+
try {
65+
operation.weight = _tryConvertPercentWeight(operationSettings.weight);
66+
} catch (error) {
67+
throw new Error(
68+
`${operation.name} (invalid weight: ${operationSettings.weight}) ${error instanceof Error ? error.message : String(error)}`
69+
);
70+
}
71+
}
4672
}
4773
}
4874
Async.validateWeightedIterable(operation);
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import os from 'node:os';
5+
6+
import type { IPhase } from '../../../api/CommandLineConfiguration';
7+
import type { RushConfigurationProject } from '../../../api/RushConfigurationProject';
8+
import type { IOperationSettings, RushProjectConfiguration } from '../../../api/RushProjectConfiguration';
9+
import {
10+
type IExecuteOperationsContext,
11+
PhasedCommandHooks
12+
} from '../../../pluginFramework/PhasedCommandHooks';
13+
import type { IOperationExecutionResult } from '../IOperationExecutionResult';
14+
import { Operation } from '../Operation';
15+
import { WeightedOperationPlugin } from '../WeightedOperationPlugin';
16+
import { MockOperationRunner } from './MockOperationRunner';
17+
18+
const MOCK_PHASE: IPhase = {
19+
name: '_phase:test',
20+
allowWarningsOnSuccess: false,
21+
associatedParameters: new Set(),
22+
dependencies: {
23+
self: new Set(),
24+
upstream: new Set()
25+
},
26+
isSynthetic: false,
27+
logFilenameIdentifier: '_phase_test',
28+
missingScriptBehavior: 'silent'
29+
};
30+
31+
function createProject(packageName: string): RushConfigurationProject {
32+
return {
33+
packageName
34+
} as RushConfigurationProject;
35+
}
36+
37+
function createOperation(options: {
38+
project: RushConfigurationProject;
39+
settings?: IOperationSettings;
40+
isNoOp?: boolean;
41+
}): Operation {
42+
const { project, settings, isNoOp } = options;
43+
return new Operation({
44+
phase: MOCK_PHASE,
45+
project,
46+
settings,
47+
runner: new MockOperationRunner(`${project.packageName} (${MOCK_PHASE.name})`, undefined, false, isNoOp),
48+
logFilenameIdentifier: `${project.packageName}_phase_test`
49+
});
50+
}
51+
52+
function createExecutionRecords(operation: Operation): Map<Operation, IOperationExecutionResult> {
53+
return new Map([
54+
[
55+
operation,
56+
{
57+
operation,
58+
runner: operation.runner
59+
} as unknown as IOperationExecutionResult
60+
]
61+
]);
62+
}
63+
64+
function createContext(
65+
projectConfigurations: ReadonlyMap<RushConfigurationProject, RushProjectConfiguration>,
66+
parallelism: number = os.availableParallelism()
67+
): IExecuteOperationsContext {
68+
return {
69+
projectConfigurations,
70+
parallelism
71+
} as IExecuteOperationsContext;
72+
}
73+
74+
async function applyWeightPluginAsync(
75+
operations: Map<Operation, IOperationExecutionResult>,
76+
context: IExecuteOperationsContext
77+
): Promise<void> {
78+
const hooks: PhasedCommandHooks = new PhasedCommandHooks();
79+
new WeightedOperationPlugin().apply(hooks);
80+
await hooks.beforeExecuteOperations.promise(operations, context);
81+
}
82+
83+
function mockAvailableParallelism(value: number): jest.SpyInstance<number, []> {
84+
return jest.spyOn(os, 'availableParallelism').mockReturnValue(value);
85+
}
86+
87+
describe(WeightedOperationPlugin.name, () => {
88+
afterEach(() => {
89+
jest.restoreAllMocks();
90+
});
91+
92+
it('applies numeric weight from operation settings', async () => {
93+
const project: RushConfigurationProject = createProject('project-number');
94+
const operation: Operation = createOperation({
95+
project,
96+
settings: {
97+
operationName: MOCK_PHASE.name,
98+
weight: 7
99+
}
100+
});
101+
102+
await applyWeightPluginAsync(
103+
createExecutionRecords(operation),
104+
createContext(new Map(), /* Set parallelism to ensure -p does not affect weight calculation */ 1)
105+
);
106+
107+
expect(operation.weight).toBe(7);
108+
});
109+
110+
it('converts percentage weight using available parallelism', async () => {
111+
mockAvailableParallelism(10);
112+
113+
const project: RushConfigurationProject = createProject('project-percent');
114+
const operation: Operation = createOperation({
115+
project,
116+
settings: {
117+
operationName: MOCK_PHASE.name,
118+
weight: '25%'
119+
} as IOperationSettings
120+
});
121+
122+
await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map()));
123+
124+
expect(operation.weight).toBe(2);
125+
});
126+
127+
it('reads weight from rush-project configuration when operation settings are undefined', async () => {
128+
mockAvailableParallelism(8);
129+
130+
const project: RushConfigurationProject = createProject('project-config');
131+
const operation: Operation = createOperation({ project });
132+
const projectConfiguration: RushProjectConfiguration = {
133+
operationSettingsByOperationName: new Map([
134+
[
135+
MOCK_PHASE.name,
136+
{
137+
operationName: MOCK_PHASE.name,
138+
weight: '50%'
139+
} as IOperationSettings
140+
]
141+
])
142+
} as unknown as RushProjectConfiguration;
143+
144+
await applyWeightPluginAsync(
145+
createExecutionRecords(operation),
146+
createContext(new Map([[project, projectConfiguration]]))
147+
);
148+
149+
expect(operation.weight).toBe(4);
150+
});
151+
152+
it('use ceiling when converting percentage weight to avoid zero weight', async () => {
153+
mockAvailableParallelism(16);
154+
155+
const project: RushConfigurationProject = createProject('project-ceiling');
156+
const operation: Operation = createOperation({
157+
project,
158+
settings: {
159+
operationName: MOCK_PHASE.name,
160+
weight: '33.3333%'
161+
} as IOperationSettings
162+
});
163+
164+
await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map()));
165+
166+
expect(operation.weight).toBe(5);
167+
});
168+
169+
it('forces NO-OP operation weight to zero ignore weight settings', async () => {
170+
const project: RushConfigurationProject = createProject('project-no-op');
171+
const operation: Operation = createOperation({
172+
project,
173+
isNoOp: true,
174+
settings: {
175+
operationName: MOCK_PHASE.name,
176+
weight: 100
177+
}
178+
});
179+
180+
await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map()));
181+
182+
expect(operation.weight).toBe(0);
183+
});
184+
185+
it('throws for invalid percentage weight format', async () => {
186+
mockAvailableParallelism(16);
187+
188+
const project: RushConfigurationProject = createProject('project-invalid');
189+
const operation: Operation = createOperation({
190+
project,
191+
// @ts-expect-error Testing invalid input
192+
settings: {
193+
operationName: MOCK_PHASE.name,
194+
weight: '12.5a%'
195+
} as IOperationSettings
196+
});
197+
198+
await expect(
199+
applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map()))
200+
).rejects.toThrow(/invalid weight: 12.5a%/i);
201+
});
202+
});

libraries/rush-lib/src/schemas/rush-project.schema.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,18 @@
106106
]
107107
},
108108
"weight": {
109-
"description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.",
110-
"type": "integer",
111-
"minimum": 0
109+
"oneOf": [
110+
{
111+
"type": "string",
112+
"pattern": "^[1-9][0-9]*(\\.\\d+)?%$",
113+
"description": "The percentage of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag."
114+
},
115+
{
116+
"description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.",
117+
"type": "integer",
118+
"minimum": 0
119+
}
120+
]
112121
},
113122
"allowCobuildWithoutCache": {
114123
"type": "boolean",

0 commit comments

Comments
 (0)