Skip to content

Commit b9ca103

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
feat(run-engine): bounded waitpoint completion fanout and lifecycle cleanup
Internal groundwork for Redis-backed waitpoints, inactive in production by default. It moves wide completion fanout out of the foreground request and into a bounded, recoverable worker, while adding explicit lifecycle rules for active and terminal coordination state. Existing waitpoint behavior remains on the Postgres path until later routing work enables this subsystem. Mono-RevId: c334b81ad8131769a503dab7460b4f003e8cc3b6
1 parent b0530e9 commit b9ca103

17 files changed

Lines changed: 11437 additions & 556 deletions

internal-packages/run-engine/harness/waitpointFanout.ts

Lines changed: 788 additions & 0 deletions
Large diffs are not rendered by default.

internal-packages/run-engine/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@
3939
"@internal/testcontainers": "workspace:*",
4040
"@opentelemetry/sdk-metrics": "2.7.1",
4141
"@types/seedrandom": "^3.0.8",
42-
"rimraf": "6.0.1"
42+
"rimraf": "6.0.1",
43+
"tsx": "4.17.0"
4344
},
4445
"scripts": {
4546
"clean": "rimraf dist",
46-
"typecheck": "tsc --noEmit -p tsconfig.build.json && tsc --noEmit -p tsconfig.freeze-test.json",
47+
"typecheck": "tsc --noEmit -p tsconfig.build.json && tsc --noEmit -p tsconfig.freeze-test.json && tsc --noEmit -p tsconfig.harness.json",
4748
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
4849
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled",
4950
"build": "pnpm run clean && tsc -p tsconfig.build.json",
5051
"dev": "tsc --watch -p tsconfig.build.json",
51-
"test:bench": "vitest --config ./vitest.bench.config.ts --run"
52+
"test:bench": "vitest --config ./vitest.bench.config.ts --run",
53+
"harness:waitpoint-fanout": "tsx harness/waitpointFanout.ts"
5254
}
5355
}

