Skip to content

Commit 2c3d0b8

Browse files
committed
feat: add non-destructive cache hydration support via isInitialHydration context
- Add SetContext type with isInitialHydration flag - Update Handler interface to accept optional SetContext in set() - Implement NX behavior in redis-strings handler when isInitialHydration is true - Update all handlers (local-lru, composite) to accept ctx parameter - Propagate isInitialHydration flag from registerInitialCache to handlers - Prevents runtime cache overwrites during app restarts and horizontal scaling Addresses #23
1 parent d5ff640 commit 2c3d0b8

File tree

6 files changed

+47
-19
lines changed

6 files changed

+47
-19
lines changed

packages/nextjs-cache-handler/src/handlers/cache-handler.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Handler,
1414
OnCreationHook,
1515
Revalidate,
16+
SetContext,
1617
} from "./cache-handler.types";
1718
import { PrerenderManifest } from "next/dist/build";
1819
import {
@@ -579,10 +580,10 @@ export class CacheHandler implements NextCacheHandler {
579580

580581
return null;
581582
},
582-
async set(key, cacheHandlerValue) {
583+
async set(key, cacheHandlerValue, ctx) {
583584
const operationsResults = await Promise.allSettled(
584585
handlersList.map((handler) =>
585-
handler.set(key, { ...cacheHandlerValue }),
586+
handler.set(key, { ...cacheHandlerValue }, ctx),
586587
),
587588
);
588589

@@ -721,7 +722,7 @@ export class CacheHandler implements NextCacheHandler {
721722
internal_lastModified?: number;
722723
tags?: string[];
723724
revalidate?: Revalidate;
724-
},
725+
} & SetContext,
725726
): Promise<void> {
726727
await CacheHandler.#ensureConfigured();
727728

@@ -773,7 +774,9 @@ export class CacheHandler implements NextCacheHandler {
773774
value: value,
774775
};
775776

776-
await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue);
777+
await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue, {
778+
isInitialHydration: ctx?.isInitialHydration,
779+
});
777780

778781
if (
779782
process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD &&

packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ export type CacheHandlerParametersGetWithTags = [
4646
string[],
4747
];
4848

49+
/**
50+
* Context information provided during cache set operations.
51+
*/
52+
export type SetContext = {
53+
/**
54+
* Indicates whether this set operation is part of initial cache hydration.
55+
* When true, cache handlers should use non-destructive write operations (e.g., Redis NX)
56+
* to avoid overwriting fresher runtime-generated cache entries with stale build-time values.
57+
*
58+
* @default false
59+
*/
60+
isInitialHydration?: boolean;
61+
};
62+
4963
/**
5064
* Represents an internal Next.js metadata for a `get` method.
5165
* This metadata is available in the `get` method of the cache handler.
@@ -125,6 +139,8 @@ export type Handler = {
125139
*
126140
* @param value - The value to be stored in the cache. See {@link CacheHandlerValue}.
127141
*
142+
* @param ctx - Optional context information for the set operation. See {@link SetContext}.
143+
*
128144
* @returns A Promise that resolves when the value has been successfully set in the cache.
129145
*
130146
* @remarks
@@ -137,7 +153,7 @@ export type Handler = {
137153
*
138154
* Use the absolute time (`expireAt`) to set and expiration time for the cache entry in your cache store to be in sync with the file system cache.
139155
*/
140-
set: (key: string, value: CacheHandlerValue) => Promise<void>;
156+
set: (key: string, value: CacheHandlerValue, ctx?: SetContext) => Promise<void>;
141157
/**
142158
* Deletes all cache entries that are associated with the specified tag.
143159
* See [fetch `options.next.tags` and `revalidateTag` ↗](https://nextjs.org/docs/app/building-your-application/caching#fetch-optionsnexttags-and-revalidatetag)

packages/nextjs-cache-handler/src/handlers/composite.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ export default function createHandler({
3737
return null;
3838
},
3939

40-
async set(key, data) {
40+
async set(key, data, ctx) {
4141
const index = strategy?.(data) ?? 0;
4242
const handler = handlers[index] ?? handlers[0]!;
43-
await handler.set(key, data);
43+
await handler.set(key, data, ctx);
4444
},
4545

4646
async revalidateTag(tag) {

packages/nextjs-cache-handler/src/handlers/local-lru.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ export default function createHandler({
9999

100100
return Promise.resolve(cacheValue);
101101
},
102-
set(key, cacheHandlerValue) {
102+
set(key, cacheHandlerValue, ctx) {
103+
// LRU cache is in-memory and ephemeral, so NX behavior is not applicable
104+
// We always set the value regardless of isInitialHydration flag
103105
lruCacheStore.set(key, cacheHandlerValue);
104106

105107
return Promise.resolve();

packages/nextjs-cache-handler/src/handlers/redis-strings.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export default function createHandler({
221221

222222
return cacheValue;
223223
},
224-
async set(key, cacheHandlerValue) {
224+
async set(key, cacheHandlerValue, ctx) {
225225
assertClientIsReady();
226226

227227
let setOperation: Promise<string | null>;
@@ -260,23 +260,27 @@ export default function createHandler({
260260

261261
switch (keyExpirationStrategy) {
262262
case "EXAT": {
263+
const setOptions =
264+
typeof lifespan?.expireAt === "number"
265+
? {
266+
EXAT: lifespan.expireAt,
267+
...(ctx?.isInitialHydration ? { NX: true } : {}),
268+
}
269+
: ctx?.isInitialHydration
270+
? { NX: true }
271+
: undefined;
272+
263273
setOperation = client
264274
.withAbortSignal(AbortSignal.timeout(timeoutMs))
265-
.set(
266-
keyPrefix + key,
267-
serializedValue,
268-
typeof lifespan?.expireAt === "number"
269-
? {
270-
EXAT: lifespan.expireAt,
271-
}
272-
: undefined,
273-
);
275+
.set(keyPrefix + key, serializedValue, setOptions);
274276
break;
275277
}
276278
case "EXPIREAT": {
279+
const setOptions = ctx?.isInitialHydration ? { NX: true } : undefined;
280+
277281
setOperation = client
278282
.withAbortSignal(AbortSignal.timeout(timeoutMs))
279-
.set(keyPrefix + key, serializedValue);
283+
.set(keyPrefix + key, serializedValue, setOptions);
280284

281285
expireOperation = lifespan
282286
? client

packages/nextjs-cache-handler/src/instrumentation/register-initial-cache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export async function registerInitialCache(
236236
revalidate,
237237
internal_lastModified: lastModified,
238238
tags: getTagsFromHeaders(meta.headers),
239+
isInitialHydration: true,
239240
});
240241
} catch (error) {
241242
if (debug) {
@@ -376,6 +377,7 @@ export async function registerInitialCache(
376377
await cacheHandler.set(cachePath, value, {
377378
revalidate,
378379
internal_lastModified: lastModified,
380+
isInitialHydration: true,
379381
});
380382

381383
if (debug) {
@@ -490,6 +492,7 @@ export async function registerInitialCache(
490492
revalidate,
491493
internal_lastModified: lastModified,
492494
tags: fetchCache.tags,
495+
isInitialHydration: true,
493496
});
494497
} catch (error) {
495498
if (debug) {

0 commit comments

Comments
 (0)