diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 62ac2a647a8..da8dfa4195b 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -4,7 +4,9 @@ import { clearCronScheduleCacheForTest, computeNextRunAtMs, computePreviousRunAtMs, + getCronScheduleCacheMaxForTest, getCronScheduleCacheSizeForTest, + hasCronInCacheForTest, } from "./schedule.js"; describe("cron schedule", () => { @@ -144,6 +146,39 @@ describe("cron schedule", () => { expect(getCronScheduleCacheSizeForTest()).toBe(2); }); + it("promotes accessed entries to avoid premature LRU eviction", () => { + const nowMs = Date.parse("2026-03-01T00:00:00.000Z"); + const cacheMax = getCronScheduleCacheMaxForTest(); + + // Fill cache to capacity with unique expressions. + // i=0 → "0 0 * * *", i=1 → "1 0 * * *", ..., i=511 → "31 8 * * *" + for (let i = 0; i < cacheMax; i++) { + computeNextRunAtMs( + { kind: "cron", expr: `${i % 60} ${Math.floor(i / 60)} * * *`, tz: "UTC" }, + nowMs, + ); + } + expect(getCronScheduleCacheSizeForTest()).toBe(cacheMax); + + // Entry #0 ("0 0 * * *") is the oldest by insertion order. + // Access it so LRU promotes it (delete + re-insert at end of Map). + computeNextRunAtMs({ kind: "cron", expr: "0 0 * * *", tz: "UTC" }, nowMs); + + // Entry #1 ("1 0 * * *") is now the least-recently-used. + // Insert a new entry to trigger one eviction. + computeNextRunAtMs({ kind: "cron", expr: "0 0 1 1 *", tz: "UTC" }, nowMs); + expect(getCronScheduleCacheSizeForTest()).toBe(cacheMax); + + // Under LRU: entry #0 survived (was promoted), entry #1 was evicted. + // Under FIFO: entry #0 would be evicted instead — this assertion would fail. + expect(hasCronInCacheForTest("0 0 * * *", "UTC")).toBe(true); + expect(hasCronInCacheForTest("1 0 * * *", "UTC")).toBe(false); + + // The new entry and a non-evicted middle entry should both be present. + expect(hasCronInCacheForTest("0 0 1 1 *", "UTC")).toBe(true); + expect(hasCronInCacheForTest("2 0 * * *", "UTC")).toBe(true); + }); + describe("cron with specific seconds (6-field pattern)", () => { // Pattern: fire at exactly second 0 of minute 0 of hour 12 every day const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" }; diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 48b7f066d3f..569c354f1d3 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -18,6 +18,9 @@ function resolveCachedCron(expr: string, timezone: string): Cron { const key = `${timezone}\u0000${expr}`; const cached = cronEvalCache.get(key); if (cached) { + // Move to end of Map iteration order for LRU eviction + cronEvalCache.delete(key); + cronEvalCache.set(key, cached); return cached; } if (cronEvalCache.size >= CRON_EVAL_CACHE_MAX) { @@ -169,3 +172,11 @@ export function clearCronScheduleCacheForTest(): void { export function getCronScheduleCacheSizeForTest(): number { return cronEvalCache.size; } + +export function getCronScheduleCacheMaxForTest(): number { + return CRON_EVAL_CACHE_MAX; +} + +export function hasCronInCacheForTest(expr: string, tz: string): boolean { + return cronEvalCache.has(`${tz}\u0000${expr}`); +}