JSPM

@0xdoublesharp/adaptive-cache

0.0.5
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 45
  • Score
    100M100P100Q81167F
  • License MIT

Adaptive caching module for Redis with Fastify and Express integration

Package Exports

  • @0xdoublesharp/adaptive-cache

Readme

@0xdoublesharp/adaptive-cache

npm version npm downloads source version CI Quality Coverage Test Coverage Publish license

Server-side response caching for APIs that need fast reads without hand-tuning every TTL.

@0xdoublesharp/adaptive-cache caches Express, Fastify, and direct AdaptiveCache results. It hashes response bodies, tracks whether content changes, and automatically grows TTLs for stable data while resetting TTLs for volatile data.

Redis remains the default authoritative backend through Lua scripts. For lower latency hot reads, add a memory-bounded clustered LRU cache in front of Redis. For single-server or one-host Node cluster deployments, the clustered LRU backend can run without Redis.

This package does not set HTTP Cache-Control headers. It caches server responses and adds diagnostic X-Cache-* headers unless disabled.

Use It For

Need What the module does
API responses that stabilize Increases TTL when response hashes stay the same.
API responses that change Resets TTL when content changes, so volatile data does not stay stale for long.
Cross-host correctness Uses Redis Lua as the atomic authority for payloads, metadata, tags, and locks.
Lower hot-read latency Uses @0xdoublesharp/lru-cache-clustered as a bounded L1 cache in front of Redis.
No-Redis deployments Provides a volatile clustered-lru backend for single-server or one-host clusters.
Expensive refreshes Provides refresh locks so only one worker performs an expensive rebuild at a time.
Targeted invalidation Clears by computed key or tag, with Redis Pub/Sub invalidation for server-local L1s.
Memory safety Sets L1 size budgets and skips entries that are too large for the configured budget.

Architecture

Adaptive Cache architecture

Feature Matrix

Capability redis l1-redis clustered-lru
Adaptive TTL growth/reset Yes Yes Yes
Redis Lua atomic metadata Yes Yes No
Bounded L1 hot reads No Yes Yes
Cross-host cache state Yes Yes No
Tag invalidation Yes Yes Yes
Refresh locks Yes Yes Host-local
Works without Redis No No Yes
Recommended production default Yes Hot APIs Single host

Installation

Redis-only usage:

pnpm add @0xdoublesharp/adaptive-cache

Express or Fastify users should also install the framework they use:

pnpm add express
pnpm add fastify fastify-plugin

LRU-backed modes require optional peer dependencies:

pnpm add @0xdoublesharp/lru-cache-clustered@^2.1.0 lru-cache@^11

Redis-only users do not need the LRU packages at runtime.

Backend Modes

Backend Redis required Best for Behavior
redis Yes Default production mode Redis Lua stores data, metadata, tags, and locks.
l1-redis Yes Production hot paths Clustered LRU serves hot reads; Redis Lua remains authoritative.
clustered-lru No Single-server or one-host Node clusters Volatile bounded cache with adaptive TTL logic in process/cluster.
custom backend Depends Advanced integrations Provide the AdaptiveCacheBackend interface.

redis

This is the default. It uses Redis Lua scripts for:

  • Fetching cached data and metadata.
  • Updating cached data and adaptive metadata atomically.
  • Refresh locks.
  • Tag invalidation.
import { adaptiveExpressCache } from '@0xdoublesharp/adaptive-cache'

app.get('/api/summary', adaptiveExpressCache(), handler)

l1-redis

Use this when Redis exists but hot reads should be served from a bounded local/clustered LRU cache.

import { adaptiveExpressCache } from '@0xdoublesharp/adaptive-cache'

app.get(
  '/api/summary',
  adaptiveExpressCache({
    backend: 'l1-redis',
    initialTTL: 5,
    maxTTL: 900,
    lru: {
      namespace: 'api-cache',
      maxSizeBytes: 64 * 1024 * 1024,
      maxEntrySizeBytes: 6 * 1024 * 1024,
    },
  }),
  handler,
)

Read path:

  1. Check clustered LRU first.
  2. On L1 hit, return immediately.
  3. On L1 miss, fetch from Redis Lua.
  4. On Redis hit, hydrate L1 with Redis remaining TTL and metadata.
  5. On Redis miss, continue to the handler.

