Skip to content

Commit fd52adc

Browse files
committed
feat(run-engine): add the store arm of the waitpoint coordinator
Implements the coordinator seam against the Redis store, so waitpoint state can live there instead of Postgres. Unreachable until a mint routes to it. Three rules carry the correctness weight: An edge that is in neither the run's pending nor its delivered set reports PENDING and increments a counter. The store keeps every edge in exactly one of those sets, so being in neither means the run shard lost state. Reading that as "not pending, therefore complete" would resume a run whose waitpoint never completed. Note this is deliberately not a rule about completion envelopes: a waitpoint can be COMPLETED carrying none, and treating that as unresolved would block a healthy run forever. A lockless absorb refuses to write item edges unless the parent's BATCH waitpoint is present and still pending. Absorbing items without the run lock is only safe while that waitpoint holds the pending set open, otherwise a concurrent completion can see an empty set mid-absorb and resume the parent early. The MANUAL projection row is written after the store commit and never read back for coordination. A failed projection write is logged and counted rather than thrown: the waitpoint already exists and is already coordinating, so failing the create would report failure for work that succeeded. Also adds a single-key record read to the store client. The seam returns the Postgres row shape and only the immutable record carries the columns that shape needs.
1 parent 61862a1 commit fd52adc

3 files changed

Lines changed: 920 additions & 0 deletions

