Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.8.0] — 2026-09-16

Added

  • Platform-Agnostic IPC Telemetry Bridge & Live CLI Top Monitor (src/ipc-telemetry.ts, src/cli.ts) — Enterprise-grade IPC bridge and live ASCII terminal monitoring:
    • Platform-Agnostic IPC: Unix domain sockets on POSIX (/tmp/tricache-<pid>.sock or $TMPDIR/...) and Windows Named Pipes (\\.\pipe\tricache-<pid>) on win32.
    • Non-Blocking Telemetry Pull: Command requests (GET_METRICS, PING, INSPECT) defer stats collection and JSON serialization across tick boundaries via setImmediate, eliminating event-loop stalls in the monitored host application.
    • Automatic Socket Hygiene: Automatically registers process.once('exit'), SIGINT, and SIGTERM signal traps to unlink orphaned .sock files synchronously on process termination, with clean programmatic teardown via server.close().
    • Interactive TTY Top Loop (tricache top): Real-time terminal dashboard with alternate screen buffer switching (\x1b[?1049h), cursor hiding (\x1b[?25l), stdin raw mode for q/Ctrl+C exit, non-interactive piped fallback (!process.stdout.isTTY or --once), and --json machine-readable output.
    • Visual Proportional ASCII Gauges: Real-time progress bars for L1 RAM, L1.5 Disk, L2 Redis, and misses, autonomous L1 memory headroom, NVMe disk quota, Latency Watchdog bypass stages, and Count-Min Sketch top hot keys.
  • Framework-Native HTTP Middlewares & Decoupled Edge Bundle (src/http/, src/edge/) — Zero-overhead HTTP caching layer with RFC 7232 ETag validation:
    • Express Middleware (createExpressMiddleware / src/http/express.ts): Deterministic query-sorted cache key generation, header whitelisting, fast weak ETag calculation (W/"..."), immediate 304 Not Modified short-circuiting on If-None-Match, and conditional bypass for Cache-Control: no-cache, no-store.
    • Fastify Plugin (createFastifyPlugin / src/http/fastify.ts): Encapsulation-safe plugin using Fastify's [Symbol.for('skip-override')] = true, intercepting requests early in onRequest and streaming response capture in onSend, with support for route preHandler hooks.
    • Decoupled Hono Edge Middleware (createHonoEdgeMiddleware / src/edge/hono.ts): Pure Web Standards implementation (Request, Response, crypto.subtle) without Node.js native dependencies (node:fs, node:worker_threads, SQLite) for Cloudflare Workers, Fastly Compute, Vercel Edge, Deno, and Bun.
    • Dedicated Package Exports: Explicit ./http and ./edge subpath exports in package.json with separate TypeScript type definitions (dist/http/index.d.ts, dist/edge/index.d.ts).
  • Dual-Constrained Autonomous L1 Memory Sizing (src/utils/cgroup.ts, src/cache-service.ts) — Container-aware memory allocation preventing V8 heap OOM crashes:
    • Automatically probes Linux cgroup v2 (/sys/fs/cgroup/memory.max) and v1 (/sys/fs/cgroup/memory/memory.limit_in_bytes) with safe error traps defaulting to Infinity on EACCES/ENOENT in hardened distroless containers.
    • Dual-constrains L1 cache capacity against V8 heap statistics: min(cgroup×0.40,v8Heap×0.50,512 MB) with a 16 MB floor, preventing Node from crashing when container memory exceeds --max-old-space-size.
  • Zero-Latency Microtask Redis Auto-Pipelining (src/adapters/auto-pipeliner.ts, src/cache-service.ts) — Microtask-coalesced Redis command batching:
    • Coalesces concurrent GET, SETEX, and DEL operations occurring in the same event-loop tick into single pipeline.exec() calls using queueMicrotask exclusively (0ms timer overhead).
    • Triggers immediate flushes without waiting for microtasks when maxPipelineBatchSize (default: 100) is reached.
    • Configured via CacheOptions.autoPipeline: true and monitored via cache.getPipelinerStats().
  • Priority-Aware Partitioned Disk Tiering (src/disk-tier.ts) — Tiered persistent storage with eviction protection for critical entries:
    • Added priority and last_accessed_at columns to SQLite metadata with automated backwards-compatible schema migrations.
    • Composite SQLite index: CREATE INDEX IF NOT EXISTS idx_priority_access ON meta (priority ASC, last_accessed_at ASC);
    • High-watermark disk pruner prioritizes evicting LOW/NORMAL priority entries before ever touching HIGH or CRITICAL entries.
  • Asymmetric Key Envelope Encryption (src/encryption.ts, src/remote-snapshot.ts) — Cryptographic wire specification for zero-trust cloud snapshots:
    • Implemented EnvelopeEncryption class generating ephemeral 256-bit AES-GCM data encryption keys (DEKs) wrapped with asymmetric RSA-OAEP (SHA-256) public keys or cloud KMS hooks.
    • Serialized into tamper-proof binary format with TRICENV1 header, 4-byte big-endian wrapped key length, wrapped DEK, 12-byte IV, 16-byte authentication tag, and ciphertext.
  • Next.js 16 App Router Demo & cacheHandlers Spec Compliance (examples/nextjs/, src/next/cache-handler.ts) (#8, #21) — Complete reference Next.js 16 App Router sample application demonstrating Cache Components ('use cache'), live latency comparisons (~1000ms vs ~1ms), on-demand revalidation via Server Actions (updateTag), and alignment with Next.js 16 cacheHandlers specification returning undefined on cache misses. Contributed by @dev-ararawi0x.
  • Dynamic Tier Latency Watchdog & Fleet Blast-Radius Shielding (src/latency-watchdog.ts) — Self-healing tier latency watchdog protecting application p95 response times under cloud NVMe and multi-tenant EBS throttling:
    • Amortized zero-allocation p95 calculation: rolling 32-sample sliding window evaluated every 32 writes on pre-allocated Float32Array ring buffers.
    • 4-Stage graduated probabilistic shedding: Stage 0 (0% bypass) → Stage 1 (25% bypass) → Stage 2 (75% bypass) → Stage 3 (100% bypass).
    • Asymmetric hysteresis on Redis circuit: cuts diversion at 15ms latency ceiling, requiring sustained recovery below 10ms over consecutive samples before re-enabling Redis traffic.
    • Anti-synchronicity cooldown jitter (±30%): randomizes cooldown durations to eliminate fleet-wide failover shockwaves across Kubernetes replica sets.
    • Single-canary half-open probing: sends a single trial read upon cooldown expiration, smoothly restoring tier routing without re-saturating degraded storage controllers.
    • Telemetry & stats: exposed via cache.getWatchdogStats() and stats().watchdog.
  • Linux Container /dev/shm tmpfs Off-Heap Resolver (src/disk-tier.ts, src/types.ts) — Automatic container runtime detection targeting POSIX shared memory:
    • resolveDefaultDiskDir(namespace) automatically targets Linux POSIX shared memory /dev/shm when capacity 256 MB and free space 128 MB.
    • Delivers 0.02ms memory bus read/write speeds, bypasses cloud EBS IOPS, and guarantees 100% compliance with CIS/SOC2 readOnlyRootFilesystem: true hardened containers.
    • Safe fallback to os.tmpdir() for Docker default 64MB environments, macOS, and Windows.
  • Strict Ephemeral Storage Quota & Eviction Defense (src/disk-tier.ts) — Autonomous host volume health monitoring preventing Kubernetes node eviction:
    • Evaluates host volume capacity every 2,000 writes via fs.statfsSync(dir).
    • Pauses disk cache spills and engages fast write shedding (spillsShedTotal) when free disk space falls below 10%, preventing Kubernetes DiskPressure and EphemeralStorageExceeded pod evictions.
    • Non-blocking chunked pruning down to 60% watermark in 500-entry batches with setImmediate event-loop cooperative yielding.
  • Native Window TinyLFU (W-TinyLFU) Admission Engine (src/wtiny-lfu.ts) — Full native implementation of the W-TinyLFU segmented cache admission policy popularized by Caffeine:
    • Three-tier segmented architecture: Window Cache (LRU, ~1% capacity) absorbs burst-recency spikes without polluting resident entries; Segmented LRU divides the main cache into Probationary SLRU (~20%) and Protected SLRU (~80%).
    • TinyLFU Admission Gate: When the Window overflows, its LRU victim competes against the Probationary victim in a 4-row Count-Min Sketch. Candidates with higher historical frequency are admitted, while low-frequency candidates are rejected.
    • Mathematical Scan Resistance: Sequential scans (e.g. 1,000 one-off keys) are dropped by the TinyLFU gate with >90% rejection rates, preserving 100% hit retention for resident hot items.
    • Dual Mode Support: Available as a high-performance standalone cache (WTinyLfuCache<K, V>), an admission policy controller (WTinyLfuPolicy), or integrated into SmartMemoryCache and CacheService via l1AdmissionPolicy: 'wtinylfu'.
    • Rejection Spill Integration: Rejected candidates seamlessly spill to the L1.5 disk tier when diskSpill is configured.
    • Real-Time Telemetry: Exposed via cache.getWTinyLfuStats() and wtinyCache.stats() tracking hit rates, admissions, rejections, promotions, demotions, and segment sizes.
  • Zero-Dependency AWS SigV4 Snapshot Adapter (src/sigv4-snapshot-adapter.ts) — Lightweight (~150 LOC) AWS SigV4 signer built purely on standard Web Crypto (crypto.subtle) and fetch:
    • Enables stateless container pods (Kubernetes, AWS ECS/Fargate, GCP Cloud Run) and edge isolates to persist and hydrate L1 snapshots directly to/from AWS S3, Cloudflare R2, MinIO, or custom S3-compatible object storage without pulling in the 30MB @aws-sdk/client-s3 dependency.
    • Implements canonical request hashing, string-to-sign generation, and chained HMAC key derivation (kDatekRegionkServicekSigning).
    • Supports virtual-hosted and path-style addressing with clean 404 / NoSuchKey detection for initial cold start deployments.
    • Added convenience factories createSigV4SnapshotAdapter, createS3SnapshotAdapter, and createR2SnapshotAdapter.
  • Pre-Baked Snapshot Flushers & Graceful Shutdown (src/cache-service.ts) — Automated flusher hooks for container orchestration:
    • Added cache.flushSnapshotOnShutdown(timeoutMs?: number) returning a Promise that flushes both local disk and remote cloud snapshots before Kubernetes kills the container.
    • Added ProcessTerminationBus.flushAll(timeoutMs?: number) to flush snapshots across all registered cache instances concurrently with timeout protection (default 8,000ms), preventing slow object storage from blocking pod eviction.
  • Edge Hydration Hook (src/edge/cache.ts)EdgeCacheService.hydrate() and exportSnapshot():
    • Primes edge isolate L1 memory directly from Cloudflare R2 bucket bindings (env.MY_BUCKET) or snapshot sources on worker initialization.
    • Seamlessly decrypts Web Crypto AEAD envelopes (enc:v1:) and automatically primes the Edge Bloom filter (bloomFilter.add(k)).
    • Enforces snapshot staleness ceilings (maxAgeMs, default 2 hours) and container clock skew tolerances (250ms).
  • BoundedDiskQueue & Cloud NVMe Backpressure Guard (src/disk-tier.ts) — Concurrency-controlled disk spill spooler protecting against libuv threadpool (UV_THREADPOOL_SIZE=4) saturation during cloud NVMe / AWS EBS latency spikes (0.4ms to 450ms):
    • Limits active async fs operations to diskMaxConcurrentWrites (default Math.min(4, Math.max(1, Math.floor(os.availableParallelism() / 4)))), preventing DNS lookups, zlib compression, and crypto operations from starving.
    • Fast-sheds incoming spills when pending writes hit diskMaxPendingWrites (default 512) before touching libuv, bounding heap memory and preserving main-thread latency.
    • Three-state Disk Circuit Breaker (closedopenhalf-open) with strict single-canary probing upon cooldown expiry, preventing recovering disk controllers from being re-flooded.
    • Real-time backpressure telemetry surfaced in stats().disk.backpressure (activeWrites, pendingWrites, spillsDropped, circuitState).
  • Coordinate vs. Monotonic Time Separation & Epsilon-Fencing — Strict separation between wall-clock coordinate timestamps and node-local monotonic intervals:
    • Migrated L2CircuitBreaker, local generational tag cache TTL checks (_getTagVersion), SWR debounce windows, and latency profiling to performance.now(), making internal invariants immune to NTP step adjustments, leap seconds, and container clock skew.
    • Added clockSkewToleranceMs (default 250ms) with ϵ-fencing in loadSnapshot and loadRemoteSnapshot, clamping negative durations (Δt=max(0,nowremoteWrittenAt)) and accepting valid cross-node snapshots within the skew tolerance window.
  • Redis Multiplexing Desync Defense (redisCommandTimeoutMs) — Enforced strict per-command timeouts (default 2,500ms) with immediate socket destruction and reconnection across Redis Cluster, Sentinel, and standalone clients, preventing delayed server responses from being assigned to subsequent FIFO pipelined requests (eliminating response byte poisoning).
  • In-Process Chaos Engineering Test Harness (tests/chaos/) — Zero-dependency programmatic chaos harness built purely on node:net:
    • ChaosTcpProxy (tests/chaos/chaos-tcp-proxy.ts): Simulates network partitions, jittered latency, packet blackholes, and abrupt TCP RST (socket.destroy(new Error('ECONNRESET'))) mid-pipeline without Docker or external Toxiproxy daemons.
    • DiskChaosInjector (tests/chaos/disk-chaos-injector.ts): Simulates multi-tenant NVMe stalls and intermittent EIO / ENOSPC disk controller faults.
    • tests/chaos/fleet-chaos.test.ts: 13 comprehensive chaos scenarios validating backpressure shedding, canary circuit-breaker recovery, clock skew tolerance, and network partition resilience.
  • Node 20 LTS Engine Compatibility (engines: ">=20.10.0") — Lowered the supported runtime floor from Node 22.13.0 to Node 20.10.0 by adding safe dynamic module probing in disk-tier.ts and availableParallelism fallback in worker-pool.ts.
  • ProcessTerminationBus with Idle Listener Teardown (src/cache-service.ts) — Centralized OS SIGTERM/SIGINT handling through a single static bus. Eliminates MaxListenersExceededWarning across multi-tenant microservices and ephemeral test runners, and automatically detaches process listeners when the active instance registry drops to zero to prevent test runner event loop hangs.
  • OpenTelemetry Semantic Conventions & Batch Spans — Full alignment with OTEL Cache Semantic Conventions:
    • cache.hit standardized to strict boolean (true or false).
    • Added cache.item.tier ('memory', 'disk', 'remote').
    • Retained legacy cache.hit_tier ('l1', 'disk', 'l2', 'miss') for complete backward compatibility with existing APM / Grafana dashboards.
    • Added batch spans tricache.mset and tricache.mdel tracking cache.batch.size and capturing failure status codes and exceptions.
  • Pluggable node-redis Driver Adapter (@redis/client Bridge) — Added NodeRedisAdapter and createNodeRedisAdapter (src/adapters/node-redis.ts) enabling enterprise organizations standardizing on @redis/client (node-redis v4/v5/v6), AWS ElastiCache IAM authentication, or Azure Managed Identities to plug their existing connection pools directly into CacheService:
    • Zero additional runtime dependencies added to TriCache.
    • Transparently translates single and multi-key deletions, camelCase methods (sAdd, sMembers, setEx, mGet), distributed locks (EVAL Lua scripts), and transactions.
    • Normalizes pipeline/multi execution into error-first tuple arrays (Array<[Error | null, T]>) for 100% compatibility with all batch and warming flows.
    • Added redisClient?: IRedisDriver | any and redisSubClient?: IRedisDriver | any options to CacheOptions.
  • Package Root Re-Exports (src/index.ts) — Re-exported key adapters and cluster utilities from 'tricache' root: createNodeRedisAdapter, createHttpMeshRelay, createCustomCrossRegionRelay, createCrossRegionWebhookHandler, TierLatencyWatchdog, resolveDefaultDiskDir.
  • Edge WebAssembly & Murmur3 Bloom Filter (tricache/edge) — High-performance cold-miss penetration defense for V8 edge isolates:
    • Removed Node.js Buffer dependency in src/wasm/bloom-filter-wasm.ts via chunked base64ToUint8Array, making the WASM Bloom filter 100% universal across Cloudflare Workers, Fastly Compute, Vercel Edge, and browsers.
    • Created Murmur3BloomFilter and murmur3_32 (src/edge/utils/murmur3.ts) implementing standard 32-bit MurmurHash3 double-hashing with Kirsch-Mitzenmacher bitset probing over a pure Uint8Array bit-array.
    • Integrated Bloom filter into EdgeCacheService (bloomFilter: boolean | IEdgeBloomFilter): ~300ns in-isolate miss rejection completely prevents expensive, metered HTTP subrequests to remote storage (Upstash Redis REST, Cloudflare KV) on 404 routes and randomized bot crawler keys.
  • Expanded Test Suite — Expanded to 756 passing tests across 71 test files with 100% test pass rate.
  • CacheCodec abstraction (src/codec.ts) — Centralized msgpackr binary serialization engine with built-in record structure deduplication (useRecords: true), rich type preservation (moreTypes: true for Set, TypedArray, Date, etc.), and strict plain-object map decoding (mapsAsObjects: true).
  • serializeToJSON option in CacheOptions — Configurable flag (defaults to true) leveraging msgpackr 2.1.0's useToJSON capability. Setting serializeToJSON: false preserves the object's actual internal properties in durable cache tiers without invoking .toJSON(), avoiding accidental HTTP response projections on cached domain entities.
  • Enterprise Documentation & Observability Suite (docs/) — Comprehensive VitePress documentation suite:
    • Interactive guides for Next.js 16/15, NestJS, Prisma, Drizzle, Express/Hono, Edge Isolates, and Visual Dashboard.
    • Dedicated Kubernetes SRE documentation covering /dev/shm, cgroups, probes, latency watchdog, and eviction defense.
    • Complete Observability guide with real-time SSE Web Dashboard, Grafana Golden Signals template, Prometheus alerting rules, and CLI.
  • Dedicated test suites — Added comprehensive coverage for previously untested integration layers, bringing the test suite to 554 tests passing:
    • tests/codec-improvements.test.ts: Record structure deduplication (~45% smaller binary size), serializeToJSON toggle, rich type round-tripping, and DoS rejection.
    • tests/drizzle.test.ts: Deterministic query hashing and withCache query execution wrapping.
    • tests/prisma.test.ts: Deterministic query argument serialization and withTriCache extension hooks with mutation tag invalidation.
    • tests/next-cache-life.test.ts: Next.js 16 cacheLife preset resolution (seconds, minutes, hours, days, weeks, max, and custom profiles).
    • tests/types-logger.test.ts: Built-in consoleLogger wrapping and formatting.
    • tests/disk-tier.test.ts: Tampered ciphertext and corrupted disk payload resilience.

Performance

  • Parallelized Tag Version Loops (N+1 Waterfall Elimination) — Replaced sequential for...of awaited _getTagVersion calls with concurrent Promise.all parallel requests across CacheService (L1 hit staleness check, L2 hash staleness check, disk hit staleness check, set() active tag versioning, and SWR background revalidation) and NextCacheHandler (entry staleness checks and write paths).
  • Cryptographic Randomness for Temp Files — Replaced Math.random() in temporary spill file naming (src/disk-tier.ts) with crypto.randomBytes(6).toString('hex') to eliminate potential collision risks under high multi-tenant spill concurrency.

Documentation & Architecture Notes

  • Documented intentional architectural choices across the codebase to eliminate false positive review warnings:
    • src/cli.ts: Documented why console.log is required for CLI stdout piping.
    • src/wasm/bloom-filter-wasm.ts: Documented why WasmBloomFilter initializes 100% synchronously from inlined precompiled bytecode with no async init phase.
    • src/cache-service.ts: Documented why fs.writeFileSync in writeSnapshot (SIGTERM/SIGINT hooks) and fs.readFileSync in loadSnapshot (constructor cold start) are intentionally synchronous.
    • src/disk-tier.ts: Documented the 16-byte fixed header (DISK_MAGIC_V2) fast path in purgeNextBucket demonstrating that full payload reads are skipped during janitor sweeps.

Security

  • Memory amplification DoS defense (msgpackr 2.0.5 → 2.1.0) — Upgraded msgpackr to 2.1.0. Malformed array32 or map32 headers declaring excessive lengths beyond the buffer boundary are rejected immediately without allocating memory, eliminating a ~32,000,000x memory amplification attack vector on untrusted payloads.

[0.7.1] — 2026-08-22

Fixed

  • P0 — Cross-key data corruption under concurrent generational reads: SmartMemoryCache.get() returned a shared, module-level reusable hit object. CacheService.get() awaits tag-version checks between the L1 hit and the return, so two concurrent get() calls for distinct tagged keys (under tagStrategy: 'generational') could receive each other's payloads with no error. get() now returns a fresh per-call object; regression-tested with a 20-key interleaved concurrency suite (tests/concurrency-aliasing.test.ts).
  • P0 — cache.lock() executed the critical section twice on business failure: when the locked function threw (or the Lua release failed), the error was swallowed by the Redis-fallback catch and the task re-ran under the in-process mutex after the distributed lock had already been released. Lock acquisition and task execution are now decoupled: a business exception propagates exactly once and is never retried (tests/distributed-lock.test.ts).
  • HTTP middleware cached 4xx/5xx responses — a transient upstream 500 poisoned its cache key for the whole TTL window across Express, Hono, and Fastify adapters. All three now gate persistence on 2xx status and evict error responses immediately.
  • Express middleware hung forever on res.end() — only res.json/res.send were intercepted as completion signals; handlers answering via res.end() never resolved the fetch promise. end() is now a third completion signal.
  • ttl: 0 created instantly-expired entries — the NestJS TriCacheStore contract documents "0 = indefinite", but expiry was computed as now + 0ms. TTL 0 now maps to a far-future expiry in both L1 and L2 write paths.
  • Prisma extension forwarded the cache pseudo-option to the query engine on mutations — the read path stripped it, the write path passed it verbatim; both paths now send clean args.
  • Disk tier leaked byte accounting on corrupt entries — the legacy decrypt-failure purge path deleted files without releasing their size from diskUsageBytes, causing phantom "disk cap reached" states in file-only mode (tests/disk-accounting.test.ts).
  • Strict decompression — corrupt compressed payloads previously fell through both zlib attempts and returned raw bytes (corruption surfaced as garbage downstream). Decompression failure now throws and every call site maps it to a clean cache miss; cross-algorithm recovery is preserved.

Changed

  • Library no longer hijacks host shutdown: the SIGTERM/SIGINT handler flushes the cold-start snapshot but no longer calls process.exit(0) — that decision belongs to the host application (Kubernetes graceful drain, NestJS onApplicationShutdown, pool drains).
  • redisHost / cluster / sentinel configuration is now honored outside production — L2 was silently disabled whenever NODE_ENV !== 'production' even with explicit connectivity config. The REDIS_HOST env fallback still does not auto-enable L2 in tests. Documented precedence: explicit disableRedis wins over everything; otherwise any explicit connection config enables Redis.
  • cache.options getter redacts encryptionKey as [REDACTED] instead of exposing raw key material to diagnostic dumps, and now also reports encryptionMode.
  • README badge/test-count drift is CI-enforced via scripts/check-test-badge.mjs (runs after the suite in all six OS×Node matrix legs).
  • Documentation accuracy: corrected the msgpackr serialization claim (L2 string values are JSON-serialised before encryption), documented invalid-key fail-open vs fail-closed behavior in SECURITY.md, fixed the disk-spill saturation test's race against in-flight .tmp staging writes, updated the test-count badge to reflect this release.

Added

  • strictKeyValidation option (fail-closed encryption config) — an invalid or empty encryptionKey previously logged an error and continued with at-rest encryption silently disabled (fail-open; still the default). Set strictKeyValidation: true to throw at construction instead — for deployments where serving plaintext at rest is unacceptable. A key that is simply not configured never throws in either mode.
  • New regression suites: concurrent generational reads (concurrency-aliasing), distributed-lock single-execution semantics (distributed-lock), HTTP error-response caching + res.end() handling (http-middleware), shutdown/config-precedence contracts (lifecycle-config), disk byte accounting (disk-accounting), strict decompression (compression), invalid-key strict mode (encryption). Test count: 484 → 511 (unit) plus a live-Redis integration suite (pnpm test:integration, requires Docker/Redis).

[0.7.0] — 2026-08-21

Added

  • Universal HTTP Caching & 304 ETag Middleware (tricache/http) — Universal middleware for Express, Connect, and Hono:
    • expressCache(options) and honoCache(options) automatically compute weak ETags, cache endpoint responses, and return 304 Not Modified on If-None-Match matches with zero body transfer overhead. Exported via subpath "./http".
  • Worker Thread Crash Auto-Recovery — Handled worker thread sudden crash/exit events with non-zero exit codes in WorkerPool, rejecting in-flight tasks cleanly and auto-spawning healthy replacement workers to preserve pool capacity.
  • First-Class Prisma Client Extension (tricache/prisma) — Added withTriCache(options) Prisma Client extension ($extends):
    • Intercepts read queries (findUnique, findFirst, findMany, count, aggregate, groupBy) with options { ttl, swr, tags, key }.
    • Automatically derives deterministic cache keys from model + operation + query args.
    • Automatically invalidates model tags on write mutations (create, update, delete, upsert, createMany, updateMany, deleteMany). Exported via subpath "./prisma".
  • First-Class Drizzle ORM Query Wrapper (tricache/drizzle) — Added withCache(query, options) wrapper for Drizzle ORM query builders:
    • Generates deterministic SHA-256 cache keys from compiled SQL + parameter bindings via query.toSQL().
    • Seamlessly wraps query execution with cache.wrap() supporting custom TTL, SWR grace windows, and tags. Exported via subpath "./drizzle".
  • Distributed Mutex & Lock Primitive (cache.lock()) — Added distributed and in-process mutual exclusion locking:
    • Atomic acquire via Redis SET lock:<key> <token> NX EX <ttl> with safe Lua script release (if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end).
    • Automatic release on return or throw to prevent deadlocks.
    • Configurable acquireTimeout and retryInterval with an in-process promise-chain mutex fallback.
  • Native OpenTelemetry Metrics (ICacheMeter) — Direct integration with OpenTelemetry Meter API:
    • Structural typing matching @opentelemetry/api Meter without mandatory runtime dependencies.
    • Registers monotonic counters (tricache.gets.total, tricache.l1.hits, tricache.l2.hits, tricache.disk.hits, tricache.fetches, tricache.stampedes.prevented, tricache.sets.total, tricache.deletes.total, tricache.swr.revalidations).
    • Registers observable batch gauges for L1 entries/bytes, disk files/bytes, and Bloom filter false-positive rate.
  • Developer & Troubleshooting CLI (npx tricache) — Zero-dependency developer CLI (bin/tricache.js / dist/cli.js):
    • npx tricache inspect: Live terminal dashboard displaying hit ratios, 3-tier latencies, L1/disk sizes, and Count-Min Sketch hot keys.
    • npx tricache ping: Measures response latencies across RAM, disk, and Redis.
    • npx tricache clear: Safely flushes all entries or keys matching a prefix across the cluster.
  • Universal cache.wrap() Primitive — Added cache.wrap<T>(key, fetchFn, options?: WrapOptions) in CacheService as an ergonomic options-object alternative to cache.get(), normalizing ttl, swr, tags, dependsOn, priority, refreshAhead, and xfetchBeta into a single configuration object for seamless integration with ORMs (Prisma, Drizzle, TypeORM).
  • Official NestJS Module, Store & Decorators (tricache/nestjs) — Dedicated dynamic module, store adapter, and declarative decorators for NestJS:
    • TriCacheModule.register() and TriCacheModule.registerAsync() for synchronous and async dependency injection.
    • TriCacheStore conforming to @nestjs/cache-manager and cache-manager v5/v6 contracts with accurate millisecond-to-second TTL conversion.
    • Declarative @Cacheable(options) and @CacheEvict(options) method decorators with dynamic key generation, argument injection, and automatic tag invalidation.
    • Zero mandatory NestJS runtime dependencies (uses structural typing with optional peer dependencies). Exported via subpath "./nestjs".
  • Zero-Copy WorkerPool Memory Transfers (transferList) — Implemented zero-copy memory transfers between the main thread and worker threads via transferList:
    • getTransferableArrayBuffer() detects dedicated buffers and transfers ownership with zero heap copying.
    • Slices from Node's internal 8 KB buffer pool (128 KB) are isolated to prevent detached-buffer memory corruption.
    • Worker thread transfers output buffers back to parent thread via [transferable], guaranteeing two-way zero-copy throughput.
  • Next.js 16 cacheLife Preset & Profile Mapper — Built-in support for Next.js 16 semantic cache profiles (PRESET_CACHE_LIFE_PROFILES and resolveCacheLife):
    • Automatically maps 'default', 'seconds', 'minutes', 'hours', 'days', 'weeks', and 'max' profiles.
    • Translates revalidate ttl and (expire - revalidate) swr for seamless "use cache" lifecycles.
  • Next.js 16 & 15 Integration Adapter (tricache/next) — Comprehensive CacheHandler implementation for Next.js 16 "use cache", React 19 RSC streaming, and legacy ISR. Includes:
    • 5-method TriCacheHandler (get, set, refreshTags, getExpiration, updateTags) and 4-method TriCacheISRHandler (get, set, revalidateTag, resetRequestCache).
    • Single-use stream safety: automatically drains incoming ReadableStream<Uint8Array> on set() into a contiguous binary buffer and creates fresh, unlocked ReadableStream instances on every get() hit.
    • Dynamic softTags verification: reads ctx.softTags (e.g. _N_T_/layout, _N_T_/page) on get() and validates against active generational tag versions, triggering misses on route/layout boundary invalidations.
    • Fail-soft error boundaries for stream drops / client disconnects and automatic NEXT_PHASE=phase-production-build detection to bypass Redis sockets during static builds.
    • Exported via "./next" in package.json with ESM, CJS, and DTS builds (TriCacheHandler exported as named and default).
  • Generational Tag Invalidation (tagStrategy: 'generational')O(1) tag invalidations replacing O(N) Redis set deletions:
    • Tag invalidation executes atomic INCR tag_ver:<tag> in Redis and memory.
    • Entries store captured tag versions in a unified Redis hash { d: envelope, t: timestamp, tv: tagVersionsJson } with an atomic MULTI/EXEC transaction.
    • Pipelined multi-tag invalidation (invalidateTags(tags)) in a single network round-trip.
    • Monotonic version progression guarantee (Math.max(existing, incoming)) preventing out-of-order packet reordering from resurrecting stale data.
    • Atomic compare-and-delete Lua script on Redis and deleteIfSetBefore(cutoffMs) on L1 RAM to prevent wiping newer data during concurrent revalidation races.
    • Time-based self-healing reconciliation (tagVersionTtlMs, default 5 s) with a 10,000 LRU bounded in-memory cache to heal network partitions and dropped broadcasts.
  • Transitive Cycle-Protected Dependency Cascades (dependsOn)_cascadeDependencies traverses multi-tier entity graphs using a visited: Set<string>, invalidating deep chains (D0DN) in O(V+E) while preventing infinite loops on circular graphs (ABA).
  • Redis Streams Invalidation Backplane (backplaneMode: 'stream') — Durable append-only invalidation logging:
    • Publishes mutations via XADD <streamKey> MAXLEN ~ 10000 * ....
    • Cluster single-slot safety via hash tags: tricache:stream:{<namespace>}.
    • Dedicated Redis consumer connection running an unref'd non-blocking XREAD BLOCK long-polling loop.
    • Zero-drop replay of missed mutations across network reconnects and GC pauses using _lastStreamId.
    • Self-healing trim gap fallback: detects when an instance fell behind the stream retention window, increments metrics.streamGaps, flushes L1, and resets stream pointer.
    • Clean instant socket teardown on destroy() without long-poll blocking stalls.
  • Read Safety Strategy (cloneStrategy: 'structuredClone') — Optional deep-clone isolation on L1 hits, L2 promotions, and fetch returns, protecting cached objects from caller mutation while preserving raw in-memory sub-microsecond performance ('none') and frozen: true dev-mode guards.
  • Atomic Disk Writes & Janitor Sweep — Staging disk writes to unique sibling .tmp files (${filePath}.${pid}.${timestamp}.${rand}.tmp) before atomic rename, Windows NTFS file-lock micro-retries, and background cleanup of abandoned .tmp files (sweepOrphanedTmpFiles).
  • Dynamic Runtime Key Rotation (rotateEncryptionKey) — Rotate active AES-256-GCM / AES-128 keys at runtime with zero downtime via cache.rotateEncryptionKey(newKey, mode). Seamlessly falls back to the previous key for reading existing L2 (Redis) and disk entries, automatically promotes key material to the worker pool via graceful thread draining (WorkerPool.drainAndReinit()), and guarantees graceful cache misses for N2 keys without unhandled exceptions.
  • Redis 7+ Sharded Pub/Sub (useShardedPubSub) — Scopes invalidation messages strictly to the cluster shard handling the key slot via SPUBLISH / SSUBSCRIBE when connected to Redis Cluster (redisClusterNodes), drastically reducing inter-node cluster bus gossip traffic on high-throughput clusters.
  • Transparent Payload Compression (Redis L2 & Disk Tier) — Built-in Brotli and Gzip compression for L2 strings and disk-tier binary blobs with configurable size threshold (compressionThresholdBytes, default 1 KB). Offloads compression and decompression to worker threads alongside encryption for large payloads, supporting a 4-state envelope matrix (cmp:v1:, ecp:v1:, enc:v1:, raw JSON) with full backward-compatibility for uncompressed legacy cache entries.
  • Redis Protocol Selection & RESP3 Auto-Diagnostic (redisProtocol) — Added optional redisProtocol?: 2 | 3 option in CacheOptions allowing explicit wire-protocol selection (RESP2 or RESP3). Includes fail-soft diagnostic detection that intercepts unknown command 'HELLO' / protocol errors on legacy proxies (Twemproxy, Envoy, older ElastiCache) and emits an actionable warning prompting the user to configure redisProtocol: 2.
  • Mission-Critical Chaos, Resilience & Ecosystem Test Suites — Added 484 passing unit, stress, and chaos tests across 41 suites:
    • 10,000 concurrent stampede coalescing (tests/stampede-10k.test.ts).
    • Redis connection flapping resilience (tests/chaos-flapping.test.ts).
    • Disk spill quota saturation & LRU recovery (tests/disk-spill-saturation.test.ts).
    • SWR outage stampedes under failing upstreams with staleIfError TTL extension (tests/deep-resilience.test.ts).
    • Truncated and corrupted snapshot cold-start recovery (tests/deep-resilience.test.ts).
    • Multi-tenant namespace isolation under mass flushes and mutations (tests/deep-resilience.test.ts).
    • 50,000-cycle high-velocity heap soak and memory leak bounds (tests/heap-soak-leak.test.ts).
    • Multi-instance generational backplane synchronization (tests/multiprocess-backplane-sync.test.ts).
    • Worker thread crash auto-recovery & replacement (tests/worker-crash-recovery.test.ts).
    • Distributed mutex lock concurrency exclusion & token release safety (tests/lock-token-safety.test.ts, tests/distributed-lock.test.ts).
    • Cross-version format evolution backward compatibility matrix (tests/format-evolution-matrix.test.ts).
    • HTTP caching middleware with 304 ETag short-circuiting for Express and Hono (tests/http-middleware.test.ts).
    • RESP3 proxy rejection diagnostic hint detection (tests/resp3-diagnostic.test.ts).
    • Native OpenTelemetry metric counters & observable gauges (tests/opentelemetry-metrics.test.ts).
    • Prisma client extension & Drizzle query wrapper caching (tests/prisma-drizzle-adapters.test.ts).
    • CLI inspect, ping, clear, version, and help commands (tests/cli.test.ts).
    • High-performance oxlint linter and full TypeScript strict checking (tsconfig.test.json).

Security & Hardening

  • ReDoS Hardening in Glob Dependency Matching — Hardened _matchesGlob against catastrophic backtracking by collapsing consecutive wildcards (\*+.*), escaping regex metacharacters, and caching compiled expressions in a bounded LRU regex cache (globRegexCache).
  • WorkerPool Availability Guards — Guarded WorkerPool._dispatch against destroyed or unavailable pools, rejecting immediately with a descriptive error rather than throwing an unhandled TypeError.

Changed

  • Zero-Allocation WASM Bloom Filter Staging — Pre-allocated stagingTarget Uint8Array in WasmBloomFilter constructor, eliminating per-probe subarray() view allocations on hot L1 lookups. Boosts filter insertions to 4.54 M/s (220 ns) and hot hit gating to 1.87 M/s (534 ns).
  • ioredis 5.11.1 → 6.0.0 — Upgraded to ioredis v6 major release with default RESP3 protocol support, improved connection lifecycle resilience, and slot routing prototype pollution defenses.
  • msgpackr 2.0.4 → 2.0.5 — Upgraded to msgpackr 2.0.5 patch release addressing sequential/stream unpacking offset tracking and TypeScript export path definitions.
  • Security & Dependency Audit Fixes — Updated devDependencies (vitest, tsup, tsx, @types/node, vite) and configured package overrides to eliminate all 7 security advisory warnings (vite, postcss, nanoid, esbuild).
  • Extracted Named Constants — Extracted DEFAULT_COUNTER_TTL_SECONDS (60 s) for in-process rate-limiting counter fallback in increment().

[0.6.7] — 2026-08-15

Security & Hardening

  • Pub/Sub Invalidation Message Validation — Added strict fail-closed runtime schema validation for incoming Redis/backplane pub/sub messages before accessing message properties. Rejects and drops non-object, scalar, non-string keys, invalid op commands, and prototype pollution attempts.
  • Log Injection Hardening — Stripped/sanitized carriage returns and newlines from raw rejected pub/sub payload strings before logging to prevent multiline log-injection attacks.
  • Cryptographic TTL Jitter — Swapped Math.random() in _jitterTtl() for uniform cryptographically secure randomness via crypto.randomInt(0, 100_000) to improve PRNG defense-in-depth hygiene.

Fixed

  • Test Stability on Windows / CI — Stabilized sub-50ms test timer margins in v0.2.0-features.test.ts to accommodate 15.6ms OS timer resolution quantization and prevent false-positive race conditions during touch() and bumpExpiry() assertions.

Added

  • Error Resilience Test Suite — Added dedicated unit tests for unhandled error recovery in DiskTier.ensureUsageCounted() (fs.statSync errors), CacheService.loadSnapshot() (fs.unlinkSync errors), and user-provided throwing onMetrics callbacks.
  • Deterministic Jitter Bounds Suite — Added deterministic unit tests verifying exact mathematical mapping of crypto.randomInt boundaries and statistical uniformity.

[0.6.6] — 2026-07-16

Added

  • Worker pool init accessor — Introduced CacheEncryption.toWorkerInit() to cleanly expose key/mode material for off-main-thread worker pool initialization, replacing unsafe private-field casts.
  • Fail-closed counter handling & divergent init detection — Detect and guard against divergent singleton initialization calls.
  • Circuit breaker & metrics — Tightened the Redis circuit breaker and surfaced new internal counters in telemetry/metrics.

Fixed

  • Disk encryption support for all modes — Refactored DiskTier to use CacheEncryption directly instead of hardcoding AES-256-GCM. All encryption modes (aes-128-gcm, aes-128-ctr, xor) now successfully save and load from the disk spill layer.
  • Eviction loop under budget limit — Fixed SmartMemoryCache.ensureCapacity() eviction logic to loop until the cache usage is actually back under both category and global limits, rather than stopping after evicting a fixed count of entries.
  • mget() fallback logic — Made mget() check L1 cache, then Redis/Valkey, then the disk spill layer before invoking the fetch function.

Changed

  • Pinned dependencies — Pinned exact dependency versions (ioredis to 5.11.1 and msgpackr to 2.0.4) in package.json.

[0.6.5] — 2026-05-31

Fixed

  • Turbopack / Next.js 16 compatibility — Two changes eliminate the "Specified module format (CommonJs) is not matching EcmaScript Modules" error seen when using tricache in Next.js 16 apps (which default to Turbopack for next build):

    1. Removed .d.cts declaration files from the published package. tsup auto-generated dist/index.d.cts and dist/serialize-worker.d.cts as CJS-type sidecars. Turbopack discovers these files through its CJS sidecar resolution path for packages that expose both import and require export conditions, then attempts to process them as runtime JavaScript — triggering a format-mismatch error because they contain ESM export {} syntax. Since the exports map already has an explicit "types": "./dist/index.d.ts" condition (which TypeScript resolves before any sidecar), the .d.cts files were redundant. A postbuild step now removes them after every build.

    2. Eliminated the __require shim from the shared ESM chunk. worker-pool.ts contained a raw require('os') call inside availableCpus(). In an ESM build, tsup/esbuild replaces raw require() calls with a __require polyfill and places it in the shared chunk (chunk-*.js), making that chunk a hybrid file (CJS var __require = ... + ESM export {}). The polyfill is now removed: require('os') is replaced with a top-level static import os from 'os', which is safe because the engines field already requires Node ≥ 22.13.0 where os.availableParallelism() is always present.

    3. Replaced createRequire with process.getBuiltinModule in disk-tier.ts. The optional node:sqlite bootstrap used createRequire(import.meta.url) to load node:sqlite synchronously. esbuild transforms this into the same __require shim. The replacement uses process.getBuiltinModule('node:sqlite'), which is available in Node ≥ 22.3.0 (within the ≥ 22.13.0 requirement), is synchronous, and requires no shim.

Fixed

  • resolveValue() deserialization bugliveValues() and CacheService.entries() were yielding raw Buffer objects for entries that did not have a cached live-object value (disk-restored entries and entries above the new LARGE_VALUE_BYTES threshold). A new SmartMemoryCache.resolveValue() helper centralises the entry.value !== undefined ? entry.value : unpack(entry.data) path and all three callsites now use it.
  • Benchmark OOM in §19c — The stability soak section deliberately saturates the V8 heap to ~94 %. The subsequent §19c worker-pool section then crashed with FATAL ERROR: Ineffective mark-compacts near heap limit. A globalThis.gc?.() call (already enabled via --expose-gc in the bench script) and a setImmediate yield are now inserted between the soak and §19c to allow V8 to reclaim garbage before allocating large worker payloads.

Changed

  • setAt field on SmartCacheEntry — Every set() now records the Unix timestamp (ms) when the entry was written as setAt. evictSetBefore() reads this field directly instead of approximating via expiresAt − ttlMs, which was incorrect when TTL had been refreshed or a clock drift occurred. Optional for backward compatibility with snapshots written by older versions.
  • LARGE_VALUE_BYTES threshold (16 384 B) — Entries larger than 16 KB no longer cache the deserialized JS object alongside the msgpackr Buffer. Previously every set() stored both the packed Buffer and the live V8 object, doubling heap usage for large entries. Entries below the threshold are unaffected; their live object is still cached for zero-alloc hot-path reads.
  • Updated BENCHMARKS.md — All benchmark rows updated with the 2026-05-31 run. Only improved measurements are recorded; rows that regressed are left at their prior values. Notable improvements: purgeExpired() SQLite mode 72 /s → 181.1 K/s, adaptive TTL cold miss 10.7 K/s → 89.5 K/s, CacheService fetchFn 9.7 K/s → 35.8 K/s, parallel I/O ratio 2.26× → 16.13×.

[0.6.3] — 2026-05-31

Fixed

  • serialize-worker.ts imports switched to explicit .ts extensions (./encryption.ts, ./types.ts). tsx resolves .ts imports natively without any hook; the previous extensionless imports were rewritten to .js by esbuild (package "type":"module") and tsx's .js.ts remap hook is not active inside worker threads on Node 22. Also adds allowImportingTsExtensions: true + rewriteRelativeImportExtensions: true to tsconfig.json to allow the explicit .ts import syntax while keeping DTS emit correct.

[0.6.2] — 2026-05-31

Fixed

  • serialize-worker.ts imports changed from './encryption.js' / './types.js' to extensionless './encryption' / './types'. tsx resolves extensionless imports directly to .ts files without needing the .js.ts remap hook, which is not active inside worker threads on Node 22. Node 24 was unaffected.

[0.6.1] — 2026-05-27

Added

  • Worker thread crypto offload (workerThreads) — AES-GCM encryption and decryption can now be offloaded from the V8 main thread to a dedicated worker_threads pool (src/worker-pool.ts + src/serialize-worker.ts). The pool is fixed-size, round-robin dispatched, and auto-sized to min(4, logical CPUs) when workerPoolSize: 0. Workers are unref()'d so they never block process exit. Offload activates only when enc.isEnabled && payload.length > workerThresholdBytes (default 128 KB), so small payloads stay on the fast synchronous path with zero overhead. Worker initialisation failure silently falls back to synchronous crypto — no configuration change required.

    OptionDefaultDescription
    workerThreadsfalseEnable off-main-thread AES-GCM offload
    workerThresholdBytes131072Minimum serialized payload size (bytes) to offload
    workerPoolSize0Fixed pool size; 0 = auto (min(4, CPUs))
  • Backplane staleness fence (backplaneMaxStalenessMs) — The Pub/Sub subscriber now tracks its most-recent disconnect timestamp. On reconnection, if the gap since the disconnect exceeds backplaneMaxStalenessMs, every L1 entry written before the disconnect is proactively evicted via SmartMemoryCache.evictSetBefore(). This prevents stale cache hits caused by silently dropped peer invalidations during network blips, Redis failovers, or container restarts. Set to 0 to disable the fence. Eviction count and gap duration are logged at warn level.

    OptionDefaultDescription
    backplaneMaxStalenessMs5000Gap threshold in ms; staleness fence fires above this
  • Serverless / ephemeral disk detection (disableDisk) — TriCache now inspects seven well-known environment variables at construction time (zero I/O) to detect AWS Lambda, Google Cloud Run/Functions, Azure Functions, Fly.io, Railway, and Vercel runtimes. When a serverless runtime is detected the disk tier, disk janitor, cold-start snapshots, and the disk spill callback are all silently disabled. The metrics().disk.disabled field reflects the current state. The new disableDisk option allows explicit override in either direction.

    OptionDefaultDescription
    disableDiskundefined (auto)true = always disable; false = always enable; undefined = auto-detect
  • Redis Cluster support (redisClusterNodes) — Pass an array of cluster seed nodes and ioredis handles slot routing, MOVED/ASK redirects, and slot-migration re-queuing transparently. The backplane subscriber is also constructed in cluster mode.

    typescript
    CacheService.create({
      redisClusterNodes: [
        { host: 'redis-node-1', port: 6379 },
        { host: 'redis-node-2', port: 6379 },
      ],
    });
  • Redis Sentinel support (redisSentinel) — Pass sentinel addresses and a master name; ioredis monitors the primary via the sentinel topology and reconnects after failover. The backplane subscriber uses sentinel mode automatically.

    typescript
    CacheService.create({
      redisSentinel: {
        name: 'mymaster',
        sentinels: [{ host: 'sentinel-1', port: 26379 }],
      },
    });
  • SmartMemoryCache.evictSetBefore(cutoffMs) — New internal method used by the staleness fence. Approximates each entry's write time as expiresAt - ttlMs and evicts entries written before cutoffMs. CRITICAL priority entries that have not yet expired are preserved. Bloom filter is rebuilt after eviction. Returns the number of evicted entries.

Changed

  • getRedis() return type widened from Promise<RedisClient> to Promise<AnyRedisClient> to cover Cluster and Sentinel connections.
  • this.redis, this.redisConnecting, and this.subClient fields are now typed as AnyRedisClient (Redis | Cluster) to support all three topology modes.
  • metrics().disk now includes a disabled boolean field alongside the existing stats fields.

Fixed

  • All disk tier call sites (disk.load, disk.delete, disk.clear, disk.close) are now guarded by if (!this._diskDisabled), preventing ENOENT-class errors on platforms where the disk tier is disabled.
  • destroy() now unconditionally drains the worker thread pool (if active) before closing Redis connections, ensuring clean shutdown when workerThreads is enabled.
  • WorkerPool._dispatch() now calls worker.ref() before posting each message and worker.unref() once the pending queue drains, so in-flight pool.encrypt() / pool.decrypt() calls are always awaited correctly in short-lived scripts (benchmarks, CLI tools) without preventing process exit when idle.

Dependencies

  • ioredis 5.10.1 → 5.11.0
  • msgpackr 2.0.1 → 2.0.2

[0.6.0] — 2026-05-26

Added

  • Adaptive TTL (adaptiveTtl) — tricache now tracks per-key fetch latency in a pre-allocated Float64Array ring buffer (default 32 samples). Once a key has ≥ 5 recorded fetch durations the library automatically derives an optimal TTL:

    adaptedTtl = clamp(p95LatencyMs × adaptiveTtlMultiplier, adaptiveTtlMin, adaptiveTtlMax)

    The caller-supplied ttlSeconds is used until enough samples are collected, then the library takes over TTL management autonomously. Expensive keys (slow DB queries) are cached longer; fast keys stay close to their base TTL. Four new options control the behaviour:

    OptionDefaultDescription
    adaptiveTtlfalseEnable adaptive TTL
    adaptiveTtlMultiplier20p95Ms × multiplier = TTL seconds
    adaptiveTtlMin10Floor TTL in seconds
    adaptiveTtlMax86400Ceiling TTL in seconds (24 h)

    metrics() gains an adaptiveTtl sub-object when the feature is enabled, reporting trackedKeys and the top-20 slowest keys by p95 fetch latency with their currently adapted TTLs.

  • l1EvictionWatermark optionSmartMemoryCache now supports a configurable watermark (fraction 0–1, default 0.9) that controls when proactive eviction fires ahead of the hard capacity ceiling. Wired through CacheOptions.l1EvictionWatermark. Lower values (e.g. 0.8) amortise eviction cost more aggressively at the expense of slightly more frequent eviction rounds; raise to 0.95 on workloads where eviction is extremely rare to squeeze a few extra percent of L1 utilisation.

Performance

  • Zero-allocation smartEvict() — The L1 eviction hot path previously allocated approximately 2.9 million short-lived heap objects per second at sustained eviction rates: two fresh Array instances, up to 16 {key, score} object literals, a spread operator for merging candidate pools, a comparator closure for Array.sort(), and a slice() call per eviction. All allocations are now eliminated:

    • _evictPool / _evictGPool — two fixed-size pools of 16 {key: '', score: 0} slots allocated once at class construction and mutated in-place on every call.
    • Manual merge loop replaces the spread operator.
    • Hand-written insertion sort (N ≤ 16, max 256 comparisons) replaces Array.sort() — no comparator closure, no timsort start-up, no allocation.
    • Index-based eviction loop replaces slice(0, EVICT_COUNT).

    Result: 0 heap allocations per smartEvict() call. Eviction soak CV reduced from ~23 % to ~17 % under pathological 100 %-fill load. The remaining ~17 % is the irreducible V8 old-generation GC floor for entry objects and pack() Buffers — structurally unavoidable without moving storage off the JS heap.

Docs

  • BENCHMARKS.md — Added "Eviction hot-path — zero-allocation design" section documenting every allocation site that was removed, the pre-allocated pool design, and the before/after CV numbers with an explanation of the practical floor.
  • BENCHMARKS.md — Added "What tricache is very good at" section: a reference table mapping the nine problems tricache was engineered to solve (thundering herd, priority inversion under flood, write-pressure latency spikes, GC pressure from bloom probes, etc.) to the concrete mechanism and the benchmark row that proves it.

[0.5.1] — 2026-05-25

Added

  • onHit / onMiss callbacks — Two new CacheOptions hooks for per-tier hit/miss observability without waiting for the onMetrics interval. onHit(key, tier) fires on every L1, disk, or L2 hit; onMiss(key) fires when all three tiers are exhausted.

    typescript
    CacheService.create({
      onHit:  (key, tier) => cloudwatch.putMetricData({ key, tier }),
      onMiss: (key)       => cloudwatch.putMetricData({ key }),
    });
  • frozen mode (development guard) — New CacheOptions.frozen option. When true, every value returned from an L1 hit is recursively frozen with Object.freeze() before being handed to the caller. Mutation attempts throw TypeError immediately, catching reference-semantic corruption bugs that would otherwise silently corrupt cached entries. Intended for non-production environments only.

    typescript
    CacheService.create({ frozen: process.env.NODE_ENV !== 'production' });
  • tags in cache.get() optstags can now be supplied directly in the opts argument of cache.get(). When fetchFn fires on a miss and populates the entry, the listed tags are automatically registered in both the in-process tagIndex and (if Redis is enabled) the Redis SADD index. This removes the need to call cache.set() separately just to attach tags.

    typescript
    const user = await cache.get(
      `user:${id}`,
      () => db.users.find(id),
      300,
      { tags: ['users', `tenant:${tenantId}`] },
    );
  • DiskTier.purgeNextBucket() — New public method that purges expired entries from exactly one of 256 subdirectory buckets per call. CacheService now drives disk cleanup with a 30-second setInterval (one bucket/tick → full sweep in ~128 minutes) instead of the former blocking purgeExpired() call every 5 minutes. Per-tick event-loop occupancy is bounded regardless of disk entry count. V3 filenames (expiry encoded in name) skip all file I/O for live entries.

Fixed

  • AES-128-CTR encryption correctnesscipher.final() / decipher.final() return values are now captured and appended in all four encrypt/decrypt paths (encryptString, decryptString, encryptBuffer, decryptBuffer). For CTR mode the final block is almost always empty, but discarding it was technically incorrect and could corrupt multi-byte plaintexts whose length is not a cipher-block multiple. Both the string and buffer paths in encryption.ts are corrected.

  • File descriptor leak in DiskTier.purgeExpired() — The V2 header-read loop opened an fd via fs.openSync() inside a try/catch but only called fs.closeSync() on the success path. An exception between open and close (e.g. permission error on statSync) left the descriptor open. A finally block now closes fd whenever it is ≥ 0.

  • Disk spill no longer blocks the L1 eviction call chaindisk.save() (called from the L1 diskSpill callback during eviction) and disk.delete() (called from cache.delete() on pattern deletes) are now both deferred via setImmediate(). The synchronous SHA-256 hash, msgpackr pack, and filesystem syscalls inside those calls no longer occupy the event loop on the critical l1.set()smartEvictdiskSpill path.

Performance

  • Size-aware Bloom filtercreateBloomFilter() now accepts maxEntries and selects between the WASM filter (hardcoded at 100 K bits, rated for ≈ 10 400 entries at 1 % FP) and a right-sized pure-JS filter. For caches configured with more entries than the WASM filter's capacity, the JS filter is instantiated with optimal bit count (m=nlnp/(ln2)2) and hash count (k=round(ln2m/n), clamped to [4, 10]). Prevents FP rate saturation that was silently forcing wasted Map.get() calls on every definite miss in large caches.

  • Pure-string glob matcher replaces RegExp on deletePattern hot path — Three-level fast path: (1) trailing-only wildcard matching a configured category prefix → O(1) via existing categoryKeys index; (2) exactly one '*' → inline startsWith + endsWith + length check, zero allocation; (3) general multi-'*' → split once, then globMatchParts() (prefix anchor + suffix anchor + left-to-right indexOf for middle segments, no backtracking). RegExp construction and .test() are gone from all three paths.

  • Proactive eviction watermarkSmartMemoryCache now runs a single eviction pass whenever either the entry count or byte usage crosses 90 % of its configured ceiling, even while headroom remains. This amortises eviction cost across many writes instead of deferring it until the hard ceiling triggers a large forced eviction.

  • Bloom filter dirty-count threshold proportional to maxEntries — The per-delete dirty counter cap is now max(256, min(capacity >>> 2, ceil(maxEntries × 0.05))). Without the cap, a right-sized JS filter for 50 K entries allowed ~12 500 ghost entries before a rebuild (5× longer than the 10 K WASM filter), raising the measured false-positive rate. The new formula restores the original cadence: rebuild after ~5 % of configured entries are deleted.

  • Bloom add() skipped on overwrites — Re-adding an existing key to the Bloom filter inflated the insertions counter, delaying phantom-bit detection (trigger 2). The add() call is now guarded by if (!existingEntry).

  • WASM module compiled once — The WasmBloomFilter constructor previously called new WebAssembly.Module(bytes) on every instantiation. The compiled module is now a module-level constant (BLOOM_WASM_MODULE), compiled once at import time. Multiple SmartMemoryCache instances (e.g. per-namespace) share the compiled module.

  • Null OTEL span singletonCacheService._nullSpan replaces the per-call { setAttribute() {…}, end() {…} } object literal returned when no tracer is configured. Eliminates one heap allocation per get/set/delete call in the common no-tracer path.

  • span.setAttribute() guarded by tracer presencesetAttribute('cache.key_prefix', …) is now only called when a tracer is actually configured, avoiding a string-split and a method dispatch on the null span for every operation.

  • _registerTags() extracted — Tag registration logic (in-process tagIndex update + Redis SADD/EXPIRE pipeline) is deduplicated into a single private _registerTags() method shared by set() and the get()-miss populate path.

  • revalidating.has(k) deferred past threshold check — The Set.has() lookup (~30 ns) for the inflight revalidation guard is now only executed when shouldRefreshAhead || shouldXFetch is already true, saving the lookup on every warm hit when neither threshold is crossed.

  • SmartMemoryCache.scan() — non-generator bulk traversal — New public method that accepts a callback (key, entry, prefixLen) => void and iterates all live entries in a single for…of loop. Avoids generator state-machine overhead and per-entry tuple allocation compared to the existing liveEntries() generator. Intended for CacheService bulk operations (e.g. hotKeys, snapshot serialisation) where the generator protocol adds measurable overhead.


[0.5.0] — 2026-05-23

Performance

  • CacheHit singleton — zero allocation on L1 hot readsSmartMemoryCache.get() previously returned a freshly allocated { value, isStale, expiresAt, ttlMs, delta } object on every call. The object is now a module-level singleton that is mutated in-place and returned. JS is single-threaded, so callers always consume all fields synchronously before the next get() call — the pattern is safe. One heap allocation eliminated per L1 hit.

    Measured impact: L1 hot-hit throughput 2.60 M/s → 2.81 M/s (+8 %), exact delete 3.94 M/s → 5.36 M/s (+36 %) (GC pressure reduction frees the CPU budget used by the delete micro-benchmark's tight loop).

  • Deferred inferPriority — 3× string.includes() saved on every warm hit — When refreshAhead or xfetchBeta opts are active, inferPriority(cacheKey) was computed unconditionally on every warm L1 hit even when the threshold check was false and no background recompute fired. The call is now deferred inside the if (shouldRefreshAhead || shouldXFetch) branch so the three string.includes() scans only run when a recompute actually triggers.

Documentation

  • BENCHMARKS.md fully refreshed — All 20+ measurement tables updated with the May 2026 macro-suite numbers. Four new sections added:
    • hotKeys(n) — live frequency ranking (O(n) scan + O(n log n) sort; slice size has negligible effect)
    • Refresh-ahead overhead — 340.8 ns/op in the full macro-suite (< 5 % in isolation; V8 polymorphic-IC effect from adjacent iterator benchmarks explained)
    • setIfAbsent() — fast path 31.59 µs (l1.has → false), miss path 78.98 µs (l1.set + eviction at capacity)
    • Negative caching (notFoundTtl) — null hit 105.08 µs vs non-null 95.49 µs (no overhead for null values)

[0.4.1] — 2026-05-23

Fixed

  • Backplane-aware dependsOn cascade — When a fleet peer published a del message for a parent key, the receiving instance evicted the parent from its L1 but did not cascade to any entries that declared dependsOn containing that key. The cascade (_cascadeDependencies) now runs on every incoming del backplane message, not just on the originating instance. Single-process behavior is unchanged; fleet environments now correctly evict dependents on all nodes.

Added

  • mget per-key TTL — The ttl parameter of cache.mget() now accepts a function (key: string) => number in addition to a plain number. The function is called only for miss keys, enabling heterogeneous TTLs in a single batch call. Plain-number callers are unaffected.

    typescript
    const results = await cache.mget(
      ['user:1', 'config:global', 'user:2'],
      fetchFn,
      (key) => key.startsWith('config:') ? 3600 : 300,
    );
  • cache.ready() — startup warm-up lifecycle hook — Returns a Promise<void> that resolves once the cache is fully initialised and any startup warming configured via warmKeys has completed. Resolves immediately when warmKeys is not set. Designed for k8s readiness probes: gate traffic until L1 is warm, then open the gate once and never block again.

  • warmKeys option — Companion to cache.ready(). Pass a Redis key glob pattern ('user:*') to automatically call warmFromL2(warmKeys) at construction time. No-op when Redis is disabled or unreachable.

    typescript
    const cache = CacheService.create({ warmKeys: 'user:*' });
    await cache.ready(); // resolves once warmFromL2('user:*') finishes
    // k8s readiness endpoint returns 200 only after this point

[0.4.0] — 2026-05-23

Added

  • Negative caching (notFoundTtl) — Prevent repeated upstream calls for keys that genuinely do not exist. When fetchFn returns null or undefined, the result is now cached for notFoundTtl seconds rather than bypassing the cache entirely. Configurable globally via CacheOptions.notFoundTtl and overridden per-call via opts.notFoundTtl in cache.get().

    typescript
    // Global: cache all "not found" results for 30 s
    CacheService.create({ notFoundTtl: 30 });
    
    // Per-call override
    const user = await cache.get('user:999', () => db.users.find(999), 300, { notFoundTtl: 10 });
  • cache.setIfAbsent(key, value, ttlSeconds?) — Atomic "set if not cached". Checks L1 first; if absent, attempts a Redis SET NX EX; on success, populates L1. Returns true if the value was written, false if a live entry already existed. Zero-cost on the common "already cached" path — no Redis round-trip.

    typescript
    const written = await cache.setIfAbsent(`session:${id}`, sessionData, 3600);
    if (!written) { /* session already exists — do not overwrite */ }
  • Refresh-ahead (opts.refreshAhead) — Per-call opt on cache.get(). When the remaining TTL falls at or below ttl × (1 - refreshAhead), a background recompute is triggered transparently — callers always receive the cached value with zero added latency. Complements SWR: refresh-ahead fires before the entry becomes stale; SWR fires after.

    typescript
    // Recompute in background when ≤ 20 % of TTL remains
    const config = await cache.get('config:global', fetchConfig, 3600, { refreshAhead: 0.2 });
  • XFetch probabilistic early expiry (opts.xfetchBeta) — Per-call opt on cache.get(). Implements the XFetch algorithm: recompute probability increases as expiry approaches and scales with last fetch duration, preventing thundering-herd spikes on expiry. Higher xfetchBeta values recompute earlier; 1.0 is the standard starting point.

    typescript
    // Probabilistic background recompute scaled to fetch duration
    const feed = await cache.get('feed:home', fetchFeed, 600, { xfetchBeta: 1.0 });
  • dependsOn cascade invalidation (opts.dependsOn on cache.set()) — Tag any entry with one or more parent keys. When a parent key is deleted (exact or glob), all dependents are automatically evicted from L1. No separate invalidation call needed.

    typescript
    await cache.set('org:42:members', members, 300, undefined, { dependsOn: ['org:42'] });
    await cache.set('org:42:config',  config,  300, undefined, { dependsOn: ['org:42'] });
    await cache.delete('org:42'); // also evicts org:42:members and org:42:config
  • cache.hotKeys(n?) — Returns the top n (default 10) live L1 keys ranked by Count-Min Sketch access frequency, with entry size. Expired entries are excluded; namespace prefix is stripped. Useful for debugging cache hotspots, adaptive pre-warming, and capacity planning.

    typescript
    const hot = cache.hotKeys(5);
    // [{ key: 'user:1', hits: 1024, sizeBytes: 512 }, ...]

Performance

  • Refresh-ahead/XFetch overhead eliminated — Previously, the refreshAhead/xfetchBeta code path called l1.get(k) followed immediately by l1.getEntry(k) to read expiresAt, ttlMs, and delta — two full Map traversals plus three Date.now() calls on every warm L1 hit when either opt was active. The CacheHit interface now carries expiresAt, ttlMs, and delta directly, populated in SmartMemoryCache.get() from the entry already in hand. The getEntry() call is gone. Warm L1 hit overhead with refreshAhead/xfetchBeta opts drops from +49 % to < 2 % (one extra Date.now() + three arithmetic ops).

[0.3.1] — 2026-05-23

Added

  • TTL jitter (ttlJitterFactor) — Spread cache expirations across a configurable ± window to prevent synchronised stampedes when large numbers of entries expire simultaneously ("thundering cliff"). Setting ttlJitterFactor: 0.15 multiplies each TTL by a random factor in [0.85, 1.15]. Clamped to [0, 1]; default 0 (no jitter). Applied in both set() and the populate path of get().

    typescript
    CacheService.create({ ttlJitterFactor: 0.15 }); // ± 15 % TTL spread
  • Batch write operations — mset() / mdel() — Write or delete many keys in a single call without hand-rolling Promise.all.

    typescript
    await cache.mset({
      'user:1': { value: alice, ttl: 300, priority: CachePriority.HIGH },
      'user:2': { value: bob,   ttl: 300 },
    });
    await cache.mdel(['user:1', 'user:2']);
  • Native OpenTelemetry span integration (tracer option) — Pass any @opentelemetry/api-compatible tracer and tricache will emit spans for get, set, and delete operations. Structurally typed — no @opentelemetry/api peer dependency; works with any compliant tracer.

    Span names: tricache.get, tricache.set, tricache.delete
    Attributes set: cache.key_prefix (first : segment), cache.hit ('l1' | 'disk' | 'l2' | 'miss')

    typescript
    import { trace } from '@opentelemetry/api';
    CacheService.create({ tracer: trace.getTracer('my-app') });

    Two lightweight interfaces are exported for typing without an OTEL peer dep:

    typescript
    import type { ICacheTracer, ICacheSpan } from 'tricache';
  • L2 circuit breaker — Automatically suspends Redis calls after l2CircuitBreakerThreshold consecutive failures (default 5) and resumes a probe after l2CircuitBreakerCooldownMs (default 30 000 ms). A successful probe resets to CLOSED; a failed probe re-opens immediately. State is exposed in cache.metrics().l2CircuitBreaker.state ('closed' | 'open' | 'half_open').

    typescript
    CacheService.create({
      l2CircuitBreakerThreshold:  3,     // open after 3 consecutive Redis errors
      l2CircuitBreakerCooldownMs: 10_000, // probe again after 10 s
    });
  • warmFromL2(pattern) — Scan Redis for keys matching a glob pattern and pre-populate L1 before serving traffic. Returns the number of keys loaded. Returns 0 silently when Redis is disabled or unreachable, so it is safe to call unconditionally at startup.

    typescript
    const loaded = await cache.warmFromL2('user:*');
    console.log(`Warmed ${loaded} user entries from Redis`);

[0.3.0] — 2026-05-23

Added

  • Count-Min Sketch frequency tracking — A 4 × 512 Uint16Array (4 KB, fits in L1d cache) now records historical access frequency for every key in L1. The per-entry hits counter resets to 1 whenever a key is re-admitted after eviction; the sketch retains the cross-eviction frequency so a key that was accessed 80 times before being evicted scores far above a burst key whose hits = 1. Benchmark: 76 % of long-resident keys survive a same-priority burst flood of 60 new keys against 50 established residents.

    • Hash: four independent Murmur3-fragment mixes derived from one FNV-1a seed — all computed inline from a single string scan.
    • Decay: all counters halved (right-shift) every 100 000 inserts — frequency-ages old counts so a past burst cannot protect a key indefinitely.
    • Zero external dependencies; 4 KB fixed footprint regardless of cache size.
  • Iterator interface on CacheService — Three lazy generator methods that skip expired entries without allocating intermediate arrays:

    MethodReturnsNotes
    cache.keys()Generator<string>Namespace prefix stripped per yield; no [key, entry] tuple allocated
    cache.values<T>()Generator<T>yield* delegation — no intermediate generator frame
    cache.entries<T>()Generator<[string, T]>Yields [strippedKey, value] pairs

    All three iterate only live (non-expired) L1 entries and silently skip entries whose TTL has elapsed since the last background cleanup sweep.

    typescript
    for (const key of cache.keys()) console.log(key);
    for (const value of cache.values<User>()) process(value);
    for (const [key, user] of cache.entries<User>()) sync(key, user);

Performance

  • keys() +19 % throughput (29.0 K/s → 34.5 K/s) — liveKeys() on SmartMemoryCache yields the key string directly from the Map iteration without constructing an intermediate [key, entry] tuple.
  • values() +4 % throughput (33.7 K/s → 35.1 K/s) — liveValues() uses yield* delegation from CacheService.values(), collapsing one generator frame. Iterates Map.values() directly so the key is never loaded into the yielded code path.

Internal

  • Removed dead rawEntries() generator — An intermediate [key, resolvedValue] generator was explored as an optimization path for entries() but proved slower due to V8 inline-cache (IC) type-feedback sharing: placing the entry.value !== undefined ternary inside the generator frame disrupted the tight-loop optimization that V8 applies to liveEntries(). Removing it reduced the generator count on SmartMemoryCache.cache from 4 → 3, which recovered the entries() monomorphic JIT budget (see BENCHMARKS.md — Iterator interface trade-offs).
  • 8 new tests (141 total): 3 Count-Min Sketch tests (liveEntries() expiry, empty cache, sketch burst-flood survival) + 5 iterator tests on CacheService (keys() namespace stripping, values() deserialization, entries() pairs, expiry skip, empty iterator).

[0.2.0] — 2026-05-23

Performance

  • L1 hot-get: +112 % throughput (1.25 M/s → 2.65 M/s, 800 ns → 377 ns) — Every cache entry now stores the deserialized JS value alongside its msgpackr buffer. get() returns the live object directly — zero unpack() call on the hot path. The packed buffer (data) is retained for disk spill and cold-start snapshot serialization, so cross-process behavior is unchanged.

  • Bloom-filter hit path: +44 % throughput (2.26 M/s → 3.26 M/s) — direct benefit of eliminating the unpack() call that followed the Map lookup.

  • CacheService L1 warm-hit: +64 % throughput (1.32 M/s → 2.16 M/s) — getIfFresh() and mget() also return entry.value directly, skipping deserialization end-to-end.

  • CacheService SWR stale-serve: +38 % throughput (1.48 M/s → 2.04 M/s) — same entry.value fast path in the stale-serve code.

  • set throughput unchangedset() still calls pack() exactly once; the only addition is storing the reference as entry.value (a pointer copy, not a serialization round-trip).

Memory note: each L1 entry now holds both the packed Buffer and the live JS object. For a typical cache payload this roughly doubles the per-entry heap overhead vs a packed-only store. The size field (used for eviction pressure) still reflects packed.byteLength — tune l1MaxEntries / l1MaxBytes accordingly.

Reference semantics: get() now returns a direct reference to the cached object, not a fresh deep copy. Mutating the returned value will corrupt the cached entry. This is consistent with high-performance in-process caches (node-lru-cache, quick-lru, etc.). If immutability is required, deep-clone at the call site.

Fixed

  • Benchmark [object Object] logger bug — The multi-tenancy category-starvation section was printing raw { entries, hits } objects in template strings because getStats().categories[key] returns { entries: number; hits: number }, not a plain number. The relevant variables now correctly access .entries.

  • Benchmark tenant-parity varianceorg_a and org_b namespace throughput benchmarks previously used independent Math.random() calls, so each run saw a different operation distribution. Both namespaces now share a single pre-generated random sequence so they execute identical workloads and JIT-warmth effects don't skew the A/B ratio.

  • Benchmark tenant-parity JIT warmth — Even with an identical operation sequence, org_b was still faster because it ran after org_a's 10 000 timed iterations had already compiled all shared CacheService / inflight-Map hot paths. Both closures are now materialised upfront and warmed in an interleaved pass (400 alternating iterations each) before either timed run begins. Parity ratio is now consistently ≈ 1.00× (previously 0.74–0.81×).

Added

  • cache.clear(prefix?) — Flush all cached entries with a single call. Passing an optional prefix (e.g. 'user:abc') limits the flush to keys with that prefix, scoped to L1 and Redis. Replaces the previous workaround of delete('prefix:*').

  • cache.rebalance() — Evict L1 entries that violate the current category or global capacity limits. Useful when categoryLimits are tightened after startup; previously, existing entries were never re-evaluated until they expired naturally.

  • cache.ttl(key) — Return the remaining TTL in seconds for a key currently held in L1, without fetching or consuming the value. Returns null if the key is absent or expired. Useful for SWR decisions and debugging.

  • cache.writeSnapshot(altPath?)writeSnapshot() now accepts an optional path argument. Calling writeSnapshot('/tmp/backup.snap') writes to that path without touching the configured default snapshot file. Useful in graceful-shutdown hooks. The zero-argument form is unchanged.

  • DiskTier.clear() — Internal method used by cache.clear() to flush the entire L1.5 disk tier.

  • cache.has(key) — Return true if the key exists in L1 and has not expired. Uses the bloom filter as a fast-path negative check. No fetch, no disk or Redis round-trip.

  • cache.touch(key, newTtlSeconds) — Extend the TTL of a key in L1 (and fire-and-forget EXPIRE in Redis) without reading or re-fetching its value. Returns false if the key is absent or already expired.

  • cache.getIfFresh(key) — Return the L1-cached value only if it is fresh (not yet in the SWR grace window). Returns null when absent, expired, or stale — without triggering a revalidation. Useful for read-your-writes patterns.

  • cache.mget(keys, fetchFn, ttl) — Batch read. Returns cached values for hot keys and calls fetchFn only with the keys that missed L1. Preserves input ordering.

  • Tag-based invalidation — Tag entries on write and invalidate whole groups atomically:

    ts
    await cache.set('product:1', data, 60, undefined, { tags: ['catalog'] });
    await cache.invalidateTag('catalog'); // evicts all entries tagged 'catalog'

    Tags are tracked in-process and mirrored to Redis SADD/SMEMBERS for multi-instance consistency.

  • cache.ping() — Measure L1 / disk / Redis latency in milliseconds. Returns { l1, disk, l2 }l2 is null when Redis is disabled. Suitable for health-check endpoints.

  • cache.drainToL2() — Pipeline all live L1 entries to Redis in a single round-trip. Useful for warming a new Redis node or for zero-downtime failover.

  • CacheService.createAsync(optionsOrPromise) — Async factory that resolves a Promise<CacheOptions> before constructing the singleton. Useful when configuration is fetched from a secret store at startup.

  • staleIfError option — Number of seconds to extend a stale L1 entry's expiry when a SWR revalidation fetch fails. Prevents serving errors while the upstream is temporarily down.

    ts
    CacheService.create({ staleIfError: 300 }) // keep stale for 5 more minutes on error
  • l2WriteMode option — Set to 'read-only' to allow Redis reads (L2 hits, snapshot load) while skipping all Redis writes (set, delete, clear, tag sync). Useful for read-replicas, canary deployments, or cost-reduction in read-heavy workloads.

    ts
    CacheService.create({ l2WriteMode: 'read-only' })
  • onEviction callback — Called synchronously whenever L1 evicts a key, with the key name and the reason ('capacity' | 'category' | 'rebalance' | 'oom' | 'ttl' | 'manual'). Never throws — errors inside the callback are silently swallowed to protect cache stability.

    ts
    CacheService.create({
      onEviction: (key, reason) => metrics.increment(`cache.eviction.${reason}`),
    })
  • instanceName option — Prometheus instance label added to every metric emitted by toPrometheusText(). Useful when multiple CacheService instances push to the same Prometheus endpoint.

    ts
    CacheService.create({ instanceName: 'api-us-east-1' })
  • previousEncryptionKey / previousEncryptionMode options — Zero-downtime encryption key rotation. The cache tries the current key first; if decryption fails it transparently retries with the previous key. Remove previousEncryptionKey after all old entries have expired.

    ts
    CacheService.create({
      encryptionKey:         newKeyBase64,
      previousEncryptionKey: oldKeyBase64,
    })
  • stats().l1.categories format — Each category entry now exposes both entries (count of live keys) and hits (L1 cache hits since startup), instead of only the entry count. Old code that read stats().l1.categories['prefix:'] as a plain number should read .entries or .hits instead.

Fixed

  • increment() now works without Redis — Previously increment() silently returned 0 every call when Redis was disabled (the default in non-production environments), making any rate-limiting logic that compared the result against a threshold permanently bypassable. It now maintains per-key counters in L1 memory with the same TTL semantics, returning 1, 2, 3… as expected. Behaviour with Redis enabled is unchanged.

  • stats().l1 now exposes sizeBytesstats().l1 previously only returned sizeKB (a rounded integer) while the internal tracking and metrics().l1 used raw bytes. Both sizeBytes (exact) and sizeKB (rounded, kept for backwards compatibility) are now present on stats().l1.

  • Redis reconnect bug — When the initial Redis connection attempt failed, the internal redisConnecting Promise was cached in the rejected state and never reset. All subsequent calls to any Redis-backed method would fail immediately without retrying. The rejected Promise is now cleared on failure, allowing the next call to attempt a fresh connection.

Migration

All additions are backward-compatible with one exception:

  • writeSnapshot() with no arguments behaves identically to 0.1.0.
  • stats().l1.sizeKB is still present; sizeBytes is a new addition.
  • increment() return values change from 0 to an accumulating count only when Redis is disabled. If your code compared the result to a threshold (e.g. if (count >= limit) { ... }), it will now work correctly in dev/test. If you were explicitly relying on the 0 return to detect a disabled-Redis state, check cache.metrics().backplane.enabled instead.
  • stats().l1.categories shape changed — values changed from number to { entries: number; hits: number }. Update any code reading stats().l1.categories[key] directly.

[0.1.0] — 2026-05-01

Initial release.