@nage-api/cache
v1.0.0-beta.4
Published
Typed two-tier cache for @nage-api — memory L1, pluggable remote L2, namespaced invalidation
Readme
@nage-api/cache
A typed cache that fails soft (PLAN.md §8, §25 P1).
Three drivers behind one service: memory for a single instance, redis for a
shared one, two-tier for both. Application code injects CacheService and
never learns which it got.
Wiring it up
import { Module } from '@nestjs/common';
import { NageCacheModule, type RedisClientPort } from '@nage-api/cache';
import type { LoggerPort } from '@nage-api/contracts';
// Whichever client the application already has: ioredis, node-redis, or a fake
// in tests. This package names the six commands it needs and depends on none of
// them. Only `redis` and `two-tier` require one.
declare const redis: RedisClientPort;
declare const logger: LoggerPort;
@Module({
imports: [
NageCacheModule.forRoot({
cache: { driver: 'two-tier', namespace: 'shop', ttl: '5m' },
redis,
// A broken shared tier is reported, not thrown. Without a handler the
// failure is silent and the only symptom is a slower application.
onRemoteError: (operation, error) => {
logger.warn('cache: remote tier failed', { operation, error });
},
}),
],
})
export class AppModule {}Using it
CacheService is exported by the module, so injecting the class is enough.
import { Injectable } from '@nestjs/common';
import { CacheService } from '@nage-api/cache';
interface Product {
readonly id: string;
readonly name: string;
}
declare const repository: { findById(id: string): Promise<Product | undefined> };
@Injectable()
export class ProductService {
constructor(private readonly cache: CacheService) {}
async find(id: string): Promise<Product | undefined> {
return this.cache.wrap(`product:${id}`, () => repository.findById(id), {
ttl: '5m',
tags: [`product:${id}`],
});
}
async onProductChanged(id: string): Promise<void> {
// Drops the row's own entry and every list that included it.
await this.cache.invalidateTag(`product:${id}`);
}
async onBulkImport(): Promise<void> {
await this.cache.namespace('product').invalidateNamespace();
}
}What it adds over a Map
Single-flight. Concurrent wrap calls for one key run the loader once. A
popular key expiring under load otherwise starts one identical query per
in-flight request — the stampede that turns a cache miss into an outage. The
in-flight entry is cleared in a finally, so a failed load doesn't poison the
key for everyone who asks next.
Bounded memory. The in-process store evicts least-recently-used entries and
sweeps expired ones on a timer. An unbounded map keyed by anything a request can
influence is a memory leak with a slow fuse. The timer is unref'd, so a cache
never keeps a process alive.
Namespaces and tags. Every key is prefixed, so a namespace can be dropped without knowing which keys it wrote, and an entry can carry labels so a write to one row invalidates every list that included it.
Absence is not cached by default. Caching undefined is how a race writes a
tombstone over a value that arrived a millisecond later. cacheEmpty: true
turns it on where "not found" is the common answer.
Two tiers
L1 is in-process, L2 is shared, and L1 holds a shorter TTL than L2 —
capped by l1TtlMs, five seconds by default. That cap is the safety argument:
an in-process copy is the one nothing can invalidate remotely, so staleness is
bounded by a number you chose rather than by when a process happens to restart.
A local delete clears L1 immediately, so the instance that made a change never
reads its own stale value.
A cache is an optimisation, so a broken L2 degrades rather than throws: remote
failures are reported through onRemoteError and the request is served from L1
or from the source. A value Redis returns that will not parse is a miss, not an
exception.
Redis without a Redis dependency
RedisCacheStore is written against RedisClientPort — six commands — rather
than against ioredis or node-redis. The heavyweight client stays out of the
install for the deployments that never use it, and the store is testable
without a daemon, which is what the suite does.
Two details worth keeping: prefix invalidation uses SCAN, never KEYS (which
is O(n) over the keyspace and blocks the server every other service depends
on), and clear() is scoped to the store's own prefix rather than flushing the
database out from under its neighbours.
Not yet implemented
- A
@Cacheable()method decorator. The explicitwrapcall is currently the only entry point, which keeps the cache key visible at the call site. - Stale-while-revalidate:
CacheOutcomereserves astalecase for it. - Redis Cluster tag indexes; the tag set assumes a single keyspace.
