Files
openclaw/src/cron/schedule.ts
Xinhua Gu dd6047d998 fix(cron): prevent duplicate fires when multiple jobs trigger simultaneously (#14256)
The `computeNextRunAtMs` function used `nowSecondMs - 1` as the
reference time for croner's `nextRun()`, which caused it to return the
current second as a valid next-run time. When a job fired at e.g.
11:00:00.500, computing the next run still yielded 11:00:00.000 (same
second, already elapsed), causing the scheduler to immediately re-fire
the job in a tight loop (15-21x observed in the wild).

Fix: use `nowSecondMs` directly (no `-1` lookback) and change the
return guard from `>=` to `>` so next-run is always strictly after
the current second.

Fixes #14164
2026-02-11 22:04:17 -06:00

68 lines
2.4 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,
});
// Cron operates at second granularity, so floor nowMs to the start of the
// 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));
if (!next) {
return undefined;
}
const nextMs = next.getTime();
return Number.isFinite(nextMs) && nextMs > nowSecondMs ? nextMs : undefined;
}