Skip to content

Commit 1084cf0

Browse files
mojazayeriCopilot
andcommitted
[rush-daemon] Harden phased request routing
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9e7c830 commit 1084cf0

8 files changed

Lines changed: 303 additions & 181 deletions

File tree

‎common/config/subspaces/default/pnpm-lock.yaml‎

Lines changed: 157 additions & 157 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
22
{
3-
"pnpmShrinkwrapHash": "7828a1fa8cadd2cfd83906d47b1915fddcb5aac9",
3+
"pnpmShrinkwrapHash": "09068e8d50f8dae26938555320be968d7ed7861d",
44
"preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c"
55
}

‎common/reviews/api/rush-daemon.api.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface IMapWorkspaceInvalidationsOptions {
6464
// @beta
6565
export interface IPhasedRequestClient {
6666
readonly abortSignal: AbortSignal;
67+
getNextEventSequence(): number;
6768
readonly sessionId: string;
6869
writeEventAsync(event: IDaemonEventEnvelope): Promise<void>;
6970
writeLogChunkAsync(operationId: string, stream: 'stdout' | 'stderr', chunk: Uint8Array): Promise<void>;

‎libraries/rush-daemon/src/PhasedRequestClient.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export interface IPhasedRequestClient {
1818
/** The connection session identifier used in structured event envelopes. */
1919
readonly sessionId: string;
2020

21+
/** Returns the next structured-event sequence number for this connection. */
22+
getNextEventSequence(): number;
23+
2124
/** Writes one structured event through the client's backpressured destination. */
2225
writeEventAsync(event: IDaemonEventEnvelope): Promise<void>;
2326

‎libraries/rush-daemon/src/PhasedRequestEventSink.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ class OrderedClientWriter {
5050
this.#onFailure = onFailure;
5151
}
5252

53-
public writeEvent(event: IDaemonEventEnvelope): void {
54-
this.#enqueue(() => this.#client.writeEventAsync(event));
53+
public writeEvent(createEvent: () => IDaemonEventEnvelope): void {
54+
this.#enqueue(() => this.#client.writeEventAsync(createEvent()));
5555
}
5656

5757
public writeLogChunk(
@@ -159,10 +159,14 @@ export class PhasedRequestEventSink implements _IOperationGraphEventSink {
159159

160160
public onOperationStreamClosed(operationId: string): void {
161161
if (this.#activeOperationIds.has(operationId)) {
162-
this.#emitEvent('extension', {
163-
data: { operationId },
164-
name: RUSHD_OPERATION_STREAM_CLOSED
165-
});
162+
this.#emitEvent(
163+
'extension',
164+
{
165+
data: { operationId },
166+
name: RUSHD_OPERATION_STREAM_CLOSED
167+
},
168+
{ required: true }
169+
);
166170
}
167171
}
168172

@@ -179,15 +183,14 @@ export class PhasedRequestEventSink implements _IOperationGraphEventSink {
179183
}
180184

181185
#emitEvent(type: DaemonEventType, payload: unknown, options?: IEventOptions): void {
182-
const sequence: number = this.#getNextSequence();
183-
this.#writer.writeEvent({
186+
this.#writer.writeEvent(() => ({
184187
eventId: randomUUID(),
185188
payload,
186189
privacy: 'public',
187190
protocolVersion: DAEMON_PROTOCOL_VERSION,
188191
required: options?.required ?? false,
189192
scope: options?.scope,
190-
sequence,
193+
sequence: this.#getNextSequence(),
191194
sessionId: this.#client.sessionId,
192195
source: {
193196
component: EVENT_SOURCE_COMPONENT,
@@ -196,6 +199,6 @@ export class PhasedRequestEventSink implements _IOperationGraphEventSink {
196199
},
197200
timestamp: new Date().toISOString(),
198201
type
199-
});
202+
}));
200203
}
201204
}

‎libraries/rush-daemon/src/PhasedRequestRouter.ts‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,10 @@ interface IResolvedSelection {
3939
}
4040

4141
interface IGraphRoutingState {
42-
readonly eventSequenceBySessionId: Map<string, number>;
4342
readonly multiplexer: PhasedRequestEventMultiplexer;
4443
readonly scheduler: RequestScheduler;
4544
}
4645

47-
const FIRST_SEQUENCE: number = 1;
4846
const ROUTING_STATE_BY_GRAPH: WeakMap<IOperationGraph, IGraphRoutingState> = new WeakMap();
4947

