# Local L1 mode ## What L1 is The local L1 cache is an optional per-instance LRU cache that sits in front of the primary-owned shared cache. When a worker reads a key that is already hot in its L1, the read is served from process-local memory with no IPC round trip. L1 is purely additive -- the primary cache remains the source of truth and handles all writes. ## Warning > L1 improves repeated read latency by avoiding IPC, but it can briefly serve stale data. Keep L1 TTL short and bypass L1 for correctness-sensitive reads. ## How it works The primary maintains a per-namespace version counter. Successful writes that change the cache bump the version and broadcasts an invalidation message to workers, including the caller. Workers receiving an invalidation drop the named key (or the entire namespace on `clear`) from their L1 before the next read. Instances in the same process also receive a local invalidation, so separate cache instances for one namespace converge without waiting for L1 TTL expiry. Version numbers travel in IPC responses so workers can detect a stale L1 without waiting for a broadcast. L1 is eventually consistent; the primary cache is always authoritative. ## Enabling L1 Pass `localL1` at construction time: ```ts const users = new LRUCacheClustered({ namespace: 'users', max: 50_000, ttl: 60_000, localL1: { enabled: true, experimental: true, ttl: 2_000 }, }); ``` In v2.1, `experimental: true` is required as an explicit opt-in acknowledgement that the consistency model differs from the base cache. Reuse the same cache instance across requests to benefit from L1. `getInstance()` initializes a new wrapper with its own local cache each time; it does not return a process-wide singleton. A primary response includes the values and remaining TTLs together. `get()` and `peek()` misses need one IPC request; `mGet()` needs one request for all local misses. Fully warm reads need no IPC. When talking to an older primary that omits TTL metadata, the worker returns the value without populating L1. `peek()` and `has()` leave local recency and TTL unchanged. `has()` requires a fresh local entry even when local `allowStale` is enabled. `get()`, `mGet()`, and `fetch()` retain their normal promotion behavior. Cold `fetch()` calls use one request to check the primary and claim a miss, then one to store the fetched result. Followers poll that same claim once per cycle and populate L1 from the returned value and TTL. Cache hits and misses in primary statistics include claim lookups. ## Choosing TTLs The L1 TTL must be less than or equal to the primary TTL -- values are clamped automatically. If you omit `localL1.ttl`, the default is `min(primaryTtl * 0.1, 5000)` with a 100 ms floor. L1 population also clamps each entry to the primary entry's remaining TTL, including per-write TTLs. Short L1 TTLs narrow the stale-data window; a value in the 1-5 second range works well for most workloads. Time spent waiting for the primary response is deducted before L1 population. Each entry also keeps a fixed deadline for the primary expiration observed by that read. Local `updateAgeOnGet` and `allowStale` cannot extend that deadline. A later primary read may observe a newer expiration and populate L1 again. For `ttl-only` mode, set `localL1.updateAgeOnGet: false` when you need local TTL to bound staleness on frequently read keys. With sliding local expiration enabled and no primary expiration, repeated hits can otherwise keep a value cached indefinitely. ## Bypassing L1 Pass `bypassL1: true` on any individual read to skip the local cache and go straight to the primary: ```ts await cache.get('id', { bypassL1: true }); ``` For code paths that always want a fresh answer, create a bypass view of the cache: ```ts const fresh = cache.withoutLocal(); await fresh.get('id'); ``` `withoutLocal()` returns a thin wrapper that routes all reads through L2 (the primary) without affecting the original instance or its L1 state. ## Primary-owned operations `incr`, `decr`, and `setIfAbsent` always read and write through the primary. There is no L1 read on the way in, and the calling worker drops its local entry before dispatching the operation. The primary serializes these operations across workers; other L1 readers converge after receiving the invalidation broadcast. ## Methods opt-out You can enable L1 for a subset of read methods: ```ts localL1: { enabled: true, experimental: true, methods: { get: true, has: false, fetch: true }, } ``` Any method whose key is absent or `false` falls through to the primary as usual. ## Invalidation mode By default, `localL1.invalidation: 'broadcast'` subscribes this instance to same-process and cross-worker invalidation pushes. Hot entries are dropped after writes before their local TTL expires. Set `localL1.invalidation: 'ttl-only'` only when you intentionally want to skip invalidation subscriptions and rely on short local TTLs for convergence. ## Stats ```ts const s = cache.localStats(); // { // enabled: true, // hits: 1820, // misses: 43, // sets: 43, // invalidations: 12, // evictions: 0, // staleHits: 0, // size: 43, // ipcAvoided: 1820, // } ``` The headline number is `ipcAvoided` -- it equals `hits` and shows how many IPC round trips were eliminated. `staleHits` counts stale local entries encountered, including values returned under `allowStale` and entries rejected because their version or primary deadline has expired. Each read counts at most once. ## Events | Event | Payload | | --------------- | --------------------------------------- | | `l1:hit` | `{ namespace, key }` | | `l1:miss` | `{ namespace, key }` | | `l1:set` | `{ namespace, key }` | | `l1:invalidate` | `{ namespace, key }` (key may be `'*'`) | | `l1:evict` | `{ namespace, key }` | | `l1:stale-hit` | `{ namespace, key }` | ```ts cache.on('l1:hit', ({ namespace, key }) => { metrics.increment('l1.hit', { namespace }); }); ``` Invalidation events with `key === '*'` indicate a full-namespace flush (e.g. from `clear()`). `l1:stale-hit` fires when a stale local entry is encountered, whether returned under `allowStale` or rejected because its version or primary deadline has expired. ## Failure modes - **IPC backpressure** -- a queued request keeps waiting for its response; `process.send()` returning `false` is not treated as a dropped message. Send callback errors reject the request, and the configured timeout still applies. - **IPC timeout on L1 miss** -- the existing `failsafe` option applies. `'resolve'` returns `undefined`; `'reject'` rejects with `Error('IPC timeout')`. - **Broadcast send fails to a worker** -- that worker's L1 may serve a stale value until the entry's TTL expires. - **Worker crash** -- L1 is process-local. A replacement worker starts with an empty L1 and warms up naturally. - **Primary crash** -- the cluster terminates per Node.js cluster semantics; L1 state is moot. ## Known limitations (v2.1) - `cacheUndefined: true` is reserved in the option type but not implemented. Negative-result caching (caching "key does not exist") is not yet supported. - The `ipc:timeout` event is deferred to v2.2. - Broadcast failures are not surfaced as events in v2.1. ## Migration Existing users see no change. The `localL1` option defaults to `{ enabled: false }` and the feature is entirely inactive unless you explicitly pass `localL1: { enabled: true, experimental: true }`. All default behaviours of the base cache are preserved.