Skip to content

Commit b430abd

Browse files
committed
test(run-store,run-engine): enumerate the parity input space, refuse an unminted cycle
Replaces hand-picked parity sampling with an exhaustive grid over every Waitpoint column the enhance oracle reads: 6144 combinations in ~35ms, compared with isDeepStrictEqual so key presence is checked too. The combination count is pinned, so a new column the oracle reads fails the assertion instead of silently shrinking coverage. Reverting the orphaned-RUN guard makes the grid report 192 divergences. A carryForward now attaches a pointer only if this incarnation actually minted the cycle. seq can be evicted while a wp:<n> key survives, and a bare key-exists check adopted a dead incarnation's order and records under a count that agreed with them, reporting no mismatch. Also drops three tests that could not fail: two asserted a literal equalled its own construction, and one compared two structurally empty arrays.
1 parent a2451f3 commit b430abd

3 files changed

Lines changed: 201 additions & 25 deletions

File tree

internal-packages/run-engine/src/engine/systems/completedWaitpointFreeze.test.ts

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
// records, and asserts the two agree field for field. The waitpoint lane owns the
44
// production resolver; this reference exists so the frozen shapes are checked rather
55
// than asserted.
6+
import { isDeepStrictEqual } from "node:util";
67
import { describe, expect, it } from "vitest";
78
import type { Waitpoint } from "@trigger.dev/database";
89
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
910
import type { CompletedWaitpoint } from "@trigger.dev/core/v3";
1011
import type {
1112
CompletedWaitpointRecord,
1213
CompletedWaitpointResolver,
13-
CompletedWaitpointsPointer,
1414
ResolveCompletedWaitpointsArgs,
1515
} from "@internal/run-store";
1616
import { enhanceExecutionSnapshotWithWaitpoints } from "./executionSnapshotSystem.js";
@@ -166,9 +166,8 @@ async function assertParity(
166166
order,
167167
records: waitpoints.map(toRecord),
168168
};
169-
// The frozen rule: count is order.length, NOT the record count. Binding it here means every
170-
// parity case enforces it, not only the dedicated "the frozen pointer shape" cases.
171-
expect(args.pointer.count).toBe(order.length);
169+
// count-carried-forward behaviour (order.length, not the record count) is covered by
170+
// the run-store Redis suite, not here -- this line only constructs `args`, not asserts.
172171
const resolved = await referenceResolver(args, async (id) => runOutputs[id]);
173172
expect(resolved).toEqual(enhanced.completedWaitpoints);
174173
return { enhanced, resolved };
@@ -311,19 +310,8 @@ describe("the frozen record shape", () => {
311310
});
312311
});
313312

314-
describe("the frozen pointer shape", () => {
315-
it("pins count to order.length, not the record count", () => {
316-
const order = ["wp_a", "wp_b", "wp_a"];
317-
const pointer: CompletedWaitpointsPointer = { cycleSeq: 7, count: order.length };
318-
expect(pointer).toEqual({ cycleSeq: 7, count: 3 });
319-
});
320-
321-
it("pins count at 0 when order is empty, even if records exist", () => {
322-
const order: string[] = [];
323-
const pointer: CompletedWaitpointsPointer = { cycleSeq: 7, count: order.length };
324-
expect(pointer).toEqual({ cycleSeq: 7, count: 0 });
325-
});
326-
});
313+
// The pointer's shape is pinned by CompletedWaitpointsPointer and tsconfig.freeze-test.json,
314+
// not by a runtime assertion here -- a value that only echoes its own construction can't fail.
327315

