@leejpsd/nextjs-cache-handler 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +132 -27
- package/dist/cache-components/index.cjs +62 -6
- package/dist/cache-components/index.cjs.map +1 -1
- package/dist/cache-components/index.d.cts +1 -1
- package/dist/cache-components/index.d.ts +1 -1
- package/dist/cache-components/index.js +62 -6
- package/dist/cache-components/index.js.map +1 -1
- package/dist/incremental/index.d.cts +1 -1
- package/dist/incremental/index.d.ts +1 -1
- package/dist/index.cjs +62 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +62 -6
- package/dist/index.js.map +1 -1
- package/dist/ops/index.d.cts +1 -1
- package/dist/ops/index.d.ts +1 -1
- package/dist/shared/client/adapter-ioredis.d.cts +1 -1
- package/dist/shared/client/adapter-ioredis.d.ts +1 -1
- package/dist/shared/client/adapter-redis.d.cts +1 -1
- package/dist/shared/client/adapter-redis.d.ts +1 -1
- package/dist/{types-CgriT7My.d.cts → types-Dq1q6qXd.d.cts} +23 -1
- package/dist/{types-CgriT7My.d.ts → types-Dq1q6qXd.d.ts} +23 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,90 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-05-10
|
|
10
|
+
|
|
11
|
+
First minor bump. Three differentiators land in this release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Single-flight refresh lock** (`singleFlight: true`,
|
|
16
|
+
`singleFlightLockTtlSec: 10`). Opt-in stampede protection at the SWR
|
|
17
|
+
boundary. Uses the bundled `refresh-tag-lock.lua` script over a Redis
|
|
18
|
+
SETNX-style lock; only the leader's stale read triggers the
|
|
19
|
+
background refresh, all others fall through to the follower path.
|
|
20
|
+
Two new `MetricEvent` types (`cache.stale.refresh.leader`,
|
|
21
|
+
`cache.stale.refresh.follower`) appear in `onMetric` so operators can
|
|
22
|
+
verify leadership balance. Lock-acquisition failures are best-effort
|
|
23
|
+
and always degrade to the safe follower path; the stale entry is
|
|
24
|
+
served regardless.
|
|
25
|
+
- **OpenTelemetry reference adapter** at
|
|
26
|
+
[`examples/opentelemetry/`](./examples/opentelemetry/). The library
|
|
27
|
+
itself stays dependency-free; the example shows the smallest viable
|
|
28
|
+
wiring of `onMetric` to OTel counters and histograms with bounded
|
|
29
|
+
cardinality (no cache keys or tag names emitted as attributes).
|
|
30
|
+
- **Integration tests against real Redis 7** (21 scenarios) covering
|
|
31
|
+
both `redis@5` and `ioredis` adapters. Each adapter runs the same
|
|
32
|
+
test grid so a regression in either client library is pinpointed
|
|
33
|
+
immediately. New scripts: `npm run test:integration`,
|
|
34
|
+
`npm run test:integration:up` / `:down` for the docker-compose
|
|
35
|
+
fixture.
|
|
36
|
+
- **CI integration job** runs the integration suite against an
|
|
37
|
+
ephemeral Redis 7 service container on every PR.
|
|
38
|
+
- **GitHub Actions OIDC publish path** in `.github/workflows/release.yml`
|
|
39
|
+
with `id-token: write` and `NPM_CONFIG_PROVENANCE=true`. Currently
|
|
40
|
+
scoped to `workflow_dispatch` trigger so the first GHA-driven publish
|
|
41
|
+
can be observed; flip to `push: main` once the changesets cadence
|
|
42
|
+
stabilizes. Tarballs published from the workflow will land on npm
|
|
43
|
+
with verified provenance.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `MetricEventType` union grew three new entries:
|
|
48
|
+
`cache.stale.refresh.leader`, `cache.stale.refresh.follower`,
|
|
49
|
+
`cache.stale.refresh.skipped`. The legacy `cache.stale` event still
|
|
50
|
+
fires when `singleFlight` is off (default).
|
|
51
|
+
- `HandlerState` now carries an `instanceId` (read from `HOSTNAME` /
|
|
52
|
+
`ECS_TASK_ID` / `pid-<pid>`) and embeds it in every refresh-lock
|
|
53
|
+
acquisition for operator-side observability.
|
|
54
|
+
|
|
55
|
+
### Compatibility
|
|
56
|
+
|
|
57
|
+
- No breaking changes. All existing 0.1.x configurations continue to
|
|
58
|
+
work; `singleFlight` defaults to `false`.
|
|
59
|
+
|
|
60
|
+
### Verified
|
|
61
|
+
|
|
62
|
+
- 72 unit tests (8 files) + 21 integration tests (1 file × 21 scenarios)
|
|
63
|
+
all green.
|
|
64
|
+
- arethetypeswrong: 28/28 cells green.
|
|
65
|
+
- publint: clean.
|
|
66
|
+
|
|
67
|
+
## [0.1.1] - 2026-05-10
|
|
68
|
+
|
|
69
|
+
Documentation-only patch released alongside the first round of external
|
|
70
|
+
write-ups. No code changes.
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- **Compatibility matrix** now carries an explicit verification date and a
|
|
75
|
+
`nextjs-turbo-redis-cache` column, so readers can spot-check the comparison
|
|
76
|
+
against the upstream READMEs as both packages evolve. The previous
|
|
77
|
+
two-column shape didn't make it obvious which competitors are tracked.
|
|
78
|
+
- **Production checklist** gains an explicit Redis Cluster row: cluster
|
|
79
|
+
deployments must set `hashTag: true` to avoid `CROSSSLOT` errors from
|
|
80
|
+
the multi-key Lua scripts. Also notes that cluster support is
|
|
81
|
+
unit-tested but not yet load-tested at scale.
|
|
82
|
+
- **Compatibility table for Redis-protocol services** (ElastiCache,
|
|
83
|
+
Upstash, DragonflyDB, KeyDB, Vercel KV) added to the production
|
|
84
|
+
section, separating "validated" from "expected to work via the
|
|
85
|
+
standard Redis protocol".
|
|
86
|
+
- **`docs/architecture.md`** now documents two memory-fallback caveats
|
|
87
|
+
that previously required reading the source: per-process isolation
|
|
88
|
+
(cross-instance correctness degrades during Redis outages) and the
|
|
89
|
+
32-bit `setTimeout` clamp (TTLs above ≈24.85 days fire the eviction
|
|
90
|
+
timer early, though `get()`'s lazy `expiresAt` check still honors the
|
|
91
|
+
configured expiration).
|
|
92
|
+
|
|
9
93
|
## [0.1.0] - 2026-05-10
|
|
10
94
|
|
|
11
95
|
First stable release. Promoted from `0.1.0-rc.1` after a 24-hour live-traffic
|
package/README.md
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
>
|
|
6
|
+
> **`v0.2.0`** — install with
|
|
7
7
|
> `npm install @leejpsd/nextjs-cache-handler`. Production-validated against
|
|
8
8
|
> AWS ECS Fargate with multi-instance Redis (24h live-traffic soak: 0
|
|
9
9
|
> errors, 0 leaks, 2ms Redis ping, namespace isolation working).
|
|
10
|
+
>
|
|
11
|
+
> v0.2 adds: optional **single-flight refresh lock** for cache-stampede
|
|
12
|
+
> protection at the SWR boundary, a reference **OpenTelemetry** wrapper,
|
|
13
|
+
> and **integration tests** (21 scenarios) running against real Redis 7
|
|
14
|
+
> over both `redis@5` and `ioredis` adapters.
|
|
10
15
|
|
|
11
16
|
The Redis cache handler for **Next.js 16** that ships **both** `cacheHandler`
|
|
12
17
|
(ISR / Pages Router) **and** `cacheHandlers` (`'use cache'` directive,
|
|
@@ -49,21 +54,29 @@ As of 2026-05, the leading OSS Redis handler `@fortedigital/nextjs-cache-handler
|
|
|
49
54
|
declares `peerDependencies.next: ">=16.1.5"` but the README marks the new
|
|
50
55
|
plural interface as ❌ **"Not yet supported - Help needed"**:
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
|
60
|
-
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
57
|
+
> 📅 **Compatibility matrix verified 2026-05-10.** The OSS Next.js cache
|
|
58
|
+
> handler ecosystem moves quickly — please verify
|
|
59
|
+
> [`@fortedigital`](https://github.com/fortedigital/nextjs-cache-handler#compatibility)
|
|
60
|
+
> and
|
|
61
|
+
> [`nextjs-turbo-redis-cache`](https://github.com/trieb-work/nextjs-turbo-redis-cache#features)
|
|
62
|
+
> directly before relying on this comparison.
|
|
63
|
+
|
|
64
|
+
| Next 16 feature | this | @fortedigital 3.2.0 | nextjs-turbo-redis-cache 1.13 |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| `cacheHandlers` config (plural) | ✅ | ❌ Help needed | ✅ since 1.11 |
|
|
67
|
+
| `'use cache'` directive | ✅ | ❌ Help needed | ✅ since 1.11 |
|
|
68
|
+
| `'use cache: remote'` | ✅ | ❌ Help needed | partial |
|
|
69
|
+
| `'use cache: private'` | n/a (uncustomizable) | n/a | n/a |
|
|
70
|
+
| `cacheComponents: true` | ✅ | ❌ Help needed | ✅ |
|
|
71
|
+
| Build-phase skip (`PHASE_PRODUCTION_BUILD`) | ✅ | ✅ (singular only) | ✅ |
|
|
72
|
+
| Auto deploy isolation | ✅ `BUILD_NAMESPACE` env-resolved | manual | ✅ `BUILD_ID` since 1.13 |
|
|
73
|
+
| Lua-atomic SET+tag | ✅ Lua scripts | partial (MULTI) | partial |
|
|
74
|
+
| AbortSignal timeout | ✅ per-op | ✅ Proxy-wrapped | ❌ |
|
|
75
|
+
| Redis Cluster | ✅ (cluster adapter, see Production checklist) | ✅ | ✅ |
|
|
76
|
+
| ioredis support | ✅ | ✅ | ✅ |
|
|
77
|
+
| In-memory fallback (TTL-aware) | ✅ | partial | ✅ L1 + Redis L2 |
|
|
78
|
+
| OpenTelemetry hook | ✅ `onMetric` | ❌ | ❌ |
|
|
79
|
+
| Live-traffic dogfood (24h+) | ✅ AWS ECS Fargate | not published | not published |
|
|
67
80
|
|
|
68
81
|
PR [#207](https://github.com/fortedigital/nextjs-cache-handler/pull/207) on
|
|
69
82
|
`@fortedigital` has been stalled for 3+ months on the same issue: the
|
|
@@ -163,13 +176,15 @@ export default async function Page() {
|
|
|
163
176
|
```ts
|
|
164
177
|
interface CacheHandlerOptions {
|
|
165
178
|
client: RedisClientFactory | RedisClientConfig;
|
|
166
|
-
keyPrefix?: string;
|
|
179
|
+
keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
|
|
167
180
|
buildNamespace?: string | (() => string); // default: env DEPLOYMENT_VERSION || GIT_HASH || "unversioned"
|
|
168
|
-
abortTimeoutMs?: number;
|
|
181
|
+
abortTimeoutMs?: number; // default: 1500
|
|
169
182
|
fallback?: "auto" | "always" | "never"; // default: "auto"
|
|
170
183
|
staleWhileRevalidate?: boolean; // default: true (cache-components only)
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
singleFlight?: boolean; // default: false — see "Single-flight refresh lock" below
|
|
185
|
+
singleFlightLockTtlSec?: number; // default: 10
|
|
186
|
+
isBuildPhase?: () => boolean; // override PHASE_PRODUCTION_BUILD detection
|
|
187
|
+
hashTag?: boolean; // default: false (set true on Redis Cluster)
|
|
173
188
|
onMetric?: (event: MetricEvent) => void;
|
|
174
189
|
logger?: Logger;
|
|
175
190
|
}
|
|
@@ -196,6 +211,13 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
|
|
|
196
211
|
to avoid static-chunk-404 issues during a deploy.
|
|
197
212
|
- [ ] **`abortTimeoutMs: 1500`** (default) — protects against a stuck Redis
|
|
198
213
|
connection from hanging the request thread.
|
|
214
|
+
- [ ] **Redis Cluster: set `hashTag: true`** — multi-key Lua scripts
|
|
215
|
+
(`set-with-tags.lua`, `revalidate-hard.lua`) require all KEYS to land
|
|
216
|
+
on the same hash slot. Without `hashTag`, cluster deployments will hit
|
|
217
|
+
`CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps
|
|
218
|
+
the namespace in `{}` so every key for a given deploy hashes together.
|
|
219
|
+
Cluster support is implemented but **not yet load-tested at production
|
|
220
|
+
scale**; PRs welcome.
|
|
199
221
|
- [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need
|
|
200
222
|
bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps
|
|
201
223
|
tag indices intact.
|
|
@@ -205,6 +227,86 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
|
|
|
205
227
|
(separate from the handler) so a Redis outage surfaces in your
|
|
206
228
|
monitoring without inducing 5xx in user requests.
|
|
207
229
|
|
|
230
|
+
### Compatibility with Redis-protocol services
|
|
231
|
+
|
|
232
|
+
| Service | How to use | Tested? |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| **Self-hosted Redis 7+** | `{ type: "redis", url }` or `{ type: "ioredis", url }` | ✅ AWS ElastiCache 24h soak |
|
|
235
|
+
| **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | unit-tested, not yet load-tested at scale |
|
|
236
|
+
| **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol |
|
|
237
|
+
| **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment |
|
|
238
|
+
| **Vercel KV** | not yet supported — `@vercel/kv` adapter ships in v0.3 | — |
|
|
239
|
+
| **DragonflyDB / KeyDB** | Redis-protocol compatible — `{ type: "redis", url }` should work | not validated |
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Single-flight refresh lock (v0.2)
|
|
244
|
+
|
|
245
|
+
The `cacheHandlers` (plural) interface returns stale entries inside the
|
|
246
|
+
SWR window so users get an instant response while the background
|
|
247
|
+
refresh completes. With many instances, the moment an entry crosses the
|
|
248
|
+
`revalidate` boundary, every instance independently triggers its own
|
|
249
|
+
refresh — N parallel re-renders for the same key, each hitting your
|
|
250
|
+
origin once.
|
|
251
|
+
|
|
252
|
+
`singleFlight: true` adds an opt-in Redis lock (`refresh-tag-lock.lua`,
|
|
253
|
+
default TTL 10s) at the SWR boundary. The first instance to acquire it
|
|
254
|
+
becomes the **leader** and runs the refresh; the rest become
|
|
255
|
+
**followers**, keep serving the same stale entry, and wait for the
|
|
256
|
+
leader's write to land. The lock is observability-only at the handler
|
|
257
|
+
layer — Next.js still drives the actual refresh; we just suppress the
|
|
258
|
+
stampede.
|
|
259
|
+
|
|
260
|
+
```js
|
|
261
|
+
createCacheComponentsHandler({
|
|
262
|
+
client: { type: "redis", url: process.env.REDIS_URL },
|
|
263
|
+
singleFlight: true, // default false
|
|
264
|
+
singleFlightLockTtlSec: 10, // default 10
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Two new `MetricEvent` types appear on `onMetric`:
|
|
269
|
+
|
|
270
|
+
| event type | meaning |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `cache.stale.refresh.leader` | this instance just acquired the lock and is the designated refresher |
|
|
273
|
+
| `cache.stale.refresh.follower` | another instance holds the lock; we serve stale and skip the refresh |
|
|
274
|
+
|
|
275
|
+
If lock acquisition fails (Redis hiccup, TTL race), the handler
|
|
276
|
+
defaults to the follower path — the stale entry is always served, never
|
|
277
|
+
dropped. This is intentional: the lock is an optimization, not a
|
|
278
|
+
correctness-critical primitive.
|
|
279
|
+
|
|
280
|
+
When **not** to enable single-flight: small fleets (1–2 instances) where
|
|
281
|
+
Next's per-process serialization already covers the stampede risk.
|
|
282
|
+
Adding a Redis round-trip per stale read isn't free.
|
|
283
|
+
|
|
284
|
+
See [`docs/architecture.md`](./docs/architecture.md#single-flight-refresh-lock-v02-)
|
|
285
|
+
for the full state machine and a reference to the Lua script body.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## OpenTelemetry instrumentation (v0.2)
|
|
290
|
+
|
|
291
|
+
The handler doesn't bundle `@opentelemetry/api` (zero runtime
|
|
292
|
+
dependencies stays a goal). Instead, the `onMetric(event)` hook gives
|
|
293
|
+
strictly-typed events you can pipe into whatever observability stack
|
|
294
|
+
you already run.
|
|
295
|
+
|
|
296
|
+
[`examples/opentelemetry/`](./examples/opentelemetry/) is a copy-paste
|
|
297
|
+
reference wrapper that:
|
|
298
|
+
|
|
299
|
+
- exposes a `nextjs_cache.events_total` counter dimensioned on
|
|
300
|
+
`type` / `freshness` / `backend` / `reason` / `op`
|
|
301
|
+
- exposes a `nextjs_cache.op_latency_ms` histogram for events that
|
|
302
|
+
carry an `ms` field
|
|
303
|
+
- keeps cardinality bounded — cache keys and tag names are never
|
|
304
|
+
emitted as attributes
|
|
305
|
+
|
|
306
|
+
See [`examples/opentelemetry/README.md`](./examples/opentelemetry/README.md)
|
|
307
|
+
for setup and three suggested dashboards (hit rate, single-flight
|
|
308
|
+
leadership distribution, op latency tails).
|
|
309
|
+
|
|
208
310
|
---
|
|
209
311
|
|
|
210
312
|
## How it differs from `@fortedigital/nextjs-cache-handler`
|
|
@@ -248,14 +350,17 @@ ESM and CJS dual-published, full TypeScript types, validated via
|
|
|
248
350
|
|
|
249
351
|
## Roadmap
|
|
250
352
|
|
|
251
|
-
- **v0.1.0** *(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
- **v0.2.0** — Single-flight refresh lock
|
|
255
|
-
|
|
353
|
+
- **v0.1.0** *(2026-05)* — Both handlers, SWR, Lua atomicity, build-phase
|
|
354
|
+
skip, `redis@5` + `ioredis` adapters, AbortSignal, in-memory fallback
|
|
355
|
+
with TTL, soft-tag freshness check ✅
|
|
356
|
+
- **v0.2.0** *(2026-05)* — Single-flight refresh lock with leader/follower
|
|
357
|
+
metrics, OpenTelemetry reference adapter, integration tests against
|
|
358
|
+
real Redis 7 (21 scenarios over `redis@5` + `ioredis`), GitHub Actions
|
|
359
|
+
OIDC publish path with provenance attestation ✅
|
|
256
360
|
- **v0.3.0** — Vercel KV / Upstash Redis adapter, `'use cache: remote'`
|
|
257
|
-
multi-tier setup
|
|
258
|
-
- **v0.4.0** — Cache stampede protection
|
|
361
|
+
multi-tier setup, Redis Cluster load testing
|
|
362
|
+
- **v0.4.0** — Cache stampede protection beyond single-flight,
|
|
363
|
+
request-scoped memoization
|
|
259
364
|
|
|
260
365
|
---
|
|
261
366
|
|
|
@@ -632,7 +632,9 @@ var DEFAULT_KEY_PREFIX = "next-cache:";
|
|
|
632
632
|
var ENTRY_SUFFIX = "entry:";
|
|
633
633
|
var TAG_SUFFIX = "tag:";
|
|
634
634
|
var TAG_EXP_SUFFIX = "tag-expiration:";
|
|
635
|
+
var LOCK_SUFFIX = "lock:";
|
|
635
636
|
var DEFAULT_ABORT_MS = 1500;
|
|
637
|
+
var DEFAULT_LOCK_TTL_SEC = 10;
|
|
636
638
|
var ONE_YEAR_SEC = 60 * 60 * 24 * 365;
|
|
637
639
|
var FALLBACK_TTL_SEC = 3600;
|
|
638
640
|
var TAG_EXP_MARKER_TTL_SEC = 60 * 60 * 24 * 7;
|
|
@@ -655,6 +657,8 @@ function init(opts) {
|
|
|
655
657
|
staleWhileRevalidate: opts.staleWhileRevalidate ?? true,
|
|
656
658
|
hashTag: opts.hashTag ?? false,
|
|
657
659
|
keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
|
|
660
|
+
singleFlight: opts.singleFlight ?? false,
|
|
661
|
+
singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
|
|
658
662
|
rest: opts
|
|
659
663
|
},
|
|
660
664
|
logger,
|
|
@@ -664,7 +668,8 @@ function init(opts) {
|
|
|
664
668
|
memTags: new MemorySetStore(),
|
|
665
669
|
memTagExp: /* @__PURE__ */ new Map(),
|
|
666
670
|
localTagTimestamps: /* @__PURE__ */ new Map(),
|
|
667
|
-
resolveNs: () => resolveBuildNamespace(opts.buildNamespace)
|
|
671
|
+
resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
|
|
672
|
+
instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
|
|
668
673
|
};
|
|
669
674
|
}
|
|
670
675
|
function entryKey(state, cacheKey) {
|
|
@@ -691,6 +696,40 @@ function tagExpKey(state, tag) {
|
|
|
691
696
|
state.opts.hashTag
|
|
692
697
|
);
|
|
693
698
|
}
|
|
699
|
+
function lockKey(state, cacheKey) {
|
|
700
|
+
return buildKey(
|
|
701
|
+
`${state.opts.keyPrefix}${LOCK_SUFFIX}`,
|
|
702
|
+
state.resolveNs(),
|
|
703
|
+
cacheKey,
|
|
704
|
+
state.opts.hashTag
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
async function tryAcquireRefreshLock(state, cacheKey) {
|
|
708
|
+
if (!state.opts.singleFlight) return false;
|
|
709
|
+
if (!gateRedis(state)) return false;
|
|
710
|
+
try {
|
|
711
|
+
return await withAbortSignal(
|
|
712
|
+
"cacheHandlers.singleFlight.lock",
|
|
713
|
+
state.opts.abortTimeoutMs,
|
|
714
|
+
async () => {
|
|
715
|
+
const client = await state.conn.getOrConnect();
|
|
716
|
+
if (!client) return false;
|
|
717
|
+
const result = await execLuaScript(
|
|
718
|
+
client,
|
|
719
|
+
"refreshTagLock",
|
|
720
|
+
[lockKey(state, cacheKey)],
|
|
721
|
+
[
|
|
722
|
+
state.instanceId,
|
|
723
|
+
String(state.opts.singleFlightLockTtlSec)
|
|
724
|
+
]
|
|
725
|
+
);
|
|
726
|
+
return result === 1;
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
694
733
|
function resolveTtlSeconds(entry) {
|
|
695
734
|
if (Number.isFinite(entry.expire) && entry.expire > 0) {
|
|
696
735
|
return Math.max(60, Math.ceil(entry.expire));
|
|
@@ -785,11 +824,28 @@ async function getImpl(state, cacheKey, softTags) {
|
|
|
785
824
|
return void 0;
|
|
786
825
|
}
|
|
787
826
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
827
|
+
if (partition.freshness === "stale") {
|
|
828
|
+
if (state.opts.singleFlight) {
|
|
829
|
+
const isLeader = await tryAcquireRefreshLock(state, cacheKey);
|
|
830
|
+
state.emit({
|
|
831
|
+
type: isLeader ? "cache.stale.refresh.leader" : "cache.stale.refresh.follower",
|
|
832
|
+
ms: Date.now() - start,
|
|
833
|
+
meta: { freshness: partition.freshness }
|
|
834
|
+
});
|
|
835
|
+
} else {
|
|
836
|
+
state.emit({
|
|
837
|
+
type: "cache.stale",
|
|
838
|
+
ms: Date.now() - start,
|
|
839
|
+
meta: { freshness: partition.freshness }
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
state.emit({
|
|
844
|
+
type: "cache.hit",
|
|
845
|
+
ms: Date.now() - start,
|
|
846
|
+
meta: { freshness: partition.freshness }
|
|
847
|
+
});
|
|
848
|
+
}
|
|
793
849
|
return {
|
|
794
850
|
value: bufferToStream(Buffer.from(envelope.value, "base64")),
|
|
795
851
|
tags: envelope.tags,
|