From 02dea2d0c24e5f47b90ad454d8360db1681a7e03 Mon Sep 17 00:00:00 2001 From: LPegasus Date: Tue, 3 Mar 2026 19:26:20 +0800 Subject: [PATCH] feature: Support percentage-based weights for operationSettings in rush-project.json --- .../rush-pr-issue-5607_2026-03-03-11-24.json | 10 + common/reviews/api/rush-lib.api.md | 2 +- .../src/api/RushProjectConfiguration.ts | 2 +- .../operations/WeightedOperationPlugin.ts | 30 ++- .../test/WeightedOperationPlugin.test.ts | 202 ++++++++++++++++++ .../src/schemas/rush-project.schema.json | 15 +- 6 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 common/changes/@microsoft/rush/rush-pr-issue-5607_2026-03-03-11-24.json create mode 100644 libraries/rush-lib/src/logic/operations/test/WeightedOperationPlugin.test.ts diff --git a/common/changes/@microsoft/rush/rush-pr-issue-5607_2026-03-03-11-24.json b/common/changes/@microsoft/rush/rush-pr-issue-5607_2026-03-03-11-24.json new file mode 100644 index 00000000000..0b37c8f7cd2 --- /dev/null +++ b/common/changes/@microsoft/rush/rush-pr-issue-5607_2026-03-03-11-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Support percentage weight in operationSettings in rush-project.json file.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1f8def84f93..e7004ddbbc2 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -676,7 +676,7 @@ export interface IOperationSettings { outputFolderNames?: string[]; parameterNamesToIgnore?: string[]; sharding?: IRushPhaseSharding; - weight?: number; + weight?: number | `${number}%`; } // @internal (undocumented) diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index 528680feb50..9a533984fa1 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -160,7 +160,7 @@ export interface IOperationSettings { * How many concurrency units this operation should take up during execution. The maximum concurrent units is * determined by the -p flag. */ - weight?: number; + weight?: number | `${number}%`; /** * If true, this operation can use cobuilds for orchestration without restoring build cache entries. diff --git a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts index 4df5596df2c..bf75db0779a 100644 --- a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import os from 'node:os'; + import { Async } from '@rushstack/node-core-library'; import type { Operation } from './Operation'; @@ -31,6 +33,20 @@ function weightOperations( context: ICreateOperationsContext ): Map { const { projectConfigurations } = context; + const availableParallelism: number = os.availableParallelism(); + + const percentageRegExp: RegExp = /^[1-9][0-9]*(\.\d+)?%$/; + + function _tryConvertPercentWeight(weight: `${number}%`): number { + if (!percentageRegExp.test(weight)) { + throw new Error(`Expected a percentage string like "100%".`); + } + + const percentValue: number = parseFloat(weight.slice(0, -1)); + + // Use as much CPU as possible, so we round down the weight here + return Math.floor((percentValue / 100) * availableParallelism); + } for (const [operation, record] of operations) { const { runner } = record as OperationExecutionRecord; @@ -41,8 +57,18 @@ function weightOperations( const projectConfiguration: RushProjectConfiguration | undefined = projectConfigurations.get(project); const operationSettings: IOperationSettings | undefined = operation.settings ?? projectConfiguration?.operationSettingsByOperationName.get(phase.name); - if (operationSettings?.weight) { - operation.weight = operationSettings.weight; + if (operationSettings?.weight !== undefined) { + if (typeof operationSettings.weight === 'number') { + operation.weight = operationSettings.weight; + } else if (typeof operationSettings.weight === 'string') { + try { + operation.weight = _tryConvertPercentWeight(operationSettings.weight); + } catch (error) { + throw new Error( + `${operation.name} (invalid weight: ${operationSettings.weight}) ${error instanceof Error ? error.message : String(error)}` + ); + } + } } } Async.validateWeightedIterable(operation); diff --git a/libraries/rush-lib/src/logic/operations/test/WeightedOperationPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/WeightedOperationPlugin.test.ts new file mode 100644 index 00000000000..864fb49d507 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/WeightedOperationPlugin.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import os from 'node:os'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import type { IOperationSettings, RushProjectConfiguration } from '../../../api/RushProjectConfiguration'; +import { + type IExecuteOperationsContext, + PhasedCommandHooks +} from '../../../pluginFramework/PhasedCommandHooks'; +import type { IOperationExecutionResult } from '../IOperationExecutionResult'; +import { Operation } from '../Operation'; +import { WeightedOperationPlugin } from '../WeightedOperationPlugin'; +import { MockOperationRunner } from './MockOperationRunner'; + +const MOCK_PHASE: IPhase = { + name: '_phase:test', + allowWarningsOnSuccess: false, + associatedParameters: new Set(), + dependencies: { + self: new Set(), + upstream: new Set() + }, + isSynthetic: false, + logFilenameIdentifier: '_phase_test', + missingScriptBehavior: 'silent' +}; + +function createProject(packageName: string): RushConfigurationProject { + return { + packageName + } as RushConfigurationProject; +} + +function createOperation(options: { + project: RushConfigurationProject; + settings?: IOperationSettings; + isNoOp?: boolean; +}): Operation { + const { project, settings, isNoOp } = options; + return new Operation({ + phase: MOCK_PHASE, + project, + settings, + runner: new MockOperationRunner(`${project.packageName} (${MOCK_PHASE.name})`, undefined, false, isNoOp), + logFilenameIdentifier: `${project.packageName}_phase_test` + }); +} + +function createExecutionRecords(operation: Operation): Map { + return new Map([ + [ + operation, + { + operation, + runner: operation.runner + } as unknown as IOperationExecutionResult + ] + ]); +} + +function createContext( + projectConfigurations: ReadonlyMap, + parallelism: number = os.availableParallelism() +): IExecuteOperationsContext { + return { + projectConfigurations, + parallelism + } as IExecuteOperationsContext; +} + +async function applyWeightPluginAsync( + operations: Map, + context: IExecuteOperationsContext +): Promise { + const hooks: PhasedCommandHooks = new PhasedCommandHooks(); + new WeightedOperationPlugin().apply(hooks); + await hooks.beforeExecuteOperations.promise(operations, context); +} + +function mockAvailableParallelism(value: number): jest.SpyInstance { + return jest.spyOn(os, 'availableParallelism').mockReturnValue(value); +} + +describe(WeightedOperationPlugin.name, () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('applies numeric weight from operation settings', async () => { + const project: RushConfigurationProject = createProject('project-number'); + const operation: Operation = createOperation({ + project, + settings: { + operationName: MOCK_PHASE.name, + weight: 7 + } + }); + + await applyWeightPluginAsync( + createExecutionRecords(operation), + createContext(new Map(), /* Set parallelism to ensure -p does not affect weight calculation */ 1) + ); + + expect(operation.weight).toBe(7); + }); + + it('converts percentage weight using available parallelism', async () => { + mockAvailableParallelism(10); + + const project: RushConfigurationProject = createProject('project-percent'); + const operation: Operation = createOperation({ + project, + settings: { + operationName: MOCK_PHASE.name, + weight: '25%' + } as IOperationSettings + }); + + await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map())); + + expect(operation.weight).toBe(2); + }); + + it('reads weight from rush-project configuration when operation settings are undefined', async () => { + mockAvailableParallelism(8); + + const project: RushConfigurationProject = createProject('project-config'); + const operation: Operation = createOperation({ project }); + const projectConfiguration: RushProjectConfiguration = { + operationSettingsByOperationName: new Map([ + [ + MOCK_PHASE.name, + { + operationName: MOCK_PHASE.name, + weight: '50%' + } as IOperationSettings + ] + ]) + } as unknown as RushProjectConfiguration; + + await applyWeightPluginAsync( + createExecutionRecords(operation), + createContext(new Map([[project, projectConfiguration]])) + ); + + expect(operation.weight).toBe(4); + }); + + it('use ceiling when converting percentage weight to avoid zero weight', async () => { + mockAvailableParallelism(16); + + const project: RushConfigurationProject = createProject('project-ceiling'); + const operation: Operation = createOperation({ + project, + settings: { + operationName: MOCK_PHASE.name, + weight: '33.3333%' + } as IOperationSettings + }); + + await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map())); + + expect(operation.weight).toBe(5); + }); + + it('forces NO-OP operation weight to zero ignore weight settings', async () => { + const project: RushConfigurationProject = createProject('project-no-op'); + const operation: Operation = createOperation({ + project, + isNoOp: true, + settings: { + operationName: MOCK_PHASE.name, + weight: 100 + } + }); + + await applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map())); + + expect(operation.weight).toBe(0); + }); + + it('throws for invalid percentage weight format', async () => { + mockAvailableParallelism(16); + + const project: RushConfigurationProject = createProject('project-invalid'); + const operation: Operation = createOperation({ + project, + // @ts-expect-error Testing invalid input + settings: { + operationName: MOCK_PHASE.name, + weight: '12.5a%' + } as IOperationSettings + }); + + await expect( + applyWeightPluginAsync(createExecutionRecords(operation), createContext(new Map())) + ).rejects.toThrow(/invalid weight: 12.5a%/i); + }); +}); diff --git a/libraries/rush-lib/src/schemas/rush-project.schema.json b/libraries/rush-lib/src/schemas/rush-project.schema.json index 33fb7933403..003742dce1d 100644 --- a/libraries/rush-lib/src/schemas/rush-project.schema.json +++ b/libraries/rush-lib/src/schemas/rush-project.schema.json @@ -106,9 +106,18 @@ ] }, "weight": { - "description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.", - "type": "integer", - "minimum": 0 + "oneOf": [ + { + "type": "string", + "pattern": "^[1-9][0-9]*(\\.\\d+)?%$", + "description": "The percentage of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag." + }, + { + "description": "The number of concurrency units that this operation should take up. The maximum concurrency units is determined by the -p flag.", + "type": "integer", + "minimum": 0 + } + ] }, "allowCobuildWithoutCache": { "type": "boolean",