fix(cron): prevent daily jobs from skipping days (48h jump) #17852 (#17903)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 1ffe6a45af
Co-authored-by: pierreeurope <248892285+pierreeurope@users.noreply.github.com>
Co-authored-by: sebslight <19554889+sebslight@users.noreply.github.com>
Reviewed-by: @sebslight
This commit is contained in:
pierreeurope
2026-02-16 14:35:49 +01:00
committed by GitHub
parent 095d522099
commit fec4be8dec
5 changed files with 195 additions and 23 deletions

View File

@@ -102,6 +102,35 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
/** Maximum consecutive schedule errors before auto-disabling a job. */
const MAX_SCHEDULE_ERRORS = 3;
function recordScheduleComputeError(params: {
state: CronServiceState;
job: CronJob;
err: unknown;
}): boolean {
const { state, job, err } = params;
const errorCount = (job.state.scheduleErrorCount ?? 0) + 1;
const errText = String(err);
job.state.scheduleErrorCount = errorCount;
job.state.nextRunAtMs = undefined;
job.state.lastError = `schedule error: ${errText}`;
if (errorCount >= MAX_SCHEDULE_ERRORS) {
job.enabled = false;
state.deps.log.error(
{ jobId: job.id, name: job.name, errorCount, err: errText },
"cron: auto-disabled job after repeated schedule errors",
);
} else {
state.deps.log.warn(
{ jobId: job.id, name: job.name, errorCount, err: errText },
"cron: failed to compute next run for job (skipping)",
);
}
return true;
}
function normalizeJobTickState(params: { state: CronServiceState; job: CronJob; nowMs: number }): {
changed: boolean;
skip: boolean;
@@ -184,23 +213,8 @@ export function recomputeNextRuns(state: CronServiceState): boolean {
changed = true;
}
} catch (err) {
const errorCount = (job.state.scheduleErrorCount ?? 0) + 1;
job.state.scheduleErrorCount = errorCount;
job.state.nextRunAtMs = undefined;
job.state.lastError = `schedule error: ${String(err)}`;
changed = true;
if (errorCount >= MAX_SCHEDULE_ERRORS) {
job.enabled = false;
state.deps.log.error(
{ jobId: job.id, name: job.name, errorCount, err: String(err) },
"cron: auto-disabled job after repeated schedule errors",
);
} else {
state.deps.log.warn(
{ jobId: job.id, name: job.name, errorCount, err: String(err) },
"cron: failed to compute next run for job (skipping)",
);
if (recordScheduleComputeError({ state, job, err })) {
changed = true;
}
}
}
@@ -222,10 +236,21 @@ export function recomputeNextRunsForMaintenance(state: CronServiceState): boolea
// 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;
try {
const newNext = computeJobNextRunAtMs(job, now);
if (job.state.nextRunAtMs !== newNext) {
job.state.nextRunAtMs = newNext;
changed = true;
}
// Clear schedule error count on successful computation.
if (job.state.scheduleErrorCount) {
job.state.scheduleErrorCount = undefined;
changed = true;
}
} catch (err) {
if (recordScheduleComputeError({ state, job, err })) {
changed = true;
}
}
}
return changed;

View File

@@ -7,7 +7,6 @@ import { sweepCronRunSessions } from "../session-reaper.js";
import {
computeJobNextRunAtMs,
nextWakeAtMs,
recomputeNextRuns,
recomputeNextRunsForMaintenance,
resolveJobPayloadTextForMain,
} from "./jobs.js";
@@ -283,7 +282,12 @@ export async function onTimer(state: CronServiceState) {
}
}
recomputeNextRuns(state);
// Use maintenance-only recompute to avoid advancing past-due
// nextRunAtMs values that became due between findDueJobs and this
// locked block. The full recomputeNextRuns would silently skip
// those jobs (advancing nextRunAtMs without execution), causing
// daily cron schedules to jump 48 h instead of 24 h (#17852).
recomputeNextRunsForMaintenance(state);
await persist(state);
});
}