File tree

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
import { createRedisClient, type RedisOptions } from "@internal/redis";
2+
import { containerTest } from "@internal/testcontainers";
3+
import { getMeter } from "@internal/tracing";
4+
import { Logger } from "@trigger.dev/core/logger";
5+
import { generateRunOpsId, generateWaitpointId } from "@trigger.dev/core/v3/isomorphic";
6+
import type { PrismaClient } from "@trigger.dev/database";
7+
import { describe, expect } from "vitest";
8+
import { PostgresRunStore } from "@internal/run-store";
9+
import { setupAuthenticatedEnvironment } from "../tests/setup.js";
10+
import { runBlockKeys } from "./keys.js";
11+
import { StoreWaitpointCoordinatorArm } from "./storeArm.js";
12+
import { WaitpointStoreCoordinator, type WaitpointRecordInput } from "./storeCoordinator.js";
13+
14+
const RUN_ID = "run_blocked";
15+
const NOW = "2026-08-26T12:00:00.000Z";
16+
17+
function setup(redisOptions: RedisOptions, prisma: PrismaClient) {
18+
const store = new WaitpointStoreCoordinator({ redisOptions });
19+
const arm = new StoreWaitpointCoordinatorArm({
20+
store,
21+
runStore: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }),
22+
logger: new Logger("storeArm.test", "error"),
23+
meter: getMeter("storeArm.test"),
24+
});
25+
26+
return { store, arm };
27+
}
28+
29+
function record(
30+
id: string,
31+
environmentId: string,
32+
projectId: string,
33+
overrides: Partial<WaitpointRecordInput> = {}
34+
): WaitpointRecordInput {
35+
return {
36+
id,
37+
friendlyId: `waitpoint_${id}`,
38+
type: "MANUAL",
39+
environmentId,
40+
projectId,
41+
createdAt: NOW,
42+
updatedAt: NOW,
43+
userProvidedIdempotencyKey: false,
44+
tags: [],
45+
idempotencyKey: `idem_${id}`,
46+
...overrides,
47+
};
48+
}
49+
50+
describe("StoreWaitpointCoordinatorArm", () => {
51+
containerTest(
52+
"reports COMPLETED once a blocked waitpoint is delivered",
53+
async ({ prisma, redisOptions }) => {
54+
const environment = await setupEnvironment(prisma);
55+
const { store, arm } = setup(redisOptions, prisma);
56+
57+
try {
58+
const waitpointId = generateWaitpointId("MANUAL");
59+
await store.createIfAbsent({
60+
record: record(waitpointId, environment.id, environment.projectId),
61+
status: "PENDING",
62+
});
63+
64+
const { pendingCount } = await arm.registerBlocks({
65+
runId: RUN_ID,
66+
waitpointIds: [waitpointId],
67+
projectId: environment.projectId,
68+
client: prisma,
69+
});
70+
expect(pendingCount).toBe(1);
71+
72+
const beforeComplete = await arm.readRunBlockState(RUN_ID);
73+
expect(beforeComplete[0]!.waitpoint.status).toBe("PENDING");
74+
75+
await arm.complete({ waitpointId, output: { value: "42", isError: false } });
76+
77+
const afterComplete = await arm.readRunBlockState(RUN_ID);
78+
expect(afterComplete).toHaveLength(1);
79+
expect(afterComplete[0]!.waitpoint.status).toBe("COMPLETED");
80+
expect(afterComplete[0]!.waitpoint.type).toBe("MANUAL");
81+
} finally {
82+
await store.quit();
83+
}
84+
}
85+
);
86+
87+
// I10, and the only premature-resume counterexample either TLA+ campaign produced. A
88+
// run-shard loss removes the pending entry while the edge survives; "not pending,
89+
// therefore complete" would resume a run whose waitpoint never completed.
90+
containerTest(
91+
"reports PENDING for an edge that is in neither the pending nor the delivered set",
92+
async ({ prisma, redisOptions }) => {
93+
const environment = await setupEnvironment(prisma);
94+
const { store, arm } = setup(redisOptions, prisma);
95+
const redis = createRedisClient(redisOptions);
96+
97+
try {
98+
const waitpointId = generateWaitpointId("MANUAL");
99+
await store.createIfAbsent({
100+
record: record(waitpointId, environment.id, environment.projectId),
101+
status: "PENDING",
102+
});
103+
await arm.registerBlocks({
104+
runId: RUN_ID,
105+
waitpointIds: [waitpointId],
106+
projectId: environment.projectId,
107+
client: prisma,
108+
});
109+
110+
await redis.srem(runBlockKeys(RUN_ID).pend, waitpointId);
111+
112+
const edges = await arm.readRunBlockState(RUN_ID);
113+
expect(edges).toHaveLength(1);
114+
expect(edges[0]!.waitpoint.status).toBe("PENDING");
115+
} finally {
116+
await redis.quit();
117+
await store.quit();
118+
}
119+
}
120+
);
121+
122+
// The case a "has a completion envelope" rule would wedge forever: a waitpoint may be
123+
// COMPLETED with no envelope, which the reported box models on purpose.
124+
containerTest(
125+
"reports COMPLETED for a waitpoint completed before the run ever blocked on it",
126+
async ({ prisma, redisOptions }) => {
127+
const environment = await setupEnvironment(prisma);
128+
const { store, arm } = setup(redisOptions, prisma);
129+
130+
try {
131+
const waitpointId = generateWaitpointId("MANUAL");
132+
await store.createIfAbsent({
133+
record: record(waitpointId, environment.id, environment.projectId),
134+
status: "COMPLETED",
135+
});
136+
137+
const { pendingCount } = await arm.registerBlocks({
138+
runId: RUN_ID,
139+
waitpointIds: [waitpointId],
140+
projectId: environment.projectId,
141+
client: prisma,
142+
});
143+
144+
expect(pendingCount).toBe(0);
145+
146+
const edges = await arm.readRunBlockState(RUN_ID);
147+
expect(edges[0]!.waitpoint.status).toBe("COMPLETED");
148+
} finally {
149+
await store.quit();
150+
}
151+
}
152+
);
153+
154+
// §5.4's guard. Unmodeled in both campaigns, so this assertion is its only protection.
155+
containerTest(
156+
"refuses a lockless absorb when the parent BATCH waitpoint is absent",
157+
async ({ prisma, redisOptions }) => {
158+
const environment = await setupEnvironment(prisma);
159+
const { store, arm } = setup(redisOptions, prisma);
160+
161+
try {
162+
const itemWaitpointId = generateWaitpointId("RUN");
163+
const batchWaitpointId = generateWaitpointId("BATCH");
164+
165+
await expect(
166+
arm.registerBlocksLockless({
167+
runId: RUN_ID,
168+
waitpointIds: [itemWaitpointId],
169+
projectId: environment.projectId,
170+
batchId: "batch_1",
171+
batchIndex: 0,
172+
batchWaitpointId,
173+
})
174+
).rejects.toThrow(/BATCH waitpoint/);
175+
} finally {
176+
await store.quit();
177+
}
178+
}
179+
);
180+
181+
// Present-but-not-pending is the half of the guard a presence-only check would miss.
182+
containerTest(
183+
"refuses a lockless absorb when the parent BATCH waitpoint is already complete",
184+
async ({ prisma, redisOptions }) => {
185+
const environment = await setupEnvironment(prisma);
186+
const { store, arm } = setup(redisOptions, prisma);
187+
188+
try {
189+
const batchWaitpointId = generateWaitpointId("BATCH");
190+
await store.createIfAbsent({
191+
record: record(batchWaitpointId, environment.id, environment.projectId, {
192+
type: "BATCH",
193+
}),
194+
status: "PENDING",
195+
});
196+
await arm.registerBlocks({
197+
runId: RUN_ID,
198+
waitpointIds: [batchWaitpointId],
199+
projectId: environment.projectId,
200+
client: prisma,
201+
});
202+
await arm.complete({ waitpointId: batchWaitpointId, output: undefined });
203+
204+
await expect(
205+
arm.registerBlocksLockless({
206+
runId: RUN_ID,
207+
waitpointIds: [generateWaitpointId("RUN")],
208+
projectId: environment.projectId,
209+
batchId: "batch_1",
210+
batchIndex: 0,
211+
batchWaitpointId,
212+
})
213+
).rejects.toThrow(/BATCH waitpoint/);
214+
} finally {
215+
await store.quit();
216+
}
217+
}
218+
);
219+
220+
containerTest(
221+
"allows a lockless absorb while the parent BATCH waitpoint is pending",
222+
async ({ prisma, redisOptions }) => {
223+
const environment = await setupEnvironment(prisma);
224+
const { store, arm } = setup(redisOptions, prisma);
225+
226+
try {
227+
const batchWaitpointId = generateWaitpointId("BATCH");
228+
await store.createIfAbsent({
229+
record: record(batchWaitpointId, environment.id, environment.projectId, {
230+
type: "BATCH",
231+
}),
232+
status: "PENDING",
233+
});
234+
await arm.registerBlocks({
235+
runId: RUN_ID,
236+
waitpointIds: [batchWaitpointId],
237+
projectId: environment.projectId,
238+
client: prisma,
239+
});
240+
241+
const itemWaitpointId = generateWaitpointId("RUN");
242+
await store.createIfAbsent({
243+
record: record(itemWaitpointId, environment.id, environment.projectId, { type: "RUN" }),
244+
status: "PENDING",
245+
});
246+
247+
await arm.registerBlocksLockless({
248+
runId: RUN_ID,
249+
waitpointIds: [itemWaitpointId],
250+
projectId: environment.projectId,
251+
batchId: "batch_1",
252+
batchIndex: 0,
253+
batchWaitpointId,
254+
});
255+
256+
// The parent's BATCH waitpoint is still pending after the item absorbed, which is
257+
// the invariant: the pending set is never momentarily empty mid-absorb.
258+
const edges = await arm.readRunBlockState(RUN_ID);
259+
const stillPending = edges.filter((e) => e.waitpoint.status === "PENDING");
260+
expect(stillPending.map((e) => e.waitpoint.id).sort()).toEqual(
261+
[batchWaitpointId, itemWaitpointId].sort()
262+
);
263+
} finally {
264+
await store.quit();
265+
}
266+
}
267+
);
268+
269+
containerTest(
270+
"writes the MANUAL projection row after the store commit",
271+
async ({ prisma, redisOptions }) => {
272+
const environment = await setupEnvironment(prisma);
273+
const { store, arm } = setup(redisOptions, prisma);
274+
275+
try {
276+
const result = await arm.createManualWaitpoint({
277+
mintKind: "store",
278+
environmentId: environment.id,
279+
projectId: environment.projectId,
280+
tags: ["alpha"],
281+
});
282+
283+
expect(result.kind).toBe("created");
284+
285+
const row = await prisma.waitpoint.findFirst({ where: { id: result.waitpoint.id } });
286+
expect(row?.type).toBe("MANUAL");
287+
expect(row?.tags).toEqual(["alpha"]);
288+
289+
// The store is the system of record; the row is a projection of it.
290+
const held = await store.readWaitpoint(result.waitpoint.id);
291+
expect(held?.status).toBe("PENDING");
292+
} finally {
293+
await store.quit();
294+
}
295+
}
296+
);
297+
298+
containerTest(
299+
"returns the cached waitpoint for a repeated idempotency key",
300+
async ({ prisma, redisOptions }) => {
301+
const environment = await setupEnvironment(prisma);
302+
const { store, arm } = setup(redisOptions, prisma);
303+
304+
try {
305+
const args = {
306+
mintKind: "store" as const,
307+
environmentId: environment.id,
308+
projectId: environment.projectId,
309+
idempotencyKey: "same-key",
310+
};
311+
312+
const first = await arm.createManualWaitpoint(args);
313+
const second = await arm.createManualWaitpoint(args);
314+
315+
expect(first.kind).toBe("created");
316+
expect(second.kind).toBe("cached");
317+
expect(second.waitpoint.id).toBe(first.waitpoint.id);
318+
} finally {
319+
await store.quit();
320+
}
321+
}
322+
);
323+
324+
containerTest(
325+
"returns null when the batch already has a waitpoint",
326+
async ({ prisma, redisOptions }) => {
327+
const environment = await setupEnvironment(prisma);
328+
const { store, arm } = setup(redisOptions, prisma);
329+
330+
try {
331+
const batchId = `batch_${generateRunOpsId()}`;
332+
const args = {
333+
batchId,
334+
environmentId: environment.id,
335+
projectId: environment.projectId,
336+
mintKind: "store" as const,
337+
};
338+
339+
const first = await arm.createBatchWaitpoint(args);
340+
expect(first).not.toBeNull();
341+
expect(first!.type).toBe("BATCH");
342+
expect(first!.completedByBatchId).toBe(batchId);
343+
344+
const second = await arm.createBatchWaitpoint(args);
345+
expect(second).toBeNull();
346+
} finally {
347+
await store.quit();
348+
}
349+
}
350+
);
351+
352+
containerTest(
353+
"creates the RUN waitpoint at the anchor-derived id, idempotently",
354+
async ({ prisma, redisOptions }) => {
355+
const environment = await setupEnvironment(prisma);
356+
const { store, arm } = setup(redisOptions, prisma);
357+
358+
try {
359+
const runId = generateRunOpsId();
360+
const data = arm.mintAssociatedWaitpointData({
361+
projectId: environment.projectId,
362+
environmentId: environment.id,
363+
anchorRunId: runId,
364+
});
365+
366+
// Pure function of the run id, which is what removes the need for a lock.
367+
expect(data.id.slice(0, 24)).toBe(runId.slice(0, 24));
368+
369+
const first = await arm.createAssociatedWaitpoint({ runId, data });
370+
const second = await arm.createAssociatedWaitpoint({ runId, data });
371+
372+
expect(first.id).toBe(data.id);
373+
expect(second.id).toBe(data.id);
374+
expect(second.status).toBe("PENDING");
375+
} finally {
376+
await store.quit();
377+
}
378+
}
379+
);
380+
});
381+
382+
async function setupEnvironment(prisma: PrismaClient) {
383+
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
384+
return { id: environment.id, projectId: environment.project.id };
385+
}

0 commit comments

Comments
 (0)