Skip to content

Commit 3ac2c12

Browse files
d-csclaude
andcommitted
fix(run-engine): key the records gate on the organisation, and pin it properly
The gate took only a run id. The decision it answers is organisation-scoped -- it follows the snapshot store's own rollout -- and an opaque run id cannot answer that without a lookup, which would put a read back on the path the gate exists to keep free. Both call sites already hold the organisation on the snapshot they are transitioning from, so it now travels with the run id and costs nothing. The test claimed "once per resume" and did not show it. It blocked the run on a single waitpoint, where a gate consulted once per waitpoint is indistinguishable from one consulted once per resume, and asserted containment rather than a count -- so it passed whether the gate was called once, three times, or after the scan. It now blocks on three waitpoints and asserts exactly one consultation carrying the run's own organisation. A per-waitpoint gate fails it three-to-one, and passing the wrong id fails it outright. What the test still does not assert is that the gate runs BEFORE the id scan. That is not observable from outside: the scan is a pure function over ids the caller already holds, so it leaves no trace, and proving the negative would need it stubbed. The ordering is held by the code, where the predicate is the method's first statement, and is recorded as such in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8140d17 commit 3ac2c12

3 files changed

Lines changed: 58 additions & 23 deletions

File tree

internal-packages/run-engine/src/engine/systems/waitpointSystem.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ export type WaitpointSystemOptions = {
3232
* derived here: the answer is per-organisation, since it follows the snapshot store's own
3333
* rollout, and the store keeps that state private to itself.
3434
*
35+
* Takes the organisation id, not just the run id. The decision is organisation-scoped, and an
36+
* opaque run id cannot answer it without a lookup -- which would put a read back on the path
37+
* this gate exists to keep free. Both call sites already hold it on the snapshot they are
38+
* transitioning from, so it costs nothing to pass. The run id rides along for logging and for
39+
* any future per-run override.
40+
*
3541
* Defaults to never. A record set is only reachable through the snapshot store, so until the
3642
* ticket that wires that store supplies this predicate there is no run for which one could
3743
* exist, and no resume does any of this work.
3844
*/
39-
completedWaitpointRecordsEnabled?: (runId: string) => boolean;
45+
completedWaitpointRecordsEnabled?: (args: { runId: string; organizationId: string }) => boolean;
4046
};
4147

