Skip to content

Commit 992b50a

Browse files
steipeteNianJiuZst
andcommitted
fix(cron): preserve exact restart boundaries
Co-authored-by: 李兰 0668001394 <[email protected]> Co-authored-by: NianJiuZst <[email protected]>
1 parent 96add8e commit 992b50a

4 files changed

Lines changed: 203 additions & 8 deletions

File tree

src/cron/service.jobs.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
applyJobPatch,
5+
computeJobNextRunAtMs,
6+
computeJobPreviousRunAtOrBeforeMs,
57
createJob,
68
nextWakeAtMs,
79
recomputeNextRuns,
@@ -1094,6 +1096,45 @@ describe("cron stagger defaults", () => {
10941096
});
10951097
});
10961098

1099+
describe("computeJobPreviousRunAtOrBeforeMs", () => {
1100+
function createCronJob(schedule: Extract<CronJob["schedule"], { kind: "cron" }>): CronJob {
1101+
return {
1102+
id: "inclusive-previous-run",
1103+
name: "inclusive previous run",
1104+
enabled: true,
1105+
createdAtMs: 0,
1106+
updatedAtMs: 0,
1107+
schedule,
1108+
sessionTarget: "main",
1109+
wakeMode: "now",
1110+
payload: { kind: "systemEvent", text: "tick" },
1111+
state: {},
1112+
};
1113+
}
1114+
1115+
it("includes an exact boundary and keeps the prior slot between boundaries", () => {
1116+
const job = createCronJob({ kind: "cron", expr: "* * * * * *", tz: "UTC", staggerMs: 0 });
1117+
const boundary = Date.parse("2025-12-13T04:02:00.000Z");
1118+
1119+
expect(computeJobPreviousRunAtOrBeforeMs(job, boundary)).toBe(boundary);
1120+
expect(computeJobPreviousRunAtOrBeforeMs(job, boundary + 500)).toBe(boundary);
1121+
});
1122+
1123+
it("includes an exact effective boundary after per-job staggering", () => {
1124+
const job = createCronJob({
1125+
kind: "cron",
1126+
expr: "0 * * * * *",
1127+
tz: "UTC",
1128+
staggerMs: 30_000,
1129+
});
1130+
const cursor = Date.parse("2025-12-13T04:02:00.000Z");
1131+
const effectiveBoundary = computeJobNextRunAtMs(job, cursor);
1132+
1133+
expect(effectiveBoundary).toBeTypeOf("number");
1134+
expect(computeJobPreviousRunAtOrBeforeMs(job, effectiveBoundary!)).toBe(effectiveBoundary);
1135+
});
1136+
});
1137+
10971138
describe("createJob delivery defaults", () => {
10981139
const now = Date.parse("2026-02-28T12:00:00.000Z");
10991140

src/cron/service.restart-catchup.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,116 @@ describe("CronService restart catch-up", () => {
270270
}
271271
});
272272

273+
it("does not defer an isolated cron agent-turn whose persisted due slot already succeeded", async () => {
274+
const store = await makeStorePath();
275+
const startNow = Date.parse("2025-12-13T11:00:00.000Z");
276+
const dueAt = Date.parse("2025-12-13T09:10:00.000Z");
277+
const completedAt = Date.parse("2025-12-13T09:10:30.000Z");
278+
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const }));
279+
const enqueueSystemEvent = vi.fn();
280+
const requestHeartbeat = vi.fn();
281+
282+
await writeStoreJobs(store.storePath, [
283+
{
284+
id: "startup-isolated-agent-already-ok",
285+
name: "startup isolated agent already ok",
286+
enabled: true,
287+
createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
288+
updatedAtMs: completedAt,
289+
schedule: { kind: "cron", expr: "10 9 * * *", tz: "UTC" },
290+
sessionTarget: "isolated",
291+
wakeMode: "next-heartbeat",
292+
payload: { kind: "agentTurn", message: "daily reminder" },
293+
state: {
294+
nextRunAtMs: dueAt,
295+
lastRunAtMs: completedAt,
296+
lastRunStatus: "ok",
297+
},
298+
},
299+
]);
300+
301+
const cron = createRestartCronService({
302+
storePath: store.storePath,
303+
enqueueSystemEvent,
304+
requestHeartbeat,
305+
runIsolatedAgentJob,
306+
nowMs: () => startNow,
307+
startupDeferredMissedAgentJobDelayMs: 120_000,
308+
});
309+
310+
try {
311+
await cron.start();
312+
313+
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
314+
expect(enqueueSystemEvent).not.toHaveBeenCalled();
315+
expect(requestHeartbeat).not.toHaveBeenCalled();
316+
317+
const listedJobs = await cron.list({ includeDisabled: true });
318+
const updated = listedJobs.find((job) => job.id === "startup-isolated-agent-already-ok");
319+
expect(updated?.state.lastRunStatus).toBe("ok");
320+
expect(updated?.state.nextRunAtMs).toBe(Date.parse("2025-12-14T09:10:00.000Z"));
321+
} finally {
322+
cron.stop();
323+
await store.cleanup();
324+
}
325+
});
326+
327+
it("replays a newer missed cron slot behind a completed persisted slot", async () => {
328+
vi.setSystemTime(new Date("2025-12-13T04:10:00.000Z"));
329+
await withRestartedCron(
330+
[
331+
{
332+
id: "restart-completed-slot-newer-miss",
333+
name: "completed slot with newer miss",
334+
enabled: true,
335+
createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
336+
updatedAtMs: Date.parse("2025-12-13T04:01:30.000Z"),
337+
schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" },
338+
sessionTarget: "main",
339+
wakeMode: "next-heartbeat",
340+
payload: { kind: "systemEvent", text: "newer slot missed" },
341+
state: {
342+
nextRunAtMs: Date.parse("2025-12-13T04:01:00.000Z"),
343+
lastRunAtMs: Date.parse("2025-12-13T04:01:00.000Z"),
344+
lastRunStatus: "ok",
345+
},
346+
},
347+
],
348+
async ({ enqueueSystemEvent, requestHeartbeat }) => {
349+
expectQueuedSystemEvent(enqueueSystemEvent, "newer slot missed");
350+
expect(requestHeartbeat).toHaveBeenCalled();
351+
},
352+
);
353+
});
354+
355+
it("replays a cron slot due exactly at restart behind a completed persisted slot", async () => {
356+
vi.setSystemTime(new Date("2025-12-13T04:02:00.000Z"));
357+
await withRestartedCron(
358+
[
359+
{
360+
id: "restart-completed-slot-boundary-miss",
361+
name: "completed slot with boundary miss",
362+
enabled: true,
363+
createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
364+
updatedAtMs: Date.parse("2025-12-13T04:01:30.000Z"),
365+
schedule: { kind: "cron", expr: "* * * * *", tz: "UTC" },
366+
sessionTarget: "main",
367+
wakeMode: "next-heartbeat",
368+
payload: { kind: "systemEvent", text: "boundary slot missed" },
369+
state: {
370+
nextRunAtMs: Date.parse("2025-12-13T04:01:00.000Z"),
371+
lastRunAtMs: Date.parse("2025-12-13T04:01:00.000Z"),
372+
lastRunStatus: "ok",
373+
},
374+
},
375+
],
376+
async ({ enqueueSystemEvent, requestHeartbeat }) => {
377+
expectQueuedSystemEvent(enqueueSystemEvent, "boundary slot missed");
378+
expect(requestHeartbeat).toHaveBeenCalled();
379+
},
380+
);
381+
});
382+
273383
it("marks interrupted recurring jobs failed instead of replaying them on startup", async () => {
274384
const dueAt = Date.parse("2025-12-13T16:00:00.000Z");
275385
const staleRunningAt = Date.parse("2025-12-13T16:30:00.000Z");

src/cron/service/jobs.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,32 @@ function computeStaggeredCronPreviousRunAtMs(job: CronJob, nowMs: number) {
181181
return undefined;
182182
}
183183

184+
function computeStaggeredCronPreviousRunAtOrBeforeMs(job: CronJob, nowMs: number) {
185+
const previous = computeStaggeredCronPreviousRunAtMs(job, nowMs);
186+
const probeMs = nowMs + 1_000;
187+
if (!Number.isFinite(probeMs)) {
188+
return previous;
189+
}
190+
191+
// Croner previous-run queries are strict-before and second-granular. Keep
192+
// the strict result, then probe past the current second to include a slot
193+
// exactly at now without losing the prior slot between boundaries.
194+
const boundary = computeStaggeredCronPreviousRunAtMs(job, probeMs);
195+
if (
196+
isFiniteTimestamp(boundary) &&
197+
boundary <= nowMs &&
198+
(!isFiniteTimestamp(previous) || boundary > previous)
199+
) {
200+
return boundary;
201+
}
202+
return previous;
203+
}
204+
184205
function isStaggeredCronRunAtMs(job: CronJob, runAtMs: number): boolean {
185206
if (job.schedule.kind !== "cron" || !isFiniteTimestamp(runAtMs)) {
186207
return false;
187208
}
188-
// Probe past the candidate second. Croner-style second-granular schedules
189-
// normalize a 1ms probe back to the candidate's second, so
190-
// `previousRuns(1, runAtMs + 1)` returns the slot before the candidate
191-
// rather than the candidate itself and exact-second slots get misclassified
192-
// as stale. A 1s probe lands past the candidate second, matching the cursor
193-
// step used elsewhere in this file (cf. #81691).
194-
const previous = computeStaggeredCronPreviousRunAtMs(job, runAtMs + 1_000);
209+
const previous = computeStaggeredCronPreviousRunAtOrBeforeMs(job, runAtMs);
195210
return previous === runAtMs;
196211
}
197212

@@ -510,6 +525,15 @@ export function computeJobPreviousRunAtMs(job: CronJob, nowMs: number): number |
510525
return isFiniteTimestamp(previous) ? previous : undefined;
511526
}
512527

528+
/** Computes the latest effective cron timestamp at or before the supplied time. */
529+
export function computeJobPreviousRunAtOrBeforeMs(job: CronJob, nowMs: number): number | undefined {
530+
if (!isJobEnabled(job) || job.schedule.kind !== "cron") {
531+
return undefined;
532+
}
533+
const previous = computeStaggeredCronPreviousRunAtOrBeforeMs(job, nowMs);
534+
return isFiniteTimestamp(previous) ? previous : undefined;
535+
}
536+
513537
/** Maximum consecutive schedule errors before auto-disabling a job. */
514538
const MAX_SCHEDULE_ERRORS = 3;
515539

src/cron/service/timer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
} from "./failure-alerts.js";
7070
import {
7171
DEFAULT_ERROR_BACKOFF_SCHEDULE_MS,
72+
computeJobPreviousRunAtOrBeforeMs,
7273
computeJobPreviousRunAtMs,
7374
computeJobNextRunAtMs,
7475
errorBackoffMs,
@@ -1833,7 +1834,26 @@ function isRunnableJob(params: {
18331834
return false;
18341835
}
18351836
if (hasScheduledNextRunAtMs(next) && nowMs >= next) {
1836-
return true;
1837+
const lastRunAtMs = job.state.lastRunAtMs;
1838+
// Startup loads persisted state before maintenance recompute. Suppress a
1839+
// completed stale slot, but still replay a newer slot due by restart time.
1840+
const alreadyCompletedDueCronSlot =
1841+
params.allowCronMissedRunByLastRun &&
1842+
job.schedule.kind === "cron" &&
1843+
lastRunStatus === "ok" &&
1844+
typeof lastRunAtMs === "number" &&
1845+
Number.isFinite(lastRunAtMs) &&
1846+
lastRunAtMs >= next;
1847+
if (!alreadyCompletedDueCronSlot) {
1848+
return true;
1849+
}
1850+
let latestRunAtMs: number | undefined;
1851+
try {
1852+
latestRunAtMs = computeJobPreviousRunAtOrBeforeMs(job, nowMs);
1853+
} catch {
1854+
return false;
1855+
}
1856+
return typeof latestRunAtMs === "number" && latestRunAtMs > lastRunAtMs;
18371857
}
18381858
if (!params.allowCronMissedRunByLastRun || job.schedule.kind !== "cron") {
18391859
return false;

0 commit comments

Comments
 (0)