From abc46d42fd3d6bf0e26b5c9dd2e16e453b9ca504 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 12 Jul 2026 13:56:12 -0700 Subject: [PATCH 1/5] codex: use bounded LRU for local cache --- .github/workflows/ci.yaml | 2 +- README.md | 12 ++++- package.json | 3 +- pnpm-lock.yaml | 9 ++++ src/config.ts | 1 + src/dialcache.ts | 7 ++- src/internal/local-cache.ts | 51 +++++++------------- test/dialcache-local.test.ts | 93 +++++++++++++++++++++++++++++++----- 8 files changed, 126 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0855c5..fd2e6e8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ jobs: - run: pnpm test:integration - uses: actions/setup-node@v6 with: - node-version: "18" + node-version: "20" cache: "pnpm" - run: pnpm test:package diff --git a/README.md b/README.md index a2f35e3..93ab4f1 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ pnpm add dialcache pnpm add redis@~4.7.1 ``` +DialCache requires Node.js 20 or Node.js 22 and newer. + ## Quick start ```ts @@ -71,6 +73,14 @@ local cache -> Redis cache -> fallback function - Redis cache read/write failures are logged, counted in metrics, and fail open; fallback results still return when fallback succeeds. Explicit maintenance calls (`invalidateRemote`, `flushAll`) log/count Redis failures and rethrow them so callers do not assume mutation succeeded. - Missing per-layer config disables that layer, records a disabled reason, and falls through to the next layer/fallback. +The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a positive integer to change the global entry cap: + +```ts +const dialcache = new DialCache({ localMaxSize: 25_000 }); +``` + +The limit counts entries rather than estimating JavaScript object memory. Recently read entries stay resident ahead of less recently used entries when the limit is reached. + Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. Tracked reads are deliberately routed to primaries so a lagging Redis replica cannot hide an invalidation watermark. The adapter also fans `flushAll()` across every cluster master instead of silently clearing one shard. You can also provide a lazy factory that returns a script-enabled client: @@ -277,7 +287,7 @@ For non-Prometheus telemetry, inject a `DialCacheMetricsAdapter` through `new Di Included: -- Local TTL cache +- Local TTL/LRU cache with a global entry-count bound - Redis TTL cache - Local → Redis → fallback read-through chain - Lazy Redis client factory support diff --git a/package.json b/package.json index 2077e90..32bfec4 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,11 @@ }, "packageManager": "pnpm@10.33.0", "engines": { - "node": ">=18.17" + "node": "20 || >=22" }, "dependencies": { "@types/node": "^24.10.1", + "lru-cache": "^11.5.2", "prom-client": "^15.1.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f56c7..f596511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.13.3 + lru-cache: + specifier: ^11.5.2 + version: 11.5.2 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -1116,6 +1119,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2500,6 +2507,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/config.ts b/src/config.ts index b726983..3d84387 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,6 +57,7 @@ export interface DialCacheConfig { readonly cacheConfigProvider?: CacheConfigProvider; readonly urnPrefix?: string; readonly logger?: Logger; + /** Maximum local entries across every use case in this DialCache instance. Defaults to 10,000. */ readonly localMaxSize?: number; readonly redis?: RedisConfig; readonly rampSampler?: CacheRampSampler; diff --git a/src/dialcache.ts b/src/dialcache.ts index 62e6057..1c3ec23 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -71,6 +71,11 @@ export class DialCache { private readonly inFlight = new Map>(); constructor(config: DialCacheConfig = {}) { + const localMaxSize = config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE; + if (!Number.isSafeInteger(localMaxSize) || localMaxSize <= 0) { + throw new RangeError("DialCache localMaxSize must be a positive safe integer"); + } + this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider; this.urnPrefix = config.urnPrefix ?? "urn"; this.logger = config.logger ?? defaultLogger; @@ -84,7 +89,7 @@ export class DialCache { ...(config.metricsRegistry === undefined ? {} : { registry: config.metricsRegistry }), }); this.metrics = safeMetrics(metrics); - this.localCache = new LocalCache(this.configProvider, this.rampSampler, config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE); + this.localCache = new LocalCache(this.configProvider, this.rampSampler, localMaxSize); this.redisCache = config.redis === undefined ? null diff --git a/src/internal/local-cache.ts b/src/internal/local-cache.ts index 103a375..a4958ee 100644 --- a/src/internal/local-cache.ts +++ b/src/internal/local-cache.ts @@ -1,3 +1,5 @@ +import { LRUCache } from "lru-cache"; + import { CacheLayer, type CacheConfigProvider, type CacheRampSampler, type DialCacheKeyConfig } from "../config.js"; import type { DialCacheKey } from "../key.js"; import type { CacheGetResult } from "./cache-result.js"; @@ -6,18 +8,24 @@ import { fetchKeyConfig, resolveLayerConfigResult } from "./runtime-config.js"; export type Fallback = () => Promise; interface LocalEntry { - readonly expiresAtMs: number; readonly value: T; } export class LocalCache { - private readonly caches = new Map>>(); + private readonly cache: LRUCache>; constructor( private readonly configProvider: CacheConfigProvider, private readonly rampSampler: CacheRampSampler, - private readonly maxSize: number, - ) {} + maxSize: number, + ) { + this.cache = new LRUCache({ + max: maxSize, + // Keep fake timers and injected Date clocks observable instead of capturing + // the process clock when lru-cache is imported. + perf: { now: () => Date.now() }, + }); + } async get(key: DialCacheKey, fallback: Fallback): Promise { const result = await this.getIfPresentResult(key); @@ -43,16 +51,10 @@ export class LocalCache { return layerConfig; } - const cache = this.caches.get(key.useCase); - const now = Date.now(); - const hit = cache?.get(key.urn) as LocalEntry | undefined; - - if (hit !== undefined && hit.expiresAtMs > now) { - return { status: "hit", value: hit.value }; - } + const hit = this.cache.get(key.urn) as LocalEntry | undefined; if (hit !== undefined) { - cache?.delete(key.urn); + return { status: "hit", value: hit.value }; } return { status: "miss", config: layerConfig.config }; @@ -64,22 +66,11 @@ export class LocalCache { return; } - const cache = this.getOrCreateUseCaseCache(key); - cache.set(key.urn, { expiresAtMs: Date.now() + ttlSec * 1000, value }); - this.evictOldestIfNeeded(cache); + this.cache.set(key.urn, { value }, { ttl: ttlSec * 1000 }); } async flushAll(): Promise { - this.caches.clear(); - } - - private getOrCreateUseCaseCache(key: DialCacheKey): Map> { - let cache = this.caches.get(key.useCase); - if (cache === undefined) { - cache = new Map>(); - this.caches.set(key.useCase, cache); - } - return cache; + this.cache.clear(); } private async resolveLocalLayerConfig(key: DialCacheKey, keyConfig?: DialCacheKeyConfig | null) { @@ -97,14 +88,4 @@ export class LocalCache { const layerConfig = await this.resolveLocalLayerConfig(key); return layerConfig.status === "enabled" ? layerConfig.config.ttlSec : null; } - - private evictOldestIfNeeded(cache: Map>): void { - while (cache.size > this.maxSize) { - const oldestKey = cache.keys().next().value as string | undefined; - if (oldestKey === undefined) { - return; - } - cache.delete(oldestKey); - } - } } diff --git a/test/dialcache-local.test.ts b/test/dialcache-local.test.ts index 5ad1411..a26cddc 100644 --- a/test/dialcache-local.test.ts +++ b/test/dialcache-local.test.ts @@ -593,32 +593,99 @@ describe("DialCache local-only MVP", () => { expect(logger.warn).not.toHaveBeenCalled(); }); - it("evicts the oldest local entry when max size is exceeded", async () => { - // Given a local cache with room for one entry. - const dialcache = new DialCache({ localMaxSize: 1 }); - let calls = 0; - const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), { + it("evicts the least recently used local entry when max size is exceeded", async () => { + // Given a local cache with room for two entries. + const dialcache = new DialCache({ localMaxSize: 2 }); + const calls = new Map(); + const getUser = dialcache.cached(async (userId: string) => { + const call = (calls.get(userId) ?? 0) + 1; + calls.set(userId, call); + return { userId, call }; + }, { keyType: "user_id", useCase: "LocalMaxSizeEviction", cacheKey: (userId) => userId, defaultConfig: DialCacheKeyConfig.enabled(60), }); - // When two different keys are cached. + // When a hit refreshes one entry's recency before a third key is inserted. await dialcache.enable(async () => { await getUser("123"); await getUser("456"); + await getUser("123"); + await getUser("789"); + await getUser("456"); }); - const newestStillCached = await dialcache.enable(async () => await getUser("456")); - const oldestRefreshed = await dialcache.enable(async () => await getUser("123")); - const newestRefreshedAfterSecondEviction = await dialcache.enable(async () => await getUser("456")); - // Then the newest key is initially cached, the oldest key refreshes, and max-size eviction continues to apply. - expect(newestStillCached).toEqual({ userId: "456", calls: 2 }); - expect(oldestRefreshed).toEqual({ userId: "123", calls: 3 }); - expect(newestRefreshedAfterSecondEviction).toEqual({ userId: "456", calls: 4 }); + // Then the recently read key survives while the least recently used key refreshes. + expect(calls.get("123")).toBe(1); + expect(calls.get("456")).toBe(2); + expect(calls.get("789")).toBe(1); }); + it("applies localMaxSize across all use cases", async () => { + // Given two use cases sharing one DialCache instance with a one-entry local limit. + const dialcache = new DialCache({ localMaxSize: 1 }); + let userCalls = 0; + let postCalls = 0; + const getUser = dialcache.cached(async (id: string) => ({ id, call: ++userCalls }), { + keyType: "user_id", + useCase: "GlobalLimitUser", + cacheKey: (id) => id, + defaultConfig: DialCacheKeyConfig.enabled(60), + }); + const getPost = dialcache.cached(async (id: string) => ({ id, call: ++postCalls }), { + keyType: "post_id", + useCase: "GlobalLimitPost", + cacheKey: (id) => id, + defaultConfig: DialCacheKeyConfig.enabled(60), + }); + + // When the second use case fills the single global slot and the first key is requested again. + await dialcache.enable(async () => { + await getUser("123"); + await getPost("456"); + await getUser("123"); + }); + + // Then the limit is global rather than independently applied to each use case. + expect(userCalls).toBe(2); + expect(postCalls).toBe(1); + }); + + it("caches undefined local values", async () => { + // Given a valid cached function whose result is undefined. + const dialcache = new DialCache(); + let calls = 0; + const getOptional = dialcache.cached(async (id: string) => { + calls += 1; + return undefined; + }, { + keyType: "optional_id", + useCase: "UndefinedLocalValue", + cacheKey: (id) => id, + defaultConfig: DialCacheKeyConfig.enabled(60), + }); + + // When the same key is read twice inside an enabled scope. + await dialcache.enable(async () => { + await getOptional("123"); + await getOptional("123"); + }); + + // Then the entry wrapper distinguishes the cached undefined from a miss. + expect(calls).toBe(1); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid localMaxSize value %s", + (localMaxSize) => { + expect(() => new DialCache({ localMaxSize })).toThrow( + new RangeError("DialCache localMaxSize must be a positive safe integer"), + ); + }, + ); + it("round-trips values through the JSON serializer", async () => { // Given the default JSON serializer. const serializer = new JsonSerializer<{ id: string; enabled: boolean }>(); From 484ed3a1c56a17bf6da6891ffdb60820b6e4282b Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 12 Jul 2026 15:04:46 -0700 Subject: [PATCH 2/5] codex: harden bounded LRU behavior --- README.md | 2 +- src/config.ts | 5 +++- src/dialcache.ts | 4 +-- src/internal/local-cache.ts | 26 ++++++++++------- test/dialcache-local.test.ts | 56 ++++++++++++++++++++++++++++++++++-- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 93ab4f1..5d9b608 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ local cache -> Redis cache -> fallback function - Redis cache read/write failures are logged, counted in metrics, and fail open; fallback results still return when fallback succeeds. Explicit maintenance calls (`invalidateRemote`, `flushAll`) log/count Redis failures and rethrow them so callers do not assume mutation succeeded. - Missing per-layer config disables that layer, records a disabled reason, and falls through to the next layer/fallback. -The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a positive integer to change the global entry cap: +The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a nonnegative integer to change the global entry cap; `0` disables local storage: ```ts const dialcache = new DialCache({ localMaxSize: 25_000 }); diff --git a/src/config.ts b/src/config.ts index 3d84387..f59f858 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,7 +57,10 @@ export interface DialCacheConfig { readonly cacheConfigProvider?: CacheConfigProvider; readonly urnPrefix?: string; readonly logger?: Logger; - /** Maximum local entries across every use case in this DialCache instance. Defaults to 10,000. */ + /** + * Maximum local entries across every use case in this DialCache instance. + * Zero disables local storage. Defaults to 10,000. + */ readonly localMaxSize?: number; readonly redis?: RedisConfig; readonly rampSampler?: CacheRampSampler; diff --git a/src/dialcache.ts b/src/dialcache.ts index 1c3ec23..22d9e75 100644 --- a/src/dialcache.ts +++ b/src/dialcache.ts @@ -72,8 +72,8 @@ export class DialCache { constructor(config: DialCacheConfig = {}) { const localMaxSize = config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE; - if (!Number.isSafeInteger(localMaxSize) || localMaxSize <= 0) { - throw new RangeError("DialCache localMaxSize must be a positive safe integer"); + if (!Number.isSafeInteger(localMaxSize) || localMaxSize < 0) { + throw new RangeError("DialCache localMaxSize must be a nonnegative safe integer"); } this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider; diff --git a/src/internal/local-cache.ts b/src/internal/local-cache.ts index a4958ee..ae86d75 100644 --- a/src/internal/local-cache.ts +++ b/src/internal/local-cache.ts @@ -12,19 +12,25 @@ interface LocalEntry { } export class LocalCache { - private readonly cache: LRUCache>; + private readonly cache: LRUCache> | null; constructor( private readonly configProvider: CacheConfigProvider, private readonly rampSampler: CacheRampSampler, maxSize: number, ) { - this.cache = new LRUCache({ - max: maxSize, - // Keep fake timers and injected Date clocks observable instead of capturing - // the process clock when lru-cache is imported. - perf: { now: () => Date.now() }, - }); + this.cache = + maxSize === 0 + ? null + : new LRUCache({ + // Weight every entry as one so large configured limits remain sparse + // instead of eagerly preallocating max-sized storage arrays. + maxSize, + // Keep fake timers and injected Date clocks observable instead of capturing + // the process clock when lru-cache is imported. + perf: { now: () => Date.now() }, + ttlResolution: 0, + }); } async get(key: DialCacheKey, fallback: Fallback): Promise { @@ -51,7 +57,7 @@ export class LocalCache { return layerConfig; } - const hit = this.cache.get(key.urn) as LocalEntry | undefined; + const hit = this.cache?.get(key.urn) as LocalEntry | undefined; if (hit !== undefined) { return { status: "hit", value: hit.value }; @@ -66,11 +72,11 @@ export class LocalCache { return; } - this.cache.set(key.urn, { value }, { ttl: ttlSec * 1000 }); + this.cache?.set(key.urn, { value }, { size: 1, ttl: ttlSec * 1000 }); } async flushAll(): Promise { - this.cache.clear(); + this.cache?.clear(); } private async resolveLocalLayerConfig(key: DialCacheKey, keyConfig?: DialCacheKeyConfig | null) { diff --git a/test/dialcache-local.test.ts b/test/dialcache-local.test.ts index a26cddc..a8eb35e 100644 --- a/test/dialcache-local.test.ts +++ b/test/dialcache-local.test.ts @@ -345,6 +345,33 @@ describe("DialCache local-only MVP", () => { expect(calls).toBe(2); }); + it("checks local ttl against the current clock before the timers phase", async () => { + // Given a cached value whose TTL clock has been read without yielding to timers. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const dialcache = new DialCache(); + let calls = 0; + const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), { + keyType: "user_id", + useCase: "LocalTtlSameTurnExpiration", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 1 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + }); + + // When wall time advances beyond the TTL without running scheduled timers. + const first = await dialcache.enable(async () => await getUser("123")); + vi.setSystemTime(new Date("2026-01-01T00:00:01.001Z")); + const afterTtl = await dialcache.enable(async () => await getUser("123")); + + // Then the current clock is authoritative and the stale entry is not returned. + expect(first).toEqual({ userId: "123", calls: 1 }); + expect(afterTtl).toEqual({ userId: "123", calls: 2 }); + expect(calls).toBe(2); + }); + it("fails open when key construction fails", async () => { // Given a cached function whose key builder throws. const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; @@ -677,11 +704,36 @@ describe("DialCache local-only MVP", () => { expect(calls).toBe(1); }); - it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + it("allows localMaxSize zero to disable local storage", async () => { + // Given an explicit zero-entry local cache limit. + const dialcache = new DialCache({ localMaxSize: 0 }); + let calls = 0; + const getUser = dialcache.cached(async (id: string) => ({ id, call: ++calls }), { + keyType: "user_id", + useCase: "DisabledLocalStorage", + cacheKey: (id) => id, + defaultConfig: DialCacheKeyConfig.enabled(60), + }); + + // When the same key is read repeatedly. + await dialcache.enable(async () => { + await getUser("123"); + await getUser("123"); + }); + + // Then local storage stays disabled, matching the prior zero-size behavior. + expect(calls).toBe(2); + }); + + it("constructs large local entry caps without eager allocation", () => { + expect(() => new DialCache({ localMaxSize: 2 ** 32 })).not.toThrow(); + }); + + it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( "rejects invalid localMaxSize value %s", (localMaxSize) => { expect(() => new DialCache({ localMaxSize })).toThrow( - new RangeError("DialCache localMaxSize must be a positive safe integer"), + new RangeError("DialCache localMaxSize must be a nonnegative safe integer"), ); }, ); From a22aeb8f00f68e4d568bec40a411be831f4ca14e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 12 Jul 2026 15:07:39 -0700 Subject: [PATCH 3/5] codex: preserve local TTL boundary --- src/internal/local-cache.ts | 5 ++++- test/dialcache-local.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/internal/local-cache.ts b/src/internal/local-cache.ts index ae86d75..ddb1b67 100644 --- a/src/internal/local-cache.ts +++ b/src/internal/local-cache.ts @@ -72,7 +72,10 @@ export class LocalCache { return; } - this.cache?.set(key.urn, { value }, { size: 1, ttl: ttlSec * 1000 }); + // lru-cache expires when age > ttl, while DialCache historically expired + // when its integer-millisecond clock reached the configured boundary. + const ttlMs = ttlSec * 1000 - 1; + this.cache?.set(key.urn, { value }, { size: 1, ttl: ttlMs }); } async flushAll(): Promise { diff --git a/test/dialcache-local.test.ts b/test/dialcache-local.test.ts index a8eb35e..8d47934 100644 --- a/test/dialcache-local.test.ts +++ b/test/dialcache-local.test.ts @@ -361,9 +361,9 @@ describe("DialCache local-only MVP", () => { }), }); - // When wall time advances beyond the TTL without running scheduled timers. + // When wall time reaches the TTL boundary without running scheduled timers. const first = await dialcache.enable(async () => await getUser("123")); - vi.setSystemTime(new Date("2026-01-01T00:00:01.001Z")); + vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); const afterTtl = await dialcache.enable(async () => await getUser("123")); // Then the current clock is authoritative and the stale entry is not returned. From 21e4c22f0c5ea8fd9ef6720bbef1297cefff1616 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 12 Jul 2026 20:31:07 -0700 Subject: [PATCH 4/5] codex: use monotonic local TTL clock --- src/internal/local-cache.ts | 8 +++++--- test/dialcache-local.test.ts | 28 ++++++++++++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/internal/local-cache.ts b/src/internal/local-cache.ts index ddb1b67..b0e3599 100644 --- a/src/internal/local-cache.ts +++ b/src/internal/local-cache.ts @@ -1,3 +1,5 @@ +import { performance } from "node:perf_hooks"; + import { LRUCache } from "lru-cache"; import { CacheLayer, type CacheConfigProvider, type CacheRampSampler, type DialCacheKeyConfig } from "../config.js"; @@ -26,9 +28,9 @@ export class LocalCache { // Weight every entry as one so large configured limits remain sparse // instead of eagerly preallocating max-sized storage arrays. maxSize, - // Keep fake timers and injected Date clocks observable instead of capturing - // the process clock when lru-cache is imported. - perf: { now: () => Date.now() }, + // Read a fresh monotonic integer clock and avoid lru-cache's zero + // timestamp sentinel when the process or a fake clock starts at 0. + perf: { now: () => Math.floor(performance.now()) + 1 }, ttlResolution: 0, }); } diff --git a/test/dialcache-local.test.ts b/test/dialcache-local.test.ts index 8d47934..522751d 100644 --- a/test/dialcache-local.test.ts +++ b/test/dialcache-local.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { performance } from "node:perf_hooks"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CacheLayer, @@ -30,6 +32,10 @@ describe("DialCache local-only MVP", () => { vi.useRealTimers(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("keeps caching disabled by default", async () => { // Given a cached function with a valid default local configuration. const dialcache = new DialCache(); @@ -318,7 +324,8 @@ describe("DialCache local-only MVP", () => { it("expires local cache entries after their ttl", async () => { // Given a cached function with a one second local TTL. - vi.useFakeTimers(); + let nowMs = 10_000; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); const dialcache = new DialCache(); let calls = 0; const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), { @@ -333,9 +340,9 @@ describe("DialCache local-only MVP", () => { // When the same key is called before and after TTL expiration. const first = await dialcache.enable(async () => await getUser("123")); - vi.advanceTimersByTime(999); + nowMs += 999; const beforeTtl = await dialcache.enable(async () => await getUser("123")); - vi.advanceTimersByTime(2); + nowMs += 1; const afterTtl = await dialcache.enable(async () => await getUser("123")); // Then the cached value is reused before TTL and refreshed after TTL. @@ -345,10 +352,11 @@ describe("DialCache local-only MVP", () => { expect(calls).toBe(2); }); - it("checks local ttl against the current clock before the timers phase", async () => { - // Given a cached value whose TTL clock has been read without yielding to timers. - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + it("checks local ttl from an epoch-zero monotonic clock before the timers phase", async () => { + // Given a cached value whose monotonic TTL clock starts at zero and has + // been read without yielding to timers. + let nowMs = 0; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); const dialcache = new DialCache(); let calls = 0; const getUser = dialcache.cached(async (userId: string) => ({ userId, calls: ++calls }), { @@ -361,9 +369,9 @@ describe("DialCache local-only MVP", () => { }), }); - // When wall time reaches the TTL boundary without running scheduled timers. + // When monotonic time reaches the TTL boundary without running scheduled timers. const first = await dialcache.enable(async () => await getUser("123")); - vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z")); + nowMs = 1_000; const afterTtl = await dialcache.enable(async () => await getUser("123")); // Then the current clock is authoritative and the stale entry is not returned. From 664ffb99db0fe9d71bdc7e3f57097e523350c7c2 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 12 Jul 2026 20:59:39 -0700 Subject: [PATCH 5/5] codex: align local cache documentation --- README.md | 2 +- src/config.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d9b608..a2780a4 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ local cache -> Redis cache -> fallback function - Redis cache read/write failures are logged, counted in metrics, and fail open; fallback results still return when fallback succeeds. Explicit maintenance calls (`invalidateRemote`, `flushAll`) log/count Redis failures and rethrow them so callers do not assume mutation succeeded. - Missing per-layer config disables that layer, records a disabled reason, and falls through to the next layer/fallback. -The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a nonnegative integer to change the global entry cap; `0` disables local storage: +The local layer uses one process-local LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured local TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap; `0` disables local storage: ```ts const dialcache = new DialCache({ localMaxSize: 25_000 }); diff --git a/src/config.ts b/src/config.ts index f59f858..5578721 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ export interface DialCacheConfig { readonly logger?: Logger; /** * Maximum local entries across every use case in this DialCache instance. + * Must be a nonnegative safe integer. * Zero disables local storage. Defaults to 10,000. */ readonly localMaxSize?: number;