Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions common/changes/@microsoft/rush/flag-reload_2026-09-24.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Exclude `--verbose`, `--parallelism` and `--timeline` from the daemon engine parameter identity and expose them as per-request settings, so these flags no longer force the Rush daemon to reload its warm operation graph.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "selarkin@microsoft.com"
}
11 changes: 11 additions & 0 deletions common/changes/@rushstack/rush-daemon/flag-reload_2026-09-24.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon",
"comment": "Apply `--verbose` and `--parallelism` to the warm graph per iteration instead of reloading the graph, and only coalesce requests that share these settings.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "selarkin@microsoft.com"
}
4 changes: 3 additions & 1 deletion common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { IDaemonWarmSetStatus } from '@rushstack/rush-daemon-protocol';
import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol';
import type { IInputsSnapshot } from '@microsoft/rush-lib';
import { IOperationGraph } from '@microsoft/rush-lib';
import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib';
import type { ITerminal } from '@rushstack/terminal';
import { LockFile } from '@rushstack/node-core-library';
import { Operation } from '@microsoft/rush-lib';
Expand Down Expand Up @@ -399,6 +400,7 @@ export interface IResolvedDaemonPhasedRequest {
readonly kind: 'phased';
// (undocumented)
readonly request: IDaemonPhasedRequest;
readonly requestSettings?: IPhasedCommandEngineRequestSettings;
}

// @beta
Expand Down Expand Up @@ -666,7 +668,7 @@ export type MapWorkspaceInvalidationsToOperationsAsync = (options: IMapWorkspace
// @beta
export class PhasedRequestRouter {
constructor(workspaceSession: IWorkspaceSession);
executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection?: boolean, onExecutionStarting?: () => void): Promise<IDaemonPhasedRequestResult>;
executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection?: boolean, onExecutionStarting?: () => void, requestSettings?: IPhasedCommandEngineRequestSettings): Promise<IDaemonPhasedRequestResult>;
}

// @beta
Expand Down
9 changes: 9 additions & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,14 @@ export interface IPhasedCommandEngine extends AsyncDisposable {
readonly rushSession: RushSession;
}

// @alpha
export interface IPhasedCommandEngineRequestSettings {
// (undocumented)
readonly parallelism: Parallelism;
// (undocumented)
readonly quietMode: boolean;
}

