@trieb.work/nextjs-turbo-redis-cache 1.2.1 → 1.3.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/.github/workflows/ci.yml +30 -6
- package/.github/workflows/release.yml +6 -3
- package/.next/trace +11 -0
- package/.vscode/settings.json +10 -0
- package/CHANGELOG.md +54 -0
- package/README.md +149 -34
- package/dist/index.d.mts +92 -20
- package/dist/index.d.ts +92 -20
- package/dist/index.js +319 -60
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +315 -60
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -7
- package/scripts/vitest-run-staged.cjs +1 -1
- package/src/CachedHandler.ts +23 -9
- package/src/DeduplicatedRequestHandler.ts +50 -1
- package/src/RedisStringsHandler.ts +330 -89
- package/src/SyncedMap.ts +74 -4
- package/src/index.ts +4 -2
- package/src/utils/debug.ts +30 -0
- package/src/utils/json.ts +26 -0
- package/test/integration/next-app/README.md +36 -0
- package/test/integration/next-app/eslint.config.mjs +16 -0
- package/test/integration/next-app/next.config.js +6 -0
- package/test/integration/next-app/package-lock.json +5833 -0
- package/test/integration/next-app/package.json +29 -0
- package/test/integration/next-app/pnpm-lock.yaml +3679 -0
- package/test/integration/next-app/postcss.config.mjs +5 -0
- package/test/integration/next-app/public/file.svg +1 -0
- package/test/integration/next-app/public/globe.svg +1 -0
- package/test/integration/next-app/public/next.svg +1 -0
- package/test/integration/next-app/public/vercel.svg +1 -0
- package/test/integration/next-app/public/window.svg +1 -0
- package/test/integration/next-app/src/app/api/cached-static-fetch/route.ts +18 -0
- package/test/integration/next-app/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
- package/test/integration/next-app/src/app/api/revalidatePath/route.ts +15 -0
- package/test/integration/next-app/src/app/api/revalidateTag/route.ts +15 -0
- package/test/integration/next-app/src/app/api/revalidated-fetch/route.ts +17 -0
- package/test/integration/next-app/src/app/api/uncached-fetch/route.ts +15 -0
- package/test/integration/next-app/src/app/globals.css +26 -0
- package/test/integration/next-app/src/app/layout.tsx +59 -0
- package/test/integration/next-app/src/app/page.tsx +755 -0
- package/test/integration/next-app/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
- package/test/integration/next-app/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app/src/app/pages/no-fetch/default-page/page.tsx +55 -0
- package/test/integration/next-app/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
- package/test/integration/next-app/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/integration/next-app/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
- package/test/integration/next-app/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/integration/next-app/src/app/revalidation-interface.tsx +267 -0
- package/test/integration/next-app/tsconfig.json +27 -0
- package/test/integration/next-app-customized/README.md +36 -0
- package/test/integration/next-app-customized/customized-cache-handler.js +34 -0
- package/test/integration/next-app-customized/eslint.config.mjs +16 -0
- package/test/integration/next-app-customized/next.config.js +6 -0
- package/test/integration/nextjs-cache-handler.integration.test.ts +840 -0
- package/vite.config.ts +23 -8
|
@@ -1,37 +1,68 @@
|
|
|
1
1
|
import { commandOptions, createClient } from 'redis';
|
|
2
2
|
import { SyncedMap } from './SyncedMap';
|
|
3
3
|
import { DeduplicatedRequestHandler } from './DeduplicatedRequestHandler';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
CacheHandlerValue,
|
|
7
|
-
IncrementalCache,
|
|
8
|
-
} from 'next/dist/server/lib/incremental-cache';
|
|
4
|
+
import { debug } from './utils/debug';
|
|
5
|
+
import { bufferReviver, bufferReplacer } from './utils/json';
|
|
9
6
|
|
|
10
7
|
export type CommandOptions = ReturnType<typeof commandOptions>;
|
|
11
|
-
type GetParams = Parameters<IncrementalCache['get']>;
|
|
12
|
-
type SetParams = Parameters<IncrementalCache['set']>;
|
|
13
|
-
type RevalidateParams = Parameters<IncrementalCache['revalidateTag']>;
|
|
14
8
|
export type Client = ReturnType<typeof createClient>;
|
|
15
9
|
|
|
10
|
+
export type CacheEntry = {
|
|
11
|
+
value: unknown;
|
|
12
|
+
lastModified: number;
|
|
13
|
+
tags: string[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
16
|
export type CreateRedisStringsHandlerOptions = {
|
|
17
|
+
/** Redis database number to use. Uses DB 0 for production, DB 1 otherwise
|
|
18
|
+
* @default process.env.VERCEL_ENV === 'production' ? 0 : 1
|
|
19
|
+
*/
|
|
17
20
|
database?: number;
|
|
21
|
+
/** Prefix added to all Redis keys
|
|
22
|
+
* @default process.env.VERCEL_URL || 'UNDEFINED_URL_'
|
|
23
|
+
*/
|
|
18
24
|
keyPrefix?: string;
|
|
25
|
+
/** Timeout in milliseconds for Redis operations
|
|
26
|
+
* @default 5000
|
|
27
|
+
*/
|
|
19
28
|
timeoutMs?: number;
|
|
29
|
+
/** Number of entries to query in one batch during full sync of shared tags hash map
|
|
30
|
+
* @default 250
|
|
31
|
+
*/
|
|
20
32
|
revalidateTagQuerySize?: number;
|
|
33
|
+
/** Key used to store shared tags hash map in Redis
|
|
34
|
+
* @default '__sharedTags__'
|
|
35
|
+
*/
|
|
21
36
|
sharedTagsKey?: string;
|
|
37
|
+
/** Average interval in milliseconds between tag map full re-syncs
|
|
38
|
+
* @default 3600000 (1 hour)
|
|
39
|
+
*/
|
|
22
40
|
avgResyncIntervalMs?: number;
|
|
41
|
+
/** Enable deduplication of Redis get requests via internal in-memory cache
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
23
44
|
redisGetDeduplication?: boolean;
|
|
45
|
+
/** Time in milliseconds to cache Redis get results in memory. Set this to 0 to disable in-memory caching completely
|
|
46
|
+
* @default 10000
|
|
47
|
+
*/
|
|
24
48
|
inMemoryCachingTime?: number;
|
|
49
|
+
/** Default stale age in seconds for cached items
|
|
50
|
+
* @default 1209600 (14 days)
|
|
51
|
+
*/
|
|
25
52
|
defaultStaleAge?: number;
|
|
53
|
+
/** Function to calculate expire age (redis TTL value) from stale age
|
|
54
|
+
* @default Production: staleAge * 2, Other: staleAge * 1.2
|
|
55
|
+
*/
|
|
26
56
|
estimateExpireAge?: (staleAge: number) => number;
|
|
27
57
|
};
|
|
28
58
|
|
|
59
|
+
// Identifier prefix used by Next.js to mark automatically generated cache tags
|
|
60
|
+
// These tags are created internally by Next.js for route-based invalidation
|
|
29
61
|
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
|
|
30
|
-
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
|
|
31
62
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
// Redis key used to store a map of tags and their last revalidation timestamps
|
|
64
|
+
// This helps track when specific tags were last invalidated
|
|
65
|
+
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
|
|
35
66
|
|
|
36
67
|
export function getTimeoutRedisCommandOptions(
|
|
37
68
|
timeoutMs: number,
|
|
@@ -39,7 +70,7 @@ export function getTimeoutRedisCommandOptions(
|
|
|
39
70
|
return commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
|
|
40
71
|
}
|
|
41
72
|
|
|
42
|
-
export default class RedisStringsHandler
|
|
73
|
+
export default class RedisStringsHandler {
|
|
43
74
|
private client: Client;
|
|
44
75
|
private sharedTagsMap: SyncedMap<string[]>;
|
|
45
76
|
private revalidatedTagsMap: SyncedMap<number>;
|
|
@@ -63,14 +94,14 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
63
94
|
database = process.env.VERCEL_ENV === 'production' ? 0 : 1,
|
|
64
95
|
keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_',
|
|
65
96
|
sharedTagsKey = '__sharedTags__',
|
|
66
|
-
timeoutMs =
|
|
97
|
+
timeoutMs = 5_000,
|
|
67
98
|
revalidateTagQuerySize = 250,
|
|
68
|
-
avgResyncIntervalMs = 60 * 60 *
|
|
99
|
+
avgResyncIntervalMs = 60 * 60 * 1_000,
|
|
69
100
|
redisGetDeduplication = true,
|
|
70
101
|
inMemoryCachingTime = 10_000,
|
|
71
102
|
defaultStaleAge = 60 * 60 * 24 * 14,
|
|
72
103
|
estimateExpireAge = (staleAge) =>
|
|
73
|
-
process.env.VERCEL_ENV === '
|
|
104
|
+
process.env.VERCEL_ENV === 'production' ? staleAge * 2 : staleAge * 1.2,
|
|
74
105
|
}: CreateRedisStringsHandlerOptions) {
|
|
75
106
|
this.keyPrefix = keyPrefix;
|
|
76
107
|
this.timeoutMs = timeoutMs;
|
|
@@ -96,9 +127,12 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
96
127
|
.then(() => {
|
|
97
128
|
console.info('Redis client connected.');
|
|
98
129
|
})
|
|
99
|
-
.catch((
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
.catch(() => {
|
|
131
|
+
this.client.connect().catch((error) => {
|
|
132
|
+
console.error('Failed to connect Redis client:', error);
|
|
133
|
+
this.client.disconnect();
|
|
134
|
+
throw error;
|
|
135
|
+
});
|
|
102
136
|
});
|
|
103
137
|
} catch (error: unknown) {
|
|
104
138
|
console.error('Failed to initialize Redis client');
|
|
@@ -160,9 +194,8 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
160
194
|
this.deduplicatedRedisGet =
|
|
161
195
|
this.redisDeduplicationHandler.deduplicatedFunction;
|
|
162
196
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
197
|
+
|
|
198
|
+
resetRequestCache(): void { }
|
|
166
199
|
|
|
167
200
|
private async assertClientIsReady(): Promise<void> {
|
|
168
201
|
await Promise.all([
|
|
@@ -174,110 +207,273 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
174
207
|
}
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
public async get(
|
|
210
|
+
public async get(
|
|
211
|
+
key: string,
|
|
212
|
+
ctx:
|
|
213
|
+
| {
|
|
214
|
+
kind: 'APP_ROUTE' | 'APP_PAGE';
|
|
215
|
+
isRoutePPREnabled: boolean;
|
|
216
|
+
isFallback: boolean;
|
|
217
|
+
}
|
|
218
|
+
| {
|
|
219
|
+
kind: 'FETCH';
|
|
220
|
+
revalidate: number;
|
|
221
|
+
fetchUrl: string;
|
|
222
|
+
fetchIdx: number;
|
|
223
|
+
tags: string[];
|
|
224
|
+
softTags: string[];
|
|
225
|
+
isFallback: boolean;
|
|
226
|
+
},
|
|
227
|
+
): Promise<CacheEntry | null> {
|
|
228
|
+
if (
|
|
229
|
+
ctx.kind !== 'APP_ROUTE' &&
|
|
230
|
+
ctx.kind !== 'APP_PAGE' &&
|
|
231
|
+
ctx.kind !== 'FETCH'
|
|
232
|
+
) {
|
|
233
|
+
console.warn(
|
|
234
|
+
'RedisStringsHandler.get() called with',
|
|
235
|
+
key,
|
|
236
|
+
ctx,
|
|
237
|
+
' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
|
|
238
|
+
(ctx as { kind: string })?.kind,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
debug('green', 'RedisStringsHandler.get() called with', key, ctx);
|
|
178
243
|
await this.assertClientIsReady();
|
|
179
244
|
|
|
180
245
|
const clientGet = this.redisGetDeduplication
|
|
181
246
|
? this.deduplicatedRedisGet(key)
|
|
182
247
|
: this.redisGet;
|
|
183
|
-
const
|
|
248
|
+
const serializedCacheEntry = await clientGet(
|
|
184
249
|
getTimeoutRedisCommandOptions(this.timeoutMs),
|
|
185
250
|
this.keyPrefix + key,
|
|
186
251
|
);
|
|
187
252
|
|
|
188
|
-
|
|
253
|
+
debug(
|
|
254
|
+
'green',
|
|
255
|
+
'RedisStringsHandler.get() finished with result (serializedCacheEntry)',
|
|
256
|
+
serializedCacheEntry?.substring(0, 200),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (!serializedCacheEntry) {
|
|
189
260
|
return null;
|
|
190
261
|
}
|
|
191
262
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
263
|
+
const cacheEntry: CacheEntry | null = JSON.parse(
|
|
264
|
+
serializedCacheEntry,
|
|
265
|
+
bufferReviver,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
debug(
|
|
269
|
+
'green',
|
|
270
|
+
'RedisStringsHandler.get() finished with result (cacheEntry)',
|
|
271
|
+
JSON.stringify(cacheEntry).substring(0, 200),
|
|
272
|
+
);
|
|
195
273
|
|
|
196
|
-
if (!
|
|
274
|
+
if (!cacheEntry) {
|
|
197
275
|
return null;
|
|
198
276
|
}
|
|
199
277
|
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
278
|
+
if (!cacheEntry?.tags) {
|
|
279
|
+
console.warn(
|
|
280
|
+
'RedisStringsHandler.get() called with',
|
|
281
|
+
key,
|
|
282
|
+
ctx,
|
|
283
|
+
'cacheEntry is mall formed (missing tags)',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (!cacheEntry?.value) {
|
|
287
|
+
console.warn(
|
|
288
|
+
'RedisStringsHandler.get() called with',
|
|
289
|
+
key,
|
|
290
|
+
ctx,
|
|
291
|
+
'cacheEntry is mall formed (missing value)',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!cacheEntry?.lastModified) {
|
|
295
|
+
console.warn(
|
|
296
|
+
'RedisStringsHandler.get() called with',
|
|
297
|
+
key,
|
|
298
|
+
ctx,
|
|
299
|
+
'cacheEntry is mall formed (missing lastModified)',
|
|
300
|
+
);
|
|
204
301
|
}
|
|
205
302
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
303
|
+
if (ctx.kind === 'FETCH') {
|
|
304
|
+
const combinedTags = new Set([
|
|
305
|
+
...(ctx?.softTags || []),
|
|
306
|
+
...(ctx?.tags || []),
|
|
307
|
+
]);
|
|
210
308
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
309
|
+
if (combinedTags.size === 0) {
|
|
310
|
+
return cacheEntry;
|
|
311
|
+
}
|
|
214
312
|
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
this
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
313
|
+
// INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route). See revalidateTag() for more information
|
|
314
|
+
//
|
|
315
|
+
// This code checks if any of the cache tags associated with this entry (normally the internal tag of the parent page/api route containing the fetch request)
|
|
316
|
+
// have been revalidated since the entry was last modified. If any tag was revalidated more recently than the entry's
|
|
317
|
+
// lastModified timestamp, then the cached content is considered stale (therefore return null) and should be removed.
|
|
318
|
+
for (const tag of combinedTags) {
|
|
319
|
+
// Get the last revalidation time for this tag from our revalidatedTagsMap
|
|
320
|
+
const revalidationTime = this.revalidatedTagsMap.get(tag);
|
|
321
|
+
|
|
322
|
+
// If we have a revalidation time for this tag and it's more recent than when
|
|
323
|
+
// this cache entry was last modified, the entry is stale
|
|
324
|
+
if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
|
|
325
|
+
const redisKey = this.keyPrefix + key;
|
|
326
|
+
|
|
327
|
+
// We don't await this cleanup since it can happen asynchronously in the background.
|
|
328
|
+
// The cache entry is already considered invalid at this point.
|
|
329
|
+
this.client
|
|
330
|
+
.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
// If the first unlink fails, log the error and try one more time
|
|
333
|
+
console.error(
|
|
334
|
+
'Error occurred while unlinking stale data. Retrying now. Error was:',
|
|
335
|
+
err,
|
|
336
|
+
);
|
|
337
|
+
this.client.unlink(
|
|
338
|
+
getTimeoutRedisCommandOptions(this.timeoutMs),
|
|
339
|
+
redisKey,
|
|
340
|
+
);
|
|
341
|
+
})
|
|
342
|
+
.finally(async () => {
|
|
343
|
+
// Clean up our tag tracking maps after the Redis key is removed
|
|
344
|
+
await this.sharedTagsMap.delete(key);
|
|
345
|
+
await this.revalidatedTagsMap.delete(tag);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
debug(
|
|
349
|
+
'green',
|
|
350
|
+
'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
|
|
351
|
+
tag,
|
|
352
|
+
redisKey,
|
|
353
|
+
revalidationTime,
|
|
354
|
+
cacheEntry,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Return null to indicate no valid cache entry was found
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
238
360
|
}
|
|
239
361
|
}
|
|
240
362
|
|
|
241
|
-
return
|
|
363
|
+
return cacheEntry;
|
|
242
364
|
}
|
|
243
365
|
public async set(
|
|
244
|
-
key:
|
|
245
|
-
data:
|
|
246
|
-
|
|
366
|
+
key: string,
|
|
367
|
+
data:
|
|
368
|
+
| {
|
|
369
|
+
kind: 'APP_PAGE';
|
|
370
|
+
status: number;
|
|
371
|
+
headers: {
|
|
372
|
+
'x-nextjs-stale-time': string; // timestamp in ms
|
|
373
|
+
'x-next-cache-tags': string; // comma separated paths (tags)
|
|
374
|
+
};
|
|
375
|
+
html: string;
|
|
376
|
+
rscData: Buffer;
|
|
377
|
+
segmentData: unknown;
|
|
378
|
+
postboned: unknown;
|
|
379
|
+
}
|
|
380
|
+
| {
|
|
381
|
+
kind: 'APP_ROUTE';
|
|
382
|
+
status: number;
|
|
383
|
+
headers: {
|
|
384
|
+
'cache-control'?: string;
|
|
385
|
+
'x-nextjs-stale-time': string; // timestamp in ms
|
|
386
|
+
'x-next-cache-tags': string; // comma separated paths (tags)
|
|
387
|
+
};
|
|
388
|
+
body: Buffer;
|
|
389
|
+
}
|
|
390
|
+
| {
|
|
391
|
+
kind: 'FETCH';
|
|
392
|
+
data: {
|
|
393
|
+
headers: Record<string, string>;
|
|
394
|
+
body: string; // base64 encoded
|
|
395
|
+
status: number;
|
|
396
|
+
url: string;
|
|
397
|
+
};
|
|
398
|
+
revalidate: number | false;
|
|
399
|
+
},
|
|
400
|
+
ctx: {
|
|
401
|
+
revalidate: number | false;
|
|
402
|
+
isRoutePPREnabled: boolean;
|
|
403
|
+
isFallback: boolean;
|
|
404
|
+
tags?: string[];
|
|
405
|
+
},
|
|
247
406
|
) {
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
data.
|
|
251
|
-
|
|
407
|
+
if (
|
|
408
|
+
data.kind !== 'APP_ROUTE' &&
|
|
409
|
+
data.kind !== 'APP_PAGE' &&
|
|
410
|
+
data.kind !== 'FETCH'
|
|
411
|
+
) {
|
|
412
|
+
console.warn(
|
|
413
|
+
'RedisStringsHandler.set() called with',
|
|
414
|
+
key,
|
|
415
|
+
ctx,
|
|
416
|
+
data,
|
|
417
|
+
' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
|
|
418
|
+
(data as { kind: string })?.kind,
|
|
419
|
+
);
|
|
252
420
|
}
|
|
421
|
+
|
|
253
422
|
await this.assertClientIsReady();
|
|
254
423
|
|
|
255
|
-
data.
|
|
424
|
+
if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
|
|
425
|
+
const tags = data.headers['x-next-cache-tags']?.split(',');
|
|
426
|
+
ctx.tags = [...(ctx.tags || []), ...(tags || [])];
|
|
427
|
+
}
|
|
256
428
|
|
|
257
|
-
|
|
429
|
+
// Constructing and serializing the value for storing it in redis
|
|
430
|
+
const cacheEntry: CacheEntry = {
|
|
431
|
+
lastModified: Date.now(),
|
|
432
|
+
tags: ctx?.tags || [],
|
|
433
|
+
value: data,
|
|
434
|
+
};
|
|
435
|
+
const serializedCacheEntry = JSON.stringify(cacheEntry, bufferReplacer);
|
|
258
436
|
|
|
259
437
|
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
|
|
260
438
|
// the same value from redis which was just set.
|
|
261
439
|
if (this.redisGetDeduplication) {
|
|
262
|
-
this.redisDeduplicationHandler.seedRequestReturn(
|
|
440
|
+
this.redisDeduplicationHandler.seedRequestReturn(
|
|
441
|
+
key,
|
|
442
|
+
serializedCacheEntry,
|
|
443
|
+
);
|
|
263
444
|
}
|
|
264
445
|
|
|
446
|
+
// Constructing the expire time for the cache entry
|
|
265
447
|
const expireAt =
|
|
266
448
|
ctx.revalidate &&
|
|
267
|
-
|
|
268
|
-
|
|
449
|
+
Number.isSafeInteger(ctx.revalidate) &&
|
|
450
|
+
ctx.revalidate > 0
|
|
269
451
|
? this.estimateExpireAge(ctx.revalidate)
|
|
270
452
|
: this.estimateExpireAge(this.defaultStaleAge);
|
|
453
|
+
|
|
454
|
+
// Setting the cache entry in redis
|
|
271
455
|
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
272
456
|
const setOperation: Promise<string | null> = this.client.set(
|
|
273
457
|
options,
|
|
274
458
|
this.keyPrefix + key,
|
|
275
|
-
|
|
459
|
+
serializedCacheEntry,
|
|
276
460
|
{
|
|
277
461
|
EX: expireAt,
|
|
278
462
|
},
|
|
279
463
|
);
|
|
280
464
|
|
|
465
|
+
debug(
|
|
466
|
+
'blue',
|
|
467
|
+
'RedisStringsHandler.set() will set the following serializedCacheEntry',
|
|
468
|
+
this.keyPrefix,
|
|
469
|
+
key,
|
|
470
|
+
data,
|
|
471
|
+
ctx,
|
|
472
|
+
serializedCacheEntry?.substring(0, 200),
|
|
473
|
+
expireAt,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Setting the tags for the cache entry in the sharedTagsMap (locally stored hashmap synced via redis)
|
|
281
477
|
let setTagsOperation: Promise<void> | undefined;
|
|
282
478
|
if (ctx.tags && ctx.tags.length > 0) {
|
|
283
479
|
const currentTags = this.sharedTagsMap.get(key);
|
|
@@ -294,47 +490,92 @@ export default class RedisStringsHandler implements CacheHandler {
|
|
|
294
490
|
}
|
|
295
491
|
}
|
|
296
492
|
|
|
493
|
+
debug(
|
|
494
|
+
'blue',
|
|
495
|
+
'RedisStringsHandler.set() will set the following sharedTagsMap',
|
|
496
|
+
key,
|
|
497
|
+
ctx.tags as string[],
|
|
498
|
+
);
|
|
499
|
+
|
|
297
500
|
await Promise.all([setOperation, setTagsOperation]);
|
|
298
501
|
}
|
|
299
|
-
|
|
502
|
+
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
public async revalidateTag(tagOrTags: string | string[], ...rest: any[]) {
|
|
505
|
+
debug(
|
|
506
|
+
'red',
|
|
507
|
+
'RedisStringsHandler.revalidateTag() called with',
|
|
508
|
+
tagOrTags,
|
|
509
|
+
rest,
|
|
510
|
+
);
|
|
300
511
|
const tags = new Set([tagOrTags || []].flat());
|
|
301
512
|
await this.assertClientIsReady();
|
|
302
513
|
|
|
303
|
-
//
|
|
514
|
+
// find all keys that are related to this tag
|
|
515
|
+
const keysToDelete: Set<string> = new Set();
|
|
516
|
+
|
|
304
517
|
for (const tag of tags) {
|
|
305
|
-
|
|
518
|
+
// INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route)
|
|
519
|
+
//
|
|
520
|
+
// Invalidation logic for fetch requests that are related to a invalidated page.
|
|
521
|
+
// revalidateTag is called for the page tag (_N_T_...) and the fetch request needs to be invalidated as well
|
|
522
|
+
// unfortunately this is not possible since the revalidateTag is not called with any data that would allow us to find the cache entry of the fetch request
|
|
523
|
+
// in case of a fetch request get method call, the get method of the cache handler is called with some information about the pages/routes the fetch request is inside
|
|
524
|
+
// therefore we only mark the page/route as stale here (with help of the revalidatedTagsMap)
|
|
525
|
+
// and delete the cache entry of the fetch request on the next request to the get function
|
|
526
|
+
if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
|
|
306
527
|
const now = Date.now();
|
|
528
|
+
debug(
|
|
529
|
+
'red',
|
|
530
|
+
'RedisStringsHandler.revalidateTag() set revalidation time for tag',
|
|
531
|
+
tag,
|
|
532
|
+
'to',
|
|
533
|
+
now,
|
|
534
|
+
);
|
|
307
535
|
await this.revalidatedTagsMap.set(tag, now);
|
|
308
536
|
}
|
|
309
537
|
}
|
|
310
538
|
|
|
311
|
-
|
|
312
|
-
|
|
539
|
+
// Scan the whole sharedTagsMap for keys that are dependent on any of the revalidated tags
|
|
313
540
|
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
|
|
314
541
|
if (sharedTags.some((tag) => tags.has(tag))) {
|
|
315
|
-
keysToDelete.
|
|
542
|
+
keysToDelete.add(key);
|
|
316
543
|
}
|
|
317
544
|
}
|
|
318
545
|
|
|
319
|
-
|
|
546
|
+
debug(
|
|
547
|
+
'red',
|
|
548
|
+
'RedisStringsHandler.revalidateTag() found',
|
|
549
|
+
keysToDelete,
|
|
550
|
+
'keys to delete',
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// exit early if no keys are related to this tag
|
|
554
|
+
if (keysToDelete.size === 0) {
|
|
320
555
|
return;
|
|
321
556
|
}
|
|
322
557
|
|
|
323
|
-
|
|
324
|
-
|
|
558
|
+
// prepare deletion of all keys in redis that are related to this tag
|
|
559
|
+
const redisKeys = Array.from(keysToDelete);
|
|
560
|
+
const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
|
|
325
561
|
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
|
|
326
|
-
|
|
327
562
|
const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
|
|
328
563
|
|
|
329
|
-
// delete entries from in-memory deduplication cache
|
|
564
|
+
// also delete entries from in-memory deduplication cache if they get revalidated
|
|
330
565
|
if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
|
|
331
566
|
for (const key of keysToDelete) {
|
|
332
567
|
this.inMemoryDeduplicationCache.delete(key);
|
|
333
568
|
}
|
|
334
569
|
}
|
|
335
570
|
|
|
336
|
-
|
|
571
|
+
// prepare deletion of entries from shared tags map if they get revalidated so that the map will not grow indefinitely
|
|
572
|
+
const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
|
|
337
573
|
|
|
574
|
+
// execute keys and tag maps deletion
|
|
338
575
|
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
|
|
576
|
+
debug(
|
|
577
|
+
'red',
|
|
578
|
+
'RedisStringsHandler.revalidateTag() finished delete operations',
|
|
579
|
+
);
|
|
339
580
|
}
|
|
340
581
|
}
|