fix: cronservice fix

This commit is contained in:
zk
2026-05-17 19:26:13 +00:00
parent b07e780953
commit d84d04041f
2 changed files with 109 additions and 5 deletions
@@ -0,0 +1,71 @@
import { CronExpressionParser } from "cron-parser";
import { describe, expect, it } from "vitest";
import { getDueFireTime } from "./cron-service";
/** Slow reference: advance from `start` until the last fire still <= `now` (for tests only). */
function dueFireTimeForwardFrom(
schedule: string,
start: Date,
now: Date,
maxSteps: number,
): Date | null {
const it = CronExpressionParser.parse(schedule, { currentDate: start });
const first = it.next().toDate();
if (first.getTime() > now.getTime()) {
return null;
}
let lastDue = first;
for (let i = 0; i < maxSteps; i++) {
const n = it.next().toDate();
if (n.getTime() > now.getTime()) {
return lastDue;
}
lastDue = n;
}
throw new Error(`Exceeded ${maxSteps} steps (schedule ${schedule})`);
}
describe("getDueFireTime", () => {
it("null last: minutely schedule uses bounded search and matches a short forward window", () => {
const now = new Date("2026-05-17T18:45:30.123Z");
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const expected = dueFireTimeForwardFrom("* * * * *", from, now, 5000);
expect(getDueFireTime("* * * * *", null, now)).toEqual(expected);
});
it("null last: exact minute boundary matches forward iteration (prev() must not skip the current tick)", () => {
const now = new Date("2026-05-17T18:45:00.000Z");
const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const expected = dueFireTimeForwardFrom("* * * * *", from, now, 5000);
expect(getDueFireTime("* * * * *", null, now)).toEqual(expected);
});
it("null last: daily monthly and yearly examples match a bounded forward reference", () => {
const specs: Array<{ schedule: string; now: string; maxSteps: number; daysBack: number }> = [
{ schedule: "5 0 * * *", now: "2026-05-17T18:45:30.000Z", maxSteps: 50, daysBack: 3 },
{ schedule: "0 0 * * *", now: "2026-05-17T18:45:30.000Z", maxSteps: 10, daysBack: 3 },
{ schedule: "0 0 1 * *", now: "2026-03-15T12:00:00.000Z", maxSteps: 40, daysBack: 60 },
{ schedule: "0 0 1 1 *", now: "2026-06-15T12:00:00.000Z", maxSteps: 10, daysBack: 400 },
];
for (const { schedule, now: nowIso, maxSteps, daysBack } of specs) {
const now = new Date(nowIso);
const start = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);
const expected = dueFireTimeForwardFrom(schedule, start, now, maxSteps);
expect(getDueFireTime(schedule, null, now), schedule).toEqual(expected);
}
});
it("non-null last: unchanged skip-missed semantics vs forward reference", () => {
const now = new Date("2026-05-17T18:45:30.000Z");
const last = new Date("2026-05-17T18:40:00.000Z");
const expected = dueFireTimeForwardFrom("* * * * *", new Date(last.getTime() + 1), now, 200);
expect(getDueFireTime("* * * * *", last, now)).toEqual(expected);
});
it("non-null last: not due yet", () => {
const now = new Date("2026-05-17T18:44:59.000Z");
const last = new Date("2026-05-17T18:44:00.000Z");
expect(getDueFireTime("* * * * *", last, now)).toBeNull();
});
});
@@ -11,23 +11,56 @@ const CRON_ADVISORY_KEY2 = 0x63_72_6f_6e; // "cron"
/**
* Next cron fire strictly after `last`, that is still <= `now`.
* If none, `null` (not due). "Skip missed" to the latest such fire time.
*
* When `last` is null (never scheduled), walking forward from the epoch is infeasible for
* minutely schedules. We first find a recent `start` by exponential lookback where the first
* `next()` is still <= `now`, then advance with the same `next()` loop as the non-null path.
*/
export function getDueFireTime(schedule: string, last: Date | null, now: Date): Date | null {
const it = CronExpressionParser.parse(schedule, {
currentDate: last ? new Date(last.getTime() + 1) : new Date(0),
});
const first = it.next().toDate();
const startAndFirstAfter = (start: Date): { it: ReturnType<typeof CronExpressionParser.parse>; first: Date } => {
const it = CronExpressionParser.parse(schedule, { currentDate: start });
const first = it.next().toDate();
return { it, first };
};
let it: ReturnType<typeof CronExpressionParser.parse>;
let first: Date;
if (!last) {
let windowMs = 60_000;
const maxWindowMs = 800 * 24 * 60 * 60 * 1000;
for (;;) {
const start = new Date(Math.max(0, now.getTime() - windowMs));
const parsed = startAndFirstAfter(start);
if (parsed.first.getTime() <= now.getTime()) {
it = parsed.it;
first = parsed.first;
break;
}
if (windowMs >= maxWindowMs) {
return null;
}
windowMs = Math.min(windowMs * 2, maxWindowMs);
}
} else {
const parsed = startAndFirstAfter(new Date(last.getTime() + 1));
it = parsed.it;
first = parsed.first;
}
if (first.getTime() > now.getTime()) {
return null;
}
let lastDue = first;
for (;;) {
const maxSteps = 2_000_000;
for (let step = 0; step < maxSteps; step++) {
const n = it.next().toDate();
if (n.getTime() > now.getTime()) {
return lastDue;
}
lastDue = n;
}
throw new Error(`getDueFireTime exceeded ${maxSteps} next() steps (schedule ${schedule}).`);
}
export async function syncCronTasksFromRegistry(): Promise<void> {