Skip to content

Commit 79159f1

Browse files
authored
fix: use LRU eviction for cron schedule cache
Fixes #39679
1 parent 610e575 commit 79159f1

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

src/cron/schedule.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
clearCronScheduleCacheForTest,
55
computeNextRunAtMs,
66
computePreviousRunAtMs,
7+
getCronScheduleCacheMaxForTest,
78
getCronScheduleCacheSizeForTest,
9+
hasCronInCacheForTest,
810
} from "./schedule.js";
911

1012
describe("cron schedule", () => {
@@ -144,6 +146,39 @@ describe("cron schedule", () => {
144146
expect(getCronScheduleCacheSizeForTest()).toBe(2);
145147
});
146148

149+
it("promotes accessed entries to avoid premature LRU eviction", () => {
150+
const nowMs = Date.parse("2026-03-01T00:00:00.000Z");
151+
const cacheMax = getCronScheduleCacheMaxForTest();
152+
153+
// Fill cache to capacity with unique expressions.
154+
// i=0 → "0 0 * * *", i=1 → "1 0 * * *", ..., i=511 → "31 8 * * *"
155+
for (let i = 0; i < cacheMax; i++) {
156+
computeNextRunAtMs(
157+
{ kind: "cron", expr: `${i % 60} ${Math.floor(i / 60)} * * *`, tz: "UTC" },
158+
nowMs,
159+
);
160+
}
161+
expect(getCronScheduleCacheSizeForTest()).toBe(cacheMax);
162+
163+
// Entry #0 ("0 0 * * *") is the oldest by insertion order.
164+
// Access it so LRU promotes it (delete + re-insert at end of Map).
165+
computeNextRunAtMs({ kind: "cron", expr: "0 0 * * *", tz: "UTC" }, nowMs);
166+
167+
// Entry #1 ("1 0 * * *") is now the least-recently-used.
168+
// Insert a new entry to trigger one eviction.
169+
computeNextRunAtMs({ kind: "cron", expr: "0 0 1 1 *", tz: "UTC" }, nowMs);
170+
expect(getCronScheduleCacheSizeForTest()).toBe(cacheMax);
171+
172+
// Under LRU: entry #0 survived (was promoted), entry #1 was evicted.
173+
// Under FIFO: entry #0 would be evicted instead — this assertion would fail.
174+
expect(hasCronInCacheForTest("0 0 * * *", "UTC")).toBe(true);
175+
expect(hasCronInCacheForTest("1 0 * * *", "UTC")).toBe(false);
176+
177+
// The new entry and a non-evicted middle entry should both be present.
178+
expect(hasCronInCacheForTest("0 0 1 1 *", "UTC")).toBe(true);
179+
expect(hasCronInCacheForTest("2 0 * * *", "UTC")).toBe(true);
180+
});
181+
147182
describe("cron with specific seconds (6-field pattern)", () => {
148183
// Pattern: fire at exactly second 0 of minute 0 of hour 12 every day
149184
const dailyNoon = { kind: "cron" as const, expr: "0 0 12 * * *", tz: "UTC" };

src/cron/schedule.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ function resolveCachedCron(expr: string, timezone: string): Cron {
1818
const key = `${timezone}\u0000${expr}`;
1919
const cached = cronEvalCache.get(key);
2020
if (cached) {
21+
// Move to end of Map iteration order for LRU eviction
22+
cronEvalCache.delete(key);
23+
cronEvalCache.set(key, cached);
2124
return cached;
2225
}
2326
if (cronEvalCache.size >= CRON_EVAL_CACHE_MAX) {
@@ -169,3 +172,11 @@ export function clearCronScheduleCacheForTest(): void {
169172
export function getCronScheduleCacheSizeForTest(): number {
170173
return cronEvalCache.size;
171174
}
175+
176+
export function getCronScheduleCacheMaxForTest(): number {
177+
return CRON_EVAL_CACHE_MAX;
178+
}
179+
180+
export function hasCronInCacheForTest(expr: string, tz: string): boolean {
181+
return cronEvalCache.has(`${tz}\u0000${expr}`);
182+
}

0 commit comments

Comments
 (0)