mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(cron): preserve scheduling across historical timezone transitions (#118712)
* fix(cron): normalize timezone transitions and historical DST schedules * fix(cron): validate timezone transitions in scheduler retry paths
This commit is contained in:
committed by
GitHub
parent
206a784961
commit
9acbbd805c
+277
-1
@@ -1,5 +1,6 @@
|
||||
// Cron schedule tests cover schedule parsing and next-run calculations.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { Cron } from "croner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
coerceFiniteScheduleNumber,
|
||||
computeNextRunAtMs,
|
||||
@@ -49,6 +50,281 @@ describe("cron schedule", () => {
|
||||
expect(new Date(next ?? 0).getUTCFullYear()).toBe(2026);
|
||||
});
|
||||
|
||||
describe("daylight-saving transitions", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "New York repeated hour retains the later same-day reminder",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 1,3 * * *",
|
||||
now: "2026-11-01T06:15:00.000Z",
|
||||
next: "2026-11-01T08:30:00.000Z",
|
||||
previous: "2026-11-01T05:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York repeated hour retains six-field reminders",
|
||||
timezone: "America/New_York",
|
||||
expression: "15 30 1,3 * * *",
|
||||
now: "2026-11-01T06:15:00.000Z",
|
||||
next: "2026-11-01T08:30:15.000Z",
|
||||
previous: "2026-11-01T05:30:15.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York repeated hour skips duplicate per-minute occurrences",
|
||||
timezone: "America/New_York",
|
||||
expression: "* * * * *",
|
||||
now: "2026-11-01T06:15:00.000Z",
|
||||
next: "2026-11-01T07:00:00.000Z",
|
||||
previous: "2026-11-01T05:59:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York repeated hour skips duplicate per-second occurrences",
|
||||
timezone: "America/New_York",
|
||||
expression: "* * * * * *",
|
||||
now: "2026-11-01T06:15:00.000Z",
|
||||
next: "2026-11-01T07:00:00.000Z",
|
||||
previous: "2026-11-01T05:59:59.000Z",
|
||||
},
|
||||
{
|
||||
label: "Oslo repeated hour retains the later same-day reminder",
|
||||
timezone: "Europe/Oslo",
|
||||
expression: "30 2,4 * * *",
|
||||
now: "2026-10-25T01:15:00.000Z",
|
||||
next: "2026-10-25T03:30:00.000Z",
|
||||
previous: "2026-10-25T00:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe schedules the first occurrence of a half-hour fold",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "45 1 * * *",
|
||||
now: "2026-04-04T14:40:00.000Z",
|
||||
next: "2026-04-04T14:45:00.000Z",
|
||||
previous: "2026-04-03T14:45:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe repeated half-hour retains the later same-day reminder",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "45 1,2 * * *",
|
||||
now: "2026-04-04T15:10:00.000Z",
|
||||
next: "2026-04-04T16:15:00.000Z",
|
||||
previous: "2026-04-04T14:45:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe repeated half-hour skips duplicate per-second occurrences",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "* * * * * *",
|
||||
now: "2026-04-04T15:10:00.000Z",
|
||||
next: "2026-04-04T15:30:00.000Z",
|
||||
previous: "2026-04-04T14:59:59.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York skips a nonexistent spring-forward reminder",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 * * *",
|
||||
now: "2027-03-14T06:45:00.000Z",
|
||||
next: "2027-03-15T06:30:00.000Z",
|
||||
previous: "2027-03-13T07:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York previous occurrence never comes from its spring-forward gap",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 * * *",
|
||||
now: "2027-03-14T07:10:00.000Z",
|
||||
next: "2027-03-15T06:30:00.000Z",
|
||||
previous: "2027-03-13T07:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York preserves the final valid second before a spring-forward gap",
|
||||
timezone: "America/New_York",
|
||||
expression: "59 59 1,2 * * *",
|
||||
now: "2027-03-14T07:05:00.000Z",
|
||||
next: "2027-03-15T05:59:59.000Z",
|
||||
previous: "2027-03-14T06:59:59.000Z",
|
||||
},
|
||||
{
|
||||
label: "Oslo skips a nonexistent spring-forward reminder",
|
||||
timezone: "Europe/Oslo",
|
||||
expression: "30 2 * * *",
|
||||
now: "2026-03-29T00:45:00.000Z",
|
||||
next: "2026-03-30T00:30:00.000Z",
|
||||
previous: "2026-03-28T01:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe keeps the valid boundary after its half-hour spring gap",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "15,30 2 * * *",
|
||||
now: "2026-10-03T15:20:00.000Z",
|
||||
next: "2026-10-03T15:30:00.000Z",
|
||||
previous: "2026-10-02T16:00:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe keeps a valid later occurrence after its half-hour spring gap",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "15,35 2 * * *",
|
||||
now: "2026-10-03T15:20:00.000Z",
|
||||
next: "2026-10-03T15:35:00.000Z",
|
||||
previous: "2026-10-02T16:05:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Lord Howe previous occurrence never comes from its half-hour spring gap",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "15 2 * * *",
|
||||
now: "2026-10-03T15:32:00.000Z",
|
||||
next: "2026-10-04T15:15:00.000Z",
|
||||
previous: "2026-10-02T15:45:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Antarctica schedules the first occurrence of a two-hour fold",
|
||||
timezone: "Antarctica/Troll",
|
||||
expression: "0 2 * * *",
|
||||
now: "2026-10-24T23:30:00.000Z",
|
||||
next: "2026-10-25T00:00:00.000Z",
|
||||
previous: "2026-10-24T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Antarctica skips every repeated occurrence in a two-hour fold",
|
||||
timezone: "Antarctica/Troll",
|
||||
expression: "0 1,2,4 * * *",
|
||||
now: "2026-10-25T01:30:00.000Z",
|
||||
next: "2026-10-25T04:00:00.000Z",
|
||||
previous: "2026-10-25T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Antarctica skips duplicate per-second occurrences in a two-hour fold",
|
||||
timezone: "Antarctica/Troll",
|
||||
expression: "* * * * * *",
|
||||
now: "2026-10-25T01:30:00.250Z",
|
||||
next: "2026-10-25T03:00:00.000Z",
|
||||
previous: "2026-10-25T00:59:59.000Z",
|
||||
},
|
||||
{
|
||||
label: "Antarctica keeps the valid reminder after its two-hour spring gap",
|
||||
timezone: "Antarctica/Troll",
|
||||
expression: "30 2,3 * * *",
|
||||
now: "2026-03-29T00:30:00.000Z",
|
||||
next: "2026-03-29T01:30:00.000Z",
|
||||
previous: "2026-03-28T03:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York skips a nonexistent annual reminder after multiple offset changes",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 14 3 *",
|
||||
now: "2026-07-01T00:00:00.000Z",
|
||||
next: "2028-03-14T06:30:00.000Z",
|
||||
previous: "2026-03-14T06:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "New York skips a nonexistent historical annual reminder",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 8 3 *",
|
||||
now: "2026-07-01T00:00:00.000Z",
|
||||
next: "2027-03-08T07:30:00.000Z",
|
||||
previous: "2025-03-08T07:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "Moscow keeps valid annual reminders beyond multiple historical rule changes",
|
||||
timezone: "Europe/Moscow",
|
||||
expression: "30 2 * 3 SUN#L",
|
||||
now: "2007-03-01T00:00:00.000Z",
|
||||
next: "2012-03-24T22:30:00.000Z",
|
||||
previous: "1991-03-30T23:30:00.000Z",
|
||||
},
|
||||
])("$label", ({ timezone, expression, now, next, previous }) => {
|
||||
const schedule = { kind: "cron" as const, expr: expression, tz: timezone };
|
||||
const nowMs = Date.parse(now);
|
||||
|
||||
expect(computeNextRunAtMs(schedule, nowMs)).toBe(Date.parse(next));
|
||||
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(Date.parse(previous));
|
||||
});
|
||||
|
||||
it.each(["30 2 * 3 SUN#2", "0 30 2 * 3 SUN#2"])(
|
||||
"skips nonexistent future occurrences but preserves historical timezone rules for %s",
|
||||
(expression) => {
|
||||
const schedule = { kind: "cron" as const, expr: expression, tz: "America/New_York" };
|
||||
const nowMs = Date.parse("2026-01-01T00:00:00.000Z");
|
||||
|
||||
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
|
||||
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(
|
||||
Date.parse("2006-03-12T07:30:00.000Z"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("never spills a nonexistent year-limited reminder into another year", () => {
|
||||
const schedule = {
|
||||
kind: "cron" as const,
|
||||
expr: "0 30 2 14 3 * 2027",
|
||||
tz: "America/New_York",
|
||||
};
|
||||
const nowMs = Date.parse("2026-07-01T00:00:00.000Z");
|
||||
|
||||
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
|
||||
expect(computePreviousRunAtMs(schedule, nowMs)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers the first occurrence of a year-limited reminder during its repeated hour", () => {
|
||||
const schedule = {
|
||||
kind: "cron" as const,
|
||||
expr: "0 30 1 1 11 * 2026",
|
||||
tz: "America/New_York",
|
||||
};
|
||||
const nowMs = Date.parse("2026-11-01T06:15:00.000Z");
|
||||
|
||||
expect(computeNextRunAtMs(schedule, nowMs)).toBeUndefined();
|
||||
expect(computePreviousRunAtMs(schedule, nowMs)).toBe(Date.parse("2026-11-01T05:30:00.000Z"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "next-second retry rejects a real spring-forward gap",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 * * *",
|
||||
now: "2027-03-14T06:45:00.000Z",
|
||||
prior: "2027-03-13T07:30:00.000Z",
|
||||
forcedPastCalls: 1,
|
||||
expected: "2027-03-15T06:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "next-second retry selects the first real half-hour fold",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "45 1 * * *",
|
||||
now: "2026-04-04T14:40:00.000Z",
|
||||
prior: "2026-04-03T14:45:00.000Z",
|
||||
forcedPastCalls: 1,
|
||||
expected: "2026-04-04T14:45:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "tomorrow retry rejects a real spring-forward gap",
|
||||
timezone: "America/New_York",
|
||||
expression: "30 2 * * *",
|
||||
now: "2027-03-13T23:45:00.000Z",
|
||||
prior: "2027-03-13T07:30:00.000Z",
|
||||
forcedPastCalls: 2,
|
||||
expected: "2027-03-15T06:30:00.000Z",
|
||||
},
|
||||
{
|
||||
label: "tomorrow retry selects the first real half-hour fold",
|
||||
timezone: "Australia/Lord_Howe",
|
||||
expression: "45 1 * * *",
|
||||
now: "2026-04-03T23:45:00.000Z",
|
||||
prior: "2026-04-03T14:45:00.000Z",
|
||||
forcedPastCalls: 2,
|
||||
expected: "2026-04-04T14:45:00.000Z",
|
||||
},
|
||||
])("$label", ({ timezone, expression, now, prior, forcedPastCalls, expected }) => {
|
||||
const spy = vi.spyOn(Cron.prototype, "nextRun");
|
||||
for (let count = 0; count < forcedPastCalls; count += 1) {
|
||||
spy.mockImplementationOnce(() => new Date(prior));
|
||||
}
|
||||
try {
|
||||
expect(
|
||||
computeNextRunAtMs({ kind: "cron", expr: expression, tz: timezone }, Date.parse(now)),
|
||||
).toBe(Date.parse(expected));
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a clear error when cron expr is missing at runtime", () => {
|
||||
const nowMs = Date.parse("2025-12-13T00:00:00.000Z");
|
||||
expect(() =>
|
||||
|
||||
+230
-18
@@ -1,6 +1,7 @@
|
||||
/** Computes at/every/cron schedule timestamps with bounded Croner caching. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { Cron } from "croner";
|
||||
import { Cron, CronDate } from "croner";
|
||||
import { parseOffsetlessIsoDateTimeInTimeZone } from "../infra/format-time/parse-offsetless-zoned-datetime.js";
|
||||
import { pruneMapToMaxSize } from "../infra/map-size.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
@@ -9,7 +10,9 @@ import type { CronSchedule } from "./types.js";
|
||||
export { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
|
||||
const CRON_EVAL_CACHE_MAX = 512;
|
||||
const DAY_MS = 86_400_000;
|
||||
const cronEvalCache = new Map<string, Cron>();
|
||||
const cronTimezoneFormatters = new WeakMap<Cron, Intl.DateTimeFormat>();
|
||||
|
||||
function resolveCronTimezone(tz?: string) {
|
||||
const trimmed = normalizeOptionalString(tz) ?? "";
|
||||
@@ -46,6 +49,166 @@ function resolveCronFromSchedule(schedule: { tz?: string; expr?: unknown }): Cro
|
||||
return resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
|
||||
}
|
||||
|
||||
function hasNearbyCronTimezoneTransition(
|
||||
cron: Cron,
|
||||
timezone: string,
|
||||
nowMs: number,
|
||||
candidateMs: number,
|
||||
): boolean {
|
||||
let formatter = cronTimezoneFormatters.get(cron);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
timeZoneName: "longOffset",
|
||||
});
|
||||
cronTimezoneFormatters.set(cron, formatter);
|
||||
}
|
||||
const resolvedFormatter = formatter;
|
||||
const readOffset = (instantMs: number) =>
|
||||
resolvedFormatter
|
||||
.formatToParts(new Date(instantMs))
|
||||
.find((part) => part.type === "timeZoneName")?.value;
|
||||
const currentOffset = readOffset(nowMs);
|
||||
const candidateOffset = readOffset(candidateMs);
|
||||
return (
|
||||
currentOffset !== candidateOffset ||
|
||||
currentOffset !== readOffset(nowMs - DAY_MS) ||
|
||||
candidateOffset !== readOffset(candidateMs - DAY_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCronWallTimeMs(instantMs: number, timezone: string): number {
|
||||
const local = new CronDate(new Date(instantMs), timezone);
|
||||
return Date.UTC(
|
||||
local.year,
|
||||
local.month,
|
||||
local.day,
|
||||
local.hour,
|
||||
local.minute,
|
||||
local.second,
|
||||
local.ms,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFirstCronOccurrenceMs(instantMs: number, timezone: string): number | undefined {
|
||||
const wallTime = new Date(resolveCronWallTimeMs(instantMs, timezone)).toISOString().slice(0, -1);
|
||||
const resolved = parseOffsetlessIsoDateTimeInTimeZone(wallTime, timezone);
|
||||
return resolved === null ? undefined : Date.parse(resolved);
|
||||
}
|
||||
|
||||
function matchesCronOccurrence(cron: Cron, instant: Date): boolean {
|
||||
const matchesOccurrence = cron.match.bind(cron);
|
||||
return matchesOccurrence(instant);
|
||||
}
|
||||
|
||||
function findCronTimezoneTransitionMs(
|
||||
firstMs: number,
|
||||
lastMs: number,
|
||||
timezone: string,
|
||||
): number | undefined {
|
||||
let beforeSeconds = Math.floor(Math.min(firstMs, lastMs) / 1_000);
|
||||
let afterSeconds = Math.floor(Math.max(firstMs, lastMs) / 1_000);
|
||||
const previousOffsetMs =
|
||||
resolveCronWallTimeMs(beforeSeconds * 1_000, timezone) - beforeSeconds * 1_000;
|
||||
const nextOffsetMs = resolveCronWallTimeMs(afterSeconds * 1_000, timezone) - afterSeconds * 1_000;
|
||||
if (previousOffsetMs === nextOffsetMs) {
|
||||
return undefined;
|
||||
}
|
||||
while (afterSeconds - beforeSeconds > 1) {
|
||||
const middleSeconds = Math.floor((beforeSeconds + afterSeconds) / 2);
|
||||
const middleOffsetMs =
|
||||
resolveCronWallTimeMs(middleSeconds * 1_000, timezone) - middleSeconds * 1_000;
|
||||
if (middleOffsetMs === previousOffsetMs) {
|
||||
beforeSeconds = middleSeconds;
|
||||
} else {
|
||||
afterSeconds = middleSeconds;
|
||||
}
|
||||
}
|
||||
return afterSeconds * 1_000;
|
||||
}
|
||||
|
||||
function resolveCronRunAtTransitionMs(
|
||||
cron: Cron,
|
||||
transitionMs: number,
|
||||
timezone: string,
|
||||
): number | undefined {
|
||||
let transition = new Date(transitionMs);
|
||||
// Croner's finite year horizon bounds this search; historical timezone
|
||||
// policies can make many consecutive annual occurrences nonexistent.
|
||||
for (;;) {
|
||||
const candidate = matchesCronOccurrence(cron, transition)
|
||||
? transition
|
||||
: cron.nextRun(transition);
|
||||
if (!candidate) {
|
||||
return undefined;
|
||||
}
|
||||
if (matchesCronOccurrence(cron, candidate)) {
|
||||
return resolveFirstCronOccurrenceMs(candidate.getTime(), timezone);
|
||||
}
|
||||
const candidateMs = candidate.getTime();
|
||||
const nextTransitionMs = findCronTimezoneTransitionMs(
|
||||
candidateMs - DAY_MS,
|
||||
candidateMs,
|
||||
timezone,
|
||||
);
|
||||
if (nextTransitionMs === undefined || nextTransitionMs <= transition.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
transition = new Date(nextTransitionMs);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNextCronOccurrenceMs(
|
||||
cron: Cron,
|
||||
nowMs: number,
|
||||
nextMs: number,
|
||||
timezone: string,
|
||||
): number | undefined {
|
||||
if (!matchesCronOccurrence(cron, new Date(nextMs))) {
|
||||
// Croner invents an instant for a nonexistent local time; bracket the gap,
|
||||
// not the request, so annual schedules crossing several transitions work.
|
||||
const transitionMs = findCronTimezoneTransitionMs(nextMs - DAY_MS, nextMs, timezone);
|
||||
return transitionMs === undefined
|
||||
? undefined
|
||||
: resolveCronRunAtTransitionMs(cron, transitionMs, timezone);
|
||||
}
|
||||
|
||||
const firstOccurrenceMs = resolveFirstCronOccurrenceMs(nextMs, timezone);
|
||||
if (firstOccurrenceMs === undefined || firstOccurrenceMs > nowMs) {
|
||||
return firstOccurrenceMs;
|
||||
}
|
||||
|
||||
const firstCurrentOccurrenceMs = resolveFirstCronOccurrenceMs(nowMs, timezone);
|
||||
const inRepeatedInterval =
|
||||
firstCurrentOccurrenceMs !== undefined && firstCurrentOccurrenceMs < nowMs;
|
||||
const transitionStartMs = inRepeatedInterval ? firstCurrentOccurrenceMs : nowMs;
|
||||
const transitionEndMs = inRepeatedInterval ? nowMs : nextMs;
|
||||
const overlapMs = inRepeatedInterval
|
||||
? nowMs - firstCurrentOccurrenceMs
|
||||
: nextMs - firstOccurrenceMs;
|
||||
const transitionMs = findCronTimezoneTransitionMs(transitionStartMs, transitionEndMs, timezone);
|
||||
if (transitionMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Croner folds only a fixed hour. Use the actual offset change so 30-minute
|
||||
// and two-hour repeated clocks run once, at their first occurrence.
|
||||
return resolveCronRunAtTransitionMs(cron, transitionMs + overlapMs, timezone);
|
||||
}
|
||||
|
||||
function resolveValidatedNextCronOccurrenceMs(
|
||||
cron: Cron,
|
||||
nowMs: number,
|
||||
candidateMs: number,
|
||||
timezone: string,
|
||||
): number | undefined {
|
||||
if (candidateMs > nowMs && !hasNearbyCronTimezoneTransition(cron, timezone, nowMs, candidateMs)) {
|
||||
return candidateMs;
|
||||
}
|
||||
const normalizedMs = resolveNextCronOccurrenceMs(cron, nowMs, candidateMs, timezone);
|
||||
return normalizedMs !== undefined && normalizedMs > nowMs ? normalizedMs : undefined;
|
||||
}
|
||||
|
||||
/** Computes the next scheduled run timestamp after now for at/every/cron schedules. */
|
||||
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
|
||||
if (schedule.kind === "at") {
|
||||
@@ -87,26 +250,33 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timezone = resolveCronTimezone(schedule.tz);
|
||||
const normalizedNextMs = resolveValidatedNextCronOccurrenceMs(cron, nowMs, nextMs, timezone);
|
||||
if (normalizedNextMs !== undefined) {
|
||||
return normalizedNextMs;
|
||||
}
|
||||
if (nextMs > nowMs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Workaround for croner year-rollback bug: some timezone/date combinations
|
||||
// (e.g. Asia/Shanghai) cause nextRun to return a timestamp in a past year.
|
||||
// Retry from a later reference point when the returned time is not in the
|
||||
// future.
|
||||
if (nextMs <= nowMs) {
|
||||
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
|
||||
const retryMs = cron.nextRun(new Date(nextSecondMs))?.getTime();
|
||||
if (retryMs !== undefined && retryMs > nowMs) {
|
||||
return retryMs;
|
||||
const nextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000;
|
||||
const retryMs = cron.nextRun(new Date(nextSecondMs))?.getTime();
|
||||
if (retryMs !== undefined) {
|
||||
const normalizedRetryMs = resolveValidatedNextCronOccurrenceMs(cron, nowMs, retryMs, timezone);
|
||||
if (normalizedRetryMs !== undefined) {
|
||||
return normalizedRetryMs;
|
||||
}
|
||||
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
|
||||
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
|
||||
const retry2Ms = cron.nextRun(new Date(tomorrowMs))?.getTime();
|
||||
if (retry2Ms !== undefined && retry2Ms > nowMs) {
|
||||
return retry2Ms;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return nextMs;
|
||||
// Still in the past — try from start of tomorrow (UTC) as a broader reset.
|
||||
const tomorrowMs = new Date(nowMs).setUTCHours(24, 0, 0, 0);
|
||||
const retry2Ms = cron.nextRun(new Date(tomorrowMs))?.getTime();
|
||||
return retry2Ms !== undefined
|
||||
? resolveValidatedNextCronOccurrenceMs(cron, nowMs, retry2Ms, timezone)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Computes the previous cron-expression run timestamp before now. */
|
||||
@@ -118,11 +288,53 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n
|
||||
if (!cron) {
|
||||
return undefined;
|
||||
}
|
||||
const previousMs = cron.previousRuns(1, new Date(nowMs))[0]?.getTime();
|
||||
if (previousMs === undefined || previousMs >= nowMs) {
|
||||
let previousMs = cron.previousRuns(1, new Date(nowMs))[0]?.getTime();
|
||||
const timezone = resolveCronTimezone(schedule.tz);
|
||||
if (
|
||||
previousMs !== undefined &&
|
||||
previousMs < nowMs &&
|
||||
!hasNearbyCronTimezoneTransition(cron, timezone, nowMs, previousMs)
|
||||
) {
|
||||
return previousMs;
|
||||
}
|
||||
|
||||
const firstCurrentOccurrenceMs = resolveFirstCronOccurrenceMs(nowMs, timezone);
|
||||
if (firstCurrentOccurrenceMs !== undefined && firstCurrentOccurrenceMs < nowMs) {
|
||||
const transitionMs = findCronTimezoneTransitionMs(firstCurrentOccurrenceMs, nowMs, timezone);
|
||||
if (transitionMs !== undefined) {
|
||||
const overlapEndMs = transitionMs + nowMs - firstCurrentOccurrenceMs;
|
||||
const candidateMs = cron.previousRuns(1, new Date(overlapEndMs))[0]?.getTime();
|
||||
if (candidateMs !== undefined) {
|
||||
previousMs = candidateMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previousMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return previousMs;
|
||||
|
||||
// previousRuns ends at Croner's minimum supported year; never drop valid
|
||||
// historical occurrences after an arbitrary number of changed DST rules.
|
||||
while (!matchesCronOccurrence(cron, new Date(previousMs))) {
|
||||
const transitionMs = findCronTimezoneTransitionMs(previousMs - DAY_MS, previousMs, timezone);
|
||||
if (transitionMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const beforeTransition = new Date(transitionMs - 1_000);
|
||||
const candidate = matchesCronOccurrence(cron, beforeTransition)
|
||||
? beforeTransition
|
||||
: cron.previousRuns(1, beforeTransition)[0];
|
||||
const candidateMs = candidate?.getTime();
|
||||
if (candidateMs === undefined || candidateMs >= previousMs) {
|
||||
return undefined;
|
||||
}
|
||||
previousMs = candidateMs;
|
||||
}
|
||||
|
||||
const normalizedPreviousMs = resolveFirstCronOccurrenceMs(previousMs, timezone);
|
||||
return normalizedPreviousMs !== undefined && normalizedPreviousMs < nowMs
|
||||
? normalizedPreviousMs
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Clears the Croner expression cache for deterministic tests. */
|
||||
|
||||
@@ -55,4 +55,55 @@ describe("CronService restart catch-up within a cron slot's first second", () =>
|
||||
await store.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("replays the first daylight-saving fold after restarting in the repeated hour", async () => {
|
||||
const restartAtMs = Date.parse("2026-11-01T06:05:00.000Z");
|
||||
vi.setSystemTime(new Date(restartAtMs));
|
||||
const store = await makeStorePath();
|
||||
const runCommandJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
|
||||
|
||||
await writeCronStoreSnapshot({
|
||||
storePath: store.storePath,
|
||||
jobs: [
|
||||
{
|
||||
id: "restart-missed-first-daylight-saving-fold",
|
||||
name: "first daylight-saving fold",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-10-01T00:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-10-31T07:30:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "30 1,3 * * *", tz: "America/New_York" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "command", argv: ["echo", "FIRED"] },
|
||||
state: {
|
||||
nextRunAtMs: Date.parse("2026-11-01T08:30:00.000Z"),
|
||||
lastRunAtMs: Date.parse("2026-10-31T07:30:00.000Z"),
|
||||
lastStatus: "ok",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: logger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
runCommandJob: runCommandJob as never,
|
||||
});
|
||||
|
||||
try {
|
||||
await cron.start();
|
||||
expect(runCommandJob).toHaveBeenCalledTimes(1);
|
||||
expect(cron.getJob("restart-missed-first-daylight-saving-fold")?.state).toMatchObject({
|
||||
lastRunAtMs: restartAtMs,
|
||||
nextRunAtMs: Date.parse("2026-11-01T08:30:00.000Z"),
|
||||
});
|
||||
} finally {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user