Write path:

  1. Encode/compress once and hash the response.
  2. Store in L1 immediately with initialTTL.
  3. Run Redis Lua update asynchronously.
  4. When Redis returns, reconcile L1 with Redis' authoritative adaptive TTL and metadata.
  5. If Redis update fails, keep the short-lived L1 entry and log the failure.

Invalidation:

  • Explicit clears remove L1 first, then Redis, then publish an invalidation message.
  • Tag invalidation removes matching L1 entries and Redis entries.
  • Redis update publishes L1 invalidation only when content changed.
  • Other server-local L1 caches subscribe to Redis invalidation messages and delete stale keys.

Locks:

  • l1-redis uses Redis Lua locks as the distributed lock authority.

clustered-lru

Use this when Redis is not available and a volatile bounded backend is still useful.

import { adaptiveFastifyCache } from '@0xdoublesharp/adaptive-cache'

await fastify.register(
  adaptiveFastifyCache({
    backend: 'clustered-lru',
    initialTTL: 5,
    maxTTL: 300,
    lru: {
      namespace: 'api-cache',
      maxSizeBytes: 64 * 1024 * 1024,
    },
  }),
)

This mode keeps adaptive metadata in clustered LRU envelopes. It is fast and memory-bounded, but it is not cross-host durable. Treat it as process/host-local cache state.

Locks:

  • clustered-lru uses LRU lock keys scoped to the local Node cluster primary.

LRU Memory Limits

l1-redis and clustered-lru store an envelope in clustered LRU:

  • Encoded payload.
  • Response hash.
  • Adaptive metadata: dataTTL, lastChanged, changeCount.
  • Created/updated timestamps.
  • Expiration timestamp.
  • Tags.

Defaults:

Option Default Description
lru.maxSizeBytes 64 * 1024 * 1024 Total LRU size budget.
lru.maxEntrySizeBytes 10% of maxSizeBytes Entries larger than this are skipped for L1 storage.
lru.timeout package default Clustered LRU operation timeout.
lru.failsafe 'reject' Clustered LRU failure behavior.
lru.namespace 'adaptive-cache' Shared cache namespace.

Entry size is estimated as encoded payload bytes plus tag metadata and a small fixed metadata overhead. TTLs passed to clustered LRU are milliseconds; adaptive cache options use seconds.

LRU Cache Clustered v2.1 Local L1

The lru.localL1 option is passed through to @0xdoublesharp/lru-cache-clustered v2.1.0+ for per-worker local L1 caching.

adaptiveExpressCache({
  backend: 'l1-redis',
  lru: {
    namespace: 'api-cache',
    maxSizeBytes: 64 * 1024 * 1024,
    localL1: {
      enabled: true,
      experimental: true,
      ttl: 2_000,
      maxSize: 4 * 1024 * 1024,
      invalidation: 'broadcast',
      methods: {
        get: true,
        has: true,
        fetch: true,
        memoize: true,
      },
    },
  },
})

You can also pass localL1: true when using @0xdoublesharp/lru-cache-clustered v2.1.0 or newer.

Express Usage

import express from 'express'
import { adaptiveExpressCache } from '@0xdoublesharp/adaptive-cache'

const app = express()

app.get(
  '/api/items',
  adaptiveExpressCache({
    initialTTL: 10,
    maxTTL: 3600,
    ttlScaling: 1.5,
    includeDebugHeaders: true,
  }),
  async (_req, res) => {
    const items = await loadItems()
    res.json(items)
  },
)

Dynamic maxTTL

maxTTL can be a function. Middleware resolves it after your handler returns.

app.get(
  '/api/items/:id',
  adaptiveExpressCache({
    initialTTL: 60,
    maxTTL: (data) => (data.status === 'ended' ? 86400 : 300),
  }),
  async (req, res) => {
    res.json(await loadItem(req.params.id))
  },
)

Tags

Tags can be static or request-derived.

app.get(
  '/api/users/:id',
  adaptiveExpressCache({
    tags: (req) => [`user:${req.params.id}`, 'users'],
  }),
  async (req, res) => {
    res.json(await loadUser(req.params.id))
  },
)

