diff --git a/server/src/services/routines-formatter-cache.test.ts b/server/src/services/routines-formatter-cache.test.ts new file mode 100644 index 00000000..daf9064a --- /dev/null +++ b/server/src/services/routines-formatter-cache.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { nextCronTickInTimeZone } from "./routines.js"; + +describe("nextCronTickInTimeZone formatter caching", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("computes the next occurrence of a sparse monthly cron correctly", () => { + const next = nextCronTickInTimeZone( + "30 6 1 * *", + "America/New_York", + new Date("2026-06-02T00:00:00Z"), + ); + expect(next).not.toBeNull(); + // 06:30 America/New_York on July 1st is 10:30 UTC (EDT, UTC-4). + expect(next!.toISOString()).toBe("2026-07-01T10:30:00.000Z"); + }); + + it("does not construct a new Intl.DateTimeFormat per minute-step", () => { + const RealDateTimeFormat = Intl.DateTimeFormat; + let constructions = 0; + // Must be a `function` (not an arrow) so the mock stays constructable + // when the code under test calls `new Intl.DateTimeFormat(...)`. + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function ( + ...args: ConstructorParameters + ) { + constructions += 1; + return new RealDateTimeFormat(...args); + } as unknown as typeof Intl.DateTimeFormat); + + // A monthly cron scanned from just after the previous firing walks tens of + // thousands of minute-steps before it matches. Use a timezone that nothing + // else in this test file has warmed so cache misses are observable. + const next = nextCronTickInTimeZone( + "0 12 1 * *", + "Pacific/Kiritimati", + new Date("2026-06-01T12:01:00Z"), + ); + expect(next).not.toBeNull(); + + // One construction to populate the per-timezone cache; the ~43k subsequent + // minute-steps must reuse it. Before the cache this was one construction + // per step, which blocked the event loop for minutes (#8033). + expect(constructions).toBeLessThanOrEqual(1); + + constructions = 0; + nextCronTickInTimeZone("0 12 1 * *", "Pacific/Kiritimati", new Date("2026-06-01T12:01:00Z")); + expect(constructions).toBe(0); + }); +}); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 7081377c..f7336368 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -90,7 +90,7 @@ function routineWebhookSecretConfigPath(secretId: string) { function assertTimeZone(timeZone: string) { try { - new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + getZonedMinuteFormatter(timeZone).format(new Date()); } catch { throw unprocessable(`Invalid timezone: ${timeZone}`); } @@ -102,17 +102,33 @@ function floorToMinute(date: Date) { return copy; } +// Constructing an Intl.DateTimeFormat costs ~1ms of ICU work, and +// computeNextRun calls getZonedMinuteParts once per minute-step (up to +// 366*24*60*5 iterations for sparse schedules), which can block the event +// loop for minutes per scheduler tick. Formatter instances are immutable, +// so cache one per timezone. See #8033. +const zonedMinuteFormatterCache = new Map(); + +function getZonedMinuteFormatter(timeZone: string) { + let formatter = zonedMinuteFormatterCache.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + weekday: "short", + }); + zonedMinuteFormatterCache.set(timeZone, formatter); + } + return formatter; +} + function getZonedMinuteParts(date: Date, timeZone: string) { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone, - hour12: false, - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - weekday: "short", - }); + const formatter = getZonedMinuteFormatter(timeZone); const parts = formatter.formatToParts(date); const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); const weekday = WEEKDAY_INDEX[map.weekday ?? ""]; @@ -141,7 +157,7 @@ function matchesCronMinute(expression: string, timeZone: string, date: Date) { ); } -function nextCronTickInTimeZone(expression: string, timeZone: string, after: Date) { +export function nextCronTickInTimeZone(expression: string, timeZone: string, after: Date) { const trimmed = expression.trim(); assertTimeZone(timeZone); const error = validateCron(trimmed);