diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 143f6b52607..d6493999070 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -39,22 +39,22 @@ describe("cron schedule", () => { const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" }; const noonMs = Date.parse("2026-02-08T12:00:00.000Z"); - it("returns current occurrence when nowMs is exactly at the match", () => { + it("advances past current second when nowMs is exactly at the match", () => { + // Fix #14164: must NOT return the current second — that caused infinite + // re-fires when multiple jobs triggered simultaneously. const next = computeNextRunAtMs(dailyNoon, noonMs); - expect(next).toBe(noonMs); + expect(next).toBe(noonMs + 86_400_000); // next day }); - it("returns current occurrence when nowMs is mid-second (.500) within the match", () => { - // This is the core regression: without the second-floor fix, a 1ms - // lookback from 12:00:00.499 still lands inside the matching second, - // causing croner to skip to the *next day*. + it("advances past current second when nowMs is mid-second (.500) within the match", () => { + // Fix #14164: returning the current second caused rapid duplicate fires. const next = computeNextRunAtMs(dailyNoon, noonMs + 500); - expect(next).toBe(noonMs); + expect(next).toBe(noonMs + 86_400_000); // next day }); - it("returns current occurrence when nowMs is late in the matching second (.999)", () => { + it("advances past current second when nowMs is late in the matching second (.999)", () => { const next = computeNextRunAtMs(dailyNoon, noonMs + 999); - expect(next).toBe(noonMs); + expect(next).toBe(noonMs + 86_400_000); // next day }); it("advances to next day once the matching second is fully past", () => { diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 1c245988ec3..0ef221c2a89 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -50,16 +50,18 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe catch: false, }); // Cron operates at second granularity, so floor nowMs to the start of the - // current second. This prevents the lookback from landing inside a matching - // second — if nowMs is e.g. 12:00:00.500 and the pattern fires at second 0, - // a 1ms lookback (12:00:00.499) is still *within* that second, causing - // croner to skip ahead to the next occurrence (e.g. the following day). - // Flooring first ensures the lookback always falls in the *previous* second. + // current second. We ask croner for the next occurrence strictly *after* + // nowSecondMs so that a job whose schedule matches the current second is + // never re-scheduled into the same (already-elapsed) second. + // + // Previous code used `nowSecondMs - 1` which caused croner to return the + // current second as a valid next-run, leading to rapid duplicate fires when + // multiple jobs triggered simultaneously (see #14164). const nowSecondMs = Math.floor(nowMs / 1000) * 1000; - const next = cron.nextRun(new Date(nowSecondMs - 1)); + const next = cron.nextRun(new Date(nowSecondMs)); if (!next) { return undefined; } const nextMs = next.getTime(); - return Number.isFinite(nextMs) && nextMs >= nowSecondMs ? nextMs : undefined; + return Number.isFinite(nextMs) && nextMs > nowSecondMs ? nextMs : undefined; }