Skip to content

Commit e9ce909

Browse files
committed
fix(run-store): keep completed waitpoints that have no batch index
A completed waitpoint with no batch index was invisible to every Redis read, so a run resumed from the store lost that wait's result while Postgres still returned it. That is every wait.for, every single triggerAndWait and every token: the engine passes index as batchIndex ?? undefined, so only waits inside a batch carry one. The cause was reading the id set out of the ordered list. That list is the index oracle and its positions ARE the indexes, so it can only ever hold indexed ids, and deduping it yields a set missing exactly the index-less ones. Postgres has no such restriction: its completed-waitpoint join records every id. The cycle key now carries the complete distinct set in its own field, written when the cycle is minted and read back beside the order. The order keeps its meaning and stays index-only. Two tests: one asserting an index-less wait survives a round trip with an empty order, and one asserting the set matches the Postgres join for a mix of indexed and index-less waits. Verified by deriving the set from the order again, which makes the waitpoint vanish. The suites missed this because every earlier case gave each waitpoint an index.
1 parent e03a185 commit e9ce909

2 files changed

Lines changed: 103 additions & 6 deletions

File tree

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ export function deriveOrder(completedWaitpoints: CompletedWaitpointRef[]): strin
2929
.map((w) => w.id);
3030
}
3131

32+
/**
33+
* The COMPLETE distinct set of completed-waitpoint ids, including those with no batch index.
34+
*
35+
* This is deliberately not `deriveOrder` deduped. `order` is the index oracle and carries only
36+
* batch-indexed ids, because its positions ARE the indexes. A wait with no batch index (every
37+
* `wait.for`, every single `triggerAndWait`, every token) has no position and is absent from it,
38+
* while Postgres records it in the completed-waitpoint join like any other. Reading the id set back
39+
* from `order` therefore loses exactly those waits, and a run resumed from Redis loses their results.
40+
*/
41+
export function deriveDistinctIds(completedWaitpoints: CompletedWaitpointRef[]): string[] {
42+
return [...new Set(completedWaitpoints.map((w) => w.id))];
43+
}
44+
3245
// isValid is derived, never stored, so the entry JSON stays byte-identical to the caller's document.
3346
export function isValidFor(entry: { error?: unknown }): boolean {
3447
return !entry.error;
@@ -270,12 +283,14 @@ export class RedisSnapshotStore {
270283
let cycleMode = "none";
271284
let cycleSeqIn = "0";
272285
let orderJson = "";
286+
let distinctJson = "";
273287
let records = "";
274288
let orderCount = "0";
275289
if (args.cycle?.kind === "new") {
276290
const order = deriveOrder(args.cycle.completedWaitpoints);
277291
cycleMode = "new";
278292
orderJson = JSON.stringify(order);
293+
distinctJson = JSON.stringify(deriveDistinctIds(args.cycle.completedWaitpoints));
279294
records = args.cycle.records ? JSON.stringify(args.cycle.records) : "";
280295
orderCount = String(order.length);
281296
} else if (args.cycle?.kind === "carryForward") {
@@ -300,7 +315,8 @@ export class RedisSnapshotStore {
300315
records,
301316
orderCount,
302317
args.expectedCur ?? "",
303-
args.expectedCur !== undefined ? "1" : "0"
318+
args.expectedCur !== undefined ? "1" : "0",
319+
distinctJson
304320
)) as string[];
305321

306322
return this.#interpretAppend(reply, raw, orderJson, records, args.entry.runId);
@@ -407,7 +423,7 @@ export class RedisSnapshotStore {
407423
return this.#timed("getSnapshotWaitpointIds", async () => {
408424
const k = snapshotKeys(runId);
409425
const reply = await this.redis.readSnapshotWaitpointIds(k.e, k.idx, k.cur, k.seq, snapshotId);
410-
return decodeWaitpointIds(reply[0] === "1", reply[1] ?? "");
426+
return decodeWaitpointIds(reply[0] === "1", reply[1] ?? "", reply[2] ?? "");
411427
});
412428
}
413429

@@ -588,6 +604,13 @@ export class RedisSnapshotStore {
588604
if not cs then return '' end
589605
return redis.call('HGET', wpKey(cs), 'order') or ''
590606
end
607+
-- The complete id set, which is NOT the order deduped: order holds only batch-indexed ids.
608+
local function distinctFor(pointer)
609+
if not pointer then return '' end
610+
local cs = string.match(pointer, '^(%d+):')
611+
if not cs then return '' end
612+
return redis.call('HGET', wpKey(cs), 'distinct') or ''
613+
end
591614
`;
592615

593616
this.redis.defineCommand("appendSnapshotEntry", {
@@ -607,6 +630,9 @@ export class RedisSnapshotStore {
607630
local orderCount = ARGV[11]
608631
local expectedCur = ARGV[12]
609632
local casEnabled = ARGV[13] == '1'
633+
-- The COMPLETE distinct id set. Not the order deduped: order omits every id with no batch
634+
-- index, and those ids still have to come back on a read.
635+
local distinctJson = ARGV[14]
610636
611637
-- Liveness is TWO anchors: e and seq. All keys get the same PEXPIRE but expire independently
612638
-- (or seq can vanish under maxmemory eviction while e survives), so checking e alone lets a
@@ -641,7 +667,7 @@ export class RedisSnapshotStore {
641667
-- The STORE mints cycleSeq, so the sequence is dense by construction and the terminal
642668
-- PEXPIRE loop from 1..c is correct.
643669
cycleSeq = redis.call('HINCRBY', seqKey, 'c', 1)
644-
redis.call('HSET', wpKey(cycleSeq), 'order', orderJson, 'count', orderCount)
670+
redis.call('HSET', wpKey(cycleSeq), 'order', orderJson, 'count', orderCount, 'distinct', distinctJson)
645671
if records ~= '' then
646672
redis.call('HSET', wpKey(cycleSeq), 'records', records)
647673
else
@@ -736,7 +762,7 @@ export class RedisSnapshotStore {
736762
return { '0', '' }
737763
end
738764
local pointer = redis.call('HGET', eKey, id .. '#c')
739-
return { '1', orderFor(pointer) }
765+
return { '1', orderFor(pointer), distinctFor(pointer) }
740766
`,
741767
});
742768

