fix(cron): prevent recomputeNextRuns from skipping due jobs in onTimer (#9823)

* fix(cron): prevent recomputeNextRuns from skipping due jobs in onTimer

ensureLoaded(forceReload) called recomputeNextRuns before runDueJobs,
which recalculated nextRunAtMs to a strictly future time. Since
setTimeout always fires a few ms late, the due check (now >= nextRunAtMs)
always failed and every/cron jobs never executed. Fixes #9788.

* docs: add changelog entry for cron timer race fix (#9823) (thanks @pycckuu)

---------

Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
Igor Markelov
2026-02-06 07:43:37 +08:00
committed by GitHub
parent 68393bfa36
commit 313e2f2e85
4 changed files with 151 additions and 5 deletions

View File

@@ -126,7 +126,15 @@ async function getFileMtimeMs(path: string): Promise<number | null> {
}
}
export async function ensureLoaded(state: CronServiceState, opts?: { forceReload?: boolean }) {
export async function ensureLoaded(
state: CronServiceState,
opts?: {
forceReload?: boolean;
/** Skip recomputing nextRunAtMs after load so the caller can run due
* jobs against the persisted values first (see onTimer). */
skipRecompute?: boolean;
},
) {
// Fast path: store is already in memory. Other callers (add, list, run, …)
// trust the in-memory copy to avoid a stat syscall on every operation.
if (state.store && !opts?.forceReload) {
@@ -255,8 +263,9 @@ export async function ensureLoaded(state: CronServiceState, opts?: { forceReload
state.storeLoadedAtMs = state.deps.nowMs();
state.storeFileMtimeMs = fileMtimeMs;
// Recompute next runs after loading to ensure accuracy
recomputeNextRuns(state);
if (!opts?.skipRecompute) {
recomputeNextRuns(state);
}
if (mutated) {
await persist(state);

View File

@@ -1,7 +1,12 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { CronJob } from "../types.js";
import type { CronEvent, CronServiceState } from "./state.js";
import { computeJobNextRunAtMs, nextWakeAtMs, resolveJobPayloadTextForMain } from "./jobs.js";
import {
computeJobNextRunAtMs,
nextWakeAtMs,
recomputeNextRuns,
resolveJobPayloadTextForMain,
} from "./jobs.js";
import { locked } from "./locked.js";
import { ensureLoaded, persist } from "./store.js";
@@ -36,8 +41,12 @@ export async function onTimer(state: CronServiceState) {
state.running = true;
try {
await locked(state, async () => {
await ensureLoaded(state, { forceReload: true });
// Reload persisted due-times without recomputing so runDueJobs sees
// the original nextRunAtMs values. Recomputing first would advance
// every/cron slots past the current tick when the timer fires late (#9788).
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
await runDueJobs(state);
recomputeNextRuns(state);
await persist(state);
armTimer(state);
});