Clear by tag:

import { getDefaultCache } from '@0xdoublesharp/adaptive-cache'

await getDefaultCache().invalidateTags(['users'])

Clear one computed middleware key:

import { clearAdaptiveCache } from '@0xdoublesharp/adaptive-cache'

await clearAdaptiveCache('/api/users/123', {}, 'adaptive:')

For non-default backends, pass backend options to clearAdaptiveCache:

await clearAdaptiveCache('/api/users/123', {}, 'adaptive:', {
  backend: 'clustered-lru',
  lru: { namespace: 'api-cache' },
})

Fastify Usage

import Fastify from 'fastify'
import { adaptiveFastifyCache } from '@0xdoublesharp/adaptive-cache'

const fastify = Fastify()

await fastify.register(
  adaptiveFastifyCache({
    backend: 'l1-redis',
    initialTTL: 10,
    maxTTL: 3600,
    ttlScaling: 1.5,
    includeDebugHeaders: true,
    lru: {
      namespace: 'fastify-api',
      maxSizeBytes: 64 * 1024 * 1024,
    },
  }),
)

fastify.get('/api/summary', async () => {
  return await loadSummary()
})

Direct API

Use AdaptiveCache outside Express/Fastify.

import { AdaptiveCache } from '@0xdoublesharp/adaptive-cache'

const cache = new AdaptiveCache({
  backend: 'l1-redis',
  includeDebugHeaders: true,
  lru: { namespace: 'jobs' },
})

await cache.set('job:summary', { total: 42 }, { tags: ['jobs'] })

const hit = await cache.get('job:summary')
if (hit) {
  console.log(hit.data, hit.ttl, hit.metadata)
}

Generic Function Caching

cacheResult is a simple Redis helper for non-adaptive function results. It uses the default Redis client directly and does not use the adaptive backend layer.

import { cacheResult } from '@0xdoublesharp/adaptive-cache'

const data = await cacheResult('expensive-report', 60, async () => {
  return await buildReport()
})

Refresh Locks

Use refresh locks to prevent many workers from refreshing the same expensive resource at once.

import { releaseCacheRefreshLock, shouldRefreshCache } from '@0xdoublesharp/adaptive-cache'

const result = await shouldRefreshCache('report:last-update', 60)

if (result[0] === 'UPDATE') {
  const lockValue = result[1]
  try {
    await rebuildReport()
  } finally {
    await releaseCacheRefreshLock('report:last-update', lockValue)
  }
}

lockExpirationSeconds defaults to 60. You can set a process-wide default:

import { setDefaultLockExpirationSeconds } from '@0xdoublesharp/adaptive-cache'

setDefaultLockExpirationSeconds(120)

Or pass a per-call value:

await shouldRefreshCache('report:last-update', 60, false, 120)

Configuration

Option Type Default Description
initialTTL number 5 Starting cache duration in seconds.
maxTTL number | (data) => number | undefined 900 Maximum data TTL in seconds. Middleware can resolve it from response data.
ttlScaling number 2 Factor used to grow TTL when content is unchanged. Growth is damped by changeCount.
redisPrefix string 'adaptive:' Prefix for Redis keys and invalidation channels.
keyPrefix string unset Clearer alias for redisPrefix; applies to Redis and non-Redis backends.
includeHeaders boolean true Add X-Cache and X-Cache-TTL.
includeDebugHeaders boolean false Add adaptive metadata headers.
forceRefresh boolean false Bypass existing cache reads and refresh after the handler response.
lockExpirationSeconds number 60 Refresh lock expiration in seconds.
metaTTL number 604800 Metadata/tag TTL in seconds.
compress boolean true Gzip payloads and store them as base64.
logLevel 'debug' | 'info' | 'warn' | 'error' | 'silent' 'info' Logger verbosity.
backend 'redis' | 'l1-redis' | 'clustered-lru' | AdaptiveCacheBackend 'redis' Cache backend.
lru AdaptiveCacheLruOptions unset LRU options for l1-redis and clustered-lru.

Headers

