From 31072c2fa92619918a82080fe6b582d95e0bd672 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 30 Oct 2026 23:32:36 -0400 Subject: [PATCH] Synchronize routine sweep reads in the replica race test Make both replicas read the original due timestamp before either offers a firing. Promise.all alone could let the slower read see the next ancient-but-still-due timestamp, testing a different interleaving and intermittently queueing two distinct dates. Production scheduling and queue code are unchanged. AI assistance: implemented and locally validated with OpenAI Codex; maintainer review remains required. --- .../tests/routine-sweep.integration.test.ts | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/server/tests/routine-sweep.integration.test.ts b/server/tests/routine-sweep.integration.test.ts index b40450524b..4af4d17c8a 100644 --- a/server/tests/routine-sweep.integration.test.ts +++ b/server/tests/routine-sweep.integration.test.ts @@ -277,17 +277,36 @@ describe("offering the firings that are due", () => { const from = await makeDueAt(routine.id, new Date("2001-01-01T09:25:00Z")); const now = () => new Date("2001-01-01T09:26:00Z"); - const outcomes = await Promise.all([ - offerDueRoutines(sweepOptions({ now })), - offerDueRoutines(sweepOptions({ now })), - ]); - - // Both sweeps saw it as due and both offered it, which is the honest report: each did put the - // work on the queue. What must be single is the row, and the clock. - for (const outcome of outcomes) { - expect(outcome.offered).toContain(routine.id); + // Both sweeps must finish reading the same due stamp before either offers it. Promise.all + // alone does not ensure this: a slower reader can see the already-advanced stamp, which is + // still due by Postgres's real clock because this fixture deliberately dates its rows to 2001. + const dueRoutines = store.dueRoutines.bind(store); + const bothRead = Promise.withResolvers(); + let readers = 0; + const read = spyOn(store, "dueRoutines").mockImplementation( + async (limit) => { + const rows = await dueRoutines(limit); + if (++readers === 2) bothRead.resolve(); + await bothRead.promise; + return rows; + }, + ); + try { + const outcomes = await Promise.all([ + offerDueRoutines(sweepOptions({ now })), + offerDueRoutines(sweepOptions({ now })), + ]); + + // Both snapshots came from the real database. The real queue and compare-and-set must + // still collapse their two offers to one firing and one clock advance. + expect(readers).toBe(2); + for (const outcome of outcomes) { + expect(outcome.offered).toContain(routine.id); + } + expect(await firingsFor(routine.id)).toHaveLength(1); + } finally { + read.mockRestore(); } - expect(await firingsFor(routine.id)).toHaveLength(1); const after = await readRoutine(routine.id); expect(after?.nextRunAt.getTime()).toBeGreaterThan(from.getTime());