From ba3d0917842f5983467c5f5b31e3b0ae1593fbd8 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 23 Jul 2026 23:26:48 +1000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20wire=20the=20metrics=20option=20live?= =?UTF-8?q?=20=E2=80=94=20Prometheus=20module=20becomes=20the=20implementa?= =?UTF-8?q?tion=20(LAB-517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCache({ metrics }) was accepted by the types and every intent preset, then silently ignored: CacheImpl never read it and the entire Prometheus module was dead code. Silent config no-ops are trust bugs. - CacheImpl now threads metrics through get/set/delete/wrap: hits (l1/l2), misses, per-attempt operation counters, error counters, duration histograms, L1 entry/memory gauges, and the circuit-breaker state gauge. - metrics accepts boolean | MetricsConfig (prefix, defaultLabels, registry, onError). The registry option was previously accepted-and-ignored — now honored; metrics are reused via getSingleMetric so multiple caches sharing a registry no longer kill initialization with duplicate-registration. - Live L1/L2 hit and miss counters feed the SaaS X-CacheKit-L1-* telemetry headers: the CachekitIO metricsProvider is auto-wired from them (an explicit user-supplied provider still wins). - If metrics is enabled without the optional prom-client peer dependency, the SDK reports once through the library logger and degrades to no-ops — loud, never silent. - New pluggable logger hook (setLogger): all ad-hoc console.error sites (invalidation channel, background refresh, Redis error events, metrics fallback) now route through it; default remains console.error. - CacheMetrics/NoopMetrics/createMetrics/MetricsCollector/MetricsConfig and setLogger/CachekitLogger exported from the package index. Co-authored-by: multica-agent --- packages/cachekit/README.md | 27 +- .../cachekit/src/backends/cachekitio.test.ts | 73 ++++++ packages/cachekit/src/backends/redis.ts | 4 +- packages/cachekit/src/cache.metrics.test.ts | 247 ++++++++++++++++++ packages/cachekit/src/cache.ts | 228 +++++++++++----- .../cachekit/src/cache/background-refresh.ts | 4 +- packages/cachekit/src/index.test.ts | 8 + packages/cachekit/src/index.ts | 6 + packages/cachekit/src/intents.ts | 28 +- .../src/invalidation/redis-channel.ts | 10 +- packages/cachekit/src/logger.test.ts | 65 +++++ packages/cachekit/src/logger.ts | 36 +++ .../cachekit/src/metrics/prometheus.test.ts | 4 + packages/cachekit/src/metrics/prometheus.ts | 52 ++-- packages/cachekit/src/types/cache.ts | 11 +- 15 files changed, 704 insertions(+), 99 deletions(-) create mode 100644 packages/cachekit/src/cache.metrics.test.ts create mode 100644 packages/cachekit/src/logger.test.ts create mode 100644 packages/cachekit/src/logger.ts diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index d270f3b..606899a 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -218,13 +218,34 @@ npm install prom-client ```typescript const cache = createCache({ backend: { url: 'redis://localhost:6379' }, - metrics: true, // Enable Prometheus metrics + metrics: true, // or { prefix: 'myapp', defaultLabels: { env: 'prod' }, registry } }); -// Metrics: cachekit_operations_total, cachekit_hits_total, cachekit_misses_total, -// cachekit_errors_total, cachekit_operation_duration_seconds +// Counters: cachekit_operations_total{operation,status}, cachekit_hits_total{layer}, +// cachekit_misses_total, cachekit_errors_total{error_type} +// Histogram: cachekit_operation_duration_seconds{operation} +// Gauges: cachekit_l1_entries, cachekit_l1_memory_bytes, cachekit_circuit_breaker_state ``` +If `metrics` is enabled but `prom-client` is not installed, the SDK reports the +failure once through the library logger and metrics degrade to no-ops — never +silently. + +Internal error reporting (background refresh, invalidation channel, Redis +connection events) defaults to `console.error`; route it into your own logging +pipeline with `setLogger`: + +```typescript +import { setLogger } from '@cachekit-io/cachekit'; + +setLogger((message, error) => myLogger.warn({ error }, message)); +setLogger(null); // restore the default +``` + +With the CachekitIO backend, the `X-CacheKit-L1-*` telemetry headers are wired +automatically from the cache's live L1/L2 hit and miss counters; pass your own +`metricsProvider` in the backend config to override. + ## Requirements - Node.js 22+ diff --git a/packages/cachekit/src/backends/cachekitio.test.ts b/packages/cachekit/src/backends/cachekitio.test.ts index b15e444..7deeac9 100644 --- a/packages/cachekit/src/backends/cachekitio.test.ts +++ b/packages/cachekit/src/backends/cachekitio.test.ts @@ -369,5 +369,78 @@ describe('CachekitIO Backend', () => { await cache.close(); }); + + // LAB-517: the SaaS telemetry headers are auto-wired from the cache's + // live hit/miss counters — no user-supplied metricsProvider needed. + it('auto-wires X-CacheKit-L1-* telemetry headers from live cache counters', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: 'ck_test_xyz', + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + }, + l1: { enabled: true }, + }); + + fetchSpy.mockResolvedValue(mockResponse(404)); + await cache.get('ns:first'); // miss + await cache.get('ns:second'); // second request sees the first miss + + const [, secondOpts] = fetchSpy.mock.calls[1] as [string, RequestInit]; + const headers = secondOpts.headers as Record; + expect(headers['X-CacheKit-L1-Status']).toBe('miss'); + expect(headers['X-CacheKit-Misses']).toBe('1'); + expect(headers['X-CacheKit-L1-Hits']).toBe('0'); + + await cache.close(); + }); + + it('reports L1 telemetry as disabled when the cache has no L1', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: 'ck_test_xyz', + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + }, + l1: { enabled: false }, + }); + + fetchSpy.mockResolvedValueOnce(mockResponse(404)); + await cache.get('ns:key'); + + const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = opts.headers as Record; + expect(headers['X-CacheKit-L1-Status']).toBe('disabled'); + + await cache.close(); + }); + + it('a user-supplied metricsProvider overrides the auto-wired one', async () => { + const { createCache } = await import('../cache.js'); + + const cache = createCache({ + backend: { + apiKey: 'ck_test_xyz', + apiUrl: 'https://api.test.cachekit.io', + allowCustomHost: true, + metricsProvider: () => ({ l1Hits: 42, l2Hits: 7, misses: 3, l1Enabled: true }), + }, + l1: { enabled: true }, + }); + + fetchSpy.mockResolvedValueOnce(mockResponse(404)); + await cache.get('ns:key'); + + const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; + const headers = opts.headers as Record; + expect(headers['X-CacheKit-L1-Hits']).toBe('42'); + expect(headers['X-CacheKit-Misses']).toBe('3'); + + await cache.close(); + }); }); }); diff --git a/packages/cachekit/src/backends/redis.ts b/packages/cachekit/src/backends/redis.ts index fa54c7e..7596c29 100644 --- a/packages/cachekit/src/backends/redis.ts +++ b/packages/cachekit/src/backends/redis.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { Redis as IoRedis, type RedisOptions } from 'ioredis'; import { LockableBackend, RedisBackendConfig, TTLBackend } from './types.js'; import { BackendError, TimeoutError } from '../errors.js'; +import { logError } from '../logger.js'; import { DEFAULT_TTL_SECONDS, DEFAULT_REDIS_CONNECT_TIMEOUT, @@ -99,8 +100,7 @@ export class RedisBackend implements LockableBackend, TTLBackend { }; const safeMessage = sanitize(err.message) || 'Unknown Redis error'; - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error(`[cachekit] Redis error: ${safeMessage}`); + logError(`[cachekit] Redis error: ${safeMessage}`); }); } diff --git a/packages/cachekit/src/cache.metrics.test.ts b/packages/cachekit/src/cache.metrics.test.ts new file mode 100644 index 0000000..43a13ac --- /dev/null +++ b/packages/cachekit/src/cache.metrics.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Registry } from 'prom-client'; +import { createCache } from './cache.js'; +import type { SecureCache } from './types/cache.js'; +import type { Backend } from './backends/types.js'; + +/** + * LAB-517 regression tests: `metrics` must actually work, not be silently + * accepted. These drive the REAL prom-client (devDependency) through + * CacheImpl end-to-end — no mocks on the metrics path. + */ + +class InMemoryBackend implements Backend { + store = new Map(); + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + async set(key: string, value: Uint8Array): Promise { + this.store.set(key, value); + } + async delete(key: string): Promise { + return this.store.delete(key); + } + async exists(key: string): Promise { + return this.store.has(key); + } + async close(): Promise {} +} + +class FailingBackend implements Backend { + async get(): Promise { + throw new Error('backend down'); + } + async set(): Promise { + throw new Error('backend down'); + } + async delete(): Promise { + throw new Error('backend down'); + } + async exists(): Promise { + throw new Error('backend down'); + } + async close(): Promise {} +} + +interface MetricSample { + value: number; + labels: Record; + /** prom-client sets this on histogram sub-series (_count/_sum/_bucket) */ + metricName?: string; +} + +async function metricValue( + registry: Registry, + name: string, + labels?: Record +): Promise { + const metrics = (await registry.getMetricsAsJSON()) as unknown as { + name: string; + values: MetricSample[]; + }[]; + return metrics + .flatMap((m) => m.values.map((v) => ({ ...v, seriesName: v.metricName ?? m.name }))) + .filter((v) => v.seriesName === name) + .filter((v) => !labels || Object.entries(labels).every(([k, val]) => v.labels[k] === val)) + .reduce((sum, v) => sum + v.value, 0); +} + +describe('Cache metrics wiring (LAB-517)', () => { + const caches: SecureCache[] = []; + + function makeCache( + registry: Registry, + overrides: Partial[0]> = {} + ): SecureCache { + const cache = createCache({ + backend: new InMemoryBackend(), + defaultTtl: 3600, + l1: { enabled: true, maxEntries: 100 }, + metrics: { registry }, + ...overrides, + }); + caches.push(cache); + return cache; + } + + afterEach(async () => { + await Promise.all(caches.splice(0).map((c) => c.close())); + }); + + it('records misses and get operations', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + expect(await cache.get('ns:missing')).toBeNull(); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'get', + status: 'success', + }) + ).toBe(1); + }); + }); + + it('records l1 hits after set, and l2 hits when L1 is disabled', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + await cache.set('ns:key', 'value'); + expect(await cache.get('ns:key')).toBe('value'); // L1 hit + + const registry2 = new Registry(); + const noL1 = makeCache(registry2, { l1: { enabled: false } }); + await noL1.set('ns:key', 'value'); + expect(await noL1.get('ns:key')).toBe('value'); // L2 hit + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_hits_total', { layer: 'l1' })).toBe(1); + expect(await metricValue(registry2, 'cachekit_hits_total', { layer: 'l2' })).toBe(1); + }); + }); + + it('records set/delete operations and observes durations', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + await cache.set('ns:key', 'value'); + await cache.delete('ns:key'); + + await vi.waitFor(async () => { + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'set', + status: 'success', + }) + ).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'delete', + status: 'success', + }) + ).toBe(1); + expect( + await metricValue(registry, 'cachekit_operation_duration_seconds_count', { + operation: 'set', + }) + ).toBe(1); + }); + }); + + it('records errors when the backend fails (degradation still swallows)', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { backend: new FailingBackend(), l1: { enabled: false } }); + + // Degradation (default on) returns the fallback instead of throwing + expect(await cache.get('ns:key')).toBeNull(); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_errors_total', { error_type: 'Error' })).toBe(1); + expect( + await metricValue(registry, 'cachekit_operations_total', { + operation: 'get', + status: 'error', + }) + ).toBe(1); + }); + }); + + it('updates L1 gauges and circuit-breaker gauge', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { + reliability: { circuitBreaker: { failureThreshold: 5 } }, + }); + + await cache.set('ns:a', 'value'); + await cache.set('ns:b', 'value'); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_l1_entries')).toBe(2); + expect(await metricValue(registry, 'cachekit_l1_memory_bytes')).toBeGreaterThan(0); + // closed = 0; the gauge exists with a recorded value + const metrics = await registry.getMetricsAsJSON(); + const cb = metrics.find((m) => m.name === 'cachekit_circuit_breaker_state'); + expect(cb).toBeDefined(); + expect((cb as unknown as { values: { value: number }[] }).values[0]?.value).toBe(0); + }); + }); + + it('records hits and misses through wrap(), including the L1 SWR path', async () => { + const registry = new Registry(); + const cache = makeCache(registry); + + const fn = vi.fn(async (id: number) => ({ id })); + const wrapped = cache.wrap(fn, { namespace: 'users:get', ttl: 60 }); + + await wrapped(1); // miss → compute → set + await wrapped(1); // L1 hit via SWR path + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(1); + expect(await metricValue(registry, 'cachekit_hits_total', { layer: 'l1' })).toBe(1); + }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('two caches sharing a registry reuse metrics instead of throwing on duplicates', async () => { + const registry = new Registry(); + const cacheA = makeCache(registry); + const cacheB = makeCache(registry); + + await cacheA.get('ns:miss-a'); + await cacheB.get('ns:miss-b'); + + await vi.waitFor(async () => { + // Both misses land on the shared counter — second init did not fail + expect(await metricValue(registry, 'cachekit_misses_total')).toBe(2); + }); + }); + + it('supports a custom prefix', async () => { + const registry = new Registry(); + const cache = makeCache(registry, { metrics: { registry, prefix: 'myapp' } }); + + await cache.get('ns:missing'); + + await vi.waitFor(async () => { + expect(await metricValue(registry, 'myapp_misses_total')).toBe(1); + }); + }); + + it('metrics disabled (default) records nothing and touches no registry', async () => { + const registry = new Registry(); + const cache = createCache({ + backend: new InMemoryBackend(), + l1: { enabled: true }, + }); + caches.push(cache); + + await cache.set('ns:key', 'value'); + await cache.get('ns:key'); + await cache.get('ns:missing'); + + expect(await registry.getMetricsAsJSON()).toHaveLength(0); + }); +}); diff --git a/packages/cachekit/src/cache.ts b/packages/cachekit/src/cache.ts index 1d26ce8..0823535 100644 --- a/packages/cachekit/src/cache.ts +++ b/packages/cachekit/src/cache.ts @@ -9,6 +9,8 @@ import type { Backend, RedisBackendConfig, CachekitIOBackendConfig } from './bac import { redis } from './backends/redis.js'; import { cachekitio } from './backends/cachekitio-factory.js'; import { L1Cache } from './l1/lru-cache.js'; +import { logError } from './logger.js'; +import { createMetrics, type MetricsCollector } from './metrics/prometheus.js'; import { ReliabilityExecutor } from './reliability/executor.js'; import { BackgroundRefreshManager } from './cache/background-refresh.js'; import { MessagePackSerializer } from './serialization/serializer.js'; @@ -43,6 +45,10 @@ class CacheImpl implements SecureCache { private readonly serializer: MessagePackSerializer; private readonly defaultTtl: number; private readonly invalidationChannel: RedisInvalidationChannel | null = null; + private readonly metrics: MetricsCollector; + // Live hit/miss counters. Feed both the Prometheus collector and the SaaS + // X-CacheKit-L1-* telemetry headers (auto-wired metricsProvider below). + private readonly telemetry = { l1Hits: 0, l2Hits: 0, misses: 0 }; private closed = false; constructor(options: CacheOptions) { @@ -50,11 +56,30 @@ class CacheImpl implements SecureCache { if ('get' in options.backend) { this.backend = options.backend; } else if ('apiKey' in options.backend) { - this.backend = cachekitio(options.backend as CachekitIOBackendConfig); + const ioConfig = options.backend as CachekitIOBackendConfig; + this.backend = cachekitio({ + ...ioConfig, + // Auto-wire the SaaS telemetry headers from the cache's live hit/miss + // counters; an explicit user-supplied provider still wins. Reads + // `this` lazily (per request), so constructor field order is safe. + metricsProvider: + ioConfig.metricsProvider ?? + ((): import('./backends/types.js').L1Metrics => ({ + ...this.telemetry, + l1Enabled: this.l1 !== null, + })), + }); } else { this.backend = redis(options.backend as RedisBackendConfig); } + // Initialize metrics (Prometheus via optional prom-client peer dependency) + const metricsOption = options.metrics ?? false; + this.metrics = createMetrics( + metricsOption !== false, + typeof metricsOption === 'object' ? metricsOption : undefined + ); + // Initialize L1 cache if (options.l1?.enabled !== false) { this.l1 = new L1Cache(options.l1); @@ -109,13 +134,71 @@ class CacheImpl implements SecureCache { // Start the channel (fire-and-forget, channel handles errors internally) channel.start().catch((err) => { - // eslint-disable-next-line no-console - console.error('[cachekit] Failed to start invalidation channel:', err); + logError('[cachekit] Failed to start invalidation channel:', err); }); return channel; } + // ── Metrics recording ───────────────────────────────────── + // MetricsCollector methods never reject (errors route to its handler), so + // fire-and-forget `void` keeps them off the hot path. + + private recordHit(layer: 'l1' | 'l2'): void { + if (layer === 'l1') this.telemetry.l1Hits++; + else this.telemetry.l2Hits++; + void this.metrics.recordHit(layer); + } + + private recordMiss(): void { + this.telemetry.misses++; + void this.metrics.recordMiss(); + } + + private recordFailure(operation: string, error: unknown): void { + void this.metrics.recordOperation(operation, 'error'); + void this.metrics.recordError(error instanceof Error ? error.constructor.name : 'Unknown'); + } + + private publishL1Stats(): void { + if (!this.l1) return; + const stats = this.l1.stats; + void this.metrics.updateL1Stats(stats.entries, stats.memoryUsed); + } + + /** + * Run an operation through the reliability stack, then publish the + * circuit-breaker state gauge (state transitions happen inside execute). + */ + private async execute(operation: () => Promise, fallback: T): Promise { + try { + return await this.reliabilityExecutor.execute(operation, fallback); + } finally { + const state = this.reliabilityExecutor.getCircuitBreakerState(); + if (state !== null) void this.metrics.updateCircuitBreakerState(state); + } + } + + /** + * Instrument one backend attempt: duration histogram + operations counter + * by status + errors counter. Wraps the operation closure (inside retry), + * so each retry attempt counts as one operation — the honest reading of + * `operations_total`. + */ + private async instrument(operation: string, fn: () => Promise): Promise { + const endTimer = await this.metrics.startTimer(operation); + try { + const result = await fn(); + void this.metrics.recordOperation(operation, 'success'); + return result; + } catch (error) { + this.recordFailure(operation, error); + throw error; + } finally { + endTimer(); + } + } + async get(key: string): Promise { return this.getEntry(key, false); } @@ -134,6 +217,7 @@ class CacheImpl implements SecureCache { if (this.l1) { const l1Result = this.l1.get(key); if (l1Result !== null) { + this.recordHit('l1'); return l1Result as T; } } @@ -141,38 +225,44 @@ class CacheImpl implements SecureCache { const useEnvelope = !interop && this.byteStorage !== null; // Fetch from L2 (backend) - const operation = async (): Promise => { - const data = await this.backend.get(key); - if (data === null) return null; - - // Decrypt if encryption enabled - let plaintext = data; - if (this.encryption) { - plaintext = await this.encryption.decrypt(data, key, useEnvelope); - } + const operation = (): Promise => + this.instrument('get', async () => { + const data = await this.backend.get(key); + if (data === null) { + this.recordMiss(); + return null; + } - // Decompress with ByteStorage (after decryption) - if (useEnvelope) { - plaintext = this.byteStorage!.unpack(plaintext); - } + // Decrypt if encryption enabled + let plaintext = data; + if (this.encryption) { + plaintext = await this.encryption.decrypt(data, key, useEnvelope); + } - // Deserialize - const value = interop - ? decodeInteropValue(plaintext) - : this.serializer.decode(plaintext); - - // Populate L1. Interop keys are {namespace}:{operation}:{hash} — group - // under the user-facing namespace segment so namespace-level - // invalidation matches entries written through wrap(). - if (this.l1) { - const namespace = interop ? key.slice(0, key.indexOf(':')) : extractNamespace(key); - this.l1.set(key, value, (ttlSeconds ?? this.defaultTtl) * 1000, namespace); - } + // Decompress with ByteStorage (after decryption) + if (useEnvelope) { + plaintext = this.byteStorage!.unpack(plaintext); + } - return value; - }; + // Deserialize + const value = interop + ? decodeInteropValue(plaintext) + : this.serializer.decode(plaintext); + + // Populate L1. Interop keys are {namespace}:{operation}:{hash} — group + // under the user-facing namespace segment so namespace-level + // invalidation matches entries written through wrap(). + if (this.l1) { + const namespace = interop ? key.slice(0, key.indexOf(':')) : extractNamespace(key); + this.l1.set(key, value, (ttlSeconds ?? this.defaultTtl) * 1000, namespace); + this.publishL1Stats(); + } - return this.reliabilityExecutor.execute(operation, null); + this.recordHit('l2'); + return value; + }); + + return this.execute(operation, null); } async set(key: string, value: T, options?: SetOptions): Promise { @@ -210,46 +300,50 @@ class CacheImpl implements SecureCache { // (existing degrade semantics unchanged). const interopSerialized = interop ? encodeInteropValue(value) : null; - const operation = async (): Promise => { - // Serialize - const serialized = interopSerialized ?? this.serializer.encode(value); + const operation = (): Promise => + this.instrument('set', async () => { + // Serialize + const serialized = interopSerialized ?? this.serializer.encode(value); - // Compress with ByteStorage (before encryption) - let data: Uint8Array = useEnvelope ? this.byteStorage!.pack(serialized) : serialized; + // Compress with ByteStorage (before encryption) + let data: Uint8Array = useEnvelope ? this.byteStorage!.pack(serialized) : serialized; - // Encrypt if encryption enabled - if (this.encryption) { - data = await this.encryption.encrypt(data, key, useEnvelope); - } + // Encrypt if encryption enabled + if (this.encryption) { + data = await this.encryption.encrypt(data, key, useEnvelope); + } - // Store in backend - await this.backend.set(key, data, ttl); + // Store in backend + await this.backend.set(key, data, ttl); - // Update L1 - if (this.l1) { - this.l1.set(key, value, ttl * 1000, namespace); - } - }; + // Update L1 + if (this.l1) { + this.l1.set(key, value, ttl * 1000, namespace); + this.publishL1Stats(); + } + }); - await this.reliabilityExecutor.execute(operation, undefined); + await this.execute(operation, undefined); } async delete(key: string): Promise { this.ensureNotClosed(); - const operation = async (): Promise => { - // Delete from backend - const deleted = await this.backend.delete(key); + const operation = (): Promise => + this.instrument('delete', async () => { + // Delete from backend + const deleted = await this.backend.delete(key); - // Invalidate L1 - if (this.l1) { - this.l1.invalidateByKey(key); - } + // Invalidate L1 + if (this.l1) { + this.l1.invalidateByKey(key); + this.publishL1Stats(); + } - return deleted; - }; + return deleted; + }); - return this.reliabilityExecutor.execute(operation, false); + return this.execute(operation, false); } async exists(key: string): Promise { @@ -263,7 +357,7 @@ class CacheImpl implements SecureCache { } } - return this.reliabilityExecutor.execute(() => this.backend.exists(key), false); + return this.execute(() => this.backend.exists(key), false); } wrap( @@ -374,10 +468,16 @@ class CacheImpl implements SecureCache { swrResult.versionToken, this.l1, async (key, value, opts) => { - await this.setEntry(key, value, { ttl: opts.ttl, namespace: opts.namespace }, interop); + await this.setEntry( + key, + value, + { ttl: opts.ttl, namespace: opts.namespace }, + interop + ); } ); } + this.recordHit('l1'); return swrResult.value as TResult; } } @@ -390,7 +490,12 @@ class CacheImpl implements SecureCache { // Compute and cache const result = await fn(...args); - await this.setEntry(cacheKey, result, { ttl: options.ttl, namespace: options.namespace }, interop); + await this.setEntry( + cacheKey, + result, + { ttl: options.ttl, namespace: options.namespace }, + interop + ); return result; }; @@ -437,6 +542,7 @@ class CacheImpl implements SecureCache { } break; } + this.publishL1Stats(); } // Invalidate L2 for params-level (key deletion) diff --git a/packages/cachekit/src/cache/background-refresh.ts b/packages/cachekit/src/cache/background-refresh.ts index a96a776..92ba333 100644 --- a/packages/cachekit/src/cache/background-refresh.ts +++ b/packages/cachekit/src/cache/background-refresh.ts @@ -1,4 +1,5 @@ import type { L1Cache } from '../l1/lru-cache.js'; +import { logError } from '../logger.js'; /** * Options for scheduling a background refresh. @@ -75,8 +76,7 @@ export class BackgroundRefreshManager { } } catch (error) { // Log error for observability - // eslint-disable-next-line no-console - console.error( + logError( '[cachekit] Background refresh failed:', error instanceof Error ? error.message : 'Unknown error' ); diff --git a/packages/cachekit/src/index.test.ts b/packages/cachekit/src/index.test.ts index 1e33c29..aa206ed 100644 --- a/packages/cachekit/src/index.test.ts +++ b/packages/cachekit/src/index.test.ts @@ -26,4 +26,12 @@ describe('index exports', () => { expect(cachekit.NonceExhaustedError).toBe(NonceExhaustedError); expect(cachekit.SerializationError).toBe(SerializationError); }); + + // LAB-517: the metrics module and logger hook are public API + it('should export the observability surface', () => { + expect(cachekit.CacheMetrics).toBeTypeOf('function'); + expect(cachekit.NoopMetrics).toBeTypeOf('function'); + expect(cachekit.createMetrics).toBeTypeOf('function'); + expect(cachekit.setLogger).toBeTypeOf('function'); + }); }); diff --git a/packages/cachekit/src/index.ts b/packages/cachekit/src/index.ts index 2e1559f..d2d1613 100644 --- a/packages/cachekit/src/index.ts +++ b/packages/cachekit/src/index.ts @@ -59,6 +59,12 @@ export { SerializationError, } from './errors.js'; +// ============ Observability ============ +export { CacheMetrics, NoopMetrics, createMetrics } from './metrics/prometheus.js'; +export type { MetricsCollector, MetricsConfig } from './metrics/prometheus.js'; +export { setLogger } from './logger.js'; +export type { CachekitLogger } from './logger.js'; + // ============ Optional Utilities ============ // Export for advanced users who want to customize export { L1Cache } from './l1/lru-cache.js'; diff --git a/packages/cachekit/src/intents.ts b/packages/cachekit/src/intents.ts index 82359f2..5cfdce9 100644 --- a/packages/cachekit/src/intents.ts +++ b/packages/cachekit/src/intents.ts @@ -28,6 +28,7 @@ import type { InvalidationConfig, } from './types/cache.js'; import type { L1Config } from './l1/types.js'; +import type { MetricsConfig } from './metrics/prometheus.js'; import type { SerializerConfig } from './serialization/serializer.js'; import { createCache as _createCache } from './cache.js'; import { ConfigurationError } from './errors.js'; @@ -74,8 +75,13 @@ export interface ProductionOptions extends BaseIntentOptions { url: string; /** Redis key prefix */ keyPrefix?: string; - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; } @@ -98,8 +104,13 @@ export interface SecureOptions extends BaseIntentOptions { tenantId?: string; /** Redis key prefix */ keyPrefix?: string; - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; } @@ -122,8 +133,13 @@ export interface IOOptions extends BaseIntentOptions { timeout?: number; /** Optional encryption config for zero-knowledge mode */ encryption?: EncryptionConfig; - /** Enable Prometheus metrics (default: true) */ - metrics?: boolean; + /** + * Enable Prometheus metrics (default: true), or pass a MetricsConfig to + * customize prefix/labels/registry. Requires the optional `prom-client` + * peer dependency — reports once through the library logger and degrades + * to no-ops when it is missing. + */ + metrics?: boolean | MetricsConfig; /** Override default reliability settings */ reliability?: Partial; } diff --git a/packages/cachekit/src/invalidation/redis-channel.ts b/packages/cachekit/src/invalidation/redis-channel.ts index 9a1b2a5..f5081b5 100644 --- a/packages/cachekit/src/invalidation/redis-channel.ts +++ b/packages/cachekit/src/invalidation/redis-channel.ts @@ -1,5 +1,6 @@ import type { Redis } from 'ioredis'; import type { InvalidationEvent, InvalidationCallback } from '../l1/types.js'; +import { logError } from '../logger.js'; import { serializeEvent, deserializeEvent } from './event.js'; const DEFAULT_CHANNEL = 'cachekit:invalidate'; @@ -56,8 +57,7 @@ export class RedisInvalidationChannel { // Fire-and-forget - don't await, don't throw this.redis.publish(this.channelName, Buffer.from(data)).catch((err) => { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Failed to publish invalidation:', err.message); + logError('[cachekit] Failed to publish invalidation:', err.message); }); } @@ -95,13 +95,11 @@ export class RedisInvalidationChannel { try { callback(event); } catch (err) { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Invalidation callback error:', err); + logError('[cachekit] Invalidation callback error:', err); } } } catch (err) { - // eslint-disable-next-line no-console -- Library intentionally uses console for error visibility - console.error('[cachekit] Failed to deserialize invalidation event:', err); + logError('[cachekit] Failed to deserialize invalidation event:', err); } }); diff --git a/packages/cachekit/src/logger.test.ts b/packages/cachekit/src/logger.test.ts new file mode 100644 index 0000000..79ffcd7 --- /dev/null +++ b/packages/cachekit/src/logger.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setLogger, logError } from './logger.js'; +import { BackgroundRefreshManager } from './cache/background-refresh.js'; + +describe('pluggable logger (LAB-517)', () => { + afterEach(() => { + setLogger(null); + vi.restoreAllMocks(); + }); + + it('defaults to console.error', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logError('[cachekit] something failed:', new Error('boom')); + expect(spy).toHaveBeenCalledWith('[cachekit] something failed:', expect.any(Error)); + }); + + it('routes through a custom logger and silences console', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const custom = vi.fn(); + setLogger(custom); + + logError('[cachekit] something failed:', 'detail'); + + expect(custom).toHaveBeenCalledWith('[cachekit] something failed:', 'detail'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('setLogger(null) restores the console.error default', () => { + const custom = vi.fn(); + setLogger(custom); + setLogger(null); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logError('[cachekit] back to default'); + expect(custom).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith('[cachekit] back to default'); + }); + + it('background refresh failures reach the custom logger', async () => { + const custom = vi.fn(); + setLogger(custom); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const manager = new BackgroundRefreshManager(); + manager.scheduleRefresh( + 'ns:key', + async () => { + throw new Error('refresh exploded'); + }, + { ttl: 60, namespace: 'ns' }, + 0, + null, + async () => {} + ); + + await vi.waitFor(() => { + expect(custom).toHaveBeenCalledWith( + '[cachekit] Background refresh failed:', + 'refresh exploded' + ); + }); + expect(consoleSpy).not.toHaveBeenCalled(); + manager.close(); + }); +}); diff --git a/packages/cachekit/src/logger.ts b/packages/cachekit/src/logger.ts new file mode 100644 index 0000000..3862f62 --- /dev/null +++ b/packages/cachekit/src/logger.ts @@ -0,0 +1,36 @@ +/** + * Pluggable error logger for the library's internal error reporting. + * + * CacheKit never fails silently: background/fire-and-forget failures + * (invalidation channel, SWR refresh, Redis connection events, metrics + * initialization) are reported through this hook. The default sink is + * `console.error`; applications can route these into their own logging + * pipeline with {@link setLogger}. + * + * @example + * ```typescript + * import { setLogger } from '@cachekit-io/cachekit'; + * + * setLogger((message, error) => myLogger.warn({ err: error }, message)); + * setLogger(null); // restore the console.error default + * ``` + */ +export type CachekitLogger = (message: string, error?: unknown) => void; + +const defaultLogger: CachekitLogger = (message, error) => { + const args = error === undefined ? [message] : [message, error]; + // eslint-disable-next-line no-console -- default sink; replaceable via setLogger + console.error(...args); +}; + +let activeLogger: CachekitLogger = defaultLogger; + +/** Replace the library-wide error logger. Pass `null` to restore the default. */ +export function setLogger(logger: CachekitLogger | null): void { + activeLogger = logger ?? defaultLogger; +} + +/** Internal: report a library error through the active logger. */ +export function logError(message: string, error?: unknown): void { + activeLogger(message, error); +} diff --git a/packages/cachekit/src/metrics/prometheus.test.ts b/packages/cachekit/src/metrics/prometheus.test.ts index dec98a7..4108661 100644 --- a/packages/cachekit/src/metrics/prometheus.test.ts +++ b/packages/cachekit/src/metrics/prometheus.test.ts @@ -3,6 +3,10 @@ import { CacheMetrics, NoopMetrics, createMetrics } from './prometheus'; // Mock prom-client (not available in test environment) vi.mock('prom-client', () => ({ + register: { + registerMetric() {}, + getSingleMetric: () => undefined, + }, Counter: class MockCounter { inc() {} }, diff --git a/packages/cachekit/src/metrics/prometheus.ts b/packages/cachekit/src/metrics/prometheus.ts index 8b8e693..cfe4862 100644 --- a/packages/cachekit/src/metrics/prometheus.ts +++ b/packages/cachekit/src/metrics/prometheus.ts @@ -3,6 +3,7 @@ * * Uses prom-client types when available, otherwise provides compatible interface. */ +import { logError } from '../logger.js'; // Types for prom-client (peer dependency) interface Counter { @@ -22,6 +23,7 @@ interface Gauge { interface Registry { registerMetric(metric: unknown): void; + getSingleMetric?(name: string): unknown; } // Generic constructor type for prom-client metric classes @@ -68,17 +70,18 @@ export interface MetricsCollector { * - cachekit_l1_memory_bytes (gauge): Current L1 memory usage * - cachekit_circuit_breaker_state (gauge): Circuit breaker state (0=closed, 1=open, 0.5=half-open) * + * Requires the optional `prom-client` peer dependency; when it is missing, + * initialization reports once through the library logger and metrics degrade + * to no-ops. + * * @example * ```typescript - * import { CacheMetrics } from '@cachekit-io/cachekit'; + * import { createCache } from '@cachekit-io/cachekit'; * import promClient from 'prom-client'; * - * const metrics = new CacheMetrics({ prefix: 'myapp_cache' }); - * - * // Use with cache * const cache = createCache({ * backend: { url: 'redis://localhost' }, - * metrics: true, + * metrics: { prefix: 'myapp_cache' }, // or `metrics: true` for defaults * }); * * // Expose metrics endpoint @@ -91,6 +94,7 @@ export interface MetricsCollector { export class CacheMetrics implements MetricsCollector { private readonly prefix: string; private readonly defaultLabels: Record; + private readonly registry: Registry | undefined; private readonly errorHandler: ((error: Error) => void) | undefined; // Metric instances (lazy-loaded when prom-client available) @@ -112,6 +116,7 @@ export class CacheMetrics implements MetricsCollector { constructor(config: MetricsConfig = {}) { this.prefix = config.prefix ?? 'cachekit'; this.defaultLabels = config.defaultLabels ?? {}; + this.registry = config.registry; this.errorHandler = config.onError; } @@ -140,36 +145,47 @@ export class CacheMetrics implements MetricsCollector { this.HistogramClass = promClient.Histogram as unknown as MetricConstructor; this.GaugeClass = promClient.Gauge as unknown as MetricConstructor; + // Target registry: custom (config.registry) or prom-client's default. + // Reuse an already-registered metric instead of constructing a second + // one — prom-client throws on duplicate names, which would kill metrics + // for every cache instance after the first sharing a prefix+registry. + const registry = this.registry ?? (promClient.register as unknown as Registry); + const getOrCreate = (Ctor: MetricConstructor, cfg: Record): M => { + const existing = registry.getSingleMetric?.(cfg.name as string); + if (existing) return existing as M; + return new Ctor({ ...cfg, registers: [registry] }); + }; + // Operations counter - this.operationsCounter = new this.CounterClass({ + this.operationsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_operations_total`, help: 'Total cache operations', labelNames: ['operation', 'status'], }); // Hits counter - this.hitsCounter = new this.CounterClass({ + this.hitsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_hits_total`, help: 'Cache hits', labelNames: ['layer'], }); // Misses counter - this.missesCounter = new this.CounterClass({ + this.missesCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_misses_total`, help: 'Cache misses', labelNames: [], }); // Errors counter - this.errorsCounter = new this.CounterClass({ + this.errorsCounter = getOrCreate(this.CounterClass, { name: `${this.prefix}_errors_total`, help: 'Cache errors', labelNames: ['error_type'], }); // Duration histogram - this.durationHistogram = new this.HistogramClass({ + this.durationHistogram = getOrCreate(this.HistogramClass, { name: `${this.prefix}_operation_duration_seconds`, help: 'Operation duration in seconds', labelNames: ['operation'], @@ -177,21 +193,21 @@ export class CacheMetrics implements MetricsCollector { }); // L1 entries gauge - this.l1EntriesGauge = new this.GaugeClass({ + this.l1EntriesGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_l1_entries`, help: 'Current L1 cache entries', labelNames: [], }); // L1 memory gauge - this.l1MemoryGauge = new this.GaugeClass({ + this.l1MemoryGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_l1_memory_bytes`, help: 'Current L1 memory usage in bytes', labelNames: [], }); // Circuit breaker gauge - this.circuitBreakerGauge = new this.GaugeClass({ + this.circuitBreakerGauge = getOrCreate(this.GaugeClass, { name: `${this.prefix}_circuit_breaker_state`, help: 'Circuit breaker state (0=closed, 0.5=half-open, 1=open)', labelNames: [], @@ -205,8 +221,11 @@ export class CacheMetrics implements MetricsCollector { if (this.errorHandler) { this.errorHandler(err); } else { - // eslint-disable-next-line no-console - console.error('[cachekit] Failed to initialize metrics:', err.message); + logError( + '[cachekit] metrics are enabled but failed to initialize — install the optional ' + + '`prom-client` peer dependency or set `metrics: false`:', + err.message + ); } return false; @@ -223,8 +242,7 @@ export class CacheMetrics implements MetricsCollector { if (this.errorHandler) { this.errorHandler(err); } else { - // eslint-disable-next-line no-console - console.error(`[cachekit] Metrics error (${context}):`, err.message); + logError(`[cachekit] Metrics error (${context}):`, err.message); } } diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index 9850cee..6932536 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -1,5 +1,6 @@ import type { Backend, RedisBackendConfig, CachekitIOBackendConfig } from '../backends/types.js'; import type { L1Config } from '../l1/types.js'; +import type { MetricsConfig } from '../metrics/prometheus.js'; import type { CircuitBreakerConfig } from '../reliability/circuit-breaker.js'; import type { RetryConfig } from '../reliability/retry.js'; import type { SerializerConfig } from '../serialization/serializer.js'; @@ -137,8 +138,14 @@ export interface CacheOptions { /** Enable ByteStorage wire format (LZ4 compression + xxHash3-64 integrity). Default: true */ compression?: boolean; - /** Enable Prometheus metrics */ - metrics?: boolean; + /** + * Enable Prometheus metrics (`true`), or enable with configuration + * (prefix, default labels, custom registry). Requires the optional + * `prom-client` peer dependency — when it is missing, metrics report the + * failure once through the library logger and degrade to no-ops. + * Default: false. + */ + metrics?: boolean | MetricsConfig; /** Cross-instance invalidation via Redis Pub/Sub */ invalidation?: InvalidationConfig; From 11a45c4eea8e8a55051105f5a8130ed1202630fc Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 24 Jul 2026 00:37:07 +1000 Subject: [PATCH 2/5] fix(cache): route all ops through run(); instrument exists() (LAB-517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert-panel review found exists() was the only public op left uninstrumented: L1 hits went uncounted and an L2 backend error degraded to a silent false with no errors_total trace — invisible in the very metrics this PR wires live. Root cause: the instrument()-inside / execute()-outside pairing was copy-pasted per method, so exists() simply drifted out of the set. Extract it into a single run() helper that every op routes through, making the omission structurally impossible. Co-authored-by: multica-agent --- packages/cachekit/src/cache.ts | 138 +++++++++++++++++---------------- 1 file changed, 70 insertions(+), 68 deletions(-) diff --git a/packages/cachekit/src/cache.ts b/packages/cachekit/src/cache.ts index 0823535..b29d552 100644 --- a/packages/cachekit/src/cache.ts +++ b/packages/cachekit/src/cache.ts @@ -199,6 +199,16 @@ class CacheImpl implements SecureCache { } } + /** + * Route a backend op through instrumentation (timer + op/error counters, + * inside retry so each attempt counts once) and the reliability stack + * (retry + circuit breaker, publishing the CB gauge). Every public op goes + * through here so none can silently drift out of the metrics set. + */ + private run(operation: string, fallback: T, fn: () => Promise): Promise { + return this.execute(() => this.instrument(operation, fn), fallback); + } + async get(key: string): Promise { return this.getEntry(key, false); } @@ -225,44 +235,41 @@ class CacheImpl implements SecureCache { const useEnvelope = !interop && this.byteStorage !== null; // Fetch from L2 (backend) - const operation = (): Promise => - this.instrument('get', async () => { - const data = await this.backend.get(key); - if (data === null) { - this.recordMiss(); - return null; - } - - // Decrypt if encryption enabled - let plaintext = data; - if (this.encryption) { - plaintext = await this.encryption.decrypt(data, key, useEnvelope); - } + return this.run('get', null, async (): Promise => { + const data = await this.backend.get(key); + if (data === null) { + this.recordMiss(); + return null; + } - // Decompress with ByteStorage (after decryption) - if (useEnvelope) { - plaintext = this.byteStorage!.unpack(plaintext); - } + // Decrypt if encryption enabled + let plaintext = data; + if (this.encryption) { + plaintext = await this.encryption.decrypt(data, key, useEnvelope); + } - // Deserialize - const value = interop - ? decodeInteropValue(plaintext) - : this.serializer.decode(plaintext); - - // Populate L1. Interop keys are {namespace}:{operation}:{hash} — group - // under the user-facing namespace segment so namespace-level - // invalidation matches entries written through wrap(). - if (this.l1) { - const namespace = interop ? key.slice(0, key.indexOf(':')) : extractNamespace(key); - this.l1.set(key, value, (ttlSeconds ?? this.defaultTtl) * 1000, namespace); - this.publishL1Stats(); - } + // Decompress with ByteStorage (after decryption) + if (useEnvelope) { + plaintext = this.byteStorage!.unpack(plaintext); + } - this.recordHit('l2'); - return value; - }); + // Deserialize + const value = interop + ? decodeInteropValue(plaintext) + : this.serializer.decode(plaintext); + + // Populate L1. Interop keys are {namespace}:{operation}:{hash} — group + // under the user-facing namespace segment so namespace-level + // invalidation matches entries written through wrap(). + if (this.l1) { + const namespace = interop ? key.slice(0, key.indexOf(':')) : extractNamespace(key); + this.l1.set(key, value, (ttlSeconds ?? this.defaultTtl) * 1000, namespace); + this.publishL1Stats(); + } - return this.execute(operation, null); + this.recordHit('l2'); + return value; + }); } async set(key: string, value: T, options?: SetOptions): Promise { @@ -300,50 +307,44 @@ class CacheImpl implements SecureCache { // (existing degrade semantics unchanged). const interopSerialized = interop ? encodeInteropValue(value) : null; - const operation = (): Promise => - this.instrument('set', async () => { - // Serialize - const serialized = interopSerialized ?? this.serializer.encode(value); + return this.run('set', undefined, async (): Promise => { + // Serialize + const serialized = interopSerialized ?? this.serializer.encode(value); - // Compress with ByteStorage (before encryption) - let data: Uint8Array = useEnvelope ? this.byteStorage!.pack(serialized) : serialized; + // Compress with ByteStorage (before encryption) + let data: Uint8Array = useEnvelope ? this.byteStorage!.pack(serialized) : serialized; - // Encrypt if encryption enabled - if (this.encryption) { - data = await this.encryption.encrypt(data, key, useEnvelope); - } + // Encrypt if encryption enabled + if (this.encryption) { + data = await this.encryption.encrypt(data, key, useEnvelope); + } - // Store in backend - await this.backend.set(key, data, ttl); + // Store in backend + await this.backend.set(key, data, ttl); - // Update L1 - if (this.l1) { - this.l1.set(key, value, ttl * 1000, namespace); - this.publishL1Stats(); - } - }); - - await this.execute(operation, undefined); + // Update L1 + if (this.l1) { + this.l1.set(key, value, ttl * 1000, namespace); + this.publishL1Stats(); + } + }); } async delete(key: string): Promise { this.ensureNotClosed(); - const operation = (): Promise => - this.instrument('delete', async () => { - // Delete from backend - const deleted = await this.backend.delete(key); - - // Invalidate L1 - if (this.l1) { - this.l1.invalidateByKey(key); - this.publishL1Stats(); - } + return this.run('delete', false, async (): Promise => { + // Delete from backend + const deleted = await this.backend.delete(key); - return deleted; - }); + // Invalidate L1 + if (this.l1) { + this.l1.invalidateByKey(key); + this.publishL1Stats(); + } - return this.execute(operation, false); + return deleted; + }); } async exists(key: string): Promise { @@ -353,11 +354,12 @@ class CacheImpl implements SecureCache { if (this.l1) { const l1Value = this.l1.get(key); if (l1Value !== null) { + this.recordHit('l1'); return true; } } - return this.execute(() => this.backend.exists(key), false); + return this.run('exists', false, () => this.backend.exists(key)); } wrap( From 8d7e9d347f0687493fd45e13ada1b0ff85dae1ab Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Fri, 24 Jul 2026 01:13:46 +1000 Subject: [PATCH 3/5] feat(cache): cold-miss single-flight + opt-in cross-process locking (LAB-519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold L1+L2 miss previously executed the wrapped function once per concurrent caller — N cold callers meant N upstream calls, N L2 GETs (N billed misses under metered-misses), and N L2 writes. In-process (always on): concurrent wrap() calls for one cache key share a single in-flight promise covering the L2 read, compute, and write. The L2 read must be inside the flight — the GET-miss is the billed event, so deduping only the compute would still bill N misses. Cross-process (opt-in, stampede.distributedLock): wires the previously caller-less LockableBackend capability around the miss path, mirroring cachekit-py's acquire_lock flow — acquire, double-check L2, compute, write, release. Contested waiters retry the lock on an interval rather than polling get() (a poll GET against a still-cold key is itself a billed miss), then fall through to computing after lockWaitMs: the lease is best-effort mitigation, never a correctness gate. Lock calls stay outside the reliability executor so lock failures neither stack retry latency nor open the circuit breaker for data operations. Deliberately no general admission-control cap beyond L1's maxConcurrentRefreshes: on Node's single-threaded event loop concurrent misses don't compete for threads, single-flight collapses the per-key herd, and distinct-key floods are bounded by backend timeouts plus the circuit breaker. Decision recorded on StampedeConfig. Co-authored-by: multica-agent --- packages/cachekit/README.md | 45 +++ .../cachekit/src/cache.single-flight.test.ts | 293 ++++++++++++++++++ packages/cachekit/src/cache.ts | 211 ++++++++++++- packages/cachekit/src/constants.ts | 13 + packages/cachekit/src/index.ts | 1 + packages/cachekit/src/types/cache.ts | 58 ++++ 6 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 packages/cachekit/src/cache.single-flight.test.ts diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index d270f3b..2935f23 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -8,6 +8,7 @@ Production-ready Redis caching for TypeScript/Node.js. Hybrid TypeScript-Rust de - **Dual-layer caching**: L1 in-memory (~50ns) + L2 Redis (~2-50ms) - **Stale-while-revalidate**: Serve stale data while refreshing in background +- **Stampede protection**: Cold-miss single-flight per process (always on) + opt-in cross-process distributed locks - **Zero-knowledge encryption**: Optional AES-256-GCM client-side encryption - **Circuit breaker**: Automatic failure isolation with exponential backoff - **TypeScript-first**: Full type safety with strict mode @@ -133,6 +134,50 @@ const cache = createCache({ }); ``` +## Stampede Protection + +A cold cache key hit by N concurrent callers would normally execute the wrapped +function N times and issue N backend reads — on the metered-misses SaaS backend +that is N billed misses for one key. + +**In-process single-flight is always on**: concurrent `wrap()` calls for the +same cache key share one in-flight promise, so the herd costs one L2 read, one +compute, and one write. If the flight fails, every waiting caller receives the +same rejection and the next call retries fresh. + +**Cross-process locking is opt-in** via `stampede.distributedLock`, for fleets +where many processes can go cold on the same key simultaneously. It mirrors +cachekit-py's flow: acquire the backend lock, double-check L2, compute, write, +release. Contested processes retry the lock on an interval (never polling +`get()` — on metered-misses backends a poll against a still-cold key is itself +a billed miss) and fall through to computing after `lockWaitMs`; the lock is +best-effort mitigation, never a correctness gate, so lock-endpoint failures +degrade to an unlocked compute. + +```typescript +const cache = createCache({ + backend: { url: 'redis://localhost:6379' }, // Redis and CachekitIO backends support locks + stampede: { + distributedLock: true, // default false + lockTimeoutMs: 30000, // lock lease; size at/above expected recompute time + lockWaitMs: 5000, // max wait for the lock holder before computing anyway + lockPollMs: 100, // lock retry interval while contested + }, +}); +``` + +Requires a lock-capable backend: Redis, a `CachekitIOBackendConfig` (the SaaS +lock endpoint is selected automatically), `cachekitioWithLocking()`, or +`cachekitioFull()`. `createCache` throws `ConfigurationError` if +`distributedLock` is requested on a backend without `acquireLock`/`releaseLock`. + +There is deliberately no general admission-control cap beyond L1's +`maxConcurrentRefreshes`: on Node's single-threaded event loop concurrent +misses don't compete for threads, single-flight collapses the per-key herd, +and distinct-key miss floods are already bounded by backend timeouts plus the +circuit breaker. A global semaphore would add queueing latency without a +failure mode it prevents. + ## API Reference ### createCache(options) diff --git a/packages/cachekit/src/cache.single-flight.test.ts b/packages/cachekit/src/cache.single-flight.test.ts new file mode 100644 index 0000000..7ac3959 --- /dev/null +++ b/packages/cachekit/src/cache.single-flight.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createCache } from './cache.js'; +import { ConfigurationError } from './errors.js'; +import type { SecureCache } from './types/cache.js'; +import type { Backend, LockableBackend } from './backends/types.js'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * In-memory backend with operation counters. `gets` on a cold key is the + * billed-miss count under metered-misses — the number these tests exist to + * pin down (LAB-519). + */ +class CountingBackend implements Backend { + store = new Map(); + gets = 0; + sets = 0; + + async get(key: string): Promise { + this.gets++; + return this.store.get(key) ?? null; + } + + async set(key: string, value: Uint8Array, _ttl?: number): Promise { + this.sets++; + this.store.set(key, value); + } + + async delete(key: string): Promise { + return this.store.delete(key); + } + + async exists(key: string): Promise { + return this.store.has(key); + } + + async close(): Promise { + // Shared between cache instances in cross-process tests — keep the store. + } +} + +/** Lockable variant: single-holder lock table, compare-and-delete release. */ +class CountingLockableBackend extends CountingBackend implements LockableBackend { + acquires = 0; + releases = 0; + private readonly locks = new Map(); + + async acquireLock(key: string, _timeoutMs?: number): Promise { + this.acquires++; + if (this.locks.has(key)) return null; + const lockId = `lock-${this.acquires}`; + this.locks.set(key, lockId); + return lockId; + } + + async releaseLock(key: string, lockId: string): Promise { + this.releases++; + if (this.locks.get(key) !== lockId) return false; + this.locks.delete(key); + return true; + } + + heldLocks(): number { + return this.locks.size; + } +} + +describe('Cold-miss single-flight (LAB-519)', () => { + const caches: SecureCache[] = []; + + const make = (options: Parameters[0]): SecureCache => { + const cache = createCache(options); + caches.push(cache); + return cache; + }; + + afterEach(async () => { + await Promise.all(caches.splice(0).map((c) => c.close())); + }); + + describe('in-process', () => { + it('shares one L2 read, one compute, and one write across a concurrent cold herd', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async (id: number) => { + calls++; + await sleep(20); + return `value-${id}`; + }, + { namespace: 'sf:herd', ttl: 60 } + ); + + const results = await Promise.all(Array.from({ length: 10 }, () => wrapped(1))); + + expect(results).toEqual(Array.from({ length: 10 }, () => 'value-1')); + expect(calls).toBe(1); + expect(backend.gets).toBe(1); // 1 billed miss, not 10 + expect(backend.sets).toBe(1); + }); + + it('does not share flights across distinct keys', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async (id: number) => { + calls++; + await sleep(10); + return id; + }, + { namespace: 'sf:keys', ttl: 60 } + ); + + const [a, b] = await Promise.all([wrapped(1), wrapped(2)]); + + expect(a).toBe(1); + expect(b).toBe(2); + expect(calls).toBe(2); + }); + + it('evicts the flight after it settles so later calls read the cache', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: false } }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'once'; + }, + { namespace: 'sf:settle', ttl: 60 } + ); + + await wrapped(); + const second = await wrapped(); + + expect(second).toBe('once'); + expect(calls).toBe(1); + expect(backend.gets).toBe(2); // second call is its own flight: L2 hit + }); + + it('rejects every herd member on failure, then retries fresh on the next call', async () => { + const backend = new CountingBackend(); + const cache = make({ backend, l1: { enabled: true } }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + await sleep(10); + if (calls === 1) throw new Error('upstream down'); + return 'recovered'; + }, + { namespace: 'sf:err', ttl: 60 } + ); + + const herd = await Promise.allSettled(Array.from({ length: 5 }, () => wrapped())); + + expect(herd.every((r) => r.status === 'rejected')).toBe(true); + expect(calls).toBe(1); // one shared failure, not five + + // Rejected flight must not be cached: next call recomputes. + await expect(wrapped()).resolves.toBe('recovered'); + expect(calls).toBe(2); + }); + }); + + describe('cross-process distributed lock (opt-in)', () => { + const lockOptions = { distributedLock: true, lockWaitMs: 2000, lockPollMs: 10 }; + + it('contested process waits on the lock and picks up the winner’s write instead of recomputing', async () => { + // Two cache instances sharing one backend = two processes sharing L2. + const backend = new CountingLockableBackend(); + const cache1 = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + const cache2 = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + let calls = 0; + const slow = async (id: number): Promise => { + calls++; + await sleep(100); + return `result-${id}`; + }; + const w1 = cache1.wrap(slow, { namespace: 'sf:fleet', ttl: 60 }); + const w2 = cache2.wrap(slow, { namespace: 'sf:fleet', ttl: 60 }); + + const [a, b] = await Promise.all([w1(7), w2(7)]); + + expect(a).toBe('result-7'); + expect(b).toBe('result-7'); + expect(calls).toBe(1); // loser's post-grant double-check hit the winner's write + expect(backend.sets).toBe(1); + expect(backend.heldLocks()).toBe(0); // both sides released + expect(backend.releases).toBeGreaterThanOrEqual(1); + }); + + it('computes anyway when the holder never fills the cache (stampede fallthrough)', async () => { + class NeverGrantsBackend extends CountingBackend implements LockableBackend { + async acquireLock(): Promise { + return null; // permanently contested (e.g. holder crashed, lease outlives our wait) + } + async releaseLock(): Promise { + return false; + } + } + + const backend = new NeverGrantsBackend(); + const cache = make({ + backend, + l1: { enabled: false }, + stampede: { distributedLock: true, lockWaitMs: 100, lockPollMs: 20 }, + }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'computed-anyway'; + }, + { namespace: 'sf:fallthrough', ttl: 60 } + ); + + await expect(wrapped()).resolves.toBe('computed-anyway'); + expect(calls).toBe(1); + }); + + it('degrades to computing without the lock when acquireLock throws', async () => { + class BrokenLockBackend extends CountingBackend implements LockableBackend { + async acquireLock(): Promise { + throw new Error('lock endpoint unreachable'); + } + async releaseLock(): Promise { + return false; + } + } + + const backend = new BrokenLockBackend(); + const cache = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + let calls = 0; + const wrapped = cache.wrap( + async () => { + calls++; + return 'still-works'; + }, + { namespace: 'sf:broken-lock', ttl: 60 } + ); + + await expect(wrapped()).resolves.toBe('still-works'); + expect(calls).toBe(1); + expect(backend.sets).toBe(1); + }); + + it('releases the lock even when the compute throws', async () => { + const backend = new CountingLockableBackend(); + const cache = make({ backend, l1: { enabled: false }, stampede: lockOptions }); + + const wrapped = cache.wrap( + async () => { + throw new Error('compute failed'); + }, + { namespace: 'sf:release-on-error', ttl: 60 } + ); + + await expect(wrapped()).rejects.toThrow('compute failed'); + expect(backend.heldLocks()).toBe(0); + }); + + it('rejects distributedLock on a backend without lock capability', () => { + expect(() => + createCache({ + backend: new CountingBackend(), + stampede: { distributedLock: true }, + }) + ).toThrow(ConfigurationError); + }); + + it('rejects invalid stampede timing config', () => { + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockPollMs: 0 } }) + ).toThrow(ConfigurationError); + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockTimeoutMs: -1 } }) + ).toThrow(ConfigurationError); + expect(() => + createCache({ backend: new CountingBackend(), stampede: { lockWaitMs: Infinity } }) + ).toThrow(ConfigurationError); + }); + }); +}); diff --git a/packages/cachekit/src/cache.ts b/packages/cachekit/src/cache.ts index 1d26ce8..e09fdd0 100644 --- a/packages/cachekit/src/cache.ts +++ b/packages/cachekit/src/cache.ts @@ -1,13 +1,20 @@ import type { CacheOptions, SetOptions, + StampedeConfig, WrapOptions, + WrapOptionsBase, SecureCache, InvalidationConfig, } from './types/cache.js'; -import type { Backend, RedisBackendConfig, CachekitIOBackendConfig } from './backends/types.js'; +import type { + Backend, + LockableBackend, + RedisBackendConfig, + CachekitIOBackendConfig, +} from './backends/types.js'; import { redis } from './backends/redis.js'; -import { cachekitio } from './backends/cachekitio-factory.js'; +import { cachekitio, cachekitioWithLocking } from './backends/cachekitio-factory.js'; import { L1Cache } from './l1/lru-cache.js'; import { ReliabilityExecutor } from './reliability/executor.js'; import { BackgroundRefreshManager } from './cache/background-refresh.js'; @@ -28,7 +35,21 @@ import { ByteStorage } from '@cachekit-io/cachekit-core-ts'; import { RedisInvalidationChannel } from './invalidation/redis-channel.js'; import { createInvalidationEvent } from './invalidation/event.js'; import { BackendError, ConfigurationError } from './errors.js'; -import { DEFAULT_TTL_SECONDS } from './constants.js'; +import { + DEFAULT_TTL_SECONDS, + DEFAULT_LOCK_TIMEOUT_MS, + DEFAULT_LOCK_WAIT_MS, + DEFAULT_LOCK_POLL_MS, +} from './constants.js'; + +/** + * Sentinel for "the lock path did not resolve the miss — compute without + * it". Distinct from null: the wrapped function may legitimately resolve + * null, and conflating the two would compute twice under a held lock. + */ +const LOCK_FALLTHROUGH = Symbol('cachekit.lock-fallthrough'); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** * Internal cache implementation. @@ -44,17 +65,64 @@ class CacheImpl implements SecureCache { private readonly defaultTtl: number; private readonly invalidationChannel: RedisInvalidationChannel | null = null; private closed = false; + /** One in-flight cold-miss resolution per cache key (single-flight, LAB-519). */ + private readonly inflight = new Map>(); + private readonly stampede: Required; + private readonly lockable: LockableBackend | null; constructor(options: CacheOptions) { // Initialize backend if ('get' in options.backend) { this.backend = options.backend; } else if ('apiKey' in options.backend) { - this.backend = cachekitio(options.backend as CachekitIOBackendConfig); + // The plain SaaS factory has no lock capability; the distributedLock + // opt-in selects the lockable wrapper so config-based users aren't + // forced to construct the backend by hand. + this.backend = options.stampede?.distributedLock + ? cachekitioWithLocking(options.backend as CachekitIOBackendConfig) + : cachekitio(options.backend as CachekitIOBackendConfig); } else { this.backend = redis(options.backend as RedisBackendConfig); } + // Stampede config + lock capability. Duck-typed like cachekit-py's + // hasattr check: user-supplied Backend instances aren't required to + // declare the LockableBackend interface, only to implement it. + this.stampede = { + distributedLock: options.stampede?.distributedLock ?? false, + lockTimeoutMs: options.stampede?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, + lockWaitMs: options.stampede?.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS, + lockPollMs: options.stampede?.lockPollMs ?? DEFAULT_LOCK_POLL_MS, + }; + if (!Number.isFinite(this.stampede.lockTimeoutMs) || this.stampede.lockTimeoutMs <= 0) { + throw new ConfigurationError( + `stampede.lockTimeoutMs must be > 0, got ${this.stampede.lockTimeoutMs}` + ); + } + if (!Number.isFinite(this.stampede.lockPollMs) || this.stampede.lockPollMs <= 0) { + throw new ConfigurationError( + `stampede.lockPollMs must be > 0, got ${this.stampede.lockPollMs}` + ); + } + if (!Number.isFinite(this.stampede.lockWaitMs) || this.stampede.lockWaitMs < 0) { + throw new ConfigurationError( + `stampede.lockWaitMs must be >= 0, got ${this.stampede.lockWaitMs}` + ); + } + const maybeLockable = this.backend as Partial; + this.lockable = + typeof maybeLockable.acquireLock === 'function' && + typeof maybeLockable.releaseLock === 'function' + ? (this.backend as LockableBackend) + : null; + if (this.stampede.distributedLock && !this.lockable) { + throw new ConfigurationError( + 'stampede.distributedLock requires a backend with lock capability ' + + '(Redis, cachekitioWithLocking, or cachekitioFull) — the configured backend ' + + 'has no acquireLock/releaseLock' + ); + } + // Initialize L1 cache if (options.l1?.enabled !== false) { this.l1 = new L1Cache(options.l1); @@ -374,7 +442,12 @@ class CacheImpl implements SecureCache { swrResult.versionToken, this.l1, async (key, value, opts) => { - await this.setEntry(key, value, { ttl: opts.ttl, namespace: opts.namespace }, interop); + await this.setEntry( + key, + value, + { ttl: opts.ttl, namespace: opts.namespace }, + interop + ); } ); } @@ -382,18 +455,123 @@ class CacheImpl implements SecureCache { } } - // Check L2 - const cached = await this.getEntry(cacheKey, interop, options.ttl); - if (cached !== null) { - return cached; + // Cold path (L1 miss): single-flight per key per process. The flight + // covers the L2 read too, not just the compute — the L2 GET-miss is + // the billed event under metered-misses, so N concurrent cold callers + // must share one read, one compute, and one write (LAB-519). + const existing = this.inflight.get(cacheKey); + if (existing) { + return existing as Promise; + } + const flight = this.resolveMiss(cacheKey, interop, () => fn(...args), options); + this.inflight.set(cacheKey, flight); + try { + return await flight; + } finally { + this.inflight.delete(cacheKey); + } + }; + } + + /** + * Cold-path resolution shared by every concurrent caller of one key: + * L2 read, then compute + write, optionally bracketed by a distributed + * lock when the backend supports it and stampede.distributedLock is on. + */ + private async resolveMiss( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const cached = await this.getEntry(cacheKey, interop, options.ttl); + if (cached !== null) { + return cached; + } + + if (this.lockable && this.stampede.distributedLock) { + const locked = await this.resolveUnderLock(cacheKey, interop, compute, options); + if (locked !== LOCK_FALLTHROUGH) { + return locked; } + } - // Compute and cache - const result = await fn(...args); - await this.setEntry(cacheKey, result, { ttl: options.ttl, namespace: options.namespace }, interop); + return this.computeAndStore(cacheKey, interop, compute, options); + } - return result; - }; + /** + * Cross-process miss arbitration, mirroring cachekit-py's acquire_lock + * flow (wrapper.py): acquire → double-check L2 → compute → write → + * release. acquireLock never blocks on contention (LAB-240), so + * "waiting" is retrying the lock on an interval bounded by lockWaitMs — + * deliberately NOT polling get(), because on a metered-misses backend + * every poll GET against a still-cold key is itself a billed miss, + * while contested lock calls are not. + * + * Returns LOCK_FALLTHROUGH when the lock never resolved the miss + * (acquire error, or contested past the wait budget): the lease is + * best-effort stampede mitigation, never a correctness gate, so lock + * failure degrades to computing without it. + */ + private async resolveUnderLock( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const lockable = this.lockable!; + const { lockTimeoutMs, lockWaitMs, lockPollMs } = this.stampede; + const deadline = Date.now() + lockWaitMs; + + for (;;) { + let lockId: string | null; + try { + // Deliberately outside the reliability executor: retry would stack + // latency onto a best-effort call, and counting lock failures + // against the circuit breaker could open it for data operations. + lockId = await lockable.acquireLock(cacheKey, lockTimeoutMs); + } catch { + return LOCK_FALLTHROUGH; + } + + if (lockId !== null) { + try { + // Double-check: the holder we waited on (or a racing process) + // may have written between our miss and this grant — one GET + // that hits, instead of a duplicate compute + write. + const filled = await this.getEntry(cacheKey, interop, options.ttl); + if (filled !== null) { + return filled; + } + return await this.computeAndStore(cacheKey, interop, compute, options); + } finally { + // Best-effort: the lease auto-expires, and a failed release must + // not mask the compute result. + lockable.releaseLock(cacheKey, lockId).catch(() => {}); + } + } + + if (Date.now() + lockPollMs > deadline) { + return LOCK_FALLTHROUGH; + } + await sleep(lockPollMs); + } + } + + private async computeAndStore( + cacheKey: string, + interop: boolean, + compute: () => Promise, + options: WrapOptionsBase + ): Promise { + const result = await compute(); + await this.setEntry( + cacheKey, + result, + { ttl: options.ttl, namespace: options.namespace }, + interop + ); + return result; } with( @@ -466,6 +644,11 @@ class CacheImpl implements SecureCache { // Stop background refresh manager (clears in-flight refreshes) this.backgroundRefresh.close(); + // Drop single-flight registrations (in-flight promises settle on their + // own; callers already awaiting them get the result or a closed-backend + // error) + this.inflight.clear(); + // Stop invalidation channel if (this.invalidationChannel) { await this.invalidationChannel.stop(); diff --git a/packages/cachekit/src/constants.ts b/packages/cachekit/src/constants.ts index 4cd6452..99c53ea 100644 --- a/packages/cachekit/src/constants.ts +++ b/packages/cachekit/src/constants.ts @@ -126,6 +126,19 @@ export const MIN_MASTER_KEY_BYTES = 32; /** Minimum master key length in hex characters */ export const MIN_MASTER_KEY_HEX_LENGTH = 64; +// ============================================================================ +// Stampede / Single-Flight Constants +// ============================================================================ + +/** Default distributed lock lease in milliseconds (matches cachekit-py's lock_timeout=30s) */ +export const DEFAULT_LOCK_TIMEOUT_MS = 30000; + +/** Default wait for a contested lock holder to fill the cache (matches cachekit-py's blocking_timeout=5s) */ +export const DEFAULT_LOCK_WAIT_MS = 5000; + +/** Default lock retry interval while contested, in milliseconds */ +export const DEFAULT_LOCK_POLL_MS = 100; + // ============================================================================ // SWR (Stale-While-Revalidate) Constants // ============================================================================ diff --git a/packages/cachekit/src/index.ts b/packages/cachekit/src/index.ts index 2e1559f..26cc988 100644 --- a/packages/cachekit/src/index.ts +++ b/packages/cachekit/src/index.ts @@ -25,6 +25,7 @@ export type { WrapOptions, EncryptionConfig, ReliabilityConfig, + StampedeConfig, InvalidationConfig, } from './types/cache.js'; diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index 9850cee..918454b 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -100,6 +100,61 @@ export interface EncryptionConfig { tenantId?: string; } +/** + * Cold-miss stampede protection. + * + * In-process single-flight is always on: concurrent wrap() calls for the + * same cache key share one in-flight promise, so a herd of N cold callers + * costs one L2 read, one compute, and one L2 write instead of N of each — + * under metered-misses billing that is 1 billed miss per cold key instead + * of N. + * + * This config only controls the cross-process extension of that posture. + * + * Deliberately absent — general admission control (a global concurrent-miss + * cap beyond L1's maxConcurrentRefreshes): on Node's single-threaded event + * loop concurrent misses don't compete for threads, single-flight already + * collapses the per-key herd (the amplification vector metered-misses + * punishes), and distinct-key miss floods are bounded by backend timeouts + * plus the circuit breaker. A global semaphore would add queueing latency + * and a tuning knob without a failure mode it prevents; revisit only with + * evidence of backend connection exhaustion. (LAB-519) + */ +export interface StampedeConfig { + /** + * Hold a backend distributed lock around cold-miss compute, mirroring + * cachekit-py's acquire_lock flow: acquire → double-check L2 → compute → + * write → release. When contested, re-try the lock on an interval up to + * `lockWaitMs` (acquireLock never blocks — LAB-240), then compute anyway: + * the lease is best-effort stampede mitigation, never a correctness gate. + * + * Contested waiters deliberately retry the LOCK rather than polling + * get(): on a metered-misses backend every poll GET against a still-cold + * key would itself be a billed miss. + * + * Requires a LockableBackend (Redis, cachekitioWithLocking, + * cachekitioFull, or a CachekitIO config — the SaaS lock endpoint is + * selected automatically). createCache() throws ConfigurationError when + * the backend has no lock capability — an explicit opt-in that silently + * does nothing would be a lie. Default: false. + */ + distributedLock?: boolean; + /** + * Lock lease in milliseconds before auto-release. Size at or above the + * expected recompute time (default 30000, matching cachekit-py's + * lock_timeout). + */ + lockTimeoutMs?: number; + /** + * Contested: max milliseconds to wait for the lock holder to fill the + * cache before computing anyway (default 5000, matching cachekit-py's + * blocking_timeout). + */ + lockWaitMs?: number; + /** Contested: lock retry interval in milliseconds (default 100). */ + lockPollMs?: number; +} + /** * Reliability configuration for cache. */ @@ -131,6 +186,9 @@ export interface CacheOptions { /** Reliability configuration (circuit breaker, retry) */ reliability?: ReliabilityConfig; + /** Cold-miss stampede protection (cross-process lock opt-in) */ + stampede?: StampedeConfig; + /** Serializer configuration */ serializer?: Partial; From 225afee60232f278bae27d0d6dc90b190f75730c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 25 Jul 2026 02:11:32 +1000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20guard=20the=20metrics=20onError=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-supplied onError that throws escaped handleError() and rejected the collector's promise, turning the cache layer's fire-and-forget void this.metrics.*() calls into unhandled rejections. Both call sites (handleError and the initialize failure path) now invoke the handler through a guarded helper that reports the handler's own failure via the library logger — metrics stay best-effort, the never-reject invariant holds. CodeRabbit-Resolved: cache-core.ts:320:Guard the metrics error han Co-authored-by: multica-agent --- .../cachekit/src/metrics/prometheus.test.ts | 46 +++++++++++++++++++ packages/cachekit/src/metrics/prometheus.ts | 25 +++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/cachekit/src/metrics/prometheus.test.ts b/packages/cachekit/src/metrics/prometheus.test.ts index 4108661..72c43df 100644 --- a/packages/cachekit/src/metrics/prometheus.test.ts +++ b/packages/cachekit/src/metrics/prometheus.test.ts @@ -171,3 +171,49 @@ describe('CacheMetrics init failure handling', () => { timer(); // should not throw }); }); + +describe('CacheMetrics guards the onError handler', () => { + // The cache layer calls every collector method fire-and-forget + // (`void this.metrics.*()`) on the invariant that they never reject. A + // user-supplied onError that throws must not break that invariant. + + it('a throwing onError during a metric operation never rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const throwingCounter = { + inc: () => { + throw new Error('inc failed'); + }, + }; + const metrics = new CacheMetrics({ + registry: { registerMetric() {}, getSingleMetric: () => throwingCounter }, + onError: () => { + throw new Error('handler bug'); + }, + }); + + await expect(metrics.recordOperation('get', 'success')).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + '[cachekit] metrics onError handler threw:', + expect.any(Error) + ); + consoleSpy.mockRestore(); + }); + + it('a throwing onError during initialization failure never rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const metrics = new CacheMetrics({ + registry: { + registerMetric() {}, + getSingleMetric: () => { + throw new Error('registry down'); + }, + }, + onError: () => { + throw new Error('handler bug'); + }, + }); + + await expect(metrics.recordOperation('get', 'success')).resolves.toBeUndefined(); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cachekit/src/metrics/prometheus.ts b/packages/cachekit/src/metrics/prometheus.ts index cfe4862..47c84b0 100644 --- a/packages/cachekit/src/metrics/prometheus.ts +++ b/packages/cachekit/src/metrics/prometheus.ts @@ -218,9 +218,7 @@ export class CacheMetrics implements MetricsCollector { // m5 Fix: Log error instead of silently swallowing const err = error instanceof Error ? error : new Error(String(error)); - if (this.errorHandler) { - this.errorHandler(err); - } else { + if (!this.invokeErrorHandler(err)) { logError( '[cachekit] metrics are enabled but failed to initialize — install the optional ' + '`prom-client` peer dependency or set `metrics: false`:', @@ -232,6 +230,23 @@ export class CacheMetrics implements MetricsCollector { } } + /** + * Invoke the user's onError handler guarded, returning whether one was + * registered. A throwing handler must never escape: the cache layer calls + * every collector method fire-and-forget (`void this.metrics.*()`) on the + * invariant that they never reject — an unguarded handler would turn its + * own bug into unhandled rejections. Metrics stay best-effort. + */ + private invokeErrorHandler(err: Error): boolean { + if (!this.errorHandler) return false; + try { + this.errorHandler(err); + } catch (handlerError) { + logError('[cachekit] metrics onError handler threw:', handlerError); + } + return true; + } + /** * Handle errors from async metric operations. * m5 Fix: Proper error handling instead of silent failures. @@ -239,9 +254,7 @@ export class CacheMetrics implements MetricsCollector { private handleError(error: unknown, context: string): void { const err = error instanceof Error ? error : new Error(String(error)); - if (this.errorHandler) { - this.errorHandler(err); - } else { + if (!this.invokeErrorHandler(err)) { logError(`[cachekit] Metrics error (${context}):`, err.message); } } From 17b94270f9c3ba0fd05944368ac6f0e8e589a409 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 28 Jul 2026 16:12:02 +1000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20kody=20review=20=E2=80=94?= =?UTF-8?q?=20typed=20metric=20samples,=20screaming-fake=20test=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cache.metrics.test.ts: drop the wholesale 'as unknown as' reshape of getMetricsAsJSON() — use prom-client's declared types end to end, narrowing only the undocumented per-sample metricName field that prom-client emits at runtime for histogram sub-series but omits from MetricValue. The circuit-breaker gauge check needs no cast at all. - cachekitio.test.ts: hoist the fixture credential into FAKE_API_KEY ('ck_test_fake-not-a-secret', detect-secrets allowlisted) so the value is unmistakably fake; an env-sourced key would add a hidden test dependency for zero security gain, so deliberately not process.env. - background-refresh.ts: route the waitUntil-registration failure log (arrived from main, LAB-750 era) through logError like every other internal error site — keeps this PR's setLogger invariant true. Kody-Resolved: cachekitio.test.ts:380:Hardcoded-looking API key Kody-Resolved: cache.metrics.test.ts:61:Double type assertion --- .secrets.baseline | 13 +++------- .../cachekit/src/backends/cachekitio.test.ts | 12 ++++++--- packages/cachekit/src/cache.metrics.test.ts | 25 +++++++++---------- .../cachekit/src/cache/background-refresh.ts | 3 +-- 4 files changed, 24 insertions(+), 29 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index aea60f0..154f0f8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -157,21 +157,14 @@ "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "9242986caf890b01f4265ee595e88ee087af4b90", "is_verified": false, - "line_number": 19 + "line_number": 23 }, { "type": "Secret Keyword", "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", "is_verified": false, - "line_number": 45 - }, - { - "type": "Secret Keyword", - "filename": "packages/cachekit/src/backends/cachekitio.test.ts", - "hashed_secret": "310ca4c3a208b51df7a1d454bab0730b474e0d99", - "is_verified": false, - "line_number": 358 + "line_number": 49 } ], "packages/cachekit/src/intents.test.ts": [ @@ -740,5 +733,5 @@ } ] }, - "generated_at": "2026-07-25T14:27:30Z" + "generated_at": "2026-07-28T06:11:46Z" } diff --git a/packages/cachekit/src/backends/cachekitio.test.ts b/packages/cachekit/src/backends/cachekitio.test.ts index 7deeac9..281263c 100644 --- a/packages/cachekit/src/backends/cachekitio.test.ts +++ b/packages/cachekit/src/backends/cachekitio.test.ts @@ -3,6 +3,10 @@ import { cachekitio } from './cachekitio-factory.js'; import type { Backend, CachekitIOBackendConfig } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; +// Deliberately fake credential for fixtures — obviously-fake value so secret +// scanners and reviewers never mistake it for a live key. +const FAKE_API_KEY = 'ck_test_fake-not-a-secret'; // pragma: allowlist secret + // Helper to create a mock Response function mockResponse( status: number, @@ -355,7 +359,7 @@ describe('CachekitIO Backend', () => { // This should not throw — it should detect apiKey and use cachekitio() const cache = createCache({ backend: { - apiKey: 'ck_test_xyz', + apiKey: FAKE_API_KEY, apiUrl: 'https://api.test.cachekit.io', allowCustomHost: true, }, @@ -377,7 +381,7 @@ describe('CachekitIO Backend', () => { const cache = createCache({ backend: { - apiKey: 'ck_test_xyz', + apiKey: FAKE_API_KEY, apiUrl: 'https://api.test.cachekit.io', allowCustomHost: true, }, @@ -402,7 +406,7 @@ describe('CachekitIO Backend', () => { const cache = createCache({ backend: { - apiKey: 'ck_test_xyz', + apiKey: FAKE_API_KEY, apiUrl: 'https://api.test.cachekit.io', allowCustomHost: true, }, @@ -424,7 +428,7 @@ describe('CachekitIO Backend', () => { const cache = createCache({ backend: { - apiKey: 'ck_test_xyz', + apiKey: FAKE_API_KEY, apiUrl: 'https://api.test.cachekit.io', allowCustomHost: true, metricsProvider: () => ({ l1Hits: 42, l2Hits: 7, misses: 3, l1Enabled: true }), diff --git a/packages/cachekit/src/cache.metrics.test.ts b/packages/cachekit/src/cache.metrics.test.ts index 43a13ac..5526376 100644 --- a/packages/cachekit/src/cache.metrics.test.ts +++ b/packages/cachekit/src/cache.metrics.test.ts @@ -43,24 +43,23 @@ class FailingBackend implements Backend { async close(): Promise {} } -interface MetricSample { - value: number; - labels: Record; - /** prom-client sets this on histogram sub-series (_count/_sum/_bucket) */ - metricName?: string; -} - async function metricValue( registry: Registry, name: string, labels?: Record ): Promise { - const metrics = (await registry.getMetricsAsJSON()) as unknown as { - name: string; - values: MetricSample[]; - }[]; + const metrics = await registry.getMetricsAsJSON(); return metrics - .flatMap((m) => m.values.map((v) => ({ ...v, seriesName: v.metricName ?? m.name }))) + .flatMap((m) => + m.values.map((v) => ({ + value: v.value, + labels: v.labels, + // prom-client emits metricName on histogram sub-series + // (_count/_sum/_bucket) at runtime but omits it from MetricValue's + // declared type — narrow only that one optional field. + seriesName: (v as { metricName?: string }).metricName ?? m.name, + })) + ) .filter((v) => v.seriesName === name) .filter((v) => !labels || Object.entries(labels).every(([k, val]) => v.labels[k] === val)) .reduce((sum, v) => sum + v.value, 0); @@ -184,7 +183,7 @@ describe('Cache metrics wiring (LAB-517)', () => { const metrics = await registry.getMetricsAsJSON(); const cb = metrics.find((m) => m.name === 'cachekit_circuit_breaker_state'); expect(cb).toBeDefined(); - expect((cb as unknown as { values: { value: number }[] }).values[0]?.value).toBe(0); + expect(cb?.values[0]?.value).toBe(0); }); }); diff --git a/packages/cachekit/src/cache/background-refresh.ts b/packages/cachekit/src/cache/background-refresh.ts index 91a9c09..db14c84 100644 --- a/packages/cachekit/src/cache/background-refresh.ts +++ b/packages/cachekit/src/cache/background-refresh.ts @@ -110,8 +110,7 @@ export class BackgroundRefreshManager { try { waitUntil?.(refresh); } catch (error) { - // eslint-disable-next-line no-console - console.error( + logError( '[cachekit] Failed to register background refresh with waitUntil:', error instanceof Error ? error.message : 'Unknown error' );