fix: use LRU eviction for cron schedule cache

Fixes #39679
This commit is contained in:
Andrew Barnes
2026-04-28 21:56:19 -04:00
committed by GitHub
parent 610e575844
commit 79159f11f6
2 changed files with 46 additions and 0 deletions

View File

@@ -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" };

View File

@@ -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}`);
}