Skip to content

Commit cf33505

Browse files
d-csclaude
andcommitted
fix(run-store): stop a failed cycle probe minting an unresolvable cycle
A copy-forward append learns from the head probe whether its waitpoint id set continues the previous cycle. When that probe threw, the append fell back to minting a fresh cycle -- correct, because an unverified pointer must not be carried -- but a copy-forward holds no records of its own, and the probe was what would have found the previous cycle to read them from. So the mint wrote waitpoint ids with no records behind them, and the next resume refused the whole cycle rather than lose a result silently. One transient probe failure left a run unable to resume, with its rows still in Postgres. Reachable with the store healthy, because the probe parses the entry payload: a single corrupt entry is enough. The mint now inherits the previous cycle's records when the distinct id set is identical, which is the comparison the probe would have made had it succeeded. It happens inside the append script, so the read is atomic with the mint and costs nothing on any path that does not fail. A differing id set is a genuinely new wait and still starts with the caller's own records, even when that is none, so a run cannot be handed a result for a waitpoint it is not waiting on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 717e7f0 commit cf33505

3 files changed

Lines changed: 165 additions & 3 deletions

File tree

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,21 @@ export class RedisSnapshotStore {
309309
completedWaitpoints: CompletedWaitpointRef[];
310310
records?: CompletedWaitpointRecord[];
311311
}
312+
| {
313+
/**
314+
* Mint a cycle, and if the caller has no records, inherit the current cycle's when its
315+
* id set is identical.
316+
*
317+
* For the one caller that cannot tell whether this id set continues the previous cycle:
318+
* a head probe that FAILED. It must not carry a pointer it could not verify, and it
319+
* cannot read the records it would need to mint a complete replacement. Minting without
320+
* them would leave ids that resolve from nothing, so the comparison and the copy happen
321+
* here, atomically, where the previous cycle can still be read.
322+
*/
323+
kind: "newInherit";
324+
completedWaitpoints: CompletedWaitpointRef[];
325+
records?: CompletedWaitpointRecord[];
326+
}
312327
| {
313328
kind: "carryForward";
314329
cycleSeq: number;
@@ -341,9 +356,9 @@ export class RedisSnapshotStore {
341356
let distinctJson = "";
342357
let records = "";
343358
let orderCount = "0";
344-
if (args.cycle?.kind === "new") {
359+
if (args.cycle?.kind === "new" || args.cycle?.kind === "newInherit") {
345360
const order = deriveOrder(args.cycle.completedWaitpoints);
346-
cycleMode = "new";
361+
cycleMode = args.cycle.kind === "newInherit" ? "newInherit" : "new";
347362
orderJson = JSON.stringify(order);
348363
distinctJson = JSON.stringify(deriveDistinctIds(args.cycle.completedWaitpoints));
349364
records = args.cycle.records ? JSON.stringify(args.cycle.records) : "";
@@ -810,6 +825,24 @@ export class RedisSnapshotStore {
810825
811826
if cycleMode == 'new' then
812827
cycleSeq = mintCycle(records)
828+
elseif cycleMode == 'newInherit' then
829+
-- The caller's head probe failed, so it knows neither whether this id set continues the
830+
-- previous cycle nor what records that cycle holds. Minting fresh is the safe direction,
831+
-- but minting with NO records leaves ids that resolve from nothing, and the next resume
832+
-- refuses the whole cycle rather than losing a result quietly. So inherit them here.
833+
--
834+
-- Guarded on the distinct set being IDENTICAL, which is the same test the caller would
835+
-- have made had its probe succeeded. A differing set is a genuinely new wait and must
836+
-- start with the caller's own records, even when that is none. Read before mintCycle,
837+
-- because mintCycle advances the counter this reads.
838+
local inherited = records
839+
if inherited == '' then
840+
local prev = tonumber(redis.call('HGET', seqKey, 'c') or '0')
841+
if prev > 0 and redis.call('HGET', wpKey(prev), 'distinct') == distinctJson then
842+
inherited = redis.call('HGET', wpKey(prev), 'records') or ''
843+
end
844+
end
845+
cycleSeq = mintCycle(inherited)
813846
elseif cycleMode == 'carry' then
814847
-- Attach the CARRIED pointer only if this incarnation actually minted that cycle. seq can
815848
-- be evicted while a wp:<n> key survives, so a bare key-exists check would adopt a dead

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
597597
records?: CompletedWaitpointRecord[]
598598
): Promise<
599599
| {
600-
kind: "new";
600+
kind: "new" | "newInherit";
601601
completedWaitpoints: CompletedWaitpointRef[];
602602
records?: CompletedWaitpointRecord[];
603603
}
@@ -645,7 +645,15 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
645645
// A failed probe must not lose the waitpoints. Minting a fresh cycle is the safe direction:
646646
// it costs one duplicated record set, where a wrong carryForward would point at another
647647
// cycle's ids.
648+
//
649+
// `newInherit` rather than `new`, because a copy-forward caller carries no records of its
650+
// own: the probe is what would have found the previous cycle to read them from. Minting
651+
// plain `new` here writes ids with no records, and the next resume then refuses the cycle
652+
// outright -- a probe failure that recovers turns into a run that cannot resume. The script
653+
// inherits them instead, and only when the id set is identical.
648654
this.logger.warn("snapshot cycle probe failed, minting a new cycle", { runId, error });
655+
656+
return { kind: "newInherit", completedWaitpoints, ...(records && { records }) };
649657
}
650658

651659
return { kind: "new", completedWaitpoints, ...(records && { records }) };

internal-packages/run-store/src/taskRunExecutionSnapshotStore.waitpointRecords.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,127 @@ describe("the completed-waitpoint record set", () => {
252252
);
253253
});
254254

255+
// A cycle-probe failure must not mint an unresolvable cycle.
256+
//
257+
// The probe is how a copy-forward append discovers that its id set continues the previous cycle.
258+
// When it throws, the append still has to write, and minting fresh is the safe direction -- but a
259+
// copy-forward carries no records of its own, so a plain mint writes ids that resolve from
260+
// nothing. The next resume then refuses the whole cycle: one transient probe failure would leave
261+
// a run permanently unable to resume, with the join rows still sitting in Postgres.
262+
//
263+
// Reachable with Redis healthy: getLatest JSON-parses the entry payload, so one corrupt entry
264+
// does it.
265+
describe("a failed cycle probe", () => {
266+
async function recordsAtHead(
267+
redis: RedisSnapshotStore,
268+
probe: ReturnType<typeof createRedisClient>,
269+
runId: string
270+
): Promise<CompletedWaitpointRecord[] | undefined> {
271+
const head = await redis.getLatest(runId);
272+
const cycleSeq = head?.cycle?.cycleSeq;
273+
if (cycleSeq === undefined) return undefined;
274+
const raw = await probe.hget(`snap:{${runId}}:wp:${cycleSeq}`, "records");
275+
return raw ? (JSON.parse(raw) as CompletedWaitpointRecord[]) : undefined;
276+
}
277+
278+
// Fails the NEXT probe only, so the append that follows it still runs against a healthy store.
279+
function breakNextProbe(redis: RedisSnapshotStore) {
280+
const original = redis.getLatest.bind(redis);
281+
let broken = true;
282+
redis.getLatest = async (runId: string, opts?: { environmentId?: string }) => {
283+
if (broken) {
284+
broken = false;
285+
throw new Error("probe failed");
286+
}
287+
return original(runId, opts);
288+
};
289+
return () => void (redis.getLatest = original);
290+
}
291+
292+
containerTest(
293+
"inherits the records when the id set is unchanged",
294+
async ({ prisma, redisOptions }) => {
295+
const { decorated, redis } = build(prisma as never, redisOptions as never);
296+
const probe = createRedisClient(redisOptions, { onError: () => {} });
297+
298+
try {
299+
const env = await seedSnapshotEnvironment(prisma);
300+
const runId = await seedRun(decorated, redis, env);
301+
const storeId = generateWaitpointId("MANUAL");
302+
await prisma.waitpoint.create({
303+
data: {
304+
id: storeId,
305+
friendlyId: `waitpoint_${storeId}`,
306+
type: "MANUAL",
307+
status: "COMPLETED",
308+
completedAt: new Date(),
309+
idempotencyKey: `idem_${storeId.slice(-12)}`,
310+
userProvidedIdempotencyKey: false,
311+
projectId: env.projectId,
312+
environmentId: env.id,
313+
},
314+
});
315+
const waitpoints = [{ id: storeId, index: 0 }];
316+
317+
// The resume, which supplies the records and mints cycle 1.
318+
await decorated.createExecutionSnapshot(
319+
resumeInput(runId, env, waitpoints, [record(storeId)])
320+
);
321+
322+
// The copy-forward that follows it, with the probe broken. It carries the same refs and
323+
// no records of its own, exactly as attempt-start and dequeue do.
324+
const restore = breakNextProbe(redis);
325+
try {
326+
await decorated.createExecutionSnapshot(resumeInput(runId, env, waitpoints));
327+
} finally {
328+
restore();
329+
}
330+
331+
// A fresh cycle, because an unverified pointer must not be carried...
332+
const cycleKeys = await probe.keys(`snap:{${runId}}:wp:*`);
333+
expect(cycleKeys.length).toBe(2);
334+
335+
// ...but it still holds the records, so the cycle stays resolvable.
336+
const records = await recordsAtHead(redis, probe, runId);
337+
expect(records).toHaveLength(1);
338+
expect(records?.[0]?.id).toBe(storeId);
339+
} finally {
340+
await Promise.all([redis.quit(), probe.quit().catch(() => {})]);
341+
}
342+
}
343+
);
344+
345+
// The other direction: a genuinely NEW wait must not inherit the previous cycle's records, or
346+
// the resolver would hand the run a result belonging to a waitpoint it is not waiting on.
347+
containerTest("inherits nothing when the id set differs", async ({ prisma, redisOptions }) => {
348+
const { decorated, redis } = build(prisma as never, redisOptions as never);
349+
const probe = createRedisClient(redisOptions, { onError: () => {} });
350+
351+
try {
352+
const env = await seedSnapshotEnvironment(prisma);
353+
const runId = await seedRun(decorated, redis, env);
354+
const [first, second] = await seedSnapshotWaitpoints(prisma, env, 2);
355+
356+
await decorated.createExecutionSnapshot(
357+
resumeInput(runId, env, [{ id: first!, index: 0 }], [record(first!)])
358+
);
359+
360+
const restore = breakNextProbe(redis);
361+
try {
362+
await decorated.createExecutionSnapshot(
363+
resumeInput(runId, env, [{ id: second!, index: 0 }])
364+
);
365+
} finally {
366+
restore();
367+
}
368+
369+
expect(await recordsAtHead(redis, probe, runId)).toBeUndefined();
370+
} finally {
371+
await Promise.all([redis.quit(), probe.quit().catch(() => {})]);
372+
}
373+
});
374+
});
375+
255376
// A copy-forward append reads NO cycle key, whatever the ids look like.
256377
//
257378
// The record set a refusal needs is sourced inside the append script now, so the decorator does

0 commit comments

Comments
 (0)