Skip to content

Commit ffdaead

Browse files
committed
feat: add cache-hub 2.2 response cache runtime
1 parent ceb75ee commit ffdaead

17 files changed

Lines changed: 1579 additions & 48 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 目录导航
44

55
- [Unreleased](#unreleased)
6+
- [1.2.0 - 2026-06-03](#120---2026-06-03)
67
- [1.1.0 - 2026-06-03](#110---2026-06-03)
78
- [1.0.1 - 2026-06-03](#101---2026-06-03)
89
- [1.0.0 - 2026-06-02](#100---2026-06-02)
@@ -11,6 +12,15 @@
1112

1213
- No unreleased changes.
1314

15+
## 1.2.0 - 2026-06-03
16+
17+
- Add `cacheHub.mode: "redis"` and `"multi-level"` runtime options backed by `cache-hub@^2.2.0`.
18+
- Add Redis lease coordination for cross-process same-key refresh protection.
19+
- Add optional distributed tag invalidation and optional `cache.close?.()` lifecycle support.
20+
- Make `cache.clear()` invalidate the current response cache namespace by internal tag, avoiding Redis `flushdb`.
21+
- Fix `Cache-Control` helpers so unstored responses never receive a new public `max-age`.
22+
- Expand English and Chinese docs with full Memory/Redis/MultiLevel/lease/distributed configuration, lifecycle notes, and Redis troubleshooting.
23+
1424
## 1.1.0 - 2026-06-03
1525

1626
- Add response cache tags, tag invalidation, key deletion, stats, and remaining TTL APIs.

‎README.md‎

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ organization, or session IDs.
136136

137137
| Option | Type | Default | Description |
138138
|--------|------|---------|-------------|
139-
| `cacheHub` | `ResponseCacheHubOptions` | `{}` | Options passed to the internal `cache-hub` `MemoryCache`. `defaultTtl` is not exposed because response TTL is controlled by `ttl`. |
139+
| `cacheHub` | `ResponseCacheHubOptions` | `{}` | Options for the internal `cache-hub` store. Omit `mode` for Memory, or set `mode: "redis"` / `"multi-level"` explicitly. `defaultTtl` is not exposed because response TTL is controlled by `ttl`. |
140140
| `ttl` | `number` | `60000` | Cache TTL in milliseconds. `ttl <= 0` bypasses caching. |
141141
| `namespace` | `string` | `"response-cache"` | Prefix used when building cache keys. Useful when sharing one store across modules. |
142142
| `vary` | `readonly string[] \| "*"` | `[]` | Header names that separate cache entries when those headers change the response. Use `"*"` only when every request header intentionally participates in the key. |
@@ -167,7 +167,7 @@ const cache = createResponseCache({
167167
});
168168
```
169169

170-
`cacheHub` fields:
170+
Memory `cacheHub` fields:
171171

172172
| Field | Type | Default from `cache-hub` | Description |
173173
|-------|------|--------------------------|-------------|
@@ -181,6 +181,100 @@ const cache = createResponseCache({
181181
to `response-cache-kit`; tag indexes are enabled internally so response tags and
182182
`invalidateTag()` work without user store wiring.
183183

184+
Redis and multi-level modes are opt-in. They still use `cache-hub`; there is no
185+
custom external store injection.
186+
187+
```typescript
188+
const redisCache = createResponseCache({
189+
ttl: 10_000,
190+
namespace: "api",
191+
cacheHub: {
192+
mode: "redis",
193+
url: "redis://localhost:6379",
194+
metaKeyPrefix: "api:response-cache",
195+
scanCount: 200,
196+
deleteCommand: "unlink",
197+
lease: {
198+
ttl: 1_000,
199+
waitForOwner: 1_200,
200+
pollInterval: 10,
201+
onTimeout: "fetch",
202+
},
203+
},
204+
});
205+
206+
const multiLevelCache = createResponseCache({
207+
ttl: 10_000,
208+
namespace: "api",
209+
cacheHub: {
210+
mode: "multi-level",
211+
memory: { maxEntries: 5000 },
212+
redis: {
213+
url: "redis://localhost:6379",
214+
metaKeyPrefix: "api:response-cache",
215+
},
216+
writePolicy: "both",
217+
backfillOnRemoteHit: true,
218+
remoteTimeout: 50,
219+
lease: true,
220+
},
221+
});
222+
```
223+
224+
Redis mode fields:
225+
226+
| Field | Type | Default | Description |
227+
|-------|------|---------|-------------|
228+
| `mode` | `"redis"` | required | Enables the `cache-hub` Redis adapter. |
229+
| `url` | `string` | `"redis://localhost:6379"` | Redis URL used when no `client` is provided. URL mode uses cache-hub's optional `ioredis` peer. |
230+
| `client` | `object` | none | Existing Redis-like client. Lifecycle remains owned by the caller. |
231+
| `metaKeyPrefix` | `string` | cache-hub default | Prefix for tag metadata keys. |
232+
| `scanCount` | `number` | cache-hub default | SCAN batch size for pattern/tag operations. |
233+
| `deleteCommand` | `"del" \| "unlink"` | `"del"` | Redis delete command used by cache-hub. |
234+
| `lease` | `boolean \| ResponseCacheHubLeaseOptions` | `false` | Enables Redis-backed cross-process refresh coordination. |
235+
| `distributed` | `boolean \| ResponseCacheHubDistributedOptions` | `false` | Enables cache-hub distributed tag invalidation. |
236+
237+
Multi-level mode fields:
238+
239+
| Field | Type | Default | Description |
240+
|-------|------|---------|-------------|
241+
| `mode` | `"multi-level"` | required | Uses local Memory as L1 and Redis as L2. |
242+
| `memory` | `ResponseCacheHubMemoryOptions` | `{}` | L1 Memory options. |
243+
| `redis` | `ResponseCacheHubRedisTargetOptions` | `{}` | L2 Redis target and metadata options. |
244+
| `writePolicy` | `"both" \| "local-first-async-remote"` | cache-hub default | Write-through behavior. |
245+
| `backfillOnRemoteHit` | `boolean` | cache-hub default | Whether L2 hits should refill L1. |
246+
| `remoteTimeout` | `number` | cache-hub default | L2 read timeout in milliseconds. |
247+
| `remoteInvalidationErrors` | `"ignore" \| "throw"` | cache-hub default | Whether L2 tag invalidation failures throw. |
248+
| `lease` | `boolean \| ResponseCacheHubLeaseOptions` | `false` | Uses the Redis layer for cross-process lease coordination. |
249+
| `distributed` | `boolean \| ResponseCacheHubDistributedOptions` | `false` | Broadcasts tag invalidation across instances. |
250+
251+
Lease fields:
252+
253+
| Field | Type | Default | Description |
254+
|-------|------|---------|-------------|
255+
| `enabled` | `boolean` | `true` when object/`true` is provided | Set `false` to disable a prepared lease config. |
256+
| `ttl` | `number` | derived from response TTL, capped for refresh windows | Lease TTL in milliseconds. |
257+
| `waitForOwner` | `number` | `leaseTtl + 25` | How long a non-owner waits for the owner to fill cache. |
258+
| `pollInterval` | `number` | `10` | Cache polling interval while waiting. |
259+
| `onTimeout` | `"fetch" \| "throw"` | `"fetch"` | Whether to run origin or throw when no owner fills cache in time. |
260+
| `keyPrefix` | `string` | cache-hub default | Redis lease key prefix. |
261+
| `ownerId` | `string` | generated by cache-hub | Stable owner prefix for lease tokens. |
262+
263+
Distributed fields:
264+
265+
| Field | Type | Default | Description |
266+
|-------|------|---------|-------------|
267+
| `enabled` | `boolean` | `true` when object/`true` is provided | Set `false` to disable a prepared distributed config. |
268+
| `redisUrl` | `string` | cache-hub default | Redis URL for pub/sub when no `redis` object is provided. |
269+
| `redis` | `object` | none | Existing Redis-like pub connection. |
270+
| `channel` | `string` | cache-hub default | Pub/sub invalidation channel. |
271+
| `instanceId` | `string` | generated by cache-hub | Instance identifier used to ignore self messages. |
272+
273+
When Redis URL mode or distributed invalidation is enabled, the consuming
274+
application must satisfy cache-hub's optional Redis peer. Default Memory mode
275+
does not load or require Redis. Call `await cache.close?.()` during application
276+
shutdown when using Redis, MultiLevel, or distributed invalidation.
277+
184278
### Cache Lifetime: `ttl`
185279

186280
`ttl` controls how long a stored response stays fresh. Use shorter values for
@@ -279,8 +373,8 @@ designed.
279373

280374
### Internal cache-hub Store: `cacheHub`
281375

282-
`cacheHub` configures the internal `cache-hub` memory store. It does not accept
283-
an external store instance.
376+
`cacheHub` configures the internal `cache-hub` store. It does not accept an
377+
external store instance.
284378

285379
- `maxEntries`: cap the number of cached responses.
286380
- `maxMemory`: approximate memory limit in bytes; `0` means no explicit limit.
@@ -290,6 +384,12 @@ an external store instance.
290384
underlying store is disabled, so requests still run through `cache.handle()`
291385
but cannot build useful hits. Use it for local debugging or temporary cache
292386
shutdowns, not as a long-term production setting.
387+
- `mode: "redis"`: stores response snapshots in cache-hub's Redis adapter.
388+
- `mode: "multi-level"`: uses cache-hub's L1 Memory + L2 Redis cache.
389+
- `lease`: optional Redis-backed cross-process refresh coordination. Same-process
390+
single-flight remains enabled in every mode.
391+
- `distributed`: optional cache-hub Pub/Sub invalidation for tags across
392+
instances.
293393

294394
### Advanced Key Builder: `keyBuilder`
295395

@@ -398,7 +498,10 @@ Builds the cache key without reading or writing the store.
398498

399499
### `cache.clear()`
400500

401-
Clears the underlying `cache-hub` store.
501+
Clears entries written by the current response cache namespace. Stored snapshots
502+
always include an internal namespace tag, so Redis and MultiLevel modes use tag
503+
invalidation instead of `flushdb`. If a future store has no tag invalidation
504+
helper, `clear()` falls back to the store's own `clear()`.
402505

403506
### `cache.delete(key)`
404507

@@ -420,11 +523,21 @@ Returns `cache-hub` statistics such as `entries`, `hits`, `misses`, `hitRate`,
420523
Returns the remaining TTL in milliseconds, `null` for a non-expiring key, or
421524
`undefined` when the key is missing or TTL lookup is unavailable.
422525

526+
### `cache.close?()`
527+
528+
Optional lifecycle hook. Use it during application shutdown when Redis,
529+
MultiLevel, or distributed invalidation is enabled. Memory mode also exposes it,
530+
but existing mocks do not need to implement it because the public contract keeps
531+
`close` optional.
532+
423533
### `createResponseCacheHeaders(result, options?)`
424534

425535
Creates response cache headers from metadata. By default it emits `X-Cache:
426536
HIT` for hits and `X-Cache: MISS` for miss-like states. Set
427-
`cacheControl: true` to also emit `Cache-Control: public,max-age=N`.
537+
`cacheControl: true` to also emit `Cache-Control: public,max-age=N` only when
538+
the result was actually stored in response cache. Responses skipped because of
539+
`Set-Cookie`, `private`, `no-store`, TTL bypass, or cache write failure do not
540+
receive a new public `Cache-Control` header from this helper.
428541

429542
### Adapter helpers
430543

@@ -649,6 +762,8 @@ async function runVextRoute(req, res, route) {
649762
- Skips `Authorization` requests unless a `partitionKey` is provided.
650763
- Filters hop-by-hop headers from cached snapshots.
651764
- Uses same-key single-flight protection for concurrent refreshes.
765+
- Can use Redis lease coordination for cross-process same-key refreshes when
766+
`cacheHub.mode` is `"redis"` or `"multi-level"`.
652767
- Enables cache-hub tag indexes internally.
653768

654769
## Concurrent Expiry Protection
@@ -657,6 +772,20 @@ If a cached response has `ttl: 2_000` and 10000 identical requests arrive after
657772
it expires, only one request refreshes the origin. The other requests wait for
658773
the same in-flight refresh and return the same updated response snapshot.
659774

775+
That default protection is per cache instance. In multi-process deployments,
776+
enable Redis lease coordination to reduce cross-process refresh storms:
777+
778+
```typescript
779+
createResponseCache({
780+
ttl: 2_000,
781+
cacheHub: {
782+
mode: "redis",
783+
url: "redis://localhost:6379",
784+
lease: { waitForOwner: 1_000, onTimeout: "fetch" },
785+
},
786+
});
787+
```
788+
660789
Different keys are independent. Failed origin refreshes are not cached, and a
661790
later request can retry.
662791

@@ -750,9 +879,18 @@ temporary cache shutdowns, not as a long-term production setting.
750879

751880
### Can I use Redis or multi-level cache?
752881

753-
`cache-hub` is the only runtime caching dependency. Redis and multi-level cache
754-
support should be designed as a separate cache-hub integration batch, not as an
755-
external store option in `response-cache-kit`.
882+
Yes. Use `cacheHub.mode: "redis"` or `cacheHub.mode: "multi-level"`. The
883+
underlying implementation is still cache-hub; `response-cache-kit` does not
884+
accept a custom external store. Redis URL mode and distributed invalidation rely
885+
on cache-hub's optional Redis peer, while default Memory mode does not require
886+
Redis.
887+
888+
### Why did Redis mode say ioredis is missing?
889+
890+
`response-cache-kit` only has `cache-hub` as a runtime dependency. Redis support
891+
is provided by cache-hub's Redis adapter, whose Redis client is optional. Install
892+
the Redis peer in the consuming application when you use URL mode or distributed
893+
invalidation, or pass an existing Redis-like `client`.
756894

757895
### Why is `npm run benchmark` heavier than unit tests?
758896

‎changelogs/releases/v1.2.0.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# response-cache-kit v1.2.0
2+
3+
Released: 2026-06-03
4+
5+
## Highlights
6+
7+
- Added `cacheHub.mode: "redis"` and `"multi-level"` runtime options backed by `cache-hub@^2.2.0`.
8+
- Added Redis lease coordination for cross-process same-key refresh protection.
9+
- Added optional distributed tag invalidation and optional `cache.close?.()` lifecycle support.
10+
- Changed `cache.clear()` to invalidate the current response cache namespace by internal tag, avoiding Redis `flushdb`.
11+
- Fixed `Cache-Control` helpers so unstored responses never receive a new public `max-age`.
12+
- Expanded English and Chinese docs with full Memory/Redis/MultiLevel/lease/distributed configuration, lifecycle notes, and Redis troubleshooting.

0 commit comments

Comments
 (0)