Skip to content

Commit 1e0f942

Browse files
authored
feat: add non-destructive cache hydration support via isInitialHydrat… (#181)
* 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 * refactor: align initial cache write behavior with review feedback Make non-destructive cache writes opt-in, improve naming, handler behavior, and documentation following review comments.
1 parent d5ff640 commit 1e0f942

7 files changed

Lines changed: 86 additions & 19 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ export async function register() {
9999
}
100100
```
101101

102+
## Instrumentation
103+
104+
### Initial cache registration
105+
106+
By default, `registerInitialCache` populates the cache by overwriting any existing
107+
entries with values generated from build-time artifacts (fetch calls, pages, routes).
108+
109+
#### Initial cache write strategy
110+
111+
If you want to preserve values that may already exist in the cache (for example,
112+
entries written at runtime by another instance), you can enable the
113+
`setOnlyIfNotExists` option:
114+
115+
```ts
116+
await registerInitialCache(CacheHandler, {
117+
setOnlyIfNotExists: true,
118+
});
119+
```
120+
121+
When enabled, cache writes performed during the initial cache registration will only
122+
occur if the corresponding cache key does not already exist. This allows you to
123+
explicitly choose the cache population strategy instead of enforcing a single default.
124+
102125
## Handlers
103126

104127
### `redis-strings`

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+
setOnlyIfNotExists: ctx?.setOnlyIfNotExists,
779+
});
777780

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

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

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

49+
/**
50+
* Context information provided during cache set operations.
51+
*/
52+
export type SetContext = {
53+
54+
/**
55+
* When true, the cache write should only occur if the key does not already exist.
56+
* Cache handlers should use non-destructive write operations (e.g., Redis NX)
57+
* to avoid overwriting existing or fresher cache entries.
58+
*
59+
* @default false
60+
*/
61+
setOnlyIfNotExists?: boolean;
62+
};
63+
4964
/**
5065
* Represents an internal Next.js metadata for a `get` method.
5166
* This metadata is available in the `get` method of the cache handler.
@@ -125,6 +140,8 @@ export type Handler = {
125140
*
126141
* @param value - The value to be stored in the cache. See {@link CacheHandlerValue}.
127142
*
143+
* @param ctx - Optional context information for the set operation. See {@link SetContext}.
144+
*
128145
* @returns A Promise that resolves when the value has been successfully set in the cache.
129146
*
130147
* @remarks
@@ -137,7 +154,7 @@ export type Handler = {
137154
*
138155
* 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.
139156
*/
140-
set: (key: string, value: CacheHandlerValue) => Promise<void>;
157+
set: (key: string, value: CacheHandlerValue, ctx?: SetContext) => Promise<void>;
141158
/**
142159
* Deletes all cache entries that are associated with the specified tag.
143160
* 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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,12 @@ 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, but we can still honor "setOnlyIfNotExists"
104+
// by checking whether the key is already present before writing.
105+
if (ctx?.setOnlyIfNotExists && lruCacheStore.has(key)) {
106+
return Promise.resolve();
107+
}
103108
lruCacheStore.set(key, cacheHandlerValue);
104109

105110
return Promise.resolve();

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

Lines changed: 16 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,28 @@ export default function createHandler({
260260

261261
switch (keyExpirationStrategy) {
262262
case "EXAT": {
263+
const hasExpireAt = typeof lifespan?.expireAt === "number";
264+
const isNX = ctx?.setOnlyIfNotExists === true;
265+
266+
const setOptions =
267+
hasExpireAt || isNX
268+
? {
269+
...(hasExpireAt && { EXAT: lifespan.expireAt }),
270+
...(isNX && { NX: true }),
271+
}
272+
: undefined;
273+
263274
setOperation = client
264275
.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-
);
276+
.set(keyPrefix + key, serializedValue, setOptions);
274277
break;
275278
}
276279
case "EXPIREAT": {
280+
const setOptions = ctx?.setOnlyIfNotExists ? { NX: true } : undefined;
281+
277282
setOperation = client
278283
.withAbortSignal(AbortSignal.timeout(timeoutMs))
279-
.set(keyPrefix + key, serializedValue);
284+
.set(keyPrefix + key, serializedValue, setOptions);
280285

281286
expireOperation = lifespan
282287
? client

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ export type RegisterInitialCacheOptions = {
5757
* @default .next
5858
*/
5959
buildDir?: string;
60+
/**
61+
* When true, cache writes performed during the initial cache population
62+
* will only occur if the key does not already exist.
63+
*
64+
* This allows you to choose a strategy: either always overwrite entries,
65+
* or preserve any values that may have been written at runtime.
66+
*
67+
* @default false
68+
*/
69+
setOnlyIfNotExists?: boolean;
6070
/**
6171
* The maximum number of concurrent operations.
6272
* This speeds up the initial cache population because routes are read and processed in parallel.
@@ -121,6 +131,7 @@ export async function registerInitialCache(
121131
const populateFetch = options.fetch ?? true;
122132
const populatePages = options.pages ?? true;
123133
const populateRoutes = options.routes ?? true;
134+
const setOnlyIfNotExists = options.setOnlyIfNotExists ?? false;
124135

125136
let prerenderManifest: PrerenderManifest | undefined;
126137

@@ -236,6 +247,7 @@ export async function registerInitialCache(
236247
revalidate,
237248
internal_lastModified: lastModified,
238249
tags: getTagsFromHeaders(meta.headers),
250+
setOnlyIfNotExists: setOnlyIfNotExists,
239251
});
240252
} catch (error) {
241253
if (debug) {
@@ -376,6 +388,7 @@ export async function registerInitialCache(
376388
await cacheHandler.set(cachePath, value, {
377389
revalidate,
378390
internal_lastModified: lastModified,
391+
setOnlyIfNotExists: setOnlyIfNotExists,
379392
});
380393

381394
if (debug) {
@@ -490,6 +503,7 @@ export async function registerInitialCache(
490503
revalidate,
491504
internal_lastModified: lastModified,
492505
tags: fetchCache.tags,
506+
setOnlyIfNotExists: setOnlyIfNotExists,
493507
});
494508
} catch (error) {
495509
if (debug) {

0 commit comments

Comments
 (0)