328316
describe("the completed-waitpoints freeze", () => {
329317
it("expands a repeated id at each of its positions", async () => {
@@ -379,11 +367,6 @@ describe("the completed-waitpoints freeze", () => {
379367
expect(resolved[0]!.output).toBe('{"type":"STRING_ERROR"}');
380368
});
381369

382-
it("returns an empty list for no waitpoints", async () => {
383-
const { resolved } = await assertParity([], [], "batch_1");
384-
expect(resolved).toEqual([]);
385-
});
386-
387370
it("resolves through the frozen hook signature", async () => {
388371
// Exercises resolverUnderTest, so the declared CompletedWaitpointResolver type is
389372
// proved implementable at runtime, on top of the compile-time proof at its
@@ -496,6 +479,144 @@ describe("the completed-waitpoints freeze", () => {
496479
});
497480
});
498481

482+
describe("the exhaustive parity grid", () => {
483+
// Dimensions mirror every Waitpoint column the oracle reads (type, output, outputType,
484+
// outputIsError, completedByTaskRunId, completedByBatchId, completedAfter,
485+
// userProvidedIdempotencyKey, inactiveIdempotencyKey), plus order-membership and the
486+
// reading entry's batchId. A new column the oracle reads must widen a dimension here,
487+
// so the pinned combination count below fails instead of coverage silently shrinking.
488+
const TYPES: Waitpoint["type"][] = ["RUN", "BATCH", "DATETIME", "MANUAL"];
489+
const OUTPUTS: (string | null)[] = [null, '{"value":42}'];
490+
const OUTPUT_TYPES = ["application/json", "application/store"];
491+
const OUTPUT_IS_ERRORS = [false, true];
492+
const TASK_RUN_IDS: (string | null)[] = [null, "run_child"];
493+
const BATCH_IDS: (string | null)[] = [null, "batch_child"];
494+
const COMPLETED_AFTERS: (Date | null)[] = [null, new Date("2026-02-02T00:00:00.000Z")];
495+
const IDEMPOTENCY_COMBOS: Array<[boolean, string | null]> = [
496+
[false, null],
497+
[false, "cleared"],
498+
[true, null],
499+
[true, "cleared"],
500+
];
501+
const ORDER_MEMBERSHIPS = ["absent", "once", "twice"] as const;
502+
const READING_BATCH_IDS: (string | null)[] = [null, "batch_reading_entry"];
503+
504+
// Only reached when type is RUN, output is set, outputIsError is false, and
505+
// completedByTaskRunId is "run_child": the deriveFromRun branch. The value matches
506+
// OUTPUTS' non-null entry so a correct resolver is byte-identical to the oracle.
507+
const RUN_OUTPUT_LOOKUP: Record<string, string> = { run_child: '{"value":42}' };
508+
509+
it("agrees with the oracle across every combination", async () => {
510+
type Combo = {
511+
type: Waitpoint["type"];
512+
output: string | null;
513+
outputType: string;
514+
outputIsError: boolean;
515+
completedByTaskRunId: string | null;
516+
completedByBatchId: string | null;
517+
completedAfter: Date | null;
518+
userProvidedIdempotencyKey: boolean;
519+
inactiveIdempotencyKey: string | null;
520+
orderMembership: (typeof ORDER_MEMBERSHIPS)[number];
521+
readingBatchId: string | null;
522+
};
523+
const failures: Array<{ combo: Combo; oracle: unknown; resolver: unknown }> = [];
524+
let cases = 0;
525+
526+
for (const type of TYPES) {
527+
for (const output of OUTPUTS) {
528+
for (const outputType of OUTPUT_TYPES) {
529+
for (const outputIsError of OUTPUT_IS_ERRORS) {
530+
for (const completedByTaskRunId of TASK_RUN_IDS) {
531+
for (const completedByBatchId of BATCH_IDS) {
532+
for (const completedAfter of COMPLETED_AFTERS) {
533+
for (const [
534+
userProvidedIdempotencyKey,
535+
inactiveIdempotencyKey,
536+
] of IDEMPOTENCY_COMBOS) {
537+
for (const orderMembership of ORDER_MEMBERSHIPS) {
538+
for (const readingBatchId of READING_BATCH_IDS) {
539+
cases++;
540+
const combo: Combo = {
541+
type,
542+
output,
543+
outputType,
544+
outputIsError,
545+
completedByTaskRunId,
546+
completedByBatchId,
547+
completedAfter,
548+
userProvidedIdempotencyKey,
549+
inactiveIdempotencyKey,
550+
orderMembership,
551+
readingBatchId,
552+
};
553+
554+
const id = "wp_grid";
555+
const w = makeWaitpoint({
556+
id,
557+
type,
558+
output,
559+
outputType,
560+
outputIsError,
561+
completedByTaskRunId,
562+
completedByBatchId,
563+
completedAfter,
564+
idempotencyKey: "idem_user",
565+
userProvidedIdempotencyKey,
566+
inactiveIdempotencyKey,
567+
});
568+
const order =
569+
orderMembership === "absent"
570+
? ["wp_other"]
571+
: orderMembership === "once"
572+
? [id]
573+
: [id, id];
574+
575+
const enhanced = enhanceExecutionSnapshotWithWaitpoints(
576+
makeSnapshot(readingBatchId),
577+
[w],
578+
order
579+
);
580+
const args: ResolveCompletedWaitpointsArgs = {
581+
runId: "run_1",
582+
batchId: readingBatchId ?? undefined,
583+
pointer: { cycleSeq: 1, count: order.length },
584+
order,
585+
records: [toRecord(w)],
586+
};
587+
const resolved = await referenceResolver(
588+
args,
589+
async (runId) => RUN_OUTPUT_LOOKUP[runId]
590+
);
591+
592+
if (!isDeepStrictEqual(resolved, enhanced.completedWaitpoints)) {
593+
failures.push({
594+
combo,
595+
oracle: enhanced.completedWaitpoints,
596+
resolver: resolved,
597+
});
598+
}
599+
}
600+
}
601+
}
602+
}
603+
}
604+
}
605+
}
606+
}
607+
}
608+
}
609+
610+
expect(cases).toBe(6144);
611+
expect(
612+
failures.length,
613+
failures.length > 0
614+
? `${failures.length}/${cases} combinations diverged. First: ${JSON.stringify(failures[0], null, 2)}`
615+
: undefined
616+
).toBe(0);
617+
});
618+
});
619+
499620
describe("the freeze's two deliberate divergences", () => {
500621
it("pins completedAt at write time, where the oracle samples the clock", () => {
501622
// The oracle applies `w.completedAt ?? new Date()`, so a null value changes on every

internal-packages/run-store/src/redisSnapshotStore.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,57 @@ describe("append", () => {
326326
}
327327
);
328328

329+
redisTest(
330+
"a carry-forward refuses a cycle this incarnation never minted",
331+
async ({ redisOptions }) => {
332+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: 60_000 });
333+
const raw = createRedisClient(redisOptions);
334+
try {
335+
await store.append({
336+
entry: entry({ id: "snap_1" }),
337+
kind: "birth",
338+
isTerminal: false,
339+
cycle: {
340+
kind: "new",
341+
completedWaitpoints: [{ id: "w_old", index: 0 }],
342+
records: [
343+
{
344+
id: "w_old",
345+
friendlyId: "waitpoint_old",
346+
type: "MANUAL",
347+
completedAt: "2026-01-01T00:00:00.000Z",
348+
outputType: "application/json",
349+
outputIsError: false,
350+
output: { inline: "stale" },
351+
},
352+
],
353+
},
354+
});
355+
356+
// Lose the whole keyspace except the cycle key, as under maxmemory eviction.
357+
await raw.del("snap:{run_1}:e", "snap:{run_1}:idx", "snap:{run_1}:cur", "snap:{run_1}:seq");
358+
359+
const carried = await store.append({
360+
entry: entry({ id: "snap_2" }),
361+
kind: "birth",
362+
isTerminal: false,
363+
cycle: { kind: "carryForward", cycleSeq: 1 },
364+
});
365+
366+
// Written, flagged, and carrying NO pointer: the dead incarnation's waitpoints must not
367+
// be served to a fresh run under a count that agrees with them.
368+
expect(carried).toMatchObject({ outcome: "written", cycleMismatch: true });
369+
expect(carried).not.toHaveProperty("cycleSeq");
370+
const read = await store.getLatest("run_1");
371+
expect(read?.cycle).toBeUndefined();
372+
expect(read?.completedWaitpointIds).toBeUndefined();
373+
} finally {
374+
raw.disconnect();
375+
await store.quit();
376+
}
377+
}
378+
);
379+
329380
redisTest(
330381
"reports a duplicate id without overwriting the original entry",
331382
async ({ redisOptions }) => {

internal-packages/run-store/src/redisSnapshotStore.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -578,11 +578,15 @@ export class RedisSnapshotStore {
578578
redis.call('HDEL', wpKey(cycleSeq), 'records')
579579
end
580580
elseif cycleMode == 'carry' then
581-
cycleSeq = cycleSeqIn
582-
local c = redis.call('HGET', wpKey(cycleSeq), 'count')
583-
if not c then
581+
-- Attach a pointer only if this incarnation actually minted the cycle. seq can be
582+
-- evicted while a wp:<n> key survives, so a bare key-exists check would adopt a dead
583+
-- incarnation's order and records under a consistent count, invisibly.
584+
local minted = tonumber(redis.call('HGET', seqKey, 'c') or '0')
585+
local c = redis.call('HGET', wpKey(cycleSeqIn), 'count')
586+
if not c or minted < cycleSeqIn then
584587
mismatch = 1
585588
else
589+
cycleSeq = cycleSeqIn
586590
orderCount = c
587591
end
588592
end

0 commit comments

Comments
 (0)