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..a2780a4 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 nonnegative safe integer to change the global entry cap; `0` disables local storage: + +```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..5578721 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,6 +57,11 @@ export interface DialCacheConfig { readonly cacheConfigProvider?: CacheConfigProvider; readonly urnPrefix?: string; 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; readonly redis?: RedisConfig; readonly rampSampler?: CacheRampSampler; diff --git a/src/dialcache.ts b/src/dialcache.ts index 62e6057..22d9e75 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 nonnegative 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..b0e3599 100644 --- a/src/internal/local-cache.ts +++ b/src/internal/local-cache.ts @@ -1,3 +1,7 @@ +import { performance } from "node:perf_hooks"; + +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 +10,30 @@ 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> | null; constructor( private readonly configProvider: CacheConfigProvider, private readonly rampSampler: CacheRampSampler, - private readonly maxSize: number, - ) {} + maxSize: number, + ) { + 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, + // 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, + }); + } async get(key: DialCacheKey, fallback: Fallback): Promise { const result = await this.getIfPresentResult(key); @@ -43,16 +59,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 +74,14 @@ export class LocalCache { return; } - const cache = this.getOrCreateUseCaseCache(key); - cache.set(key.urn, { expiresAtMs: Date.now() + ttlSec * 1000, value }); - this.evictOldestIfNeeded(cache); + // 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 { - 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 +99,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..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,6 +352,34 @@ describe("DialCache local-only MVP", () => { expect(calls).toBe(2); }); + 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 }), { + keyType: "user_id", + useCase: "LocalTtlSameTurnExpiration", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 1 }, + ramp: { [CacheLayer.LOCAL]: 100 }, + }), + }); + + // When monotonic time reaches the TTL boundary without running scheduled timers. + const first = await dialcache.enable(async () => await getUser("123")); + 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. + 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() }; @@ -593,32 +628,124 @@ 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"); + }); + + // 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"); }); - 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 entry wrapper distinguishes the cached undefined from a miss. + expect(calls).toBe(1); }); + 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 nonnegative safe integer"), + ); + }, + ); + it("round-trips values through the JSON serializer", async () => { // Given the default JSON serializer. const serializer = new JsonSerializer<{ id: string; enabled: boolean }>();