diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 04b33a923056..257f7f0251d7 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -1286,6 +1286,45 @@ describe("recomputeNextRuns", () => { expect(job.state.nextRunAtMs).toBe(expected); }); + it("preserves exact-second cron slots that fall multiple intervals into the future (#81691)", () => { + // Regression for the stale-future repair path. `isStaggeredCronRunAtMs` + // used to probe the cron library at `runAtMs + 1` to classify whether the + // persisted timestamp was a real scheduled slot. Croner-style second- + // granular schedules normalize that 1ms probe back to the candidate's + // second, so `previousRuns(1, probe)` returns the slot before the + // candidate rather than the slot itself. The slot then looks "stale" and + // future-slot repair rebases it, even though it is a perfectly valid + // schedule slot two-or-more intervals out. + // + // The bug only surfaces when nextRun lands two-plus intervals past + // `naturalNext`, because the closer cases are already saved by the + // `nextRun === naturalNext` / `followingNaturalNext` guards in + // shouldRepairFutureCronNextRunAtMs. + const now = Date.parse("2026-05-05T12:00:00.000Z"); + // "0 9 * * *" Pacific/Honolulu (UTC-10) → 19:00 UTC daily. + // Honolulu has no DST, so the UTC offset is stable across the window. + const exactFutureSlot = Date.parse("2026-05-08T19:00:00.000Z"); + const job: CronJob = { + id: "honolulu-9am-future-slot", + name: "honolulu 9am future slot", + enabled: true, + createdAtMs: Date.parse("2026-05-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-05-01T00:00:00.000Z"), + schedule: { kind: "cron", expr: "0 9 * * *", tz: "Pacific/Honolulu", staggerMs: 0 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick" }, + state: { nextRunAtMs: exactFutureSlot }, + }; + const state = { + ...createMockState(now), + store: { version: 1 as const, jobs: [job] }, + } as CronServiceState; + + expect(recomputeNextRunsForMaintenance(state)).toBe(false); + expect(job.state.nextRunAtMs).toBe(exactFutureSlot); + }); + it("keeps future nextRunAtMs while probing malformed cron schedules", () => { const now = Date.parse("2026-05-05T12:00:00.000Z"); const future = Date.parse("2026-05-12T16:00:00.000Z"); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index c00c4d6c0330..0de5d1e47209 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -179,7 +179,13 @@ function isStaggeredCronRunAtMs(job: CronJob, runAtMs: number): boolean { if (job.schedule.kind !== "cron" || !isFiniteTimestamp(runAtMs)) { return false; } - const previous = computeStaggeredCronPreviousRunAtMs(job, runAtMs + 1); + // Probe past the candidate second. Croner-style second-granular schedules + // normalize a 1ms probe back to the candidate's second, so + // `previousRuns(1, runAtMs + 1)` returns the slot before the candidate + // rather than the candidate itself and exact-second slots get misclassified + // as stale. A 1s probe lands past the candidate second, matching the cursor + // step used elsewhere in this file (cf. #81691). + const previous = computeStaggeredCronPreviousRunAtMs(job, runAtMs + 1_000); return previous === runAtMs; }