Skip to content

Commit 57018fc

Browse files
committed
fix(run-store): three more index-less waitpoint losses on the read path
The previous fix stored the complete id set but left three places still deriving it from the ordered list, and the ordered list holds only batch-indexed ids. A carry-forward decided on the order alone. Two DIFFERENT single waits both present an empty order, so they compared equal, the second inherited the first's cycle, and a read returned the wrong waitpoint entirely. The comparison now requires the id set to match as well. The dequeue site built its Redis refs from the ordered list while the delegate connects the complete set in Postgres, so an index-less waitpoint reached Postgres and never reached Redis. Refs are now built from the complete set, with the index taken from the ordered list where the id appears in it. The entry decode derived the set from the order too, which meant getLatest and getById returned an incomplete set. That is the hot read: findLatestExecutionSnapshot hydrates the waitpoint rows from it, so a resume would have fetched no row at all for a single wait. The read scripts now return the stored set alongside the order. Four tests, each verified against its own defect: two consecutive single waits keep separate cycles, a repeated one still carries forward, the dequeue snapshot keeps an index-less id, and the hot read hydrates its row.
1 parent e9ce909 commit 57018fc

3 files changed

Lines changed: 194 additions & 21 deletions

File tree

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -457,11 +457,12 @@ export class RedisSnapshotStore {
457457
}
458458

459459
const headOrder = reply[1] ?? "";
460+
const headDistinct = reply[2] ?? "";
460461
const rows: SnapshotRead[] = [];
461462
// Tracks whether the Lua-chosen head row (always the first, i === 2) itself survives the
462463
// env filter below -- headOrder must never be attributed to a different, surviving row.
463464
let headSurvived = false;
464-
for (let i = 2; i + 3 < reply.length; i += 4) {
465+
for (let i = 3; i + 3 < reply.length; i += 4) {
465466
// orderKnown is false here: headOrder covers only the head row, resolved separately below.
466467
const decoded = this.#decode(
467468
[reply[i], reply[i + 1], reply[i + 2], reply[i + 3], ""],
@@ -471,13 +472,17 @@ export class RedisSnapshotStore {
471472
);
472473
if (decoded) {
473474
rows.push(decoded);
474-
if (i === 2) headSurvived = true;
475+
if (i === 3) headSurvived = true;
475476
}
476477
}
477478

478479
rows.reverse();
479480
const head = headSurvived ? rows[rows.length - 1] : undefined;
480-
const headWaitpointIds = decodeWaitpointIds(head !== undefined, head ? headOrder : "");
481+
const headWaitpointIds = decodeWaitpointIds(
482+
head !== undefined,
483+
head ? headOrder : "",
484+
head ? headDistinct : ""
485+
);
481486
if (head) {
482487
head.completedWaitpointIds = headWaitpointIds;
483488
if (head.cycle) {
@@ -516,11 +521,12 @@ export class RedisSnapshotStore {
516521
if (reply === null) return { kind: "miss" };
517522

518523
const headOrder = reply[1] ?? "";
524+
const headDistinct = reply[2] ?? "";
519525
const rows: SnapshotRead[] = [];
520526
// Tracks whether the Lua-chosen head row (always the first, i === 2) survives the env filter,
521527
// so headOrder is never attributed to a different, surviving row.
522528
let headSurvived = false;
523-
for (let i = 2; i + 3 < reply.length; i += 4) {
529+
for (let i = 3; i + 3 < reply.length; i += 4) {
524530
const decoded = this.#decode(
525531
[reply[i], reply[i + 1], reply[i + 2], reply[i + 3], ""],
526532
opts?.environmentId,
@@ -529,13 +535,17 @@ export class RedisSnapshotStore {
529535
);
530536
if (decoded) {
531537
rows.push(decoded);
532-
if (i === 2) headSurvived = true;
538+
if (i === 3) headSurvived = true;
533539
}
534540
}
535541

536542
rows.reverse();
537543
const head = headSurvived ? rows[rows.length - 1] : undefined;
538-
const headWaitpointIds = decodeWaitpointIds(head !== undefined, head ? headOrder : "");
544+
const headWaitpointIds = decodeWaitpointIds(
545+
head !== undefined,
546+
head ? headOrder : "",
547+
head ? headDistinct : ""
548+
);
539549
if (head) {
540550
head.completedWaitpointIds = headWaitpointIds;
541551
if (head.cycle) {
@@ -568,7 +578,7 @@ export class RedisSnapshotStore {
568578
orderKnown: boolean
569579
): SnapshotRead | null {
570580
if (!reply || reply.length === 0) return null;
571-
const [id, raw, seqStr, pointer, orderJson] = reply;
581+
const [id, raw, seqStr, pointer, orderJson, distinctJson] = reply;
572582
const entry = JSON.parse(raw) as Record<string, unknown>;
573583
if (environmentId !== undefined && entry.environmentId !== environmentId) return null;
574584
const read: SnapshotRead = {
@@ -582,7 +592,7 @@ export class RedisSnapshotStore {
582592
const [cs, count] = pointer.split(":");
583593
read.cycle = { cycleSeq: Number(cs), count: Number(count) };
584594
if (orderKnown) {
585-
const ids = decodeWaitpointIds(true, orderJson);
595+
const ids = decodeWaitpointIds(true, orderJson, distinctJson ?? "");
586596
read.completedWaitpointIds = ids;
587597
this.#checkCycleMismatch(runId, Number(count), ids.order.length);
588598
}
@@ -737,7 +747,7 @@ export class RedisSnapshotStore {
737747
local vals = redis.call('HMGET', eKey, id, id .. '#s', id .. '#c')
738748
if not vals[1] then return nil end
739749
-- Coerce every element: a Lua false TRUNCATES the returned array at that position.
740-
return { id, vals[1], vals[2] or '', vals[3] or '', orderFor(vals[3]) }
750+
return { id, vals[1], vals[2] or '', vals[3] or '', orderFor(vals[3]), distinctFor(vals[3]) }
741751
`,
742752
});
743753

@@ -749,7 +759,7 @@ export class RedisSnapshotStore {
749759
if not cur then return nil end
750760
local vals = redis.call('HMGET', eKey, cur, cur .. '#s', cur .. '#c')
751761
if not vals[1] then return nil end
752-
return { cur, vals[1], vals[2] or '', vals[3] or '', orderFor(vals[3]) }
762+
return { cur, vals[1], vals[2] or '', vals[3] or '', orderFor(vals[3]), distinctFor(vals[3]) }
753763
`,
754764
});
755765

@@ -785,7 +795,7 @@ export class RedisSnapshotStore {
785795
-- compare is a chronological compare. Walking newest-first lets the scan stop at the first
786796
-- entry at or before the cursor, which makes its length the length of the ANSWER rather
787797
-- than the length of the run's history.
788-
local out = { '', '' }
798+
local out = { '', '', '' }
789799
local headId = nil
790800
local offset = 0
791801
local page = limit
@@ -820,7 +830,9 @@ export class RedisSnapshotStore {
820830
end
821831
822832
if headId then
823-
out[2] = orderFor(redis.call('HGET', eKey, headId .. '#c'))
833+
local headPointer = redis.call('HGET', eKey, headId .. '#c')
834+
out[2] = orderFor(headPointer)
835+
out[3] = distinctFor(headPointer)
824836
end
825837
return out
826838
`,
@@ -852,7 +864,7 @@ export class RedisSnapshotStore {
852864
-- The head is the newest SURVIVING entry, and it is the only one whose cycle key is read.
853865
-- Deriving the order after the loop keeps it paired with the row it is attached to: a row
854866
-- dropped for a missing body must not donate its cycle data to the next one.
855-
local out = { sinceRaw, '' }
867+
local out = { sinceRaw, '', '' }
856868
local headId = nil
857869
for i = 1, #ids do
858870
local id = ids[i]
@@ -866,7 +878,9 @@ export class RedisSnapshotStore {
866878
end
867879
end
868880
if headId then
869-
out[2] = orderFor(redis.call('HGET', eKey, headId .. '#c'))
881+
local headPointer = redis.call('HGET', eKey, headId .. '#c')
882+
out[2] = orderFor(headPointer)
883+
out[3] = distinctFor(headPointer)
870884
end
871885
return out
872886
`,

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

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
SnapshotEntryInput,
2121
SnapshotRead,
2222
} from "./redisSnapshotStore.js";
23-
import { deriveOrder } from "./redisSnapshotStore.js";
23+
import { deriveDistinctIds, deriveOrder } from "./redisSnapshotStore.js";
2424
import {
2525
entryFromCompletion,
2626
entryFromCreateExecutionSnapshot,
@@ -384,9 +384,13 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
384384
"lockRunToWorker",
385385
entryFromLock(ctx, withStamp.snapshot),
386386
withStamp.snapshot.previousSnapshotId,
387-
// The lock site already carries the resolved order, so the refs are rebuilt from it rather
388-
// than re-derived: its index IS the position in that list.
389-
withStamp.snapshot.completedWaitpointOrder.map((id, index) => ({ id, index }))
387+
// Built from the COMPLETE id set, which is what the delegate connects in Postgres, with the
388+
// index taken from the ordered list where the id appears in it. Building from the ordered list
389+
// instead would drop every id with no batch index, exactly the ids Postgres still records.
390+
lockCycleRefs(
391+
withStamp.snapshot.completedWaitpointIds,
392+
withStamp.snapshot.completedWaitpointOrder
393+
)
390394
);
391395
return result;
392396
}
@@ -579,12 +583,21 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
579583
}
580584

581585
const order = deriveOrder(completedWaitpoints);
586+
const distinct = deriveDistinctIds(completedWaitpoints);
582587

583588
try {
584589
const head = await this.redis.getLatest(runId);
585-
const previous = head?.completedWaitpointIds?.order;
586-
587-
if (head?.cycle && previous && sameOrder(previous, order)) {
590+
const previousIds = head?.completedWaitpointIds;
591+
592+
// Both halves must match. Comparing the order alone is not enough: it holds only indexed ids,
593+
// so two DIFFERENT single waits both present an empty order and would compare equal, and the
594+
// second would inherit the first's waitpoint set instead of minting its own.
595+
if (
596+
head?.cycle &&
597+
previousIds &&
598+
sameOrder(previousIds.order, order) &&
599+
sameSet(previousIds.distinctIds, distinct)
600+
) {
588601
return { kind: "carryForward", cycleSeq: head.cycle.cycleSeq };
589602
}
590603
} catch (error) {
@@ -899,3 +912,31 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
899912
function sameOrder(a: string[], b: string[]): boolean {
900913
return a.length === b.length && a.every((id, index) => id === b[index]);
901914
}
915+
916+
/**
917+
* Turns the lock site's two lists into cycle refs. `completedWaitpointIds` is the complete set the
918+
* delegate connects; `completedWaitpointOrder` gives a position only to the ids that have one, and a
919+
* repeated id keeps each of its positions.
920+
*/
921+
function lockCycleRefs(ids: string[], order: string[]): { id: string; index?: number }[] {
922+
const refs: { id: string; index?: number }[] = [];
923+
const indexed = new Set<string>();
924+
925+
order.forEach((id, index) => {
926+
refs.push({ id, index });
927+
indexed.add(id);
928+
});
929+
930+
for (const id of ids) {
931+
if (!indexed.has(id)) refs.push({ id });
932+
}
933+
934+
return refs;
935+
}
936+
937+
/** Membership only, for the id set, which has no meaningful order. */
938+
function sameSet(a: string[], b: string[]): boolean {
939+
if (a.length !== b.length) return false;
940+
const seen = new Set(a);
941+
return b.every((id) => seen.has(id));
942+
}

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

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,124 @@ describe("completed-waitpoint cycles", () => {
305305
}
306306
);
307307

308+
containerTest(
309+
"two consecutive index-less waits do not share a cycle",
310+
async ({ prisma, redisOptions }) => {
311+
const { decorated, redis } = build(prisma as never, redisOptions as never);
312+
const probe = createRedisClient(redisOptions, { onError: () => {} });
313+
try {
314+
const env = await seedSnapshotEnvironment(prisma);
315+
const runId = await seedRun(decorated, redis, env);
316+
const [wpA, wpB] = await seedSnapshotWaitpoints(prisma, env, 2);
317+
318+
// Neither wait has a batch index, so both present an EMPTY order. Deciding carry-forward on
319+
// the order alone makes them compare equal, and the second silently inherits the first's
320+
// waitpoint set: its own result is never stored and a read returns the wrong id.
321+
const first = await decorated.createExecutionSnapshot(
322+
resumeInput(runId, env, [{ id: wpA }], "first single wait")
323+
);
324+
const second = await decorated.createExecutionSnapshot(
325+
resumeInput(runId, env, [{ id: wpB }], "second single wait")
326+
);
327+
328+
expect((await redis.getSnapshotWaitpointIds(runId, first.id)).distinctIds).toEqual([wpA]);
329+
expect((await redis.getSnapshotWaitpointIds(runId, second.id)).distinctIds).toEqual([wpB]);
330+
expect((await probe.keys(`snap:{${runId}}:wp:*`)).length).toBe(2);
331+
} finally {
332+
await Promise.all([redis.quit(), probe.quit().catch(() => {})]);
333+
}
334+
}
335+
);
336+
337+
containerTest(
338+
"the same index-less wait repeated does still carry forward",
339+
async ({ prisma, redisOptions }) => {
340+
const { decorated, redis } = build(prisma as never, redisOptions as never);
341+
const probe = createRedisClient(redisOptions, { onError: () => {} });
342+
try {
343+
const env = await seedSnapshotEnvironment(prisma);
344+
const runId = await seedRun(decorated, redis, env);
345+
const [wpA] = await seedSnapshotWaitpoints(prisma, env, 1);
346+
347+
// The copy-forward case must survive the stricter comparison: the same id set, still one key.
348+
await decorated.createExecutionSnapshot(resumeInput(runId, env, [{ id: wpA }], "wait"));
349+
const carried = await decorated.createExecutionSnapshot(
350+
resumeInput(runId, env, [{ id: wpA }], "carry")
351+
);
352+
353+
expect((await probe.keys(`snap:{${runId}}:wp:*`)).length).toBe(1);
354+
expect((await redis.getSnapshotWaitpointIds(runId, carried.id)).distinctIds).toEqual([wpA]);
355+
} finally {
356+
await Promise.all([redis.quit(), probe.quit().catch(() => {})]);
357+
}
358+
}
359+
);
360+
361+
containerTest(
362+
"the dequeue snapshot keeps an index-less waitpoint",
363+
async ({ prisma, redisOptions }) => {
364+
const { decorated, redis } = build(prisma as never, redisOptions as never);
365+
try {
366+
const env = await seedSnapshotEnvironment(prisma);
367+
const runId = await seedRun(decorated, redis, env);
368+
const [wpA, wpB] = await seedSnapshotWaitpoints(prisma, env, 2);
369+
const head = await redis.getLatest(runId);
370+
const snapshotId = generateInternalId();
371+
372+
// Postgres connects completedWaitpointIds, the COMPLETE set. Building the Redis refs from
373+
// completedWaitpointOrder instead drops every id that has no position in it.
374+
await decorated.lockRunToWorker(runId, {
375+
lockedAt: new Date(),
376+
lockedById: undefined,
377+
lockedToVersionId: undefined,
378+
lockedQueueId: undefined,
379+
startedAt: new Date(),
380+
baseCostInCents: 0,
381+
machinePreset: "small-1x",
382+
taskVersion: "1.0.0",
383+
snapshot: {
384+
id: snapshotId,
385+
previousSnapshotId: head!.id,
386+
attemptNumber: 1,
387+
environmentId: env.id,
388+
environmentType: env.type,
389+
projectId: env.projectId,
390+
organizationId: env.organizationId,
391+
completedWaitpointIds: [wpA, wpB],
392+
completedWaitpointOrder: [wpA],
393+
},
394+
} as never);
395+
396+
const ids = await redis.getSnapshotWaitpointIds(runId, snapshotId);
397+
expect([...ids.distinctIds].sort()).toEqual([wpA, wpB].sort());
398+
expect(ids.order).toEqual([wpA]);
399+
} finally {
400+
await redis.quit();
401+
}
402+
}
403+
);
404+
405+
containerTest(
406+
"findLatestExecutionSnapshot hydrates an index-less waitpoint row",
407+
async ({ prisma, redisOptions }) => {
408+
const { decorated, redis } = build(prisma as never, redisOptions as never);
409+
try {
410+
const env = await seedSnapshotEnvironment(prisma);
411+
const runId = await seedRun(decorated, redis, env);
412+
const [wpA] = await seedSnapshotWaitpoints(prisma, env, 1);
413+
414+
await decorated.createExecutionSnapshot(resumeInput(runId, env, [{ id: wpA }], "single"));
415+
416+
// The hot read hydrates the rows from the id set, so an incomplete set means the resume
417+
// gets no waitpoint at all.
418+
const latest = await decorated.findLatestExecutionSnapshot(runId);
419+
expect(latest!.completedWaitpoints.map((w) => w.id)).toEqual([wpA]);
420+
} finally {
421+
await redis.quit();
422+
}
423+
}
424+
);
425+
308426
containerTest(
309427
"findLatestExecutionSnapshot returns the index oracle",
310428
async ({ prisma, redisOptions }) => {

0 commit comments

Comments
 (0)