Skip to content

Commit 6b705a6

Browse files
mojazayeriCopilot
andcommitted
Fix shared-build admission and selection precedence
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent f1e6c55 commit 6b705a6

3 files changed

Lines changed: 103 additions & 19 deletions

File tree

libraries/rush-daemon/src/PhasedRequestRouter.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class PhasedRequestBatchCoordinator {
210210
#acceptingCurrentBatch: boolean = false;
211211
#currentBatch: ReadonlyArray<IBatchEntry> | undefined;
212212
#drainScheduled: boolean = false;
213+
#nextGraphLeasePromise: Promise<IRequestLease> | undefined;
213214
#running: boolean = false;
214215

215216
public constructor(
@@ -265,6 +266,9 @@ class PhasedRequestBatchCoordinator {
265266
return;
266267
}
267268
this.#drainScheduled = true;
269+
this.#nextGraphLeasePromise = this.#graphExecutionScheduler.acquireAsync({
270+
exclusivityClass: RequestExclusivityClass.Exclusive
271+
});
268272
setImmediate(() => {
269273
this.#drainScheduled = false;
270274
void this.#drainAsync();
@@ -277,6 +281,13 @@ class PhasedRequestBatchCoordinator {
277281
}
278282
this.#running = true;
279283
try {
284+
if (this.#pending.length === 0) {
285+
const unusedGraphLeasePromise: Promise<IRequestLease> | undefined =
286+
this.#nextGraphLeasePromise;
287+
this.#nextGraphLeasePromise = undefined;
288+
(await unusedGraphLeasePromise)?.release();
289+
return;
290+
}
280291
while (this.#pending.length > 0) {
281292
const first: IBatchEntry = this.#pending.shift()!;
282293
const batch: IBatchEntry[] = [first];
@@ -307,12 +318,14 @@ class PhasedRequestBatchCoordinator {
307318
}
308319

309320
#canJoinCurrentBatch(request: IPreparedPhasedRequest): boolean {
321+
if (request.exclusivityClass !== RequestExclusivityClass.SharedBuild) {
322+
return false;
323+
}
310324
if (!this.#running) {
311325
return true;
312326
}
313327
return (
314328
this.#acceptingCurrentBatch &&
315-
request.exclusivityClass === RequestExclusivityClass.SharedBuild &&
316329
this.#currentBatch?.[0]?.exclusivityClass === RequestExclusivityClass.SharedBuild
317330
);
318331
}
@@ -331,9 +344,12 @@ class PhasedRequestBatchCoordinator {
331344
}
332345

333346
async #executeBatchAsync(batch: IBatchEntry[]): Promise<void> {
334-
const graphLeasePromise: Promise<IRequestLease> = this.#graphExecutionScheduler.acquireAsync({
335-
exclusivityClass: RequestExclusivityClass.Exclusive
336-
});
347+
const graphLeasePromise: Promise<IRequestLease> =
348+
this.#nextGraphLeasePromise ??
349+
this.#graphExecutionScheduler.acquireAsync({
350+
exclusivityClass: RequestExclusivityClass.Exclusive
351+
});
352+
this.#nextGraphLeasePromise = undefined;
337353
const graphLease: IRequestLease = await graphLeasePromise;
338354
try {
339355
if (this.#graph.hasScheduledIteration || this.#graph.status === OperationStatus.Executing) {
@@ -743,23 +759,33 @@ function applySelections(
743759
graph: IOperationGraph,
744760
selections: ReadonlyArray<IResolvedSelection>
745761
): void {
746-
graph.setEnabledStates(graph.operations, false, 'unsafe');
747-
graph.setEnabledStates(
748-
selections.flatMap(
749-
(selection: IResolvedSelection) => selection.ignoreDependencyOperations
750-
),
751-
'ignore-dependency-changes',
752-
'safe'
762+
const enabledOperations: ReadonlyArray<Operation> = selections.flatMap(
763+
(selection: IResolvedSelection) => selection.enabledOperations
753764
);
754-
graph.setEnabledStates(
755-
selections.flatMap((selection: IResolvedSelection) => selection.enabledOperations),
756-
true,
757-
'safe'
765+
const ignoreDependencyOperations: ReadonlyArray<Operation> = selections.flatMap(
766+
(selection: IResolvedSelection) => selection.ignoreDependencyOperations
767+
);
768+
const enabledClosureBySelection: ReadonlyArray<ReadonlySet<Operation>> = selections.map(
769+
(selection: IResolvedSelection) =>
770+
new Set(collectSelectionClosure(selection.enabledOperations, []))
758771
);
772+
const effectiveIgnoreDependencyOperations: Operation[] = [];
773+
selections.forEach((selection: IResolvedSelection, selectionIndex: number) => {
774+
for (const operation of selection.ignoreDependencyOperations) {
775+
const requiredByAnotherSelection: boolean = enabledClosureBySelection.some(
776+
(enabledClosure: ReadonlySet<Operation>, enabledSelectionIndex: number) =>
777+
enabledSelectionIndex !== selectionIndex && enabledClosure.has(operation)
778+
);
779+
if (!requiredByAnotherSelection) {
780+
effectiveIgnoreDependencyOperations.push(operation);
781+
}
782+
}
783+
});
784+
graph.setEnabledStates(graph.operations, false, 'unsafe');
785+
graph.setEnabledStates(ignoreDependencyOperations, 'ignore-dependency-changes', 'safe');
786+
graph.setEnabledStates(enabledOperations, true, 'safe');
759787
graph.setEnabledStates(
760-
selections.flatMap(
761-
(selection: IResolvedSelection) => selection.ignoreDependencyOperations
762-
),
788+
effectiveIgnoreDependencyOperations,
763789
'ignore-dependency-changes',
764790
'unsafe'
765791
);

libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,63 @@ describe('shared phased request batching', () => {
364364
expect(scheduleSpy).toHaveBeenCalledTimes(3);
365365
});
366366

367+
it('applies graph admission to same-turn shared-read requests', async () => {
368+
const operationStarted: IDeferred = createDeferred();
369+
const releaseOperation: IDeferred = createDeferred();
370+
const fixture: ITestRoutingFixture = createFixture({
371+
actionAAsync: async (): Promise<void> => {
372+
operationStarted.resolve();
373+
await releaseOperation.promise;
374+
}
375+
});
376+
const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session);
377+
const first = router.executeAsync(
378+
{ ...createRequest('first', OPERATION_A), commandName: 'list' },
379+
new TestPhasedRequestClient('one')
380+
);
381+
const noWait = router.executeAsync(
382+
{
383+
...createRequest('no-wait', OPERATION_C),
384+
admission: { noWait: true },
385+
commandName: 'list'
386+
},
387+
new TestPhasedRequestClient('two')
388+
);
389+
390+
const noWaitResult: IDaemonPhasedRequestResult = await noWait;
391+
expect(noWaitResult).toMatchObject({ admissionErrorCode: 'no-wait', outcome: 'failure' });
392+
await operationStarted.promise;
393+
releaseOperation.resolve();
394+
await first;
395+
});
396+
397+
it('keeps true enabled state dominant across merged selections', async () => {
398+
const fixture: ITestRoutingFixture = createFixture();
399+
const enabledStates: Array<boolean | 'ignore-dependency-changes' | undefined> = [];
400+
fixture.graph.hooks.onIterationScheduled.tap('capture enabled state', () => {
401+
enabledStates.push(fixture.operations.get(OPERATION_A)?.enabled);
402+
});
403+
const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session);
404+
405+
await Promise.all([
406+
router.executeAsync(
407+
{
408+
...createRequest('ignore-dependency', OPERATION_A),
409+
operationSelection: [
410+
{ enabledState: 'ignore-dependency-changes', operationId: OPERATION_A }
411+
]
412+
},
413+
new TestPhasedRequestClient('one')
414+
),
415+
router.executeAsync(
416+
createRequest('requires-dependency', OPERATION_B),
417+
new TestPhasedRequestClient('two')
418+
)
419+
]);
420+
421+
expect(enabledStates).toEqual([true]);
422+
});
423+
367424
it('preserves per-client backpressure and final-result ordering in a merged batch', async () => {
368425
const fixture: ITestRoutingFixture = createFixture({
369426
actionAAsync: async (terminal: ITerminal): Promise<void> => {

libraries/rush-daemon/src/test/RequestAdmissionIntegration.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,14 @@ function createPhasedRequest(
116116
}
117117

118118
function createLegacyPhasedRequest(requestId: string): IDaemonPhasedRequest {
119-
return {
119+
const legacyRequest: Partial<IDaemonPhasedRequest> = {
120120
commandName: 'build',
121121
engineShape: TEST_ENGINE_SHAPE,
122122
environment: {},
123123
operationSelection: [{ enabledState: true, operationId: TEST_OPERATION }],
124124
requestId
125125
};
126+
return legacyRequest as IDaemonPhasedRequest;
126127
}
127128

128129
function createDeferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {

0 commit comments

Comments
 (0)