diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 50593f2c53e..6feb729346c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1458,7 +1458,7 @@ the dispatch time of a known future deadline. Failed scans retry at that same interval. Future timers do not hold idle work open, and standby, idle drain and shutdown retain the coordinator's admission gates. External database writers rely on the periodic recovery scan. Sparse cron searches reuse one parsed -expression so repeated parsing cannot block unrelated deadlines. +expression and skip impossible UTC dates before localized formatting. Keep relevant heartbeat extractions and their focused tests in `server/src/services/heartbeat/`. Follow [Heartbeat execution boundaries](../rules/heartbeat-execution-boundaries.mdc) diff --git a/server/src/services/routines-formatter-cache.test.ts b/server/src/services/routines-formatter-cache.test.ts index 93f674d0948..8ff9d4b9ac0 100644 --- a/server/src/services/routines-formatter-cache.test.ts +++ b/server/src/services/routines-formatter-cache.test.ts @@ -20,6 +20,24 @@ describe("nextCronTickInTimeZone formatter caching", () => { expect(parses).toBe(1); }); + it("skips impossible UTC dates before localized formatting", () => { + const format = Intl.DateTimeFormat.prototype.formatToParts; + let formats = 0; + vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts").mockImplementation(function(this: Intl.DateTimeFormat, date) { + formats++; + if (formats > 48) throw new Error("Sparse UTC schedules must skip impossible dates"); + return format.call(this, date); + }); + const next = nextCronTickInTimeZone("0 12 1 1 *", "UTC", new Date("2020-01-01T12:00:00Z")); + expect(next?.toISOString()).toBe("2021-01-01T12:00:00.000Z"); + expect(formats).toBeLessThanOrEqual(48); + }); + + it.each(["UTC", "America/New_York"])("rejects invalid search dates in %s", timeZone => { + expect(() => nextCronTickInTimeZone("0 12 1 1 *", timeZone, new Date(Number.NaN))) + .toThrow(RangeError); + }); + it("computes the next occurrence of a sparse monthly cron correctly", () => { const next = nextCronTickInTimeZone( "30 6 1 * *", diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 642c70f939c..d92bae9560a 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -255,6 +255,12 @@ function getZonedMinuteParts(date: Date, timeZone: string) { } function matchesCronMinute(cron: ReturnType, timeZone: string, date: Date) { + if (timeZone === "UTC" && Number.isFinite(date.getTime()) && ( + !cron.minutes.includes(date.getUTCMinutes()) + || !cron.daysOfMonth.includes(date.getUTCDate()) + || !cron.months.includes(date.getUTCMonth() + 1) + || !cron.daysOfWeek.includes(date.getUTCDay()) + )) return false; const parts = getZonedMinuteParts(date, timeZone); return ( cron.minutes.includes(parts.minute) &&