Header Meaning
X-Cache: HIT Cached response returned.
X-Cache: MISS No cache entry; handler ran.
X-Cache: BYPASS Cache read skipped because forceRefresh or ?refresh=true was used.
X-Cache: RETRY Cache read/decode failed; handler ran without failing the request.
X-Cache-TTL Remaining data TTL in seconds.
X-Cache-Data-TTL Debug: adaptive TTL assigned to the data version.
X-Cache-Last-Modified Debug: Unix timestamp when content last changed, or unknown.
X-Cache-Refreshed Debug: content change count.

Redis Keys and Lua Metadata

Given an adaptive key, this package uses:

  • ${key}data: encoded payload.
  • ${key}meta: adaptive metadata hash.
  • ${redisPrefix}tag:${tag}: Redis set of keys for tag invalidation.
  • ${redisPrefix}l1:invalidate: Redis Pub/Sub channel for L1 invalidations.
  • ${lastUpdateKey}-lock: refresh lock key.

Lua fetch returns:

[data, remainingTTL, dataTTL, lastChanged, changeCount, hash]

Lua update returns:

['CACHED', dataTTL, lastChanged, changeCount, hash, changedFlag]

The original leading tuple fields are preserved for compatibility. New metadata fields are appended so L1 caches can hydrate from Redis without extra round trips.

Adaptive TTL Formula

On changed content:

  • changeCount += 1
  • dataTTL = initialTTL
  • lastChanged = now

On unchanged content:

  • If dataTTL >= maxTTL, keep maxTTL.
  • Otherwise compute a damped increase:
decayFactor = 1.0 - min(0.9, changeCount * 0.01)
increaseFactor = max(0, ttlScaling - 1)
increase = ceil(floor(dataTTL * increaseFactor) * decayFactor)
dataTTL = max(initialTTL, min(dataTTL + increase, maxTTL))

Environment Variables

Variable Description
REDIS_TLS_URL Preferred Redis TLS connection string.
REDIS_URL Redis connection string. Used when REDIS_TLS_URL is not set.
REDIS_HOST Redis host fallback. Defaults to localhost.
REDIS_PORT Redis port fallback. Defaults to 6379.
CACHE_TIME Default duration string for cache(), for example "5 seconds".

In NODE_ENV=production, Redis URL clients are created with TLS options.

Custom Backends

Advanced users can provide a backend object instead of a backend name.

import type { AdaptiveCacheBackend } from '@0xdoublesharp/adaptive-cache'

const backend: AdaptiveCacheBackend = {
  name: 'custom',
  async fetch(input) {
    return null
  },
  async update(input) {
    return ['CACHED', input.initialTTL]
  },
  async clear(key, dataKey) {},
  async invalidateTags(tags, redisPrefix) {
    return []
  },
  async shouldRefresh(lastUpdateKey, refreshThreshold, currentTime, force, lockExpirationSeconds, lockValue) {
    return ['UPDATE', lockValue]
  },
  async releaseLock(lastUpdateKey, currentTime, lockValue) {
    return ['UPDATED']
  },
}

Operational Notes

  • Redis is the durable, cross-host authority in redis and l1-redis modes.
  • clustered-lru is volatile and local to the host/Node cluster.
  • L1 entries are intentionally short-lived until Redis Lua returns the authoritative adaptive TTL.
  • Oversized entries are skipped for L1 storage rather than risking unbounded server memory growth.
  • l1-redis invalidation uses Redis Pub/Sub, so processes must share the same Redis and prefix to invalidate each other.
  • redisPrefix still works; prefer keyPrefix for new code when you want a backend-neutral name.

Development

pnpm install
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run test:coverage
pnpm run quality
pnpm run build
pnpm run bench:configs

pnpm run test runs the full suite, including Redis/Testcontainers integration tests.

pnpm run test:coverage runs the deterministic source coverage suite and currently verifies:

Statements : 100%
Branches   : 100%
Functions  : 100%
Lines      : 100%

pnpm run quality runs lint, typecheck, coverage, and build.

pnpm run bench:configs compares redis, l1-redis, and clustered-lru behavior against a local Redis using unique benchmark key prefixes. It does not call FLUSHALL.

License

MIT. See package.json for package metadata.