mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-10 08:18:12 +03:00
fix: cronservice fix
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user