// @alpha
export interface IPhasedCommandPlugin {
apply(hooks: PhasedCommandHooks): void;
Expand Down Expand Up @@ -1509,6 +1517,7 @@ export class PhasedCommandEngine {
readonly parameterIdentity: string;
// (undocumented)
static parseAsync(options: IParsePhasedCommandOptions): Promise<PhasedCommandEngine>;
get requestSettings(): IPhasedCommandEngineRequestSettings;
selectOperationsAsync(graph: IOperationGraph): Promise<ReadonlyMap<Operation, OperationEnabledState>>;
}

Expand Down
6 changes: 5 additions & 1 deletion libraries/rush-daemon/src/DaemonRequestDispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib';
import type {
IDaemonCommandResult,
IDaemonEventEnvelope,
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface IResolvedDaemonPhasedRequest {
readonly request: IDaemonPhasedRequest;
/** Native selection has already resolved all required project/phase dependencies. */
readonly exactSelection?: boolean;
/** Verbosity and parallelism for this request; applied to the shared graph before its iteration. */
readonly requestSettings?: IPhasedCommandEngineRequestSettings;
}

/** A resolver outcome that uses the existing isolated global executor contract. @beta */
Expand Down Expand Up @@ -187,7 +190,8 @@ async function dispatchWorkspaceRequestAsync(
resolved.request,
createPhasedClient(client),
resolved.exactSelection,
onExecutionStarting
onExecutionStarting,
resolved.requestSettings
);
}
const globalRouter: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(workspaceSession);
Expand Down
30 changes: 27 additions & 3 deletions libraries/rush-daemon/src/PhasedRequestRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import type {
IOperationExecutionResult,
IOperationGraph,
IPhasedCommandEngineRequestSettings,
Operation,
_IOperationGraphEventSink
} from '@microsoft/rush-lib';
Expand Down Expand Up @@ -65,6 +66,9 @@ interface IPreparedPhasedRequest {
readonly exclusivityClass: RequestExclusivityClass;
readonly interactiveSession: IInteractiveRequestSession | undefined;
readonly request: IDaemonPhasedRequest;
/** Only requests with the same settings share one graph iteration. */
readonly requestSettings: IPhasedCommandEngineRequestSettings | undefined;
readonly requestSettingsKey: string;
readonly selection: IResolvedSelection;
readonly warningsAllowedByEnvironment: boolean;
}
Expand Down Expand Up @@ -111,7 +115,8 @@ export class PhasedRequestRouter {
request: IDaemonPhasedRequest,
client: IPhasedRequestClient,
exactSelection: boolean = false,
onExecutionStarting?: () => void
onExecutionStarting?: () => void,
requestSettings?: IPhasedCommandEngineRequestSettings
): Promise<IDaemonPhasedRequestResult> {
validateRequestIdentity(request);
const interactiveSession: IInteractiveRequestSession | undefined = validateInteractiveSession(
Expand Down Expand Up @@ -189,6 +194,8 @@ export class PhasedRequestRouter {
exclusivityClass,
interactiveSession,
request,
requestSettings,
requestSettingsKey: JSON.stringify(requestSettings ?? null),
selection,
warningsAllowedByEnvironment,
onExecutionStarting
Expand Down Expand Up @@ -336,14 +343,19 @@ class PhasedRequestBatchCoordinator {
}
return (
this.#acceptingCurrentBatch &&
this.#currentBatch?.[0]?.exclusivityClass === RequestExclusivityClass.SharedBuild
this.#currentBatch?.[0]?.exclusivityClass === RequestExclusivityClass.SharedBuild &&
this.#currentBatch[0].requestSettingsKey === request.requestSettingsKey
Comment thread
TheLarkInn marked this conversation as resolved.
);
}

#takeCompatiblePending(batch: IBatchEntry[]): void {
const { requestSettingsKey } = batch[0];
for (let index: number = 0; index < this.#pending.length; ) {
const entry: IBatchEntry = this.#pending[index];
if (entry.exclusivityClass === RequestExclusivityClass.SharedBuild) {
if (
entry.exclusivityClass === RequestExclusivityClass.SharedBuild &&
entry.requestSettingsKey === requestSettingsKey
) {
this.#pending.splice(index, 1);
entry.executionStarted = true;
batch.push(entry);
Expand Down Expand Up @@ -393,6 +405,7 @@ class PhasedRequestBatchCoordinator {
return;
}

applyRequestSettings(this.#graph, participants[0].requestSettings);
applySelections(
this.#graph,
participants.map((entry: IBatchEntry) => entry.selection)
Expand Down Expand Up @@ -812,6 +825,17 @@ function collectSelectionClosure(
return Array.from(activeOperations);
}

/** Presentation/scheduling settings are request-scoped, so they are applied per iteration, not per graph. */
function applyRequestSettings(
graph: IOperationGraph,
settings: IPhasedCommandEngineRequestSettings | undefined
): void {
if (settings) {
graph.quietMode = settings.quietMode;
graph.parallelism = settings.parallelism;
}
}

function applySelections(graph: IOperationGraph, selections: ReadonlyArray<IResolvedSelection>): void {
const enabledClosureBySelection: ReadonlyArray<ReadonlySet<Operation>> = selections.map(
(selection: IResolvedSelection) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import type { IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle';
* Binds the standalone host to a real native build/rebuild graph on its first request.
*
* @remarks
* A host is pinned to its first command and non-selection parameters. Incompatible parameters,
* A host is pinned to its first command and graph-affecting, non-selection parameters. Presentation and
* scheduling parameters (`--verbose`, `--parallelism`, `--timeline`) are applied per request instead.
* Incompatible parameters,
* environments, or graph inputs are rejected before scheduling; no request is retried automatically.
* The initial supported surface excludes external plugins, .env initialization, install/watch,
* event-hook scripts, and rushx/global commands. Use the unchanged native CLI for those surfaces.
Expand Down Expand Up @@ -117,6 +119,7 @@ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver {
return {
kind: 'phased',
exactSelection: true,
requestSettings: command.requestSettings,
request: {
admission: envelope.admission,
commandName: envelope.commandName,
Expand Down
40 changes: 40 additions & 0 deletions libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '@rushstack/rush-daemon-protocol';
import { RUSHD_OPERATION_HEADER } from '@rushstack/rush-daemon-protocol';
import { OperationStatus } from '@microsoft/rush-lib';
import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib';

import { PhasedRequestRouter } from '../PhasedRequestRouter';
import {
Expand Down Expand Up @@ -100,6 +101,45 @@ function eventOperationId(event: IDaemonEventEnvelope): string | undefined {
}

describe('shared phased request batching', () => {
it('schedules separate iterations for overlapping requests with different request settings', async () => {
const fixture: ITestRoutingFixture = createFixture();
const graph: ITestRoutingFixture['graph'] = fixture.graph;
const scheduledSettings: IPhasedCommandEngineRequestSettings[] = [];
const originalScheduleAsync: typeof graph.scheduleIterationAsync =
graph.scheduleIterationAsync.bind(graph);
const scheduleSpy: jest.SpyInstance = jest
.spyOn(graph, 'scheduleIterationAsync')
.mockImplementation((...args: Parameters<typeof graph.scheduleIterationAsync>) => {
scheduledSettings.push({ parallelism: graph.parallelism, quietMode: graph.quietMode });
return originalScheduleAsync(...args);
});
const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session);
const defaultSettings: IPhasedCommandEngineRequestSettings = { parallelism: 4, quietMode: true };
const verboseSerialSettings: IPhasedCommandEngineRequestSettings = { parallelism: 1, quietMode: false };

const [first, second] = await Promise.all([
router.executeAsync(
createRequest('default', OPERATION_A),
new TestPhasedRequestClient('one'),
false,
undefined,
defaultSettings
),
router.executeAsync(
createRequest('verbose-serial', OPERATION_B),
new TestPhasedRequestClient('two'),
false,
undefined,
verboseSerialSettings
)
]);

expect(scheduleSpy).toHaveBeenCalledTimes(2);
expect(scheduledSettings).toEqual([defaultSettings, verboseSerialSettings]);
expect(first).toMatchObject({ exitCode: 0, outcome: 'success' });
expect(second).toMatchObject({ exitCode: 0, outcome: 'success' });
});

it('merges overlapping selections into one real graph iteration and executes shared operations once', async () => {
const fixture: ITestRoutingFixture = createFixture();
const scheduleSpy: jest.SpyInstance = jest.spyOn(fixture.graph, 'scheduleIterationAsync');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { WorkspaceInputChangeTier, type IOperationGraph } from '@microsoft/rush-lib';

import { DaemonGraphTestFixture } from './DaemonGraphTestFixture';
import { setDaemonPolicy } from './WarmGenerationTestUtilities';

jest.setTimeout(30_000);

it('applies --verbose and --parallelism per request without reloading the warm graph', async () => {
const fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {}));
try {
const buildAsync = async (...extra: string[]): Promise<void> => {
const result = await fixture.runAsync(['build', '--to', 'b', ...extra]);
expect(result.terminal).toMatchObject({ payload: { exitCode: 0 } });
};
await buildAsync();
expect(fixture.runs()).toEqual(['a', 'b']);
const generation: number = fixture.host.workspaceGeneration;
const graph: IOperationGraph | undefined = fixture.session.operationGraph;
const defaultParallelism: number = graph!.parallelism;
const expectWarm = (): void => {
expect(fixture.host.workspaceGeneration).toBe(generation);
expect(fixture.session.operationGraph).toBe(graph);
expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse);
expect(fixture.runs()).toEqual(['a', 'b']);
};

await buildAsync();
expectWarm();
await buildAsync('--verbose');
expectWarm();
expect(graph!.quietMode).toBe(false);
await buildAsync();
expectWarm();
expect(graph!.quietMode).toBe(true);
await buildAsync('-p', '1', '--timeline');
expectWarm();
expect(graph!.parallelism).toBe(1);
await buildAsync();
expectWarm();
expect(graph!.parallelism).toBe(defaultParallelism);

fixture.write('a/input.txt', 'two');
await buildAsync('--verbose', '-p', '1');
expect(fixture.host.workspaceGeneration).toBe(generation);
expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']);
} finally {
await fixture[Symbol.asyncDispose]();
}
});
17 changes: 17 additions & 0 deletions libraries/rush-lib/src/api/PhasedCommandEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PhasedScriptAction } from '../cli/scriptActions/PhasedScriptAction';
import type { GetInputsSnapshotAsyncFn, IInputsSnapshot } from '../logic/incremental/InputsSnapshot';
import type { IOperationGraph } from '../logic/operations/IOperationGraph';
import type { Operation, OperationEnabledState } from '../logic/operations/Operation';
import type { Parallelism } from '../logic/operations/ParseParallelism';
import { PhasedCommandEngineExecution } from '../logic/operations/PhasedCommandEngineExecution';
import type { RushSession } from '../pluginFramework/RushSession';
import type { RushConfiguration } from './RushConfiguration';
Expand Down Expand Up @@ -44,6 +45,17 @@ export interface IParsePhasedCommandOptions {
readonly terminalProvider: ITerminalProvider;
}

/**
* Presentation and scheduling settings of one parsed command. They do not affect the operation graph or any
* operation hash, so they are not part of `PhasedCommandEngine.parameterIdentity`; hosts apply them to the
* shared graph (`IOperationGraph.quietMode` / `IOperationGraph.parallelism`) before each iteration.
* @alpha
*/
export interface IPhasedCommandEngineRequestSettings {
readonly quietMode: boolean;
readonly parallelism: Parallelism;
}

/**
* A parsed native build/rebuild command. Parsing never runs scripts or changes cwd/process.env.
*
Expand Down Expand Up @@ -164,4 +176,9 @@ export class PhasedCommandEngine {
): Promise<ReadonlyMap<Operation, OperationEnabledState>> {
return await this._action.selectEngineOperationsAsync(graph);
}

/** Presentation and scheduling settings requested by this command; not part of `parameterIdentity`. */
public get requestSettings(): IPhasedCommandEngineRequestSettings {
return this._action.getEngineRequestSettings();
}
}
Loading
Loading