4248
type WaitpointContinuationWaitpoint = Pick<Waitpoint, "id" | "type" | "completedAfter" | "status">;
@@ -60,7 +66,7 @@ export class WaitpointSystem {
6066
private readonly executionSnapshotSystem: ExecutionSnapshotSystem;
6167
private readonly enqueueSystem: EnqueueSystem;
6268
private readonly coordinator: WaitpointCoordinator;
63-
private readonly recordsEnabled: (runId: string) => boolean;
69+
private readonly recordsEnabled: (args: { runId: string; organizationId: string }) => boolean;
6470

6571
constructor(private readonly options: WaitpointSystemOptions) {
6672
this.$ = options.resources;
@@ -627,6 +633,7 @@ export class WaitpointSystem {
627633
// appending, and they must not pay an envelope read to do it.
628634
const completedWaitpointRecords = await this.#completedWaitpointRecordsFor(
629635
runId,
636+
snapshot.organizationId,
630637
blockingWaitpoints
631638
);
632639

@@ -700,6 +707,7 @@ export class WaitpointSystem {
700707

701708
const completedWaitpointRecords = await this.#completedWaitpointRecordsFor(
702709
runId,
710+
snapshot.organizationId,
703711
blockingWaitpoints
704712
);
705713

@@ -792,13 +800,14 @@ export class WaitpointSystem {
792800
*/
793801
async #completedWaitpointRecordsFor(
794802
runId: string,
803+
organizationId: string,
795804
blockingWaitpoints: RunBlockEdge[]
796805
): Promise<CompletedWaitpointRecord[] | undefined> {
797806
// O(1) first, and unconditionally first: a run whose snapshots cannot hold a record set has
798807
// nothing to build, and deciding that by walking its blocking waitpoints made every resume
799808
// for every organisation pay a scan proportional to its fan-in to reach the same answer.
800809
// Defaults to never, so today this returns here for everyone.
801-
if (!this.recordsEnabled(runId)) {
810+
if (!this.recordsEnabled({ runId, organizationId })) {
802811
return undefined;
803812
}
804813

internal-packages/run-engine/src/engine/tests/completedWaitpointRecordsGate.test.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ vi.setConfig({ testTimeout: 60_000 });
3434
function engineWith(
3535
prisma: never,
3636
redisOptions: never,
37-
completedWaitpointRecordsEnabled?: (runId: string) => boolean
37+
completedWaitpointRecordsEnabled?: (args: { runId: string; organizationId: string }) => boolean
3838
) {
3939
return new RunEngine({
4040
prisma,
@@ -54,12 +54,19 @@ function engineWith(
5454
}
5555

5656
describe("the completed-waitpoint records gate", () => {
57+
// Asserts what is observable: that the predicate is consulted exactly once per resume, with
58+
// the right organisation, and that a false answer leaves the resume itself untouched.
59+
//
60+
// It does NOT assert that the call happens BEFORE the id scan. That ordering is not observable
61+
// from outside: `parseWaitpointId` is a pure function on ids the caller already holds, so a scan
62+
// leaves no trace, and proving the negative would need it stubbed -- which this repo does not do.
63+
// The ordering is held by the code instead: the predicate is the first statement in the method.
5764
containerTest(
58-
"is consulted once per resume, and the resume is unaffected",
65+
"is consulted exactly once per resume, with the run's organisation",
5966
async ({ prisma, redisOptions }) => {
60-
const consulted: string[] = [];
61-
const engine = engineWith(prisma as never, redisOptions as never, (runId) => {
62-
consulted.push(runId);
67+
const consulted: { runId: string; organizationId: string }[] = [];
68+
const engine = engineWith(prisma as never, redisOptions as never, (args) => {
69+
consulted.push(args);
6370
return false;
6471
});
6572

@@ -98,27 +105,43 @@ describe("the completed-waitpoint records gate", () => {
98105
snapshotId: dequeued[0]!.snapshot.id,
99106
});
100107

101-
// Block on a manual waitpoint, then release it: the release is what resumes the run and
102-
// reaches the gate.
103-
const waitpoint = await engine.createManualWaitpoint({
104-
environmentId: env.id,
105-
projectId: env.projectId,
106-
});
108+
// THREE waitpoints, not one. With a single blocker a gate consulted once per waitpoint
109+
// is indistinguishable from one consulted once per resume, so a one-waitpoint run cannot
110+
// hold the "exactly once" property this test exists for.
111+
const waitpoints = [];
112+
for (let i = 0; i < 3; i++) {
113+
const created = await engine.createManualWaitpoint({
114+
environmentId: env.id,
115+
projectId: env.projectId,
116+
});
117+
waitpoints.push(created.waitpoint.id);
118+
}
119+
107120
await engine.blockRunWithWaitpoint({
108121
runId: run.id,
109-
waitpoints: [waitpoint.waitpoint.id],
122+
waitpoints,
110123
projectId: env.project.id,
111124
organizationId: env.organization.id,
112125
});
113126

114-
await engine.completeWaitpoint({ id: waitpoint.waitpoint.id });
127+
// The LAST completion is the one that unblocks the run and reaches the gate.
128+
for (const id of waitpoints) {
129+
await engine.completeWaitpoint({ id });
130+
}
115131

116132
const { completedWaitpointIds } = await waitForResume(engine, run.id);
117133

118-
// The run resumed, because a disabled gate only skips the record build...
119-
expect(completedWaitpointIds).toContain(waitpoint.waitpoint.id);
120-
// ...and the gate was consulted for it, which is what puts it in the resume path.
121-
expect(consulted).toContain(run.id);
134+
// The run resumed, because a disabled gate only skips the record build.
135+
expect(completedWaitpointIds).toEqual(expect.arrayContaining(waitpoints));
136+
137+
// Exactly one consultation for this run, not merely at least one: a gate called per
138+
// waitpoint, or twice per resume, would satisfy a containment check.
139+
const forThisRun = consulted.filter((c) => c.runId === run.id);
140+
expect(forThisRun).toHaveLength(1);
141+
142+
// And it carries the organisation the decision is keyed on, which is the whole reason the
143+
// predicate takes more than a run id.
144+
expect(forThisRun[0]?.organizationId).toBe(env.organization.id);
122145
expect(attempt.run.id).toBe(run.id);
123146
} finally {
124147
await engine.quit();

internal-packages/run-engine/src/engine/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,13 @@ export type RunEngineOptions = {
8181
* Whether a run's snapshots can hold a completed-waitpoint record set. Must be O(1): it is
8282
* consulted on every resume, ahead of any work proportional to the run's fan-in.
8383
*
84-
* Per-run rather than global because the answer follows the snapshot store's own
85-
* per-organisation rollout. Omitted means never, so no resume builds a record set.
84+
* Organisation-scoped, because that is what the snapshot store's own rollout is keyed on. The
85+
* run id rides along for logging and for any future per-run override; resolving the
86+
* organisation from it would put a read back on the path this gate keeps free.
87+
*
88+
* Omitted means never, so no resume builds a record set.
8689
*/
87-
completedWaitpointRecordsEnabled?: (runId: string) => boolean;
90+
completedWaitpointRecordsEnabled?: (args: { runId: string; organizationId: string }) => boolean;
8891
queue: {
8992
redis: RedisOptions;
9093
shardCount?: number;

0 commit comments

Comments
 (0)