internal-packages/run-engine/src/engine/bench/waitpointCoordinator.bench.test.ts

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
* `COUNT(*) ... WHERE status='PENDING'`, over the same population. Like for like.
99
* 2. Read amplification — the store's `readBlockState` against a full-payload `SELECT`
1010
* of the same waitpoints. Like for like.
11-
* 3. Store-only write paths — block+complete+deliver and K-watcher fan-out. Absolute
12-
* numbers with NO Postgres counterpart: no single statement on the previous path
13-
* corresponds to a Redis round trip that both blocks a run and delivers to watchers.
11+
* 3. Store-only write paths — block+complete+drain, then foreground completion versus
12+
* worker drain at several fan-out widths. Absolute numbers with NO Postgres
13+
* counterpart: no single statement on the previous path corresponds to a Redis round
14+
* trip that both blocks a run and delivers to watchers. The width sweep is the
15+
* evidence for the flat-foreground claim — completion should not track K, the drain
16+
* should.
1417
* 4. Register cost versus edge count — `registerBlocks` registers each edge with its own
1518
* round trip before the single absorb. This measures whether that serial loop is a
1619
* real cost at a wide fan-in, or a non-issue, at several fan-in widths.
@@ -19,10 +22,11 @@
1922
* empty table measures nothing.
2023
*
2124
* Knobs: BENCH_WP_ITERATIONS, BENCH_WP_FANIN, BENCH_WP_WATCHERS, BENCH_WP_REGISTER_WIDTHS,
22-
* BENCH_WP_REGISTER_SAMPLES.
25+
* BENCH_WP_REGISTER_SAMPLES, BENCH_WP_FANOUT_WIDTHS.
2326
*/
2427
import { containerTest } from "@internal/testcontainers";
2528
import type { PrismaClient } from "@trigger.dev/database";
29+
import { WaitpointFanoutWorker } from "../waitpointCoordinator/fanoutWorker.js";
2630
import {
2731
WaitpointStoreCoordinator,
2832
type BlockEdge,
@@ -40,6 +44,13 @@ const REGISTER_WIDTHS = (process.env.BENCH_WP_REGISTER_WIDTHS ?? "1,10,100,1001"
4044
.map((raw) => Number(raw.trim()))
4145
.filter((width) => Number.isFinite(width) && width > 0);
4246
const REGISTER_SAMPLES = Number(process.env.BENCH_WP_REGISTER_SAMPLES ?? 20);
47+
const FANOUT_WIDTHS = (process.env.BENCH_WP_FANOUT_WIDTHS ?? `1,10,100,${WATCHERS}`)
48+
.split(",")
49+
.map((raw) => Number(raw.trim()))
50+
.filter(
51+
(width, index, all) => Number.isFinite(width) && width > 0 && all.indexOf(width) === index
52+
)
53+
.sort((a, b) => a - b);
4354
const NOW = new Date().toISOString();
4455

4556
type Sample = { label: string; count: number; p50: number; p99: number; totalMs: number };
@@ -121,6 +132,7 @@ containerTest(
121132
async ({ prisma, redisOptions }) => {
122133
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
123134
const store = new WaitpointStoreCoordinator({ redisOptions });
135+
const worker = new WaitpointFanoutWorker({ coordinator: store, enabled: true });
124136
const samples: Sample[] = [];
125137
const registerCost: Array<{
126138
width: number;
@@ -143,13 +155,18 @@ containerTest(
143155
}
144156
await store.registerBlocks({
145157
runId: "bench_run_fanin",
158+
blockId: "bench_blk_fanin",
146159
edges: ids.map((id, index) => edge(id, index)),
147160
});
148161

149162
// --- group 1: the pending-count gate, like for like ---
150163
samples.push(
151164
await measure("store.pendingCount", ITERATIONS, async () => {
152-
await store.absorbBlockers({ runId: "bench_run_fanin", edges: [] });
165+
await store.absorbBlockers({
166+
runId: "bench_run_fanin",
167+
blockId: "bench_blk_fanin",
168+
edges: [],
169+
});
153170
})
154171
);
155172
samples.push(
@@ -179,40 +196,50 @@ containerTest(
179196
record: record(id, env.id, env.project.id),
180197
status: "PENDING",
181198
});
182-
await store.registerBlocks({ runId: `bench_run_${i}`, edges: [edge(id)] });
183-
const done = await store.complete({ waitpointId: id, completion });
184-
for (const watcher of done.watchers) {
185-
await store.deliverCompletion({
186-
runId: watcher.runId,
187-
waitpointId: id,
188-
completion: done.completion!,
189-
});
190-
}
199+
await store.registerBlocks({
200+
runId: `bench_run_${i}`,
201+
blockId: `bench_blk_${i}`,
202+
edges: [edge(id)],
203+
});
204+
await store.complete({ waitpointId: id, completion });
205+
await worker.visit(id);
191206
})
192207
);
193208

194-
const fanOutId = "bench_fanout_w";
195-
await store.createIfAbsent({
196-
record: record(fanOutId, env.id, env.project.id),
197-
status: "PENDING",
198-
});
199-
for (let i = 0; i < WATCHERS; i++) {
200-
await store.registerBlocks({ runId: `bench_watcher_${i}`, edges: [edge(fanOutId)] });
209+
// Foreground completion versus watcher count. The claim under test is that the
210+
// first number is FLAT in K while the second grows with it — that is what "bounded
211+
// foreground completion" means in practice, and a single width could not show it.
212+
for (const width of FANOUT_WIDTHS) {
213+
const fanOutId = `bench_fanout_w_${width}`;
214+
await store.createIfAbsent({
215+
record: record(fanOutId, env.id, env.project.id),
216+
status: "PENDING",
217+
});
218+
for (let i = 0; i < width; i++) {
219+
await store.registerBlocks({
220+
runId: `bench_watcher_${width}_${i}`,
221+
blockId: `bench_blk_watcher_${width}_${i}`,
222+
edges: [edge(fanOutId)],
223+
});
224+
}
225+
226+
samples.push(
227+
await measure(`store.complete(foreground, watchers=${width})`, 1, async () => {
228+
await store.complete({ waitpointId: fanOutId, completion });
229+
})
230+
);
231+
samples.push(
232+
await measure(`worker.drain(watchers=${width})`, 1, async () => {
233+
let visits = 0;
234+
// The visit page budget is bounded, so a wide fan-out takes several visits.
235+
for (;;) {
236+
const summary = await worker.visit(fanOutId);
237+
visits++;
238+
if (summary.outcome !== "more" || visits > 1_000) break;
239+
}
240+
})
241+
);
201242
}
202-
samples.push(
203-
await measure(`store.complete+deliver(watchers=${WATCHERS})`, 1, async () => {
204-
const done = await store.complete({ waitpointId: fanOutId, completion });
205-
// Serial on purpose: this is the worst case, and it is the number that says
206-
// whether delivery needs to pipeline.
207-
for (const watcher of done.watchers) {
208-
await store.deliverCompletion({
209-
runId: watcher.runId,
210-
waitpointId: fanOutId,
211-
completion: done.completion!,
212-
});
213-
}
214-
})
215-
);
216243

217244
// --- group 4: register cost versus edge count ---
218245
// registerBlocks registers each edge with its own round trip, serially, before the
@@ -236,7 +263,12 @@ containerTest(
236263
`store.registerBlocks(edges=${width})`,
237264
REGISTER_SAMPLES,
238265
async () => {
239-
await store.registerBlocks({ runId: `bench_register_${width}_${call++}`, edges });
266+
const n = call++;
267+
await store.registerBlocks({
268+
runId: `bench_register_${width}_${n}`,
269+
blockId: `bench_blk_register_${width}_${n}`,
270+
edges,
271+
});
240272
}
241273
);
242274
samples.push(sample);
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* The inertness gate: the ordinary application, booted the way production boots it, with
3+
* store-resident waitpoint minting off.
4+
*
5+
* The store coordinator and its fanout worker are exported but nothing in `RunEngine`
6+
* constructs either one, so a full legacy block-and-complete cycle must leave the Waitpoint
7+
* namespace completely untouched — no records, no watcher queues, no fanout entries and no
8+
* partition index — while behaving exactly as it did before. Asserting the keyspace rather
9+
* than the wiring is deliberate: it fails if a later change starts writing there, however
10+
* it is introduced.
11+
*/
12+
import { createRedisClient } from "@internal/redis";
13+
import { assertNonNullable, containerTest } from "@internal/testcontainers";
14+
import { trace } from "@internal/tracing";
15+
import { setTimeout } from "node:timers/promises";
16+
import { expect } from "vitest";
17+
import { FANOUT_PARTITION_COUNT, fanoutIndexKeys } from "../waitpointCoordinator/keys.js";
18+
import { RunEngine } from "../index.js";
19+
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js";
20+
21+
vi.setConfig({ testTimeout: 60_000 });
22+
23+
describe("Waitpoint fanout stays inert while store-resident minting is disabled", () => {
24+
containerTest(
25+
"a legacy block and complete cycle writes nothing into the Waitpoint namespace",
26+
async ({ prisma, redisOptions }) => {
27+
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
28+
const probe = createRedisClient(redisOptions);
29+
30+
const engine = new RunEngine({
31+
prisma,
32+
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
33+
queue: {
34+
redis: redisOptions,
35+
masterQueueConsumersDisabled: true,
36+
processWorkerQueueDebounceMs: 50,
37+
},
38+
runLock: { redis: redisOptions },
39+
machines: {
40+
defaultMachine: "small-1x",
41+
machines: {
42+
"small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
43+
},
44+
baseCostInCents: 0.0001,
45+
},
46+
tracer: trace.getTracer("test", "0.0.0"),
47+
});
48+
49+
try {
50+
// Positive control first: an assertion that can only ever pass is not a guard, so
51+
// prove the scan finds a `wp:` key when one exists before relying on it finding none.
52+
await probe.set("wp:{sentinel}", "1");
53+
expect(await waitpointNamespaceKeys()).toEqual(["wp:{sentinel}"]);
54+
await probe.del("wp:{sentinel}");
55+
expect(await waitpointNamespaceKeys()).toEqual([]);
56+
57+
const taskIdentifier = "test-task";
58+
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
59+
60+
const run = await engine.trigger(
61+
{
62+
number: 1,
63+
friendlyId: "run_pinert1",
64+
environment: authenticatedEnvironment,
65+
taskIdentifier,
66+
payload: "{}",
67+
payloadType: "application/json",
68+
context: {},
69+
traceContext: {},
70+
traceId: "t_inert",
71+
spanId: "s_inert",
72+
workerQueue: "main",
73+
queue: `task/${taskIdentifier}`,
74+
isTest: false,
75+
tags: [],
76+
},
77+
prisma
78+
);
79+
80+
await setTimeout(500);
81+
const dequeued = await engine.dequeueFromWorkerQueue({
82+
consumerId: "test_inert",
83+
workerQueue: "main",
84+
});
85+
await engine.startRunAttempt({
86+
runId: dequeued[0]!.run.id,
87+
snapshotId: dequeued[0]!.snapshot.id,
88+
});
89+
90+
const created = await engine.createManualWaitpoint({
91+
environmentId: authenticatedEnvironment.id,
92+
projectId: authenticatedEnvironment.projectId,
93+
});
94+
95+
await engine.blockRunWithWaitpoint({
96+
runId: run.id,
97+
waitpoints: created.waitpoint.id,
98+
projectId: authenticatedEnvironment.projectId,
99+
organizationId: authenticatedEnvironment.organizationId,
100+
});
101+
102+
expect(
103+
(await engine.getRunExecutionData({ runId: run.id }))?.snapshot.executionStatus
104+
).toBe("EXECUTING_WITH_WAITPOINTS");
105+
// Blocked, and the block is recorded where it always was.
106+
const blocking = await prisma.taskRunWaitpoint.findFirst({ where: { taskRunId: run.id } });
107+
assertNonNullable(blocking);
108+
expect(blocking.waitpointId).toBe(created.waitpoint.id);
109+
110+
// Mid-cycle: a blocked run is exactly when watcher and fanout state would exist.
111+
expect(await waitpointNamespaceKeys()).toEqual([]);
112+
113+
await engine.completeWaitpoint({ id: created.waitpoint.id });
114+
await setTimeout(200);
115+
116+
// Unchanged legacy behaviour: the run resumes and its block row is gone.
117+
expect(
118+
(await engine.getRunExecutionData({ runId: run.id }))?.snapshot.executionStatus
119+
).toBe("EXECUTING");
120+
expect(
121+
await prisma.taskRunWaitpoint.findFirst({ where: { taskRunId: run.id } })
122+
).toBeNull();
123+
124+
expect(await waitpointNamespaceKeys()).toEqual([]);
125+
for (let partition = 0; partition < FANOUT_PARTITION_COUNT; partition++) {
126+
expect(await probe.zcard(fanoutIndexKeys(partition).due)).toBe(0);
127+
expect(await probe.zcard(fanoutIndexKeys(partition).quarantine)).toBe(0);
128+
}
129+
} finally {
130+
probe.disconnect();
131+
await engine.quit();
132+
}
133+
134+
// The engine's own subsystems use their configured key prefixes, so an unprefixed
135+
// `wp:` key can only have come from the waitpoint store coordinator.
136+
async function waitpointNamespaceKeys(): Promise<string[]> {
137+
const found: string[] = [];
138+
let cursor = "0";
139+
do {
140+
const [next, batch] = await probe.scan(cursor, "MATCH", "wp:*", "COUNT", 1_000);
141+
found.push(...batch);
142+
cursor = next;
143+
} while (cursor !== "0");
144+
return found.sort();
145+
}
146+
}
147+
);
148+
});

0 commit comments

Comments
 (0)