fix: prevent cron jobs from skipping execution when nextRunAtMs advances (#14068)

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
WalterSumbon
2026-02-12 13:33:22 +08:00
committed by GitHub
parent a88ea42ec7
commit 39e3d58fe1
3 changed files with 187 additions and 1 deletions

View File

@@ -163,6 +163,58 @@ export function recomputeNextRuns(state: CronServiceState): boolean {
return changed;
}
/**
* Maintenance-only version of recomputeNextRuns that handles disabled jobs
* and stuck markers, but does NOT recompute nextRunAtMs for enabled jobs
* with existing values. Used during timer ticks when no due jobs were found
* to prevent silently advancing past-due nextRunAtMs values without execution
* (see #13992).
*/
export function recomputeNextRunsForMaintenance(state: CronServiceState): boolean {
if (!state.store) {
return false;
}
let changed = false;
const now = state.deps.nowMs();
for (const job of state.store.jobs) {
if (!job.state) {
job.state = {};
changed = true;
}
if (!job.enabled) {
if (job.state.nextRunAtMs !== undefined) {
job.state.nextRunAtMs = undefined;
changed = true;
}
if (job.state.runningAtMs !== undefined) {
job.state.runningAtMs = undefined;
changed = true;
}
continue;
}
const runningAt = job.state.runningAtMs;
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
state.deps.log.warn(
{ jobId: job.id, runningAtMs: runningAt },
"cron: clearing stuck running marker",
);
job.state.runningAtMs = undefined;
changed = true;
}
// Only compute missing nextRunAtMs, do NOT recompute existing ones.
// If a job was past-due but not found by findDueJobs, recomputing would
// cause it to be silently skipped.
if (job.state.nextRunAtMs === undefined) {
const newNext = computeJobNextRunAtMs(job, now);
if (newNext !== undefined) {
job.state.nextRunAtMs = newNext;
changed = true;
}
}
}
return changed;
}
export function nextWakeAtMs(state: CronServiceState) {
const jobs = state.store?.jobs ?? [];
const enabled = jobs.filter((j) => j.enabled && typeof j.state.nextRunAtMs === "number");

View File

@@ -8,6 +8,7 @@ import {
computeJobNextRunAtMs,
nextWakeAtMs,
recomputeNextRuns,
recomputeNextRunsForMaintenance,
resolveJobPayloadTextForMain,
} from "./jobs.js";
import { locked } from "./locked.js";
@@ -187,7 +188,10 @@ export async function onTimer(state: CronServiceState) {
const due = findDueJobs(state);
if (due.length === 0) {
const changed = recomputeNextRuns(state);
// Use maintenance-only recompute to avoid advancing past-due nextRunAtMs
// values without execution. This prevents jobs from being silently skipped
// when the timer wakes up but findDueJobs returns empty (see #13992).
const changed = recomputeNextRunsForMaintenance(state);
if (changed) {
await persist(state);
}