Skip to content

Commit 39e3d58

Browse files
fix: prevent cron jobs from skipping execution when nextRunAtMs advances (#14068)
Co-authored-by: Tak Hoffman <[email protected]>
1 parent a88ea42 commit 39e3d58

File tree

3 files changed

+187
-1
lines changed

3 files changed

+187
-1
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { CronServiceState } from "./service/state.js";
3+
import type { CronJob } from "./types.js";
4+
import { recomputeNextRunsForMaintenance } from "./service/jobs.js";
5+
6+
describe("issue #13992 regression - cron jobs skip execution", () => {
7+
function createMockState(jobs: CronJob[]): CronServiceState {
8+
return {
9+
store: { version: 1, jobs },
10+
running: false,
11+
timer: null,
12+
storeLoadedAtMs: Date.now(),
13+
deps: {
14+
storePath: "/mock/path",
15+
cronEnabled: true,
16+
nowMs: () => Date.now(),
17+
log: {
18+
debug: () => {},
19+
info: () => {},
20+
warn: () => {},
21+
error: () => {},
22+
} as never,
23+
},
24+
};
25+
}
26+
27+
it("should NOT recompute nextRunAtMs for past-due jobs during maintenance", () => {
28+
const now = Date.now();
29+
const pastDue = now - 60_000; // 1 minute ago
30+
31+
const job: CronJob = {
32+
id: "test-job",
33+
name: "test job",
34+
enabled: true,
35+
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
36+
payload: { kind: "systemEvent", text: "test" },
37+
sessionTarget: "main",
38+
createdAtMs: now - 3600_000,
39+
updatedAtMs: now - 3600_000,
40+
state: {
41+
nextRunAtMs: pastDue, // This is in the past and should NOT be recomputed
42+
},
43+
};
44+
45+
const state = createMockState([job]);
46+
recomputeNextRunsForMaintenance(state);
47+
48+
// Should not have changed the past-due nextRunAtMs
49+
expect(job.state.nextRunAtMs).toBe(pastDue);
50+
});
51+
52+
it("should compute missing nextRunAtMs during maintenance", () => {
53+
const now = Date.now();
54+
55+
const job: CronJob = {
56+
id: "test-job",
57+
name: "test job",
58+
enabled: true,
59+
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
60+
payload: { kind: "systemEvent", text: "test" },
61+
sessionTarget: "main",
62+
createdAtMs: now,
63+
updatedAtMs: now,
64+
state: {
65+
// nextRunAtMs is missing
66+
},
67+
};
68+
69+
const state = createMockState([job]);
70+
recomputeNextRunsForMaintenance(state);
71+
72+
// Should have computed a nextRunAtMs
73+
expect(typeof job.state.nextRunAtMs).toBe("number");
74+
expect(job.state.nextRunAtMs).toBeGreaterThan(now);
75+
});
76+
77+
it("should clear nextRunAtMs for disabled jobs during maintenance", () => {
78+
const now = Date.now();
79+
const futureTime = now + 3600_000;
80+
81+
const job: CronJob = {
82+
id: "test-job",
83+
name: "test job",
84+
enabled: false, // Disabled
85+
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
86+
payload: { kind: "systemEvent", text: "test" },
87+
sessionTarget: "main",
88+
createdAtMs: now,
89+
updatedAtMs: now,
90+
state: {
91+
nextRunAtMs: futureTime,
92+
},
93+
};
94+
95+
const state = createMockState([job]);
96+
recomputeNextRunsForMaintenance(state);
97+
98+
// Should have cleared nextRunAtMs for disabled job
99+
expect(job.state.nextRunAtMs).toBeUndefined();
100+
});
101+
102+
it("should clear stuck running markers during maintenance", () => {
103+
const now = Date.now();
104+
const stuckTime = now - 3 * 60 * 60_000; // 3 hours ago (> 2 hour threshold)
105+
const futureTime = now + 3600_000;
106+
107+
const job: CronJob = {
108+
id: "test-job",
109+
name: "test job",
110+
enabled: true,
111+
schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" },
112+
payload: { kind: "systemEvent", text: "test" },
113+
sessionTarget: "main",
114+
createdAtMs: now,
115+
updatedAtMs: now,
116+
state: {
117+
nextRunAtMs: futureTime,
118+
runningAtMs: stuckTime, // Stuck running marker
119+
},
120+
};
121+
122+
const state = createMockState([job]);
123+
recomputeNextRunsForMaintenance(state);
124+
125+
// Should have cleared stuck running marker
126+
expect(job.state.runningAtMs).toBeUndefined();
127+
// But should NOT have changed nextRunAtMs (it's still future)
128+
expect(job.state.nextRunAtMs).toBe(futureTime);
129+
});
130+
});

src/cron/service/jobs.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,58 @@ export function recomputeNextRuns(state: CronServiceState): boolean {
163163
return changed;
164164
}
165165

166+
/**
167+
* Maintenance-only version of recomputeNextRuns that handles disabled jobs
168+
* and stuck markers, but does NOT recompute nextRunAtMs for enabled jobs
169+
* with existing values. Used during timer ticks when no due jobs were found
170+
* to prevent silently advancing past-due nextRunAtMs values without execution
171+
* (see #13992).
172+
*/
173+
export function recomputeNextRunsForMaintenance(state: CronServiceState): boolean {
174+
if (!state.store) {
175+
return false;
176+
}
177+
let changed = false;
178+
const now = state.deps.nowMs();
179+
for (const job of state.store.jobs) {
180+
if (!job.state) {
181+
job.state = {};
182+
changed = true;
183+
}
184+
if (!job.enabled) {
185+
if (job.state.nextRunAtMs !== undefined) {
186+
job.state.nextRunAtMs = undefined;
187+
changed = true;
188+
}
189+
if (job.state.runningAtMs !== undefined) {
190+
job.state.runningAtMs = undefined;
191+
changed = true;
192+
}
193+
continue;
194+
}
195+
const runningAt = job.state.runningAtMs;
196+
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
197+
state.deps.log.warn(
198+
{ jobId: job.id, runningAtMs: runningAt },
199+
"cron: clearing stuck running marker",
200+
);
201+
job.state.runningAtMs = undefined;
202+
changed = true;
203+
}
204+
// Only compute missing nextRunAtMs, do NOT recompute existing ones.
205+
// If a job was past-due but not found by findDueJobs, recomputing would
206+
// cause it to be silently skipped.
207+
if (job.state.nextRunAtMs === undefined) {
208+
const newNext = computeJobNextRunAtMs(job, now);
209+
if (newNext !== undefined) {
210+
job.state.nextRunAtMs = newNext;
211+
changed = true;
212+
}
213+
}
214+
}
215+
return changed;
216+
}
217+
166218
export function nextWakeAtMs(state: CronServiceState) {
167219
const jobs = state.store?.jobs ?? [];
168220
const enabled = jobs.filter((j) => j.enabled && typeof j.state.nextRunAtMs === "number");

src/cron/service/timer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
computeJobNextRunAtMs,
99
nextWakeAtMs,
1010
recomputeNextRuns,
11+
recomputeNextRunsForMaintenance,
1112
resolveJobPayloadTextForMain,
1213
} from "./jobs.js";
1314
import { locked } from "./locked.js";
@@ -187,7 +188,10 @@ export async function onTimer(state: CronServiceState) {
187188
const due = findDueJobs(state);
188189

189190
if (due.length === 0) {
190-
const changed = recomputeNextRuns(state);
191+
// Use maintenance-only recompute to avoid advancing past-due nextRunAtMs
192+
// values without execution. This prevents jobs from being silently skipped
193+
// when the timer wakes up but findDueJobs returns empty (see #13992).
194+
const changed = recomputeNextRunsForMaintenance(state);
191195
if (changed) {
192196
await persist(state);
193197
}

0 commit comments

Comments
 (0)