5048
/**
@@ -139,7 +137,7 @@ export class PhasedRequestRouter {
139137
const requestSink: PhasedRequestEventSink = new PhasedRequestEventSink({
140138
activeOperationIds,
141139
client,
142-
getNextSequence: () => getNextEventSequence(routingState, client.sessionId),
140+
getNextSequence: () => client.getNextEventSequence(),
143141
onWriteFailure: abortIteration,
144142
rushVersion: this.#workspaceSession.metadata.rushVersion
145143
});
@@ -232,7 +230,7 @@ function getGraphRoutingState(graph: IDualEmitOperationGraph): IGraphRoutingStat
232230
const multiplexer: PhasedRequestEventMultiplexer = new PhasedRequestEventMultiplexer(
233231
getGraphEventSink(graph)
234232
);
235-
state = { eventSequenceBySessionId: new Map(), multiplexer, scheduler: new RequestScheduler() };
233+
state = { multiplexer, scheduler: new RequestScheduler() };
236234
ROUTING_STATE_BY_GRAPH.set(graph, state);
237235
setGraphEventSink(graph, multiplexer);
238236
} else if (getGraphEventSink(graph) !== state.multiplexer) {
@@ -241,12 +239,6 @@ function getGraphRoutingState(graph: IDualEmitOperationGraph): IGraphRoutingStat
241239
return state;
242240
}
243241

244-
function getNextEventSequence(state: IGraphRoutingState, sessionId: string): number {
245-
const sequence: number = state.eventSequenceBySessionId.get(sessionId) ?? FIRST_SEQUENCE;
246-
state.eventSequenceBySessionId.set(sessionId, sequence + 1);
247-
return sequence;
248-
}
249-
250242
function validateRequestIdentity(request: IDaemonPhasedRequest): void {
251243
validateNonemptyName(request.requestId, 'request id');
252244
validateNonemptyName(request.commandName, 'command name');
@@ -344,6 +336,11 @@ function applySelection(graph: IOperationGraph, selection: IResolvedSelection):
344336
'safe'
345337
);
346338
graph.setEnabledStates(selection.enabledOperations, true, 'safe');
339+
graph.setEnabledStates(
340+
selection.ignoreDependencyOperations,
341+
'ignore-dependency-changes',
342+
'unsafe'
343+
);
347344
}
348345

349346
function collectOperationResults(
@@ -360,7 +357,9 @@ function collectOperationResults(
360357
if (status === undefined) {
361358
continue;
362359
}
363-
const errorMessage: string | undefined = observed?.errorMessage ?? retained?.error?.message;
360+
const errorMessage: string | undefined = observed
361+
? observed.errorMessage
362+
: retained?.error?.message;
364363
results.push({ operationId: operation.name, status, errorMessage });
365364
}
366365
return results;

‎libraries/rush-daemon/src/test/PhasedRequestRouter.test.ts‎

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
IDaemonPhasedOperationSelection,
88
IDaemonPhasedRequest
99
} from '@rushstack/rush-daemon-protocol';
10+
import { RUSHD_OPERATION_STREAM_CLOSED } from '@rushstack/rush-daemon-protocol';
1011
import { OperationStatus } from '@microsoft/rush-lib';
1112

1213
import { PhasedRequestRouter } from '../PhasedRequestRouter';
@@ -143,6 +144,23 @@ describe(PhasedRequestRouter.name, () => {
143144
expect(ignoredDependencyFixture.operations.get(OPERATION_A)?.enabled).toBe(
144145
'ignore-dependency-changes'
145146
);
147+
148+
const mixedFixture: ITestRoutingFixture = createThreeOperationFixture();
149+
await new PhasedRequestRouter(mixedFixture.session).executeAsync(
150+
createRequest([
151+
{
152+
enabledState: 'ignore-dependency-changes',
153+
operationId: OPERATION_A
154+
},
155+
select(OPERATION_B)
156+
]),
157+
new TestPhasedRequestClient()
158+
);
159+
160+
expect(mixedFixture.operations.get(OPERATION_A)?.enabled).toBe(
161+
'ignore-dependency-changes'
162+
);
163+
expect(mixedFixture.operations.get(OPERATION_B)?.enabled).toBe(true);
146164
});
147165

148166
it('reconciles invalidations, applies the safe dependency closure, and runs one iteration', async () => {
@@ -220,6 +238,50 @@ describe(PhasedRequestRouter.name, () => {
220238
.map(getEventOperationId)
221239
.filter((operationId: string | undefined): operationId is string => !!operationId);
222240
expect(new Set(eventOperationIds)).toEqual(new Set([OPERATION_A]));
241+
const streamClosedEvent: IDaemonEventEnvelope | undefined = client.writes
242+
.map((write: ITestClientWrite) => write.event)
243+
.find(
244+
(event: IDaemonEventEnvelope | undefined) =>
245+
(event?.payload as { name?: unknown } | undefined)?.name ===
246+
RUSHD_OPERATION_STREAM_CLOSED
247+
);
248+
expect(streamClosedEvent?.required).toBe(true);
249+
});
250+
251+
it('allocates event sequences when queued writes are invoked', async () => {
252+
let releaseFirstEvent: (() => void) | undefined;
253+
let markFirstEventStarted: (() => void) | undefined;
254+
const firstEventStarted: Promise<void> = new Promise<void>((resolve) => {
255+
markFirstEventStarted = resolve;
256+
});
257+
const fixture: ITestRoutingFixture = createThreeOperationFixture();
258+
const client: TestPhasedRequestClient = new TestPhasedRequestClient();
259+
let hasBlockedEvent: boolean = false;
260+
client.onWriteAsync = async (write: ITestClientWrite): Promise<void> => {
261+
if (write.event && !hasBlockedEvent) {
262+
hasBlockedEvent = true;
263+
markFirstEventStarted?.();
264+
await new Promise<void>((resolve) => {
265+
releaseFirstEvent = resolve;
266+
});
267+
}
268+
};
269+
270+
const requestPromise = new PhasedRequestRouter(fixture.session).executeAsync(
271+
createRequest([select(OPERATION_A)]),
272+
client
273+
);
274+
await firstEventStarted;
275+
const interleavedSequence: number = client.getNextEventSequence();
276+
releaseFirstEvent?.();
277+
await requestPromise;
278+
279+
const routedSequences: number[] = client.writes
280+
.map(({ event }) => event?.sequence)
281+
.filter((sequence: number | undefined): sequence is number => sequence !== undefined);
282+
expect(routedSequences[0]).toBe(1);
283+
expect(interleavedSequence).toBe(2);
284+
expect(routedSequences.slice(1).every((sequence) => sequence > interleavedSequence)).toBe(true);
223285
});
224286

225287
it('returns client-scoped failures without converting them to routing errors', async () => {
@@ -289,6 +351,48 @@ describe(PhasedRequestRouter.name, () => {
289351
expect(followUp.operationResults[0]?.status).toBe(OperationStatus.Success);
290352
});
291353

354+
it('does not combine an observed result with an error retained from a prior iteration', async () => {
355+
let invocation: number = 0;
356+
let releaseOperationA: (() => void) | undefined;
357+
let markOperationAStarted: (() => void) | undefined;
358+
const operationAStarted: Promise<void> = new Promise<void>((resolve) => {
359+
markOperationAStarted = resolve;
360+
});
361+
const fixture: ITestRoutingFixture = createThreeOperationFixture({
362+
actionAAsync: async (): Promise<void> => {
363+
if (invocation++ === 0) {
364+
throw new Error('first iteration failure');
365+
}
366+
markOperationAStarted?.();
367+
await new Promise<void>((resolve) => {
368+
releaseOperationA = resolve;
369+
});
370+
}
371+
});
372+
const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session);
373+
const first = await router.executeAsync(
374+
createRequest([select(OPERATION_B)]),
375+
new TestPhasedRequestClient()
376+
);
377+
expect(
378+
first.operationResults.find(({ operationId }) => operationId === OPERATION_A)?.errorMessage
379+
).toBe('first iteration failure');
380+
381+
const secondClient: TestPhasedRequestClient = new TestPhasedRequestClient();
382+
const secondPromise = router.executeAsync(
383+
{ ...createRequest([select(OPERATION_B)]), requestId: 'request-2' },
384+
secondClient
385+
);
386+
await operationAStarted;
387+
secondClient.abortController.abort();
388+
releaseOperationA?.();
389+
const second = await secondPromise;
390+
391+
expect(
392+
second.operationResults.find(({ operationId }) => operationId === OPERATION_A)?.errorMessage
393+
).toBeUndefined();
394+
});
395+
292396
it('aborts and unsubscribes when a disconnected client rejects a write', async () => {
293397
let releaseOperationA: (() => void) | undefined;
294398
const fixture: ITestRoutingFixture = createThreeOperationFixture({
@@ -398,8 +502,9 @@ describe(PhasedRequestRouter.name, () => {
398502
}
399503
});
400504
const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session);
401-
const firstClient: TestPhasedRequestClient = new TestPhasedRequestClient();
402-
const secondClient: TestPhasedRequestClient = new TestPhasedRequestClient();
505+
const sequenceState: { next: number } = { next: 1 };
506+
const firstClient: TestPhasedRequestClient = new TestPhasedRequestClient(sequenceState);
507+
const secondClient: TestPhasedRequestClient = new TestPhasedRequestClient(sequenceState);
403508

404509
const first = await router.executeAsync(
405510
createRequest([select(OPERATION_A)]),

‎libraries/rush-daemon/src/test/PhasedRequestRouterTestUtilities.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,22 @@ export class TestPhasedRequestClient implements IPhasedRequestClient {
5656
public readonly sessionId: string = 'test-session';
5757
public readonly writes: ITestClientWrite[] = [];
5858
public onWriteAsync: ((write: ITestClientWrite) => Promise<void>) | undefined;
59+
readonly #sequenceState: { next: number };
60+
61+
public constructor(sequenceState: { next: number } = { next: 1 }) {
62+
this.#sequenceState = sequenceState;
63+
}
5964

6065
public get abortSignal(): AbortSignal {
6166
return this.abortController.signal;
6267
}
6368

69+
public getNextEventSequence(): number {
70+
const sequence: number = this.#sequenceState.next;
71+
this.#sequenceState.next = sequence + 1;
72+
return sequence;
73+
}
74+
6475
public async writeEventAsync(event: IDaemonEventEnvelope): Promise<void> {
6576
const write: ITestClientWrite = { event };
6677
await this.onWriteAsync?.(write);

0 commit comments

Comments
 (0)