Skip to content

Commit 482e86e

Browse files
d-csclaude
andcommitted
test(run-engine): round-trip a record set through the cycle key
The envelope build and the resolver were covered separately and the join between them was not. The run-store suite proves records reach the cycle key, then reads them back with a raw probe. The equivalence suite proves the resolver reproduces the existing hydration, from records built by hand. Neither runs write, read and resolve in one pass, so an envelope emitted in a shape the resolver does not expect passes both and fails in neither. This sources envelopes from real waitpoint rows through the arm the engine actually constructs, writes them through the real store, reads the id lists back through the store's own read API, resolves, and compares against enhanceExecutionSnapshotWithWaitpoints over the same rows. Five cases: an inline output, a RUN output the record defers to its run rather than carrying, a mixed legacy and store-format snapshot where each half reads its index from the same order, one waitpoint at two batch indexes, and a member id whose records were lost, which must refuse rather than resume short. The records field is read with a probe because no read API for it exists yet; that hydration belongs to the snapshot-store lane. Everything either side of that read is production code, which the tests pin: emptying the arm's envelope build fails four of the five. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4a67855 commit 482e86e

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
// The seam: a record set written into a real cycle key, read back out, and resolved.
2+
//
3+
// The two halves were covered separately and the join between them was not, so an envelope built
4+
// in a shape the resolver does not expect survives both suites and fails only here. Envelopes come
5+
// from real Postgres rows through the legacy arm; the oracle is the existing hydration over the
6+
// same rows.
7+
//
8+
// The records field is read with a probe because the read API for it does not exist yet -- that
9+
// hydration belongs to the snapshot-store lane. Everything either side of that read is production
10+
// code.
11+
import { createRedisClient } from "@internal/redis";
12+
import { PostgresRunStore, RedisSnapshotStore, type SnapshotEntryInput } from "@internal/run-store";
13+
import { Logger } from "@trigger.dev/core/logger";
14+
import { generateInternalId, generateWaitpointId } from "@trigger.dev/core/v3/isomorphic";
15+
import type { PrismaClient, Waitpoint } from "@trigger.dev/database";
16+
import { containerTest } from "@internal/testcontainers";
17+
import { describe, expect } from "vitest";
18+
import { enhanceExecutionSnapshotWithWaitpoints } from "../systems/executionSnapshotSystem.js";
19+
import { setupAuthenticatedEnvironment } from "../tests/setup.js";
20+
import { buildCompletedWaitpointRecords } from "./completedWaitpointRecords.js";
21+
import {
22+
createCompletedWaitpointResolver,
23+
createRunOutputsReader,
24+
UnresolvableWaitpointId,
25+
} from "./completedWaitpointResolver.js";
26+
import { LegacyPostgresWaitpointCoordinator } from "./legacyPostgresCoordinator.js";
27+
28+
const COMPLETED_TTL_MS = 72 * 60 * 60 * 1000;
29+
30+
type Env = Awaited<ReturnType<typeof setupAuthenticatedEnvironment>>;
31+
32+
function entryFor(runId: string, env: Env): SnapshotEntryInput {
33+
return {
34+
id: generateInternalId(),
35+
engine: "V2",
36+
executionStatus: "EXECUTING_WITH_WAITPOINTS",
37+
description: "Run resumed",
38+
runId,
39+
runStatus: "EXECUTING",
40+
createdAt: new Date().toISOString(),
41+
environmentId: env.id,
42+
environmentType: env.type,
43+
projectId: env.project.id,
44+
organizationId: env.organization.id,
45+
};
46+
}
47+
48+
// `id` omitted gives Prisma's cuid default, i.e. the legacy format; a minted id gives the store
49+
// format. Both halves of a mixed snapshot come from here so they cannot drift apart.
50+
async function seedWaitpoint(
51+
prisma: PrismaClient,
52+
env: Env,
53+
fields: {
54+
id?: string;
55+
output?: string | null;
56+
outputType?: string;
57+
completedByTaskRunId?: string;
58+
}
59+
): Promise<Waitpoint> {
60+
const key = `idem_${generateInternalId().slice(-16)}`;
61+
return prisma.waitpoint.create({
62+
data: {
63+
...(fields.id ? { id: fields.id } : {}),
64+
friendlyId: `waitpoint_${key}`,
65+
type: fields.completedByTaskRunId ? "RUN" : "MANUAL",
66+
status: "COMPLETED",
67+
completedAt: new Date(),
68+
idempotencyKey: key,
69+
userProvidedIdempotencyKey: false,
70+
output: fields.output ?? null,
71+
outputType: fields.outputType ?? "application/json",
72+
...(fields.completedByTaskRunId && { completedByTaskRunId: fields.completedByTaskRunId }),
73+
projectId: env.project.id,
74+
environmentId: env.id,
75+
},
76+
});
77+
}
78+
79+
async function seedCompletedRun(prisma: PrismaClient, env: Env, output: string): Promise<string> {
80+
const suffix = generateInternalId().slice(-12);
81+
const run = await prisma.taskRun.create({
82+
data: {
83+
engine: "V2",
84+
status: "COMPLETED_SUCCESSFULLY",
85+
friendlyId: `run_child${suffix}`,
86+
runtimeEnvironmentId: env.id,
87+
environmentType: env.type,
88+
organizationId: env.organization.id,
89+
projectId: env.project.id,
90+
taskIdentifier: "child-task",
91+
payload: "{}",
92+
payloadType: "application/json",
93+
traceContext: {},
94+
traceId: `trace_${suffix}`,
95+
spanId: `span_${suffix}`,
96+
queue: "task/child-task",
97+
isTest: false,
98+
taskEventStore: "taskEvent",
99+
depth: 1,
100+
output,
101+
outputType: "application/json",
102+
},
103+
select: { id: true },
104+
});
105+
return run.id;
106+
}
107+
108+
type Harness = {
109+
runId: string;
110+
store: RedisSnapshotStore;
111+
probe: ReturnType<typeof createRedisClient>;
112+
coordinator: LegacyPostgresWaitpointCoordinator;
113+
resolve: ReturnType<typeof createCompletedWaitpointResolver>;
114+
runStore: PostgresRunStore;
115+
};
116+
117+
function harness(prisma: PrismaClient, redisOptions: never): Harness {
118+
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
119+
return {
120+
runId: generateInternalId(),
121+
store: new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS }),
122+
probe: createRedisClient(redisOptions, { onError: () => {} }),
123+
coordinator: new LegacyPostgresWaitpointCoordinator({
124+
runStore: runStore as never,
125+
prisma,
126+
logger: new Logger("roundtrip", "error"),
127+
}),
128+
resolve: createCompletedWaitpointResolver({
129+
readRunOutputs: createRunOutputsReader(runStore),
130+
}),
131+
runStore,
132+
};
133+
}
134+
135+
// Envelopes from rows, records into the cycle key, id lists back out through the store's read,
136+
// then resolve. Returns the resolver's answer beside the oracle's for the same rows.
137+
async function roundTrip(
138+
h: Harness,
139+
env: Env,
140+
rows: Waitpoint[],
141+
order: string[],
142+
storeFormatIds: string[]
143+
) {
144+
const refs = rows.map((row) => {
145+
const index = order.indexOf(row.id);
146+
return index === -1 ? { id: row.id } : { id: row.id, index };
147+
});
148+
// Every position, not just the first: a run at two batch indexes must keep both.
149+
const withRepeats = order.flatMap((id, index) =>
150+
refs.some((r) => r.id === id) ? [{ id, index }] : []
151+
);
152+
const completedWaitpoints = [
153+
...withRepeats,
154+
...refs.filter((r) => r.index === undefined).map((r) => ({ id: r.id })),
155+
];
156+
157+
// Production envelope build, over real rows, through the arm the engine actually constructs.
158+
const sources = await h.coordinator.readCompletionEnvelopes({
159+
runId: h.runId,
160+
waitpointIds: storeFormatIds,
161+
});
162+
const records = buildCompletedWaitpointRecords(sources);
163+
164+
const appended = await h.store.append({
165+
entry: entryFor(h.runId, env),
166+
kind: "birth",
167+
isTerminal: false,
168+
cycle: { kind: "new", completedWaitpoints, records },
169+
});
170+
expect(appended.outcome).toBe("written");
171+
172+
// Back out through the store's own read, not through the objects we just wrote.
173+
const read = await h.store.getLatest(h.runId);
174+
expect(read?.cycle).toBeDefined();
175+
expect(read?.danglingCycle).toBeFalsy();
176+
177+
const raw = await h.probe.hget(`snap:{${h.runId}}:wp:${read!.cycle!.cycleSeq}`, "records");
178+
const storedRecords = raw ? JSON.parse(raw) : [];
179+
180+
const legacyIds = rows.map((r) => r.id).filter((id) => !storeFormatIds.includes(id));
181+
182+
const actual = await h.resolve({
183+
runId: h.runId,
184+
pointer: read!.cycle!,
185+
order: read!.completedWaitpointIds?.order ?? [],
186+
distinctIds: read!.completedWaitpointIds?.distinctIds ?? [],
187+
records: storedRecords,
188+
...(legacyIds.length > 0 && { resolvedElsewhere: legacyIds }),
189+
});
190+
191+
const oracle = enhanceExecutionSnapshotWithWaitpoints(
192+
{ id: generateInternalId(), runId: h.runId, batchId: null } as never,
193+
rows,
194+
order
195+
).completedWaitpoints;
196+
197+
const sort = <T extends { id: string; index?: number }>(xs: T[]) =>
198+
[...xs].sort((a, b) => a.id.localeCompare(b.id) || (a.index ?? -1) - (b.index ?? -1));
199+
200+
return {
201+
actual: sort(actual),
202+
// The resolver returns its own half only; the legacy half stays with the caller.
203+
expected: sort(oracle.filter((w) => storeFormatIds.includes(w.id))),
204+
storedRecords,
205+
read,
206+
};
207+
}
208+
209+
describe("a record set round-trips through the cycle key", () => {
210+
containerTest("for an inline MANUAL output", async ({ prisma, redisOptions }) => {
211+
const h = harness(prisma as never, redisOptions as never);
212+
try {
213+
const env = await setupAuthenticatedEnvironment(prisma as never, "PRODUCTION");
214+
const row = await seedWaitpoint(prisma as never, env, {
215+
id: generateWaitpointId("MANUAL"),
216+
output: '{"token":1}',
217+
});
218+
219+
const { actual, expected, storedRecords } = await roundTrip(
220+
h,
221+
env,
222+
[row],
223+
[row.id],
224+
[row.id]
225+
);
226+
227+
expect(storedRecords).toHaveLength(1);
228+
expect(actual).toEqual(expected);
229+
expect(actual[0]?.output).toBe('{"token":1}');
230+
} finally {
231+
await Promise.all([h.store.quit(), h.probe.quit().catch(() => {})]);
232+
}
233+
});
234+
235+
// deriveFromRun is the one place the written record and the resolved entry legitimately
236+
// differ, so it has to survive the real round trip.
237+
containerTest("for a RUN output derived from the run row", async ({ prisma, redisOptions }) => {
238+
const h = harness(prisma as never, redisOptions as never);
239+
try {
240+
const env = await setupAuthenticatedEnvironment(prisma as never, "PRODUCTION");
241+
const childRunId = await seedCompletedRun(prisma as never, env, '{"child":true}');
242+
const row = await seedWaitpoint(prisma as never, env, {
243+
id: generateWaitpointId("RUN"),
244+
output: '{"child":true}',
245+
completedByTaskRunId: childRunId,
246+
});
247+
248+
const { actual, expected, storedRecords } = await roundTrip(
249+
h,
250+
env,
251+
[row],
252+
[row.id],
253+
[row.id]
254+
);
255+
256+
// The record carries a marker, not the value.
257+
expect(storedRecords[0]?.output).toEqual({ deriveFromRun: true });
258+
// And the resolved entry carries the value, byte-identical to the oracle's.
259+
expect(actual).toEqual(expected);
260+
expect(actual[0]?.output).toBe('{"child":true}');
261+
} finally {
262+
await Promise.all([h.store.quit(), h.probe.quit().catch(() => {})]);
263+
}
264+
});
265+
266+
// Both halves read their index from the same order, so the positions agree with no
267+
// coordination between them.
268+
containerTest(
269+
"for a mixed legacy and store-format snapshot",
270+
async ({ prisma, redisOptions }) => {
271+
const h = harness(prisma as never, redisOptions as never);
272+
try {
273+
const env = await setupAuthenticatedEnvironment(prisma as never, "PRODUCTION");
274+
const storeRow = await seedWaitpoint(prisma as never, env, {
275+
id: generateWaitpointId("MANUAL"),
276+
output: '{"store":true}',
277+
});
278+
// No id: Prisma's cuid default, which is the legacy format.
279+
const legacyRow = await seedWaitpoint(prisma as never, env, { output: '{"legacy":true}' });
280+
281+
const order = [legacyRow.id, storeRow.id];
282+
const { actual, expected, read } = await roundTrip(h, env, [storeRow, legacyRow], order, [
283+
storeRow.id,
284+
]);
285+
286+
// The store read carries BOTH ids, because the cycle records the whole membership...
287+
expect(read?.completedWaitpointIds?.order).toEqual(order);
288+
// ...but the resolver answers for its half only, at its real position.
289+
expect(actual).toEqual(expected);
290+
expect(actual).toHaveLength(1);
291+
expect(actual[0]?.id).toBe(storeRow.id);
292+
expect(actual[0]?.index).toBe(1);
293+
} finally {
294+
await Promise.all([h.store.quit(), h.probe.quit().catch(() => {})]);
295+
}
296+
}
297+
);
298+
299+
// Repeats live in the order, never in the record set, so this pins that the round trip does
300+
// not collapse them.
301+
containerTest("for one waitpoint at two batch indexes", async ({ prisma, redisOptions }) => {
302+
const h = harness(prisma as never, redisOptions as never);
303+
try {
304+
const env = await setupAuthenticatedEnvironment(prisma as never, "PRODUCTION");
305+
const row = await seedWaitpoint(prisma as never, env, {
306+
id: generateWaitpointId("RUN"),
307+
output: '{"twice":true}',
308+
});
309+
310+
const { actual, expected, storedRecords } = await roundTrip(
311+
h,
312+
env,
313+
[row],
314+
[row.id, row.id],
315+
[row.id]
316+
);
317+
318+
expect(storedRecords).toHaveLength(1);
319+
expect(actual).toEqual(expected);
320+
expect(actual.map((w) => w.index)).toEqual([0, 1]);
321+
} finally {
322+
await Promise.all([h.store.quit(), h.probe.quit().catch(() => {})]);
323+
}
324+
});
325+
326+
// Losing the records while the id list survives is the shape an eviction leaves behind.
327+
containerTest("and refuses when a member id has no record", async ({ prisma, redisOptions }) => {
328+
const h = harness(prisma as never, redisOptions as never);
329+
try {
330+
const env = await setupAuthenticatedEnvironment(prisma as never, "PRODUCTION");
331+
const row = await seedWaitpoint(prisma as never, env, {
332+
id: generateWaitpointId("MANUAL"),
333+
output: '{"lost":true}',
334+
});
335+
336+
const sources = await h.coordinator.readCompletionEnvelopes({
337+
runId: h.runId,
338+
waitpointIds: [row.id],
339+
});
340+
await h.store.append({
341+
entry: entryFor(h.runId, env),
342+
kind: "birth",
343+
isTerminal: false,
344+
cycle: {
345+
kind: "new",
346+
completedWaitpoints: [{ id: row.id, index: 0 }],
347+
records: buildCompletedWaitpointRecords(sources),
348+
},
349+
});
350+
351+
const read = await h.store.getLatest(h.runId);
352+
// The records field alone is lost; the id list survives.
353+
await h.probe.hdel(`snap:{${h.runId}}:wp:${read!.cycle!.cycleSeq}`, "records");
354+
355+
const failure = await h
356+
.resolve({
357+
runId: h.runId,
358+
pointer: read!.cycle!,
359+
order: read!.completedWaitpointIds?.order ?? [],
360+
distinctIds: read!.completedWaitpointIds?.distinctIds ?? [],
361+
records: [],
362+
})
363+
.catch((caught: unknown) => caught as UnresolvableWaitpointId);
364+
365+
expect(failure).toBeInstanceOf(UnresolvableWaitpointId);
366+
expect(failure.waitpointId).toBe(row.id);
367+
expect(failure.reason).toBe("no-source");
368+
} finally {
369+
await Promise.all([h.store.quit(), h.probe.quit().catch(() => {})]);
370+
}
371+
});
372+
});

0 commit comments

Comments
 (0)