Skip to content

Commit cc69ff4

Browse files
authored
feat(run-engine): Redis waitpoint store coordinator, Lua protocol, and waitpoint ids (#4761)
Builds the Redis-backed half of the waitpoint coordinator, beside the Postgres coordinator that #4753 extracted. Adds the coordination protocol as Lua scripts, the run-ops-format waitpoint id scheme, and the key layout. **No caller wires any of it up.** Refs TRI-13440. ## Inert by construction Merging this changes nothing observable. 3180 insertions, **zero deletions**, nine new or additively-edited files. - `WaitpointStoreCoordinator` is never constructed outside its own tests and the benchmark. - No env var, no config plumbing, no connection. It takes `redisOptions` as a constructor argument. - `waitpointSystem.ts` is untouched. Every live waitpoint operation still runs on Postgres through the coordinator merged in #4753. - No changeset and no `.server-changes` note — nothing here is user-facing yet. Deploying this needs no Redis or MemoryDB instance. That becomes a prerequisite when a later change routes traffic onto the store behind a per-organisation flag. ## What's here **Nine Lua scripts**, each atomic on one hash tag. Seven mutate state — create-if-absent, register-or-report, complete, idempotency reserve, absorb, deliver, clear. One reads state (`runReadBlockState`) and is separate because the pending, delivered and edge sets must be read as one consistent view. One discards an idempotency loser. **Two hash tags, deliberately.** `wp:{waitpointId}` holds a waitpoint's record, status, completion envelope and watcher hash. `wp:run:{runId}:*` holds one run's pending set, delivered set and edge set. A waitpoint has N watchers, so it cannot live under any single run's tag. **Waitpoint ids** reuse the run-ops body layout: a 24-char base32hex core, a type char (`r`/`b`/`d`/`m`), and version char `w`. RUN and BATCH ids derive from their anchor's core, so create-if-absent is idempotent with no lock. `parseWaitpointId` is total and never throws. **The single-slot guard.** Every script invocation goes through one private wrapper that asserts all keys share a hash tag. A single-node test server accepts what a real cluster rejects, so this assertion is the only enforcement — and it is mutation-tested: removing it fails a test. ## Measured Against the same population of real Postgres rows: | | store | postgres | |---|---|---| | pending count (the blocked/unblocked gate) | 0.13 ms p50 | 3.32 ms p50 | | full-payload read | 1.45 ms p50 | 7.70 ms p50 | Both are lower bounds: the benchmark charges Postgres a `COUNT(*)`, while the resume-time read is a join with a partial select plus filtering in JavaScript. Store-only paths, no Postgres counterpart: block+complete+deliver 0.88 ms p50; 100-watcher fan-out 13.8 ms; a 1001-edge fan-in 149.8 ms, flat at 0.15 ms per edge and round-trip bound rather than algorithmic. The benchmark lives in `*.bench.test.ts` and is excluded from the default suite. ## Review notes - **The type surfaces are not reconciled yet, on purpose.** `types.ts` (from #4753) carries the coordinator interface; `storeCoordinator.ts` declares its own operation types because this was built in parallel. The wiring change reconciles them. - **The read-time resolver is not here.** Another lane froze its contract while this was in flight, and its frozen types are not yet on main. Building a second copy would fork a just-frozen contract. - **Teardown is one-shard while registration is two-shard.** A terminal clear leaves a run registered as a watcher on the waitpoints it was blocked on, because the watcher hash is under a different tag and no script may span slots. Recorded, not fixed here — it needs a retention decision, and nothing observes it while the code is unwired. ## Verification 79 tests in the coordinator suite, 58 in the id suite. `typecheck` on run-engine and webapp, `build` on core, `knip`, `oxfmt` and `oxlint` all clean. The engine corpus passes 82/82. Every invariant is mutation-tested rather than merely asserted. A whole-branch review ran 14 mutants and killed 12; the two survivors were fixed with their own mutation checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent f98e303 commit cc69ff4

9 files changed

Lines changed: 3543 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* Waitpoint coordination benchmark. Reports numbers; asserts nothing — on a shared runner
3+
* the timings swing far more than any threshold worth gating on.
4+
*
5+
* Four groups, and only the first two are pairs:
6+
*
7+
* 1. Pending count — the store's SCARD gate against the previous path's
8+
* `COUNT(*) ... WHERE status='PENDING'`, over the same population. Like for like.
9+
* 2. Read amplification — the store's `readBlockState` against a full-payload `SELECT`
10+
* 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.
14+
* 4. Register cost versus edge count — `registerBlocks` registers each edge with its own
15+
* round trip before the single absorb. This measures whether that serial loop is a
16+
* real cost at a wide fan-in, or a non-issue, at several fan-in widths.
17+
*
18+
* Every Postgres measurement here runs against rows this file inserts. A baseline over an
19+
* empty table measures nothing.
20+
*
21+
* Knobs: BENCH_WP_ITERATIONS, BENCH_WP_FANIN, BENCH_WP_WATCHERS, BENCH_WP_REGISTER_WIDTHS,
22+
* BENCH_WP_REGISTER_SAMPLES.
23+
*/
24+
import { containerTest } from "@internal/testcontainers";
25+
import type { PrismaClient } from "@trigger.dev/database";
26+
import {
27+
WaitpointStoreCoordinator,
28+
type BlockEdge,
29+
type WaitpointRecordInput,
30+
} from "../waitpointCoordinator/storeCoordinator.js";
31+
import { setupAuthenticatedEnvironment } from "../tests/setup.js";
32+
33+
vi.setConfig({ testTimeout: 900_000 });
34+
35+
const ITERATIONS = Number(process.env.BENCH_WP_ITERATIONS ?? 100);
36+
const FANIN = Number(process.env.BENCH_WP_FANIN ?? 1001);
37+
const WATCHERS = Number(process.env.BENCH_WP_WATCHERS ?? 100);
38+
const REGISTER_WIDTHS = (process.env.BENCH_WP_REGISTER_WIDTHS ?? "1,10,100,1001")
39+
.split(",")
40+
.map((raw) => Number(raw.trim()))
41+
.filter((width) => Number.isFinite(width) && width > 0);
42+
const REGISTER_SAMPLES = Number(process.env.BENCH_WP_REGISTER_SAMPLES ?? 20);
43+
const NOW = new Date().toISOString();
44+
45+
type Sample = { label: string; count: number; p50: number; p99: number; totalMs: number };
46+
47+
function percentile(sorted: number[], p: number): number {
48+
if (sorted.length === 0) return 0;
49+
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]!;
50+
}
51+
52+
async function measure(label: string, count: number, run: (i: number) => Promise<void>) {
53+
const durations: number[] = [];
54+
const started = Date.now();
55+
for (let i = 0; i < count; i++) {
56+
const t0 = performance.now();
57+
await run(i);
58+
durations.push(performance.now() - t0);
59+
}
60+
durations.sort((a, b) => a - b);
61+
const sample: Sample = {
62+
label,
63+
count,
64+
p50: percentile(durations, 50),
65+
p99: percentile(durations, 99),
66+
totalMs: Date.now() - started,
67+
};
68+
console.log(
69+
`[bench] ${sample.label} n=${sample.count} p50=${sample.p50.toFixed(2)}ms ` +
70+
`p99=${sample.p99.toFixed(2)}ms total=${sample.totalMs}ms`
71+
);
72+
return sample;
73+
}
74+
75+
function record(id: string, environmentId: string, projectId: string): WaitpointRecordInput {
76+
return {
77+
id,
78+
friendlyId: `waitpoint_${id}`,
79+
type: "MANUAL",
80+
environmentId,
81+
projectId,
82+
createdAt: NOW,
83+
updatedAt: NOW,
84+
userProvidedIdempotencyKey: false,
85+
tags: [],
86+
};
87+
}
88+
89+
const completion = {
90+
completedAt: NOW,
91+
outputType: "application/json",
92+
outputIsError: false,
93+
output: { inline: '{"ok":true}' },
94+
};
95+
96+
function edge(waitpointId: string, batchIndex?: number): BlockEdge {
97+
return { waitpointId, batchIndex, createdAt: NOW, type: "MANUAL" };
98+
}
99+
100+
async function insertWaitpoints(
101+
prisma: PrismaClient,
102+
ids: string[],
103+
environmentId: string,
104+
projectId: string
105+
) {
106+
await prisma.waitpoint.createMany({
107+
data: ids.map((id) => ({
108+
id,
109+
friendlyId: `waitpoint_${id}`,
110+
type: "MANUAL" as const,
111+
idempotencyKey: id,
112+
userProvidedIdempotencyKey: false,
113+
projectId,
114+
environmentId,
115+
})),
116+
});
117+
}
118+
119+
containerTest(
120+
"waitpoint coordination: pending count, read amplification, store write paths, register cost",
121+
async ({ prisma, redisOptions }) => {
122+
const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
123+
const store = new WaitpointStoreCoordinator({ redisOptions });
124+
const samples: Sample[] = [];
125+
const registerCost: Array<{
126+
width: number;
127+
p50Ms: number;
128+
p99Ms: number;
129+
perEdgeMsP50: number;
130+
}> = [];
131+
132+
try {
133+
const ids = Array.from({ length: FANIN }, (_, i) => `bench_w_${i}`);
134+
135+
// Both stores get the SAME population. A Postgres baseline over an empty table
136+
// measures an index probe against nothing.
137+
await insertWaitpoints(prisma, ids, env.id, env.project.id);
138+
for (const id of ids) {
139+
await store.createIfAbsent({
140+
record: record(id, env.id, env.project.id),
141+
status: "PENDING",
142+
});
143+
}
144+
await store.registerBlocks({
145+
runId: "bench_run_fanin",
146+
edges: ids.map((id, index) => edge(id, index)),
147+
});
148+
149+
// --- group 1: the pending-count gate, like for like ---
150+
samples.push(
151+
await measure("store.pendingCount", ITERATIONS, async () => {
152+
await store.absorbBlockers({ runId: "bench_run_fanin", edges: [] });
153+
})
154+
);
155+
samples.push(
156+
await measure("postgres.pendingCount", ITERATIONS, async () => {
157+
await prisma.$queryRaw`SELECT COUNT(*) FROM "Waitpoint" WHERE id = ANY(${ids}::text[]) AND status = 'PENDING'`;
158+
})
159+
);
160+
161+
// --- group 2: read amplification, like for like ---
162+
samples.push(
163+
await measure("store.readBlockState", ITERATIONS, async () => {
164+
await store.readBlockState("bench_run_fanin");
165+
})
166+
);
167+
samples.push(
168+
await measure("postgres.hydrateFullPayload", ITERATIONS, async () => {
169+
// Every column of every waitpoint — the amplification the store removes.
170+
await prisma.waitpoint.findMany({ where: { id: { in: ids } } });
171+
})
172+
);
173+
174+
// --- group 3: store-only write paths, no Postgres counterpart ---
175+
samples.push(
176+
await measure("store.block+complete+deliver", ITERATIONS, async (i) => {
177+
const id = `bench_cycle_${i}`;
178+
await store.createIfAbsent({
179+
record: record(id, env.id, env.project.id),
180+
status: "PENDING",
181+
});
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+
}
191+
})
192+
);
193+
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)] });
201+
}
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+
);
216+
217+
// --- group 4: register cost versus edge count ---
218+
// registerBlocks registers each edge with its own round trip, serially, before the
219+
// single absorb. A review flagged that a wide fan-in therefore serializes one round
220+
// trip per edge. This measures the real cost at several widths rather than predicting
221+
// it, so the decision about bounded concurrency is made against a number.
222+
const registerPoolWidth = Math.max(0, ...REGISTER_WIDTHS);
223+
const registerIds = Array.from({ length: registerPoolWidth }, (_, i) => `bench_reg_w_${i}`);
224+
await insertWaitpoints(prisma, registerIds, env.id, env.project.id);
225+
for (const id of registerIds) {
226+
await store.createIfAbsent({
227+
record: record(id, env.id, env.project.id),
228+
status: "PENDING",
229+
});
230+
}
231+
232+
for (const width of REGISTER_WIDTHS) {
233+
const edges = registerIds.slice(0, width).map((id, index) => edge(id, index));
234+
let call = 0;
235+
const sample = await measure(
236+
`store.registerBlocks(edges=${width})`,
237+
REGISTER_SAMPLES,
238+
async () => {
239+
await store.registerBlocks({ runId: `bench_register_${width}_${call++}`, edges });
240+
}
241+
);
242+
samples.push(sample);
243+
registerCost.push({
244+
width,
245+
p50Ms: sample.p50,
246+
p99Ms: sample.p99,
247+
perEdgeMsP50: sample.p50 / width,
248+
});
249+
console.log(
250+
`[bench] store.registerBlocks(edges=${width}) implied per-edge cost ` +
251+
`p50=${(sample.p50 / width).toFixed(3)}ms p99=${(sample.p99 / width).toFixed(3)}ms`
252+
);
253+
}
254+
255+
console.log(
256+
`[bench] groups 1 and 2 are like-for-like pairs. Group 3 and the register-cost ` +
257+
`group (4) have no Postgres counterpart: no single statement on the previous ` +
258+
`path corresponds to a Redis round trip that blocks, completes and delivers, ` +
259+
`or to a serial per-edge register loop.`
260+
);
261+
console.log(`[bench] summary\n${JSON.stringify({ samples, registerCost }, null, 2)}`);
262+
} finally {
263+
await store.quit();
264+
}
265+
}
266+
);
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
WaitpointKeyTagError,
4+
assertSingleSlot,
5+
edgeField,
6+
idempotencyKey,
7+
runBlockKeys,
8+
waitpointIdFromEdgeField,
9+
waitpointKeys,
10+
watcherField,
11+
} from "./keys.js";
12+
13+
describe("waitpointKeys", () => {
14+
it("puts the record and its watchers under one hash tag", () => {
15+
const k = waitpointKeys("abc123w");
16+
expect(k.record).toBe("wp:{abc123w}");
17+
expect(k.watchers).toBe("wp:{abc123w}:w");
18+
});
19+
});
20+
21+
describe("runBlockKeys", () => {
22+
it("puts all three run keys under one hash tag", () => {
23+
const k = runBlockKeys("run_abc");
24+
expect(k.pend).toBe("wp:run:{run_abc}:pend");
25+
expect(k.done).toBe("wp:run:{run_abc}:done");
26+
expect(k.edge).toBe("wp:run:{run_abc}:edge");
27+
});
28+
});
29+
30+
describe("idempotencyKey", () => {
31+
it("tags by environment, so one environment's reservations share a slot", () => {
32+
expect(idempotencyKey("env_1", "my-key")).toBe("wp:idem:{env_1}:my-key");
33+
});
34+
});
35+
36+
describe("edgeField", () => {
37+
it("keys by waitpoint id and batch index, matching the Postgres unique key", () => {
38+
expect(edgeField("w_a", 3)).toBe("w_a#3");
39+
});
40+
41+
it("collapses a null or absent batch index onto one field", () => {
42+
expect(edgeField("w_a")).toBe("w_a#");
43+
expect(edgeField("w_a", null)).toBe("w_a#");
44+
});
45+
46+
it("distinguishes index 0 from an absent index", () => {
47+
expect(edgeField("w_a", 0)).not.toBe(edgeField("w_a"));
48+
});
49+
});
50+
51+
describe("waitpointIdFromEdgeField", () => {
52+
it("round-trips back to the waitpoint id", () => {
53+
for (const index of [undefined, null, 0, 7]) {
54+
expect(waitpointIdFromEdgeField(edgeField("w_a", index))).toBe("w_a");
55+
}
56+
});
57+
58+
it("returns undefined for a field with no separator", () => {
59+
expect(waitpointIdFromEdgeField("nope")).toBeUndefined();
60+
});
61+
62+
it("splits on the last separator, tolerating a '#' inside the waitpoint id", () => {
63+
expect(waitpointIdFromEdgeField("a#b#3")).toBe("a#b");
64+
});
65+
});
66+
67+
describe("watcherField", () => {
68+
it("keys by run id and batch index, so one run can watch at several indexes", () => {
69+
expect(watcherField("run_a", 2)).toBe("run_a#2");
70+
expect(watcherField("run_a")).toBe("run_a#");
71+
expect(watcherField("run_a", 0)).not.toBe(watcherField("run_a"));
72+
});
73+
});
74+
75+
describe("assertSingleSlot", () => {
76+
it("accepts keys that share one tag", () => {
77+
const k = runBlockKeys("run_abc");
78+
expect(() => assertSingleSlot("runReadBlockState", [k.pend, k.done, k.edge])).not.toThrow();
79+
});
80+
81+
it("accepts a single tagged key", () => {
82+
expect(() => assertSingleSlot("wpIdemReserve", [idempotencyKey("env_1", "k")])).not.toThrow();
83+
});
84+
85+
it("accepts an empty key list", () => {
86+
expect(() => assertSingleSlot("noKeys", [])).not.toThrow();
87+
});
88+
89+
it("rejects keys from two different tags", () => {
90+
const wp = waitpointKeys("w_a");
91+
const run = runBlockKeys("run_abc");
92+
expect(() => assertSingleSlot("bad", [wp.record, run.pend])).toThrow(WaitpointKeyTagError);
93+
});
94+
95+
it("rejects an untagged key", () => {
96+
expect(() => assertSingleSlot("bad", ["wp:no-tag"])).toThrow(WaitpointKeyTagError);
97+
});
98+
99+
it("rejects an empty tag", () => {
100+
expect(() => assertSingleSlot("bad", ["wp:{}"])).toThrow(WaitpointKeyTagError);
101+
});
102+
103+
it("rejects an empty first pair, matching Redis rather than skipping to a later one", () => {
104+
// Redis stops at the first `{`/`}` pair. An empty one means no tag at all, so it hashes
105+
// the whole key. A regex would have found `a` here and wrongly claimed a shared slot.
106+
expect(() => assertSingleSlot("bad", ["wp:{}{a}", "wp:{}{a}"])).toThrow(WaitpointKeyTagError);
107+
});
108+
109+
it("takes the first pair when several are present", () => {
110+
expect(() => assertSingleSlot("ok", ["wp:{a}{b}", "wp:{a}:w"])).not.toThrow();
111+
expect(() => assertSingleSlot("bad", ["wp:{a}{b}", "wp:{b}:w"])).toThrow(WaitpointKeyTagError);
112+
});
113+
114+
it("does not degrade on a key made of many opening braces", () => {
115+
const started = performance.now();
116+
expect(() => assertSingleSlot("bad", ["{".repeat(50_000)])).toThrow(WaitpointKeyTagError);
117+
expect(performance.now() - started).toBeLessThan(1_000);
118+
});
119+
120+
it("names the operation and the offending key in the error", () => {
121+
const wp = waitpointKeys("w_a");
122+
const run = runBlockKeys("run_abc");
123+
try {
124+
assertSingleSlot("myOperation", [wp.record, run.pend]);
125+
throw new Error("should have thrown");
126+
} catch (error) {
127+
expect(error).toBeInstanceOf(WaitpointKeyTagError);
128+
expect((error as Error).message).toContain("myOperation");
129+
expect((error as Error).message).toContain(run.pend);
130+
}
131+
});
132+
});

0 commit comments

Comments
 (0)