Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/dialcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export class DialCache {
private readonly inFlight = new Map<string, Promise<unknown>>();

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;
Expand All @@ -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
Expand Down
62 changes: 27 additions & 35 deletions src/internal/local-cache.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -6,18 +10,30 @@ import { fetchKeyConfig, resolveLayerConfigResult } from "./runtime-config.js";
export type Fallback<T> = () => Promise<T>;

interface LocalEntry<T> {
readonly expiresAtMs: number;
readonly value: T;
}

export class LocalCache {
private readonly caches = new Map<string, Map<string, LocalEntry<unknown>>>();
private readonly cache: LRUCache<string, LocalEntry<unknown>> | 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<T>(key: DialCacheKey, fallback: Fallback<T>): Promise<T> {
const result = await this.getIfPresentResult<T>(key);
Expand All @@ -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<T> | undefined;

if (hit !== undefined && hit.expiresAtMs > now) {
return { status: "hit", value: hit.value };
}
const hit = this.cache?.get(key.urn) as LocalEntry<T> | undefined;

if (hit !== undefined) {
cache?.delete(key.urn);
return { status: "hit", value: hit.value };
}

return { status: "miss", config: layerConfig.config };
Expand All @@ -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<void> {
this.caches.clear();
}

private getOrCreateUseCaseCache(key: DialCacheKey): Map<string, LocalEntry<unknown>> {
let cache = this.caches.get(key.useCase);
if (cache === undefined) {
cache = new Map<string, LocalEntry<unknown>>();
this.caches.set(key.useCase, cache);
}
return cache;
this.cache?.clear();
}

private async resolveLocalLayerConfig(key: DialCacheKey, keyConfig?: DialCacheKeyConfig | null) {
Expand All @@ -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<string, LocalEntry<unknown>>): void {
while (cache.size > this.maxSize) {
const oldestKey = cache.keys().next().value as string | undefined;
if (oldestKey === undefined) {
return;
}
cache.delete(oldestKey);
}
}
}
Loading
Loading