Files
Moltbot/src/cron/schedule.ts
Operative-001 de6cc05e7e 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.
2026-02-17 00:01:53 +01:00

72 lines
2.7 KiB
TypeScript

import { Cron } from "croner";
import type { CronSchedule } from "./types.js";
import { parseAbsoluteTimeMs } from "./parse.js";
function resolveCronTimezone(tz?: string) {
const trimmed = typeof tz === "string" ? tz.trim() : "";
if (trimmed) {
return trimmed;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
// The store migration should convert atMs→at, but be defensive in case
// the migration hasn't run yet or was bypassed.
const sched = schedule as { at?: string; atMs?: number | string };
const atMs =
typeof sched.atMs === "number" && Number.isFinite(sched.atMs) && sched.atMs > 0
? sched.atMs
: typeof sched.atMs === "string"
? parseAbsoluteTimeMs(sched.atMs)
: typeof sched.at === "string"
? parseAbsoluteTimeMs(sched.at)
: null;
if (atMs === null) {
return undefined;
}
return atMs > nowMs ? atMs : undefined;
}
if (schedule.kind === "every") {
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
if (nowMs < anchor) {
return anchor;
}
const elapsed = nowMs - anchor;
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
return anchor + steps * everyMs;
}
const expr = schedule.expr.trim();
if (!expr) {
return undefined;
}
const cron = new Cron(expr, {
timezone: resolveCronTimezone(schedule.tz),
catch: false,
});
// Ask croner for the next occurrence starting from the NEXT second.
// This prevents re-scheduling into the current second when a job fires
// at 13:00:00.014 and completes at 13:00:00.021 — without this fix,
// croner could return 13:00:00.000 (same second) causing a spin loop
// where the job fires hundreds of times per second (see #17821).
//
// By asking from the next second (e.g., 13:00:01.000), we ensure croner
// returns the following day's occurrence (e.g., 13:00:00.000 tomorrow).
//
// 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 12:00:00.000
// (today's match) since it uses >= semantics for the start time.
const askFromNextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
const next = cron.nextRun(new Date(askFromNextSecondMs));
if (!next) {
return undefined;
}
const nextMs = next.getTime();
return Number.isFinite(nextMs) ? nextMs : undefined;
}