Skip to content

Commit 4dd449c

Browse files
author
Operative-001
committed
fix(cron): prevent spin loop when job completes within firing second (#17821)
When a cron job fires at 13:00:00.014 and completes at 13:00:00.021, computeNextRunAtMs was flooring nowMs to 13:00:00.000 and asking croner for the next occurrence from that exact boundary. Croner could return 13:00:00.000 (same second) since it uses >= semantics, causing the job to be immediately re-triggered hundreds of times. Fix: Ask croner for the next occurrence starting from the NEXT second (e.g., 13:00:01.000). This ensures we always skip the current/elapsed second and correctly return the next day's occurrence. This also correctly handles the before-match case: if nowMs is 11:59:59.500, we ask from 12:00:00.000, and croner returns today's 12:00:00.000 match. Added regression tests for the spin loop scenario.
1 parent d841c9b commit 4dd449c

File tree

2 files changed

+32
-10
lines changed

2 files changed

+32
-10
lines changed

src/cron/schedule.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,23 @@ describe("cron schedule", () => {
6666
const next = computeNextRunAtMs(dailyNoon, noonMs - 500);
6767
expect(next).toBe(noonMs);
6868
});
69+
70+
it("advances to next day when job completes within same second it fired (#17821)", () => {
71+
// Regression test for #17821: cron jobs that fire and complete within
72+
// the same second (e.g., fire at 12:00:00.014, complete at 12:00:00.021)
73+
// were getting nextRunAtMs set to the same second, causing a spin loop.
74+
//
75+
// Simulating: job scheduled for 12:00:00, fires at .014, completes at .021
76+
const completedAtMs = noonMs + 21; // 12:00:00.021
77+
const next = computeNextRunAtMs(dailyNoon, completedAtMs);
78+
expect(next).toBe(noonMs + 86_400_000); // must be next day, NOT noonMs
79+
});
80+
81+
it("advances to next day when job completes just before second boundary (#17821)", () => {
82+
// Edge case: job completes at .999, still within the firing second
83+
const completedAtMs = noonMs + 999; // 12:00:00.999
84+
const next = computeNextRunAtMs(dailyNoon, completedAtMs);
85+
expect(next).toBe(noonMs + 86_400_000); // next day
86+
});
6987
});
7088
});

src/cron/schedule.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,23 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
4949
timezone: resolveCronTimezone(schedule.tz),
5050
catch: false,
5151
});
52-
// Cron operates at second granularity, so floor nowMs to the start of the
53-
// current second. We ask croner for the next occurrence strictly *after*
54-
// nowSecondMs so that a job whose schedule matches the current second is
55-
// never re-scheduled into the same (already-elapsed) second.
52+
// Ask croner for the next occurrence starting from the NEXT second.
53+
// This prevents re-scheduling into the current second when a job fires
54+
// at 13:00:00.014 and completes at 13:00:00.021 — without this fix,
55+
// croner could return 13:00:00.000 (same second) causing a spin loop
56+
// where the job fires hundreds of times per second (see #17821).
5657
//
57-
// Previous code used `nowSecondMs - 1` which caused croner to return the
58-
// current second as a valid next-run, leading to rapid duplicate fires when
59-
// multiple jobs triggered simultaneously (see #14164).
60-
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
61-
const next = cron.nextRun(new Date(nowSecondMs));
58+
// By asking from the next second (e.g., 13:00:01.000), we ensure croner
59+
// returns the following day's occurrence (e.g., 13:00:00.000 tomorrow).
60+
//
61+
// This also correctly handles the "before match" case: if nowMs is
62+
// 11:59:59.500, we ask from 12:00:00.000, and croner returns 12:00:00.000
63+
// (today's match) since it uses >= semantics for the start time.
64+
const askFromNextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
65+
const next = cron.nextRun(new Date(askFromNextSecondMs));
6266
if (!next) {
6367
return undefined;
6468
}
6569
const nextMs = next.getTime();
66-
return Number.isFinite(nextMs) && nextMs > nowSecondMs ? nextMs : undefined;
70+
return Number.isFinite(nextMs) ? nextMs : undefined;
6771
}

0 commit comments

Comments
 (0)