ARCHITECTURE.md
Rama main · réplica de lectura del repositorio original.
# Architecture – Cache Handler Logic
This document describes the internal architecture of the cache handler, focusing on the three core functions (`get`, `set`, `revalidateTag` / `updateTags`), the shared hash maps, and the supporting infrastructure (`SyncedMap`, `DeduplicatedRequestHandler`).
Two handler implementations exist side by side:
| Handler | Next.js API | File |
| -------------------------- | ------------------------------------------------------ | ------------------------------- |
| **RedisStringsHandler** | Legacy `cacheHandler` (Next.js 15) | `src/RedisStringsHandler.ts` |
| **CacheComponentsHandler** | `cacheHandlers.default` (Next.js 16+ Cache Components) | `src/CacheComponentsHandler.ts` |
Both follow the same fundamental pattern – store serialized data in Redis strings, maintain two in-memory hash maps for tags, and synchronize those maps across instances – but differ in the data shapes they receive from Next.js and in some performance optimizations.
---
## Table of Contents
1. [High-Level Overview](#high-level-overview)
2. [Shared Hash Maps & Why They Exist](#shared-hash-maps--why-they-exist)
3. [SyncedMap – The Synchronization Primitive](#syncedmap--the-synchronization-primitive)
4. [DeduplicatedRequestHandler](#deduplicatedrequesthandler)
5. [Core Function: `get`](#core-function-get)
6. [Core Function: `set`](#core-function-set)
7. [Core Function: `revalidateTag` / `updateTags`](#core-function-revalidatetag--updatetags)
8. [RedisStringsHandler vs CacheComponentsHandler](#redisstringshandler-vs-cachecomponentshandler)
---
## High-Level Overview
```mermaid
flowchart TB
subgraph "Next.js Runtime"
NX["Next.js Page / Route / Fetch / 'use cache'"]
end
subgraph "Cache Handler Instance (per Node.js process)"
GET["get()"]
SET["set()"]
REV["revalidateTag() / updateTags()"]
subgraph "In-Memory Maps"
STM["sharedTagsMap\n(SyncedMap<string[]>)\ncacheKey → tags"]
RTM["revalidatedTagsMap\n(SyncedMap<number>)\ntag → timestamp"]
DEDUP["inMemoryDeduplicationCache\n(SyncedMap<Promise>)"]
end
end
subgraph "Redis"
RS["Redis Strings\n(cached page/fetch data)"]
RH1["Redis Hash\n__sharedTags__"]
RH2["Redis Hash\n__revalidated_tags__"]
PUBSUB["Pub/Sub Channels\n(sync messages)"]
KSN["Keyspace Notifications\n(expired / evicted)"]
end
NX -->|"get(key, ctx)"| GET
NX -->|"set(key, data, ctx)"| SET
NX -->|"revalidateTag(tags)"| REV
GET <-->|"GET key"| RS
SET -->|"SET key EX ttl"| RS
REV -->|"UNLINK keys"| RS
STM <-->|"HSET / HDEL / HSCAN"| RH1
RTM <-->|"HSET / HDEL / HSCAN"| RH2
STM <-->|"PUBLISH / SUBSCRIBE"| PUBSUB
RTM <-->|"PUBLISH / SUBSCRIBE"| PUBSUB
KSN -->|"evicted / expired"| STM
```
---
## Shared Hash Maps & Why They Exist
### The Problem
Next.js calls `revalidateTag("product")` with **only the tag name**. It does **not** provide the list of cache keys that belong to that tag.
At the same time:
- Redis strings (the actual cache entries) are keyed by a cache key (e.g. `/products/[id]`), not by tag.
- Redis has no native secondary index that maps a tag to all keys that carry it.
Without an additional data structure, the only way to find all keys for a tag would be a `KEYS *` or `SCAN` over the entire keyspace, parsing every entry – far too expensive at scale.
### The Solution: Two SyncedMaps
| Map | Key | Value | Purpose |
| -------------------- | ------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sharedTagsMap` | cache key (e.g. `/products/42`) | `string[]` of tags | Reverse index: given a tag, iterate this map to find all affected cache keys |
| `revalidatedTagsMap` | tag name (e.g. `product`) | timestamp or `{ stale, expired }` | Tracks _when_ a tag was last revalidated. ISR uses a number and lazy-checks it in `get()`. Cache Components stores `{ stale, expired }` matching Next.js `tagsManifest`; `get()` applies `areTagsExpired` / `areTagsStale`, and `getExpiration()` returns max `expired` |
Both maps live **in-memory** in every Node.js process and are **synchronized across instances** through Redis Hash + Pub/Sub (see [SyncedMap](#syncedmap--the-synchronization-primitive)).
```mermaid
flowchart LR
subgraph "sharedTagsMap"
K1["/products/42"] -->|"tags"| V1["['product', '_N_T_/products']"]
K2["/products/99"] -->|"tags"| V2["['product', '_N_T_/products']"]
K3["/blog/hello"] -->|"tags"| V3["['blog', '_N_T_/blog']"]
end
subgraph "revalidatedTagsMap"
T1["_N_T_/products"] -->|"timestamp"| TS1["1716123456789"]
end
```
When `revalidateTag("product")` is called, the handler iterates `sharedTagsMap` to find `/products/42` and `/products/99`, then batch-deletes them from Redis in a single `UNLINK` call.
---
## SyncedMap – The Synchronization Primitive
`SyncedMap<V>` (`src/SyncedMap.ts`) wraps a standard `Map<string, V>` and keeps it synchronized across all Node.js processes via three mechanisms:
### 1. Redis Hash (persistent state)
Every `set()` writes to both the local map **and** a Redis Hash (`HSET`). On startup, `initialSync()` uses `HSCAN` to load the full hash into memory. A periodic re-sync (default ~1 hour, jittered) guards against drift.
### 2. Pub/Sub (real-time cross-instance sync)
Every `set()` and `delete()` also publishes a message on a dedicated channel. All other instances subscribe and apply the change to their local map immediately. This avoids the need for polling.
```mermaid
sequenceDiagram
participant I1 as Instance 1
participant Redis as Redis
participant I2 as Instance 2
I1->>Redis: HSET __sharedTags__ "/p/42" '["product"]'
I1->>Redis: PUBLISH sync-channel {type:"insert", key:"/p/42", value:["product"]}
Redis-->>I2: message on sync-channel
I2->>I2: map.set("/p/42", ["product"])
```
### 3. Keyspace Notifications (eviction / expiry cleanup)
When Redis evicts or expires a cache key, the corresponding entry must be removed from the `sharedTagsMap` as well – otherwise the map would grow indefinitely. `SyncedMap` subscribes to `__keyevent@<db>__:evicted` and `__keyevent@<db>__:expired` and automatically deletes matching entries.
This requires Redis to be configured with `notify-keyspace-events Exe`.
```mermaid
flowchart LR
REDIS["Redis expires key\n/products/42"] -->|"keyevent notification"| SM["SyncedMap\ndeletes '/products/42'\nfrom local map + hash"]
```
### Why not store tags inside the Redis string value?
The tag information _is_ stored inside the cached entry value as well. However, during `revalidateTag`, the handler must find _all_ keys that have a given tag without deserializing every cache entry. The in-memory `sharedTagsMap` enables O(n) iteration over a lightweight map instead of O(n) deserialization of potentially large page payloads.
---
## DeduplicatedRequestHandler
`DeduplicatedRequestHandler<T, K>` (`src/DeduplicatedRequestHandler.ts`) is a generic request deduplication + short-lived in-memory cache wrapper. It is used by **both** `RedisStringsHandler` and `CacheComponentsHandler`.
### Motivation
In a single Next.js request, the same cache key can be read multiple times (e.g. layout + page + multiple fetch calls referencing the same data). Without deduplication, each `get()` would issue a separate `Redis GET`. With deduplication:
1. The first call creates the Redis request promise and stores it in the `inMemoryDeduplicationCache`.
2. Subsequent calls for the same key within the caching window (default 10s) return the stored promise.
3. After the caching timeout, the entry is evicted.
### Seed on Set
When `set()` stores a value in Redis, it also **seeds** the deduplication cache with the serialized value. This means an immediately following `get()` is served from memory without hitting Redis at all.
```mermaid
sequenceDiagram
participant NX as Next.js
participant H as Cache Handler (either)
participant DC as DeduplicationCache
participant R as Redis
NX->>H: get("key-A")
H->>DC: has("key-A")? No
H->>R: GET "prefix:key-A"
H->>DC: set("key-A", promise)
R-->>H: serialized value
H-->>NX: parsed CacheEntry
NX->>H: get("key-A") (same or different request within 10s)
H->>DC: has("key-A")? Yes
DC-->>H: cached promise
H-->>NX: structuredClone of result
Note over DC: After 10s timeout
DC->>DC: delete("key-A")
```
The `inMemoryDeduplicationCache` itself is a `SyncedMap` configured with `withoutRedisHashmap: true` and `withoutSetSync: true` – it only uses the Pub/Sub delete channel so that revalidations on other instances can evict stale entries.
---
## Core Function: `get`
### What Next.js Passes In
**RedisStringsHandler:**
```typescript
get(key: string, ctx: {
kind: 'APP_ROUTE' | 'APP_PAGE' | 'FETCH';
tags?: string[]; // explicit tags (FETCH only)
softTags?: string[]; // implicit tags like _N_T_/path (FETCH only)
revalidate?: number; // FETCH only
fetchUrl?: string; // FETCH only
isFallback: boolean;
})
```
**CacheComponentsHandler:**
```typescript
get(cacheKey: string, softTags: string[])
```
The Cache Components interface is simpler: it receives only the cache key and soft tags. Soft-tag staleness for implicit `_N_T_` tags is handled by Next.js via `getExpiration()`. Explicit `cacheTag()`s are checked in `get()` (`areTagsExpired` / `areTagsStale`), matching `DefaultCacheHandler.get()`.
### What `get` Does
```mermaid
flowchart TD
A["get(key, ctx)"] --> B["Redis GET prefix:key"]
B --> C{Result found?}
C -->|No| RN["Return null/undefined"]
C -->|Yes| D["JSON.parse result"]
D --> CC{CacheComponents?}
CC -->|Yes| EXP{"entry.expire TTL elapsed?"}
EXP -->|Yes| DEL1["UNLINK key\nDelete from sharedTagsMap\nReturn undefined"]
EXP -->|No| TAGX{"areTagsExpired(entry.tags)?"}
TAGX -->|Yes| DEL1
TAGX -->|No| TAGS{"areTagsStale(entry.tags)?"}
TAGS -->|Yes| SETR["Set revalidate = -1"]
TAGS -->|No| H["Return cache entry"]
SETR --> H
CC -->|No / ISR| E["Check revalidatedTagsMap\nfor all tags + softTags"]
E --> F{Any tag revalidated\nafter entry.lastModified/timestamp?}
F -->|Yes| G["UNLINK key from Redis\nDelete from sharedTagsMap\nReturn null/undefined"]
F -->|No| H
H --> H2["RedisStringsHandler:\nreturn { value, lastModified, tags }"]
H --> H3["CacheComponentsHandler:\nConvert base64 → Uint8Array → ReadableStream\nreturn { value, tags, stale, timestamp, expire, revalidate }"]
```
### Key Details
1. **Timeout**: Every `Redis GET` uses `AbortSignal.timeout(getTimeoutMs)` (default 500ms). If Redis is slow, the handler returns `null` so the page can be server-rendered instead of waiting.
2. **Deduplication** (both handlers): Before hitting Redis, the deduplication cache is checked for an existing in-flight or recently resolved promise for the same key. Enabled by default (`redisGetDeduplication: true`) with a 10s caching window (`inMemoryCachingTime: 10_000`).
3. **Lazy tag invalidation**: Instead of eagerly deleting all fetch entries when a page tag is revalidated, `RedisStringsHandler` records the revalidation timestamp in `revalidatedTagsMap`. During `get`, it compares `lastModified` against the max revalidation timestamp of all related tags. If the entry is stale, it is deleted and `null` is returned. This is necessary because `revalidateTag` for implicit tags (`_N_T_` prefix) does not know which fetch cache keys are affected. **Cache Components** matches Next.js `DefaultCacheHandler.get()`: `areTagsExpired` → miss, `areTagsStale` → return the entry with `revalidate: -1` (SWR). Next.js uses `getExpiration()` only for implicit/soft tags.
4. **Value transformation** (CacheComponentsHandler): The stored value is a base64-encoded string (from a `ReadableStream<Uint8Array>`). On read, it is decoded back to `Uint8Array` and wrapped in a new `ReadableStream`.
---
## Core Function: `set`
### What Next.js Passes In
**RedisStringsHandler:**
```typescript
set(key: string, data: {
kind: 'APP_PAGE' | 'APP_ROUTE' | 'FETCH';
// APP_PAGE: { html, rscData, headers: { 'x-next-cache-tags', 'x-nextjs-stale-time' } }
// APP_ROUTE: { body, status, headers: { 'x-next-cache-tags', 'cache-control' } }
// FETCH: { data: { headers, body, status, url }, revalidate }
}, ctx: {
tags?: string[];
revalidate?: number | false;
cacheControl?: { revalidate: number; expire: number };
})
```
**CacheComponentsHandler:**
```typescript
set(cacheKey: string, pendingEntry: Promise<{
value: ReadableStream<Uint8Array>;
tags: string[];
stale: number;
timestamp: number;
expire: number;
revalidate: number;
}>)
```
Note that the Cache Components handler receives a **Promise** of the entry – the value is not yet available when `set` is called.
### What `set` Does
```mermaid
flowchart TD
A["set(key, data, ctx)"] --> B["Construct CacheEntry"]
B --> B2["RedisStringsHandler:\nExtract tags from headers (APP_PAGE/APP_ROUTE)\nMerge with ctx.tags\nWrap in { value: data, lastModified: Date.now(), tags }"]
B --> B3["CacheComponentsHandler:\nAwait pendingEntry promise\nTee the ReadableStream (don't mutate original)\nConvert stream → Uint8Array → base64 string"]
B2 --> C["JSON.stringify(cacheEntry)"]
B3 --> C
C --> D["Calculate TTL"]
D --> D2["RedisStringsHandler:\nresolveCacheEntryTtlSeconds()\nprefer cacheControl.expire,\nlegacy fallback to estimateExpireAge(revalidate)"]
D --> D3["CacheComponentsHandler:\nentry.expire (already in seconds)"]
D2 --> E["Redis SET prefix:key serialized EX ttl"]
D3 --> E
C --> SEED["Seed deduplication cache\n(if redisGetDeduplication enabled)"]
E --> F{Tags changed?}
F -->|"Yes (or new)"| G["sharedTagsMap.set(key, tags)\n→ HSET + PUBLISH"]
F -->|"No change"| H["Skip tag update"]
G --> I["Done"]
H --> I
```
### Key Details
1. **Tag extraction** (RedisStringsHandler): For `APP_PAGE` and `APP_ROUTE`, tags are encoded in the `x-next-cache-tags` header as a comma-separated string. The handler splits this and merges with `ctx.tags`.
2. **Tag deduplication**: Before writing to `sharedTagsMap`, both handlers check if the current tags are identical to the already stored tags. If so, the write is skipped to reduce Redis operations.
3. **Dedup cache seeding** (both handlers): The serialized value is immediately seeded into the `DeduplicatedRequestHandler`, so a following `get()` for the same key can be served from memory.
4. **Stream handling** (CacheComponentsHandler): The `ReadableStream` from Next.js is tee'd – one branch is consumed to produce the stored base64 value, while the original stream is left intact for Next.js to continue using.
5. **Parallel operations**: The Redis `SET` and the `sharedTagsMap.set()` run concurrently via `Promise.all`.
---
## Core Function: `revalidateTag` / `updateTags`
### What Next.js Passes In
**RedisStringsHandler:**
```typescript
revalidateTag(tagOrTags: string | string[])
```
**CacheComponentsHandler:**
```typescript
updateTags(tags: string[], durations?: { expire?: number })
```
In both cases, the handler receives **only tag names** – no cache keys.
### What `revalidateTag` / `updateTags` Does
```mermaid
flowchart TD
A["revalidateTag / updateTags"] --> B["Normalize tags to Set"]
B --> ISR{Cache Components?}
ISR -->|No ISR| C["Persist Date.now() in revalidatedTagsMap"]
C --> D["Scan sharedTagsMap for matching keys"]
D --> E{keysToDelete empty?}
E -->|Yes| F["Return early"]
E -->|No| G["UNLINK matching Redis keys"]
G --> H["Delete from sharedTagsMap + Pub/Sub"]
G --> I["Delete from inMemoryDeduplicationCache"]
H --> J["Done"]
I --> J
ISR -->|Yes| K{"durations provided?"}
K -->|No updateTag / deprecated revalidateTag| L["Write { expired: now }"]
K -->|Yes| M["Write stale = now"]
M --> N{"durations.expire defined?"}
N -->|Yes| O["expired = now + expire * 1000"]
N -->|No| P["keep prior expired"]
L --> Q["Persist in revalidatedTagsMap\nno UNLINK"]
O --> Q2["expire: 0 → expired=now blocking miss\nexpire>0 → SWR window"]
P --> Q
Q2 --> Q
```
### Key Details
1. **Implicit tags (`_N_T_` prefix)**: When Next.js calls `revalidatePath("/products")`, it internally translates this to `revalidateTag("_N_T_/products")`. The handler cannot know which _fetch_ cache keys are nested inside that page. Therefore, it only records the timestamp in `revalidatedTagsMap`. The actual cleanup happens lazily in `get()` when the fetch entry is next accessed.
2. **SWR `updateTags` (Cache Components)**: Matches Next.js `DefaultCacheHandler.updateTags()` (`default.js` in 16.0.11, 16.2.6, 16.3.0) and the [revalidateTag docs](https://nextjs.org/docs/app/api-reference/functions/revalidateTag). `revalidateTag(tag, profile)` looks up `cacheLife[profile].expire` (seconds) and calls `updateTags(tags, { expire })`. The handler never unlinks in `updateTags`:
- No `durations` (`updateTag` / deprecated single-arg `revalidateTag`): `{ expired: now }` — next `get()` is a hard miss.
- `{ expire: 0 }`: `{ stale: now, expired: now }` — blocking miss, same HTTP effect.
- `{ expire: N }` including `'max'` (~1 year) and `'default'` (`INFINITE_CACHE`): `{ stale: now, expired: now + N * 1000 }`. `get()` returns the entry with `revalidate: -1` until `expired`. Past `expired`, `areTagsExpired` is a miss.
3. **Batch deletion (ISR only)**: `RedisStringsHandler.revalidateTag` deletes matching Redis keys in a single `UNLINK`. Cache Components leaves keys in place; `get()` drops them only after `areTagsExpired`.
4. **Cross-instance propagation**: Tag-manifest writes publish via Pub/Sub, so all instances see `{ stale, expired }` without waiting for `refreshTags()`.
5. **Preset expire seconds** (from Next.js `config-shared.js`): `seconds` 60, `minutes` 3600, `hours` 86400, `days` 604800, `weeks` 2592000, `max` 31536000, `default` 4294967294 (`0xfffffffe`).
### Reviewer notes (automated review false positives)
**Legacy plain numbers in `__cacheComponents_revalidated_tags__`**
Next.js always calls `updateTags(tags, durations?)` with `{ stale, expired }` semantics. It does not write plain numbers into the cache handler. Plain numbers in Redis are a rolling-upgrade artifact from older package versions that stored `Date.now()` directly. A _future-looking_ plain number is not an SWR `expired` window (those always include `stale`); it only arises from cross-instance clock skew. `normalizeTagManifest()` clamps legacy plain numbers to `Math.min(stored, now)` on read so slower instances do not delay invalidation.
**`resolveCacheEntryTtlSeconds()` returning `undefined`**
Returning `undefined` when both `cacheControl.expire` and `revalidate` are absent is deliberate: Redis keys are written without `EX` and invalidation relies on tags. `revalidate: false` is handled separately and maps to `defaultStaleAge` (see `cache-ttl.test.ts` and Pages Router `/static-forever` integration). Next.js supplies `revalidate: false` for fully static pages in practice.
**Tag-manifest keys vs `SyncedMap` orphan cleanup**
`cleanupKeysNotInRedis()` SCANs Redis string keys and HDELs hash fields whose names are missing from that set. That is correct for `sharedTagsMap` (cache key → tags). It is wrong for `revalidatedTagsMap` (tag name → `{ stale, expired }`): tag names are never top-level keys, so startup or the hourly resync would wipe every invalidation and publish the delete fleet-wide. Tag-manifest maps set `customizedSync.withoutOrphanCleanup`.
**Dedup cache vs Redis UNLINK**
`updateTags` no longer deletes Redis entries (Next.js `DefaultCacheHandler` does not either). A 10s in-memory GET cache can still hold the pre-invalidation payload. `get()` must not `UNLINK` a Redis value that differs from the payload it just judged expired — that would delete a replacement written by another instance. Expired reads bypass dedup, compare-and-delete only the same serialized value, and `updateTags` evicts matching dedup entries.
**Rolling upgrades and `{ stale, expired }` objects**
Older package versions stored a number and compared it numerically. Immediate hard-expires (`expired <= now` and no newer `stale`) are still persisted as that number so mixed fleets invalidate. A later stale-only update (`updateTags(tags, {})`) that outruns a past `expired` stays an object. SWR windows stay `{ stale, expired }` objects; instances that cannot parse them keep serving until TTL. Prefer a coordinated rollout (or a unique `keyPrefix`) when using `revalidateTag(tag, profile)` SWR.
---
## RedisStringsHandler vs CacheComponentsHandler
| Aspect | RedisStringsHandler | CacheComponentsHandler |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Next.js version** | 15+ (legacy `cacheHandler`) | 16+ (`cacheHandlers.default`) |
| **Cache kinds** | `APP_PAGE`, `APP_ROUTE`, `FETCH` | Unified (all via `'use cache'`, `cacheTag`, `cacheLife`) |
| **Value format** | Arbitrary JSON (page HTML, RSC data, fetch response) | `ReadableStream<Uint8Array>` ↔ base64 string |
| **Entry shape** | `{ value, lastModified, tags }` | `{ value, tags, stale, timestamp, expire, revalidate }` |
| **set() receives** | Resolved data | `Promise<CacheComponentsEntry>` (may not yet be resolved) |
| **TTL calculation** | `resolveCacheEntryTtlSeconds()` – prefers `cacheControl.expire`, legacy `estimateExpireAge(revalidate)` fallback | `entry.expire` – passed directly by Next.js |
| **Tag source in set** | `x-next-cache-tags` header + `ctx.tags` | `entry.tags` |
| **Request deduplication** | Yes (`DeduplicatedRequestHandler`, default on) | Yes (`DeduplicatedRequestHandler`, default on) |
| **In-memory caching** | Yes (configurable `inMemoryCachingTime`, default 10s) | Yes (configurable `inMemoryCachingTime`, default 10s) |
| **Revalidation function** | `revalidateTag(tagOrTags)` | `updateTags(tags, durations?)` |
| **Implicit tag handling** | Stores timestamp in `revalidatedTagsMap`, lazy check in `get()` for `FETCH` kind | Stores `{ stale, expired }` in `revalidatedTagsMap`; `get()` applies `areTagsExpired` / `areTagsStale`; `getExpiration()` returns max `expired` (soft tags) |
| **Singleton pattern** | External (user wraps in `module.exports`) | Built-in `getRedisCacheComponentsHandler()` singleton |
| **Key prefix resolution** | `keyPrefix` option or `KEY_PREFIX` / `VERCEL_URL` env | `resolveKeyPrefix()` with BUILD_ID fallback |
### Shared Architecture
Despite the API differences, the core invalidation architecture is identical:
```mermaid
flowchart LR
subgraph "Both Handlers"
direction TB
A["Redis Strings\n(actual cache data)"]
B["sharedTagsMap\n(key → tags)"]
C["revalidatedTagsMap\n(tag → timestamp)"]
D["inMemoryDeduplicationCache\n(key → Promise)"]
end
SET["set()"] --> A
SET --> B
SET -.->|"seed"| D
GET["get()"] --> D
GET -->|"on miss"| A
GET -.->|"ISR: check staleness"| C
EXP["getExpiration()"] -->|"CC: timestamp now"| C
REV["revalidateTag()\nupdateTags()"] --> C
REV -->|"evict"| D
GET -->|"expired + same value: UNLINK"| A
```
Both handlers rely on `SyncedMap` for cross-instance consistency of the tag maps and use the same pattern of "find affected keys via `sharedTagsMap` → batch delete from Redis → clean up maps". Both also use `DeduplicatedRequestHandler` (enabled by default) to reduce Redis load by deduplicating concurrent `get()` calls for the same key and seeding the cache on `set()`.