fix(cron): treat exact-second cron slots as valid in stale-future repair (#81731)

isStaggeredCronRunAtMs probed the cron library at runAtMs + 1 to decide
whether a persisted nextRunAtMs was a real schedule slot. Croner-style
second-granular schedules normalize that 1ms probe back to the candidate
second, so previousRuns(1, runAtMs + 1) returns the slot before the
candidate instead of the candidate itself. shouldRepairFutureCronNextRunAtMs
then classified valid exact-second slots two-plus intervals out as stale
and rebased them.

Probe at runAtMs + 1_000 instead so the previous-run lookup lands past the
candidate second, matching the +1s cursor step used elsewhere in this file.

Fixes #81691

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
yashkot007
2026-06-28 15:19:07 -07:00
committed by GitHub
parent 542e9a4e67
commit e99f254f1c
2 changed files with 46 additions and 1 deletions
+39
View File
@@ -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");
+7 -1
View File
@@ -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;
}