Skip to content

Commit f10572e

Browse files
committed
test(run-store): container-level proof of the snapshot repair against the real append script
UNRUN. Docker on this machine is saturated by another workspace (222 running containers), so these four cases were written and typechecked but not executed. They are the only place the append script's duplicate and no-keyspace guards are exercised by the repair rather than asserted about it.
1 parent 721d31d commit f10572e

1 file changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// The whole loss-and-recovery cycle against a real Postgres and a real Redis: an injected fault
2+
// models the process dying between the two writes, and the repair is then asked to close the gap it
3+
// left. Only the append script can prove the two guards the repair leans on, so the sibling
4+
// container-free suite covers the decisions and this one covers the guards.
5+
import { describe, expect } from "vitest";
6+
import { containerTest } from "@internal/testcontainers";
7+
import { generateInternalId } from "@trigger.dev/core/v3/isomorphic";
8+
import { PostgresRunStore } from "./PostgresRunStore.js";
9+
import { RedisSnapshotStore } from "./redisSnapshotStore.js";
10+
import { entryFromCreateRun } from "./snapshotEntry.js";
11+
import { InjectedSnapshotFault } from "./snapshotFaultInjection.js";
12+
import { TaskRunExecutionSnapshotStore } from "./taskRunExecutionSnapshotStore.js";
13+
import type { RunStore } from "./types.js";
14+
import {
15+
buildCreateRunData,
16+
seedSnapshotEnvironment,
17+
type SnapshotFixtureEnv,
18+
} from "./testFixtures/snapshotIdFixture.js";
19+
20+
const COMPLETED_TTL_MS = 72 * 60 * 60 * 1000;
21+
22+
function harness(
23+
prisma: never,
24+
redisOptions: never,
25+
opts?: { faults?: ConstructorParameters<typeof TaskRunExecutionSnapshotStore>[1]["faults"] }
26+
) {
27+
const redis = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
28+
const repairs: { runId: string; snapshotId: string; executionStatus: string }[] = [];
29+
30+
const decorated = new TaskRunExecutionSnapshotStore(
31+
new PostgresRunStore({ prisma, readOnlyPrisma: prisma }) as unknown as RunStore,
32+
{
33+
store: redis,
34+
mode: "redis-read",
35+
readPercent: 100,
36+
...(opts?.faults && { faults: opts.faults }),
37+
onAppendFailure: async (args) => {
38+
repairs.push(args);
39+
},
40+
}
41+
);
42+
43+
return { decorated, redis, repairs };
44+
}
45+
46+
async function seedBirth(
47+
decorated: TaskRunExecutionSnapshotStore,
48+
redis: RedisSnapshotStore,
49+
runId: string,
50+
env: SnapshotFixtureEnv
51+
): Promise<void> {
52+
const snapshot = {
53+
id: generateInternalId(),
54+
engine: "V2" as const,
55+
executionStatus: "RUN_CREATED" as const,
56+
description: "Run was created",
57+
runStatus: "PENDING" as const,
58+
environmentId: env.id,
59+
environmentType: env.type,
60+
projectId: env.projectId,
61+
organizationId: env.organizationId,
62+
};
63+
64+
await redis.append({
65+
entry: entryFromCreateRun({ id: snapshot.id, runId, createdAt: new Date() }, snapshot),
66+
kind: "birth",
67+
isTerminal: false,
68+
});
69+
70+
await decorated.createRun({ data: buildCreateRunData(runId, env), snapshot });
71+
}
72+
73+
function executingSnapshot(runId: string, env: SnapshotFixtureEnv, previousSnapshotId?: string) {
74+
return {
75+
id: generateInternalId(),
76+
run: { id: runId, status: "EXECUTING" as const, attemptNumber: 1 },
77+
snapshot: { executionStatus: "EXECUTING" as const, description: "Run is executing" },
78+
environmentId: env.id,
79+
environmentType: env.type,
80+
projectId: env.projectId,
81+
organizationId: env.organizationId,
82+
...(previousSnapshotId && { previousSnapshotId }),
83+
};
84+
}
85+
86+
describe("snapshot repair end to end", () => {
87+
containerTest(
88+
"re-appends an EXECUTING snapshot whose append the process died before making",
89+
async ({ prisma, redisOptions }) => {
90+
let dropNext = true;
91+
const { decorated, redis, repairs } = harness(prisma as never, redisOptions as never, {
92+
faults: (boundary) => {
93+
if (boundary === "afterPgBeforeRedis" && dropNext) {
94+
dropNext = false;
95+
throw new InjectedSnapshotFault(boundary);
96+
}
97+
},
98+
});
99+
100+
try {
101+
const env = await seedSnapshotEnvironment(prisma);
102+
const runId = generateInternalId();
103+
await seedBirth(decorated, redis, runId, env);
104+
105+
const created = await decorated.createExecutionSnapshot(executingSnapshot(runId, env));
106+
107+
expect(repairs).toEqual([{ runId, snapshotId: created.id, executionStatus: "EXECUTING" }]);
108+
expect(await redis.getById(runId, created.id)).toBeNull();
109+
110+
await expect(decorated.repairRedisHead(runId, created.id)).resolves.toBe("reappended");
111+
112+
const read = await redis.getLatest(runId);
113+
expect(read!.id).toBe(created.id);
114+
expect(read!.entry["executionStatus"]).toBe("EXECUTING");
115+
expect(read!.entry["createdAt"]).toBe(created.createdAt.toISOString());
116+
} finally {
117+
await redis.quit();
118+
}
119+
}
120+
);
121+
122+
containerTest(
123+
"is safe to run twice: the second attempt adds no second entry",
124+
async ({ prisma, redisOptions }) => {
125+
let dropNext = true;
126+
const { decorated, redis } = harness(prisma as never, redisOptions as never, {
127+
faults: (boundary) => {
128+
if (boundary === "afterPgBeforeRedis" && dropNext) {
129+
dropNext = false;
130+
throw new InjectedSnapshotFault(boundary);
131+
}
132+
},
133+
});
134+
135+
try {
136+
const env = await seedSnapshotEnvironment(prisma);
137+
const runId = generateInternalId();
138+
await seedBirth(decorated, redis, runId, env);
139+
140+
const created = await decorated.createExecutionSnapshot(executingSnapshot(runId, env));
141+
142+
await expect(decorated.repairRedisHead(runId, created.id)).resolves.toBe("reappended");
143+
const first = await redis.getLatest(runId);
144+
145+
await expect(decorated.repairRedisHead(runId, created.id)).resolves.toBe("alreadyCurrent");
146+
const second = await redis.getLatest(runId);
147+
148+
expect(second!.seq).toBe(first!.seq);
149+
} finally {
150+
await redis.quit();
151+
}
152+
}
153+
);
154+
155+
containerTest(
156+
"does not resurrect a run that was never resident in Redis",
157+
async ({ prisma, redisOptions }) => {
158+
const { decorated, redis } = harness(prisma as never, redisOptions as never);
159+
160+
try {
161+
// Born through the UNDECORATED store, so Postgres holds a head and Redis holds no keyspace.
162+
// This is every pre-cutover run.
163+
const env = await seedSnapshotEnvironment(prisma);
164+
const runId = generateInternalId();
165+
const plain = new PostgresRunStore({
166+
prisma,
167+
readOnlyPrisma: prisma,
168+
}) as unknown as RunStore;
169+
170+
await plain.createRun({
171+
data: buildCreateRunData(runId, env),
172+
snapshot: {
173+
id: generateInternalId(),
174+
engine: "V2",
175+
executionStatus: "RUN_CREATED",
176+
description: "Run was created",
177+
runStatus: "PENDING",
178+
environmentId: env.id,
179+
environmentType: env.type,
180+
projectId: env.projectId,
181+
organizationId: env.organizationId,
182+
},
183+
});
184+
185+
const head = await plain.findLatestExecutionSnapshot(runId);
186+
187+
await expect(decorated.repairRedisHead(runId, head!.id)).resolves.toBe("notResident");
188+
expect(await redis.getLatest(runId)).toBeNull();
189+
} finally {
190+
await redis.quit();
191+
}
192+
}
193+
);
194+
195+
containerTest(
196+
"heals the head after the run has transitioned past the lost snapshot",
197+
async ({ prisma, redisOptions }) => {
198+
let dropping = true;
199+
const { decorated, redis } = harness(prisma as never, redisOptions as never, {
200+
faults: (boundary) => {
201+
if (boundary === "afterPgBeforeRedis" && dropping) {
202+
throw new InjectedSnapshotFault(boundary);
203+
}
204+
},
205+
});
206+
207+
try {
208+
const env = await seedSnapshotEnvironment(prisma);
209+
const runId = generateInternalId();
210+
await seedBirth(decorated, redis, runId, env);
211+
212+
const lost = await decorated.createExecutionSnapshot(executingSnapshot(runId, env));
213+
const later = await decorated.createExecutionSnapshot(
214+
executingSnapshot(runId, env, lost.id)
215+
);
216+
dropping = false;
217+
218+
await expect(decorated.repairRedisHead(runId, lost.id)).resolves.toBe("reappended");
219+
220+
const read = await redis.getLatest(runId);
221+
expect(read!.id).toBe(later.id);
222+
} finally {
223+
await redis.quit();
224+
}
225+
}
226+
);
227+
});

0 commit comments

Comments
 (0)