@leejpsd/nextjs-cache-handler 0.1.1 → 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 CHANGED
@@ -6,6 +6,64 @@ 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
+
9
67
  ## [0.1.1] - 2026-05-10
10
68
 
11
69
  Documentation-only patch released alongside the first round of external
package/README.md CHANGED
@@ -3,10 +3,15 @@
3
3
  [![npm](https://img.shields.io/npm/v/@leejpsd/nextjs-cache-handler.svg)](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)
4
4
  [![license](https://img.shields.io/npm/l/@leejpsd/nextjs-cache-handler.svg)](./LICENSE)
5
5
 
6
- > **Stable `v0.1.0`** — install with
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,
@@ -171,13 +176,15 @@ export default async function Page() {
171
176
  ```ts
172
177
  interface CacheHandlerOptions {
173
178
  client: RedisClientFactory | RedisClientConfig;
174
- keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
179
+ keyPrefix?: string; // default: "next-cache:" / "next-incremental:"
175
180
  buildNamespace?: string | (() => string); // default: env DEPLOYMENT_VERSION || GIT_HASH || "unversioned"
176
- abortTimeoutMs?: number; // default: 1500
181
+ abortTimeoutMs?: number; // default: 1500
177
182
  fallback?: "auto" | "always" | "never"; // default: "auto"
178
183
  staleWhileRevalidate?: boolean; // default: true (cache-components only)
179
- isBuildPhase?: () => boolean; // override PHASE_PRODUCTION_BUILD detection
180
- hashTag?: boolean; // default: false (set true on Redis Cluster)
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)
181
188
  onMetric?: (event: MetricEvent) => void;
182
189
  logger?: Logger;
183
190
  }
@@ -233,6 +240,75 @@ Full reference: [`docs/api.md`](./docs/api.md) (auto-generated).
233
240
 
234
241
  ---
235
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
+
310
+ ---
311
+
236
312
  ## How it differs from `@fortedigital/nextjs-cache-handler`
237
313
 
238
314
  Three deliberate departures, all rooted in lessons from production
@@ -274,14 +350,17 @@ ESM and CJS dual-published, full TypeScript types, validated via
274
350
 
275
351
  ## Roadmap
276
352
 
277
- - **v0.1.0** *(target: 2026-05)* — Both handlers, SWR, Lua atomicity,
278
- build-phase skip, `redis@5` + `ioredis` adapters, AbortSignal,
279
- in-memory fallback with TTL, soft-tag freshness check
280
- - **v0.2.0** — Single-flight refresh lock (Lua SETNX), OpenTelemetry
281
- forwarder example
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 ✅
282
360
  - **v0.3.0** — Vercel KV / Upstash Redis adapter, `'use cache: remote'`
283
- multi-tier setup
284
- - **v0.4.0** — Cache stampede protection, request-scoped memoization
361
+ multi-tier setup, Redis Cluster load testing
362
+ - **v0.4.0** — Cache stampede protection beyond single-flight,
363
+ request-scoped memoization
285
364
 
286
365
  ---
287
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
- state.emit({
789
- type: partition.freshness === "stale" ? "cache.stale" : "cache.hit",
790
- ms: Date.now() - start,
791
- meta: { freshness: partition.freshness }
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,