Skip to content

Commit 7f9da73

Browse files
committed
test(run-store): pin that a refused carry-forward mints with its records
The base branch gained a refusal path: when the store declines an untrustworthy cycle pointer it mints a replacement inside the same call, from the refs the caller carried. That replacement needs the records too. A cycle holding ids with no records makes the resolver's coverage check reject a legitimate resume, because every distinct id must resolve through exactly one half. Also pins the no-refs case, where writing no pointer at all stays correct.
1 parent efb947e commit 7f9da73

1 file changed

Lines changed: 132 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// A refused carry-forward mints a replacement cycle inside the same call. That replacement must
2+
// carry the records, not just the ids: the resolver's coverage check requires every distinct id to
3+
// resolve through exactly one half, so a cycle holding ids with no records makes a legitimate
4+
// resume fail loud.
5+
import { createRedisClient } from "@internal/redis";
6+
import { redisTest } from "@internal/testcontainers";
7+
import { describe, expect } from "vitest";
8+
import {
9+
RedisSnapshotStore,
10+
type CompletedWaitpointRecord,
11+
type SnapshotEntryInput,
12+
} from "./redisSnapshotStore.js";
13+
14+
function entry(over: Partial<SnapshotEntryInput> = {}): SnapshotEntryInput {
15+
return {
16+
id: "snap_1",
17+
engine: "V2",
18+
executionStatus: "RUN_CREATED",
19+
description: "created",
20+
runId: "run_1",
21+
runStatus: "PENDING",
22+
createdAt: "2026-08-21T00:00:00.000Z",
23+
environmentId: "env_1",
24+
environmentType: "PRODUCTION",
25+
projectId: "proj_1",
26+
organizationId: "org_1",
27+
...over,
28+
};
29+
}
30+
31+
function record(id: string, output: string): CompletedWaitpointRecord {
32+
return {
33+
id,
34+
friendlyId: `waitpoint_${id}`,
35+
type: "MANUAL",
36+
completedAt: "2026-08-25T00:00:00.000Z",
37+
outputType: "application/json",
38+
outputIsError: false,
39+
output: { inline: output },
40+
};
41+
}
42+
43+
async function recordsAt(
44+
raw: ReturnType<typeof createRedisClient>,
45+
cycleSeq: number
46+
): Promise<CompletedWaitpointRecord[] | undefined> {
47+
const stored = await raw.hget(`snap:{run_1}:wp:${cycleSeq}`, "records");
48+
return stored ? (JSON.parse(stored) as CompletedWaitpointRecord[]) : undefined;
49+
}
50+
51+
describe("a refused carry-forward", () => {
52+
redisTest("mints a replacement that carries the records", async ({ redisOptions }) => {
53+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: 60_000 });
54+
const raw = createRedisClient(redisOptions, { onError: () => {} });
55+
try {
56+
await store.append({
57+
entry: entry({ id: "snap_1" }),
58+
kind: "birth",
59+
isTerminal: false,
60+
cycle: {
61+
kind: "new",
62+
completedWaitpoints: [{ id: "w_a", index: 0 }],
63+
records: [record("w_a", "first")],
64+
},
65+
});
66+
67+
// Lose everything except the cycle key, as under maxmemory eviction. The carried pointer is
68+
// now untrustworthy, so the store refuses it.
69+
await raw.del("snap:{run_1}:e", "snap:{run_1}:idx", "snap:{run_1}:cur", "snap:{run_1}:seq");
70+
71+
const carried = await store.append({
72+
entry: entry({ id: "snap_2" }),
73+
kind: "birth",
74+
isTerminal: false,
75+
cycle: {
76+
kind: "carryForward",
77+
cycleSeq: 1,
78+
completedWaitpoints: [{ id: "w_b", index: 0 }],
79+
records: [record("w_b", "second")],
80+
},
81+
});
82+
83+
expect(carried).toMatchObject({ outcome: "written", cycleMismatch: true });
84+
85+
// The replacement holds the CARRIED records, not the dead incarnation's.
86+
const read = await store.getLatest("run_1");
87+
const mintedSeq = read?.cycle?.cycleSeq;
88+
expect(mintedSeq).toBeDefined();
89+
90+
const records = await recordsAt(raw, mintedSeq!);
91+
expect(records).toHaveLength(1);
92+
expect(records?.[0]?.id).toBe("w_b");
93+
expect(records?.[0]?.output).toEqual({ inline: "second" });
94+
} finally {
95+
await Promise.all([store.quit(), raw.quit().catch(() => {})]);
96+
}
97+
});
98+
99+
// Without refs there is nothing to mint from, so the entry is written with no pointer. That is
100+
// the older behaviour and it stays: no pointer is safe, a pointer with no records is not.
101+
redisTest("writes no pointer when the caller carried no refs", async ({ redisOptions }) => {
102+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: 60_000 });
103+
const raw = createRedisClient(redisOptions, { onError: () => {} });
104+
try {
105+
await store.append({
106+
entry: entry({ id: "snap_1" }),
107+
kind: "birth",
108+
isTerminal: false,
109+
cycle: {
110+
kind: "new",
111+
completedWaitpoints: [{ id: "w_a", index: 0 }],
112+
records: [record("w_a", "first")],
113+
},
114+
});
115+
116+
await raw.del("snap:{run_1}:e", "snap:{run_1}:idx", "snap:{run_1}:cur", "snap:{run_1}:seq");
117+
118+
const carried = await store.append({
119+
entry: entry({ id: "snap_2" }),
120+
kind: "birth",
121+
isTerminal: false,
122+
cycle: { kind: "carryForward", cycleSeq: 1 },
123+
});
124+
125+
expect(carried).toMatchObject({ outcome: "written", cycleMismatch: true });
126+
const read = await store.getLatest("run_1");
127+
expect(read?.cycle).toBeUndefined();
128+
} finally {
129+
await Promise.all([store.quit(), raw.quit().catch(() => {})]);
130+
}
131+
});
132+
});

0 commit comments

Comments
 (0)