@@ -848,9 +874,16 @@ export class RedisSnapshotStore {
848874
}
849875
}
850876

851-
export function decodeWaitpointIds(present: boolean, orderJson: string): WaitpointIds {
877+
export function decodeWaitpointIds(
878+
present: boolean,
879+
orderJson: string,
880+
distinctJson = ""
881+
): WaitpointIds {
852882
const order: string[] = orderJson === "" ? [] : (JSON.parse(orderJson) as string[]);
853-
return { present, distinctIds: [...new Set(order)], order };
883+
// The complete set is stored separately, because `order` omits every id with no batch index.
884+
const distinctIds: string[] =
885+
distinctJson === "" ? [...new Set(order)] : (JSON.parse(distinctJson) as string[]);
886+
return { present, distinctIds, order };
854887
}
855888

856889
declare module "@internal/redis" {
@@ -873,6 +906,7 @@ declare module "@internal/redis" {
873906
orderCount: string,
874907
expectedCur: string,
875908
casEnabled: string,
909+
distinctJson: string,
876910
callback?: Callback<string[]>
877911
): Result<string[], Context>;
878912
readSnapshotById(

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,69 @@ describe("completed-waitpoint cycles", () => {
242242
}
243243
});
244244

245+
containerTest(
246+
"keeps a completed waitpoint that has no batch index",
247+
async ({ prisma, redisOptions }) => {
248+
const { decorated, redis } = build(prisma as never, redisOptions as never);
249+
try {
250+
const env = await seedSnapshotEnvironment(prisma);
251+
const runId = await seedRun(decorated, redis, env);
252+
const [wpA] = await seedSnapshotWaitpoints(prisma, env, 1);
253+
254+
// Every wait.for, every single triggerAndWait and every token resumes with no batch index:
255+
// the engine passes `index: b.batchIndex ?? undefined`. Postgres records the id in the
256+
// completed-waitpoint join regardless. The ordered list cannot hold it, because its
257+
// positions ARE the indexes, so the complete set has to be stored separately or the wait's
258+
// result vanishes on a Redis read.
259+
const created = await decorated.createExecutionSnapshot(
260+
resumeInput(runId, env, [{ id: wpA }], "single wait")
261+
);
262+
263+
const ids = await redis.getSnapshotWaitpointIds(runId, created.id);
264+
expect(ids.present).toBe(true);
265+
expect(ids.distinctIds).toEqual([wpA]);
266+
// No position, so it is absent from the oracle. That part is correct.
267+
expect(ids.order).toEqual([]);
268+
} finally {
269+
await redis.quit();
270+
}
271+
}
272+
);
273+
274+
containerTest(
275+
"matches the Postgres join for a mix of indexed and index-less waits",
276+
async ({ prisma, redisOptions }) => {
277+
const { decorated, redis } = build(prisma as never, redisOptions as never);
278+
try {
279+
const env = await seedSnapshotEnvironment(prisma);
280+
const runId = await seedRun(decorated, redis, env);
281+
const [wpA, wpB, wpC] = await seedSnapshotWaitpoints(prisma, env, 3);
282+
283+
const created = await decorated.createExecutionSnapshot(
284+
resumeInput(
285+
runId,
286+
env,
287+
[{ id: wpA, index: 0 }, { id: wpB }, { id: wpC, index: 1 }],
288+
"mixed wait"
289+
)
290+
);
291+
292+
// Parity with what Postgres holds is the actual requirement: the engine iterates the rows
293+
// this set fetches, and uses the order only to assign each one its index.
294+
const fromRedis = await redis.getSnapshotWaitpointIds(runId, created.id);
295+
const fromPostgres = await new PostgresRunStore({
296+
prisma,
297+
readOnlyPrisma: prisma,
298+
}).findSnapshotCompletedWaitpointIds(created.id, undefined, runId);
299+
300+
expect([...fromRedis.distinctIds].sort()).toEqual([...fromPostgres].sort());
301+
expect(fromRedis.order).toEqual([wpA, wpC]);
302+
} finally {
303+
await redis.quit();
304+
}
305+
}
306+
);
307+
245308
containerTest(
246309
"findLatestExecutionSnapshot returns the index oracle",
247310
async ({ prisma, redisOptions }) => {

0 commit comments

Comments
 (0)