Skip to content

Commit 4a67855

Browse files
d-csclaude
andcommitted
fix(run-store): compare the inherited cycle's id set as a set, not as a string
The inherit guard compared the two serialised id arrays. Both are derived from Postgres reads with no ORDER BY -- the resume reads the run's block edges, the copy-forward reads the snapshot's waitpoint rows -- so the same set of ids arrives in an arbitrary order on each append. String equality therefore failed for almost every wait holding two or more waitpoints, and the mint fell back to writing a cycle with no records: the exact state the inherit exists to prevent, missed for the batch fan-in it matters most for. It now decodes both sides and compares membership, which is what the decorator's own sameSet does and for the same reason. The test that was meant to hold this used a single waitpoint, the one case where string and set comparison agree, so it passed for the wrong reason. It now uses two waitpoints and re-passes them in the opposite order, and fails against the string comparison. Also corrects the guard's comment. It is deliberately weaker than the carry test the decorator applies on a successful probe, which additionally requires the order to match: a pointer hands the reader the previous cycle's order, whereas this mints a fresh cycle from the caller's own, so only the records have to be right and records are keyed by waitpoint id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cf33505 commit 4a67855

3 files changed

Lines changed: 98 additions & 32 deletions

File tree

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

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,10 @@ export class RedisSnapshotStore {
456456
if (orderJson !== "") {
457457
// The whole wp:<cycleSeq> key, not just its order field: records dominates it once populated.
458458
//
459-
// `records` is what the CALLER sent, so this under-reports one case: a refused carry-forward,
460-
// where the script copies the record set from the cycle it replaces and the client never sees
461-
// it. Sizing that exactly would cost the read this path exists to avoid, and the copied set
462-
// was already measured when the cycle it came from was minted.
459+
// `records` is what the CALLER sent, so this under-reports the two cases where the script
460+
// sources the set itself and the client never sees it: a refused carry-forward, and a mint
461+
// after a failed probe that inherits. Sizing either exactly would cost the read those paths
462+
// exist to avoid, and the set was already measured when the cycle it came from was minted.
463463
const cycleBytes = Buffer.byteLength(orderJson, "utf8") + Buffer.byteLength(records, "utf8");
464464
this.metrics?.recordCycleKeyBytes(cycleBytes);
465465
if (this.highWater.cycleKeyBytes !== undefined && cycleBytes > this.highWater.cycleKeyBytes) {
@@ -806,6 +806,32 @@ export class RedisSnapshotStore {
806806
807807
-- The STORE mints cycleSeq, so the sequence is dense by construction and the terminal
808808
-- PEXPIRE loop from 1..c is correct.
809+
-- Set equality, NOT string equality.
810+
--
811+
-- Both sides come from deriveDistinctIds over a Postgres read with no ORDER BY -- the
812+
-- resume reads the run's block edges, the copy-forward reads the snapshot's waitpoint
813+
-- rows -- so the same set of ids arrives in an arbitrary order on each append. Comparing
814+
-- the serialised arrays would therefore disagree for almost every wait holding two or
815+
-- more waitpoints, which is the batch fan-in this inherit exists to protect.
816+
--
817+
-- Both arrays are already deduped by construction, so equal length plus membership one
818+
-- way is set equality. This matches the decorator's own sameSet, which is deliberately
819+
-- order-insensitive for the same reason.
820+
local function sameDistinctSet(stored, incoming)
821+
if not stored or stored == '' or incoming == '' then return false end
822+
if stored == incoming then return true end
823+
local ok, left = pcall(cjson.decode, stored)
824+
if not ok or type(left) ~= 'table' then return false end
825+
local right = cjson.decode(incoming)
826+
if #left ~= #right then return false end
827+
local seen = {}
828+
for i = 1, #left do seen[left[i]] = true end
829+
for i = 1, #right do
830+
if not seen[right[i]] then return false end
831+
end
832+
return true
833+
end
834+
809835
-- Takes the record set rather than closing over ARGV, because the two mint sites source it
810836
-- differently: a 'new' cycle uses what the caller sent, and the refusal path below reads it
811837
-- from the cycle it is replacing.
@@ -831,14 +857,18 @@ export class RedisSnapshotStore {
831857
-- but minting with NO records leaves ids that resolve from nothing, and the next resume
832858
-- refuses the whole cycle rather than losing a result quietly. So inherit them here.
833859
--
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.
860+
-- Guarded on the distinct SET matching, which is sufficient and is deliberately weaker
861+
-- than the carry test the decorator applies on a successful probe. That test also
862+
-- requires the order to match, because a pointer hands the reader the previous cycle's
863+
-- order; this mints a fresh cycle from the caller's OWN order, so only the records have
864+
-- to be right, and records are keyed by waitpoint id. A differing set is a genuinely new
865+
-- wait and must start with the caller's own records, even when that is none.
866+
--
867+
-- Read before mintCycle, because mintCycle advances the counter this reads.
838868
local inherited = records
839869
if inherited == '' then
840870
local prev = tonumber(redis.call('HGET', seqKey, 'c') or '0')
841-
if prev > 0 and redis.call('HGET', wpKey(prev), 'distinct') == distinctJson then
871+
if prev > 0 and sameDistinctSet(redis.call('HGET', wpKey(prev), 'distinct'), distinctJson) then
842872
inherited = redis.call('HGET', wpKey(prev), 'records') or ''
843873
end
844874
end

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,10 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
586586
* to serve a branch that needs eviction to reach. Those are the hot paths; the resume path is
587587
* the one that supplies `records` and never read at all.
588588
*
589+
* Three arms: `new` mints from the caller's own records, `newInherit` mints after a probe that
590+
* FAILED and lets the script inherit the previous cycle's records when the id set matches, and
591+
* `carryForward` points at a cycle already minted.
592+
*
589593
* `records` rides the mint arms only. A carryForward passes none: it points at a cycle already
590594
* minted, and if the store refuses that pointer and mints a replacement inside the same call,
591595
* the script reads the record set off the cycle it is replacing. A legacy-only wait supplies

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

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -289,41 +289,74 @@ describe("a failed cycle probe", () => {
289289
return () => void (redis.getLatest = original);
290290
}
291291

292+
async function seedStoreWaitpoint(
293+
prisma: never,
294+
env: SnapshotFixtureEnv,
295+
id: string
296+
): Promise<void> {
297+
await (
298+
prisma as unknown as { waitpoint: { create: (a: unknown) => Promise<unknown> } }
299+
).waitpoint.create({
300+
data: {
301+
id,
302+
friendlyId: `waitpoint_${id}`,
303+
type: "MANUAL",
304+
status: "COMPLETED",
305+
completedAt: new Date(),
306+
idempotencyKey: `idem_${id.slice(-12)}`,
307+
userProvidedIdempotencyKey: false,
308+
projectId: env.projectId,
309+
environmentId: env.id,
310+
},
311+
});
312+
}
313+
314+
// TWO waitpoints, and the copy-forward re-passes them in the OPPOSITE order.
315+
//
316+
// That is not a contrived permutation: both id lists are derived from Postgres reads with no
317+
// ORDER BY -- the resume reads the run's block edges, the copy-forward reads the snapshot's
318+
// waitpoint rows -- so an arbitrary order on each append is the normal case. A guard comparing
319+
// the serialised id arrays would miss here and mint a record-less cycle, which is the very
320+
// state this path exists to prevent, for every wait holding more than one waitpoint.
321+
//
322+
// A single-id version of this test passes whether the guard compares sets or strings, so it
323+
// cannot hold the property on its own.
292324
containerTest(
293-
"inherits the records when the id set is unchanged",
325+
"inherits the records when the id set is unchanged but reordered",
294326
async ({ prisma, redisOptions }) => {
295327
const { decorated, redis } = build(prisma as never, redisOptions as never);
296328
const probe = createRedisClient(redisOptions, { onError: () => {} });
297329

298330
try {
299331
const env = await seedSnapshotEnvironment(prisma);
300332
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 }];
333+
const first = generateWaitpointId("MANUAL");
334+
const second = generateWaitpointId("MANUAL");
335+
await seedStoreWaitpoint(prisma as never, env, first);
336+
await seedStoreWaitpoint(prisma as never, env, second);
316337

317338
// The resume, which supplies the records and mints cycle 1.
318339
await decorated.createExecutionSnapshot(
319-
resumeInput(runId, env, waitpoints, [record(storeId)])
340+
resumeInput(
341+
runId,
342+
env,
343+
[
344+
{ id: first, index: 0 },
345+
{ id: second, index: 1 },
346+
],
347+
[record(first), record(second)]
348+
)
320349
);
321350

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.
351+
// The copy-forward: same ids, same indexes, arbitrary order, no records of its own.
324352
const restore = breakNextProbe(redis);
325353
try {
326-
await decorated.createExecutionSnapshot(resumeInput(runId, env, waitpoints));
354+
await decorated.createExecutionSnapshot(
355+
resumeInput(runId, env, [
356+
{ id: second, index: 1 },
357+
{ id: first, index: 0 },
358+
])
359+
);
327360
} finally {
328361
restore();
329362
}
@@ -332,10 +365,9 @@ describe("a failed cycle probe", () => {
332365
const cycleKeys = await probe.keys(`snap:{${runId}}:wp:*`);
333366
expect(cycleKeys.length).toBe(2);
334367

335-
// ...but it still holds the records, so the cycle stays resolvable.
368+
// ...holding BOTH records, so the cycle stays resolvable.
336369
const records = await recordsAtHead(redis, probe, runId);
337-
expect(records).toHaveLength(1);
338-
expect(records?.[0]?.id).toBe(storeId);
370+
expect(records?.map((r) => r.id).sort()).toEqual([first, second].sort());
339371
} finally {
340372
await Promise.all([redis.quit(), probe.quit().catch(() => {})]);
341373
}

0 commit comments

Comments
 (0)