@trieb.work/nextjs-turbo-redis-cache 1.7.0 → 1.8.0-beta.1

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.
@@ -60,6 +60,10 @@ export type CreateRedisStringsHandlerOptions = {
60
60
  * @default Production: staleAge * 2, Other: staleAge * 1.2
61
61
  */
62
62
  estimateExpireAge?: (staleAge: number) => number;
63
+ /** Kill container on Redis client error if error threshold is reached
64
+ * @default 0 (0 means no error threshold)
65
+ */
66
+ killContainerOnErrorThreshold?: number;
63
67
  /** Additional Redis client socket options
64
68
  * @example { tls: true, rejectUnauthorized: false }
65
69
  */
@@ -84,6 +88,7 @@ export function getTimeoutRedisCommandOptions(
84
88
  return commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
85
89
  }
86
90
 
91
+ let killContainerOnErrorCount: number = 0;
87
92
  export default class RedisStringsHandler {
88
93
  private client: Client;
89
94
  private sharedTagsMap: SyncedMap<string[]>;
@@ -103,6 +108,7 @@ export default class RedisStringsHandler {
103
108
  private inMemoryCachingTime: number;
104
109
  private defaultStaleAge: number;
105
110
  private estimateExpireAge: (staleAge: number) => number;
111
+ private killContainerOnErrorThreshold: number;
106
112
 
107
113
  constructor({
108
114
  redisUrl = process.env.REDIS_URL
@@ -113,7 +119,9 @@ export default class RedisStringsHandler {
113
119
  database = process.env.VERCEL_ENV === 'production' ? 0 : 1,
114
120
  keyPrefix = process.env.VERCEL_URL || 'UNDEFINED_URL_',
115
121
  sharedTagsKey = '__sharedTags__',
116
- timeoutMs = 5_000,
122
+ timeoutMs = process.env.REDIS_COMMAND_TIMEOUT_MS
123
+ ? (Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT_MS) ?? 5_000)
124
+ : 5_000,
117
125
  revalidateTagQuerySize = 250,
118
126
  avgResyncIntervalMs = 60 * 60 * 1_000,
119
127
  redisGetDeduplication = true,
@@ -121,111 +129,189 @@ export default class RedisStringsHandler {
121
129
  defaultStaleAge = 60 * 60 * 24 * 14,
122
130
  estimateExpireAge = (staleAge) =>
123
131
  process.env.VERCEL_ENV === 'production' ? staleAge * 2 : staleAge * 1.2,
132
+ killContainerOnErrorThreshold = process.env
133
+ .KILL_CONTAINER_ON_ERROR_THRESHOLD
134
+ ? (Number.parseInt(process.env.KILL_CONTAINER_ON_ERROR_THRESHOLD) ?? 0)
135
+ : 0,
124
136
  socketOptions,
125
137
  clientOptions,
126
138
  }: CreateRedisStringsHandlerOptions) {
127
- this.keyPrefix = keyPrefix;
128
- this.timeoutMs = timeoutMs;
129
- this.redisGetDeduplication = redisGetDeduplication;
130
- this.inMemoryCachingTime = inMemoryCachingTime;
131
- this.defaultStaleAge = defaultStaleAge;
132
- this.estimateExpireAge = estimateExpireAge;
133
-
134
139
  try {
135
- // Create Redis client with properly typed configuration
136
- this.client = createClient({
137
- url: redisUrl,
138
- ...(database !== 0 ? { database } : {}),
139
- ...(socketOptions ? { socket: socketOptions } : {}),
140
- ...(clientOptions || {}),
140
+ this.keyPrefix = keyPrefix;
141
+ this.timeoutMs = timeoutMs;
142
+ this.redisGetDeduplication = redisGetDeduplication;
143
+ this.inMemoryCachingTime = inMemoryCachingTime;
144
+ this.defaultStaleAge = defaultStaleAge;
145
+ this.estimateExpireAge = estimateExpireAge;
146
+ this.killContainerOnErrorThreshold = killContainerOnErrorThreshold;
147
+
148
+ try {
149
+ // Create Redis client with properly typed configuration
150
+ this.client = createClient({
151
+ url: redisUrl,
152
+ pingInterval: 5000, // Useful with Redis deployments that do not use TCP Keep-Alive. Restarts the connection if it is idle for too long.
153
+ ...(database !== 0 ? { database } : {}),
154
+ ...(socketOptions
155
+ ? { socket: { connectTimeout: timeoutMs, ...socketOptions } }
156
+ : { connectTimeout: timeoutMs }),
157
+ ...(clientOptions || {}),
158
+ });
159
+
160
+ this.client.on('error', (error) => {
161
+ console.error(
162
+ 'Redis client error',
163
+ error,
164
+ killContainerOnErrorCount++,
165
+ );
166
+ setTimeout(
167
+ () =>
168
+ this.client.connect().catch((error) => {
169
+ console.error(
170
+ 'Failed to reconnect Redis client after connection loss:',
171
+ error,
172
+ );
173
+ }),
174
+ 1000,
175
+ );
176
+ if (
177
+ this.killContainerOnErrorThreshold > 0 &&
178
+ killContainerOnErrorCount >= this.killContainerOnErrorThreshold
179
+ ) {
180
+ console.error(
181
+ 'Redis client error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
182
+ error,
183
+ killContainerOnErrorCount++,
184
+ );
185
+ this.client.disconnect();
186
+ this.client.quit();
187
+ setTimeout(() => {
188
+ process.exit(1);
189
+ }, 500);
190
+ }
191
+ });
192
+
193
+ this.client
194
+ .connect()
195
+ .then(() => {
196
+ console.info('Redis client connected.');
197
+ })
198
+ .catch(() => {
199
+ this.client.connect().catch((error) => {
200
+ console.error('Failed to connect Redis client:', error);
201
+ this.client.disconnect();
202
+ throw error;
203
+ });
204
+ });
205
+ } catch (error: unknown) {
206
+ console.error('Failed to initialize Redis client');
207
+ throw error;
208
+ }
209
+
210
+ const filterKeys = (key: string): boolean =>
211
+ key !== REVALIDATED_TAGS_KEY && key !== sharedTagsKey;
212
+
213
+ this.sharedTagsMap = new SyncedMap<string[]>({
214
+ client: this.client,
215
+ keyPrefix,
216
+ redisKey: sharedTagsKey,
217
+ database,
218
+ timeoutMs,
219
+ querySize: revalidateTagQuerySize,
220
+ filterKeys,
221
+ resyncIntervalMs:
222
+ avgResyncIntervalMs -
223
+ avgResyncIntervalMs / 10 +
224
+ Math.random() * (avgResyncIntervalMs / 10),
141
225
  });
142
226
 
143
- this.client.on('error', (error) => {
144
- console.error('Redis client error', error);
227
+ this.revalidatedTagsMap = new SyncedMap<number>({
228
+ client: this.client,
229
+ keyPrefix,
230
+ redisKey: REVALIDATED_TAGS_KEY,
231
+ database,
232
+ timeoutMs,
233
+ querySize: revalidateTagQuerySize,
234
+ filterKeys,
235
+ resyncIntervalMs:
236
+ avgResyncIntervalMs +
237
+ avgResyncIntervalMs / 10 +
238
+ Math.random() * (avgResyncIntervalMs / 10),
145
239
  });
146
240
 
147
- this.client
148
- .connect()
149
- .then(() => {
150
- console.info('Redis client connected.');
151
- })
152
- .catch(() => {
153
- this.client.connect().catch((error) => {
154
- console.error('Failed to connect Redis client:', error);
155
- this.client.disconnect();
156
- throw error;
157
- });
158
- });
159
- } catch (error: unknown) {
160
- console.error('Failed to initialize Redis client');
241
+ this.inMemoryDeduplicationCache = new SyncedMap({
242
+ client: this.client,
243
+ keyPrefix,
244
+ redisKey: 'inMemoryDeduplicationCache',
245
+ database,
246
+ timeoutMs,
247
+ querySize: revalidateTagQuerySize,
248
+ filterKeys,
249
+ customizedSync: {
250
+ withoutRedisHashmap: true,
251
+ withoutSetSync: true,
252
+ },
253
+ });
254
+
255
+ const redisGet: Client['get'] = this.client.get.bind(this.client);
256
+ this.redisDeduplicationHandler = new DeduplicatedRequestHandler(
257
+ redisGet,
258
+ inMemoryCachingTime,
259
+ this.inMemoryDeduplicationCache,
260
+ );
261
+ this.redisGet = redisGet;
262
+ this.deduplicatedRedisGet =
263
+ this.redisDeduplicationHandler.deduplicatedFunction;
264
+ } catch (error) {
265
+ console.error(
266
+ 'RedisStringsHandler constructor error',
267
+ error,
268
+ killContainerOnErrorCount++,
269
+ );
270
+ if (
271
+ killContainerOnErrorThreshold > 0 &&
272
+ killContainerOnErrorCount >= killContainerOnErrorThreshold
273
+ ) {
274
+ console.error(
275
+ 'RedisStringsHandler constructor error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
276
+ error,
277
+ killContainerOnErrorCount++,
278
+ );
279
+ process.exit(1);
280
+ }
161
281
  throw error;
162
282
  }
163
-
164
- const filterKeys = (key: string): boolean =>
165
- key !== REVALIDATED_TAGS_KEY && key !== sharedTagsKey;
166
-
167
- this.sharedTagsMap = new SyncedMap<string[]>({
168
- client: this.client,
169
- keyPrefix,
170
- redisKey: sharedTagsKey,
171
- database,
172
- timeoutMs,
173
- querySize: revalidateTagQuerySize,
174
- filterKeys,
175
- resyncIntervalMs:
176
- avgResyncIntervalMs -
177
- avgResyncIntervalMs / 10 +
178
- Math.random() * (avgResyncIntervalMs / 10),
179
- });
180
-
181
- this.revalidatedTagsMap = new SyncedMap<number>({
182
- client: this.client,
183
- keyPrefix,
184
- redisKey: REVALIDATED_TAGS_KEY,
185
- database,
186
- timeoutMs,
187
- querySize: revalidateTagQuerySize,
188
- filterKeys,
189
- resyncIntervalMs:
190
- avgResyncIntervalMs +
191
- avgResyncIntervalMs / 10 +
192
- Math.random() * (avgResyncIntervalMs / 10),
193
- });
194
-
195
- this.inMemoryDeduplicationCache = new SyncedMap({
196
- client: this.client,
197
- keyPrefix,
198
- redisKey: 'inMemoryDeduplicationCache',
199
- database,
200
- timeoutMs,
201
- querySize: revalidateTagQuerySize,
202
- filterKeys,
203
- customizedSync: {
204
- withoutRedisHashmap: true,
205
- withoutSetSync: true,
206
- },
207
- });
208
-
209
- const redisGet: Client['get'] = this.client.get.bind(this.client);
210
- this.redisDeduplicationHandler = new DeduplicatedRequestHandler(
211
- redisGet,
212
- inMemoryCachingTime,
213
- this.inMemoryDeduplicationCache,
214
- );
215
- this.redisGet = redisGet;
216
- this.deduplicatedRedisGet =
217
- this.redisDeduplicationHandler.deduplicatedFunction;
218
283
  }
219
284
 
220
285
  resetRequestCache(): void {}
221
286
 
287
+ private clientReadyCalls = 0;
288
+
222
289
  private async assertClientIsReady(): Promise<void> {
223
- await Promise.all([
224
- this.sharedTagsMap.waitUntilReady(),
225
- this.revalidatedTagsMap.waitUntilReady(),
290
+ if (this.clientReadyCalls > 10) {
291
+ throw new Error(
292
+ 'assertClientIsReady called more than 10 times without being ready.',
293
+ );
294
+ }
295
+ await Promise.race([
296
+ Promise.all([
297
+ this.sharedTagsMap.waitUntilReady(),
298
+ this.revalidatedTagsMap.waitUntilReady(),
299
+ ]),
300
+ new Promise((_, reject) =>
301
+ setTimeout(() => {
302
+ reject(
303
+ new Error(
304
+ 'assertClientIsReady: Timeout waiting for Redis maps to be ready',
305
+ ),
306
+ );
307
+ }, this.timeoutMs * 5),
308
+ ),
226
309
  ]);
310
+ this.clientReadyCalls = 0;
227
311
  if (!this.client.isReady) {
228
- throw new Error('Redis client is not ready yet or connection is lost.');
312
+ throw new Error(
313
+ 'assertClientIsReady: Redis client is not ready yet or connection is lost.',
314
+ );
229
315
  }
230
316
  }
231
317
 
@@ -247,139 +333,170 @@ export default class RedisStringsHandler {
247
333
  isFallback: boolean;
248
334
  },
249
335
  ): Promise<CacheEntry | null> {
250
- if (
251
- ctx.kind !== 'APP_ROUTE' &&
252
- ctx.kind !== 'APP_PAGE' &&
253
- ctx.kind !== 'FETCH'
254
- ) {
255
- console.warn(
256
- 'RedisStringsHandler.get() called with',
257
- key,
258
- ctx,
259
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
260
- (ctx as { kind: string })?.kind,
261
- );
262
- }
263
-
264
- debug('green', 'RedisStringsHandler.get() called with', key, ctx);
265
- await this.assertClientIsReady();
266
-
267
- const clientGet = this.redisGetDeduplication
268
- ? this.deduplicatedRedisGet(key)
269
- : this.redisGet;
270
- const serializedCacheEntry = await clientGet(
271
- getTimeoutRedisCommandOptions(this.timeoutMs),
272
- this.keyPrefix + key,
273
- );
274
-
275
- debug(
276
- 'green',
277
- 'RedisStringsHandler.get() finished with result (serializedCacheEntry)',
278
- serializedCacheEntry?.substring(0, 200),
279
- );
336
+ try {
337
+ if (
338
+ ctx.kind !== 'APP_ROUTE' &&
339
+ ctx.kind !== 'APP_PAGE' &&
340
+ ctx.kind !== 'FETCH'
341
+ ) {
342
+ console.warn(
343
+ 'RedisStringsHandler.get() called with',
344
+ key,
345
+ ctx,
346
+ ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
347
+ (ctx as { kind: string })?.kind,
348
+ );
349
+ }
280
350
 
281
- if (!serializedCacheEntry) {
282
- return null;
283
- }
351
+ debug('green', 'RedisStringsHandler.get() called with', key, ctx);
352
+ await this.assertClientIsReady();
284
353
 
285
- const cacheEntry: CacheEntry | null = JSON.parse(
286
- serializedCacheEntry,
287
- bufferReviver,
288
- );
354
+ const clientGet = this.redisGetDeduplication
355
+ ? this.deduplicatedRedisGet(key)
356
+ : this.redisGet;
357
+ const serializedCacheEntry = await clientGet(
358
+ getTimeoutRedisCommandOptions(this.timeoutMs),
359
+ this.keyPrefix + key,
360
+ );
289
361
 
290
- debug(
291
- 'green',
292
- 'RedisStringsHandler.get() finished with result (cacheEntry)',
293
- JSON.stringify(cacheEntry).substring(0, 200),
294
- );
362
+ debug(
363
+ 'green',
364
+ 'RedisStringsHandler.get() finished with result (serializedCacheEntry)',
365
+ serializedCacheEntry?.substring(0, 200),
366
+ );
295
367
 
296
- if (!cacheEntry) {
297
- return null;
298
- }
368
+ if (!serializedCacheEntry) {
369
+ return null;
370
+ }
299
371
 
300
- if (!cacheEntry?.tags) {
301
- console.warn(
302
- 'RedisStringsHandler.get() called with',
303
- key,
304
- ctx,
305
- 'cacheEntry is mall formed (missing tags)',
306
- );
307
- }
308
- if (!cacheEntry?.value) {
309
- console.warn(
310
- 'RedisStringsHandler.get() called with',
311
- key,
312
- ctx,
313
- 'cacheEntry is mall formed (missing value)',
372
+ const cacheEntry: CacheEntry | null = JSON.parse(
373
+ serializedCacheEntry,
374
+ bufferReviver,
314
375
  );
315
- }
316
- if (!cacheEntry?.lastModified) {
317
- console.warn(
318
- 'RedisStringsHandler.get() called with',
319
- key,
320
- ctx,
321
- 'cacheEntry is mall formed (missing lastModified)',
376
+
377
+ debug(
378
+ 'green',
379
+ 'RedisStringsHandler.get() finished with result (cacheEntry)',
380
+ JSON.stringify(cacheEntry).substring(0, 200),
322
381
  );
323
- }
324
382
 
325
- if (ctx.kind === 'FETCH') {
326
- const combinedTags = new Set([
327
- ...(ctx?.softTags || []),
328
- ...(ctx?.tags || []),
329
- ]);
383
+ if (!cacheEntry) {
384
+ return null;
385
+ }
330
386
 
331
- if (combinedTags.size === 0) {
332
- return cacheEntry;
387
+ if (!cacheEntry?.tags) {
388
+ console.warn(
389
+ 'RedisStringsHandler.get() called with',
390
+ key,
391
+ ctx,
392
+ 'cacheEntry is mall formed (missing tags)',
393
+ );
394
+ }
395
+ if (!cacheEntry?.value) {
396
+ console.warn(
397
+ 'RedisStringsHandler.get() called with',
398
+ key,
399
+ ctx,
400
+ 'cacheEntry is mall formed (missing value)',
401
+ );
402
+ }
403
+ if (!cacheEntry?.lastModified) {
404
+ console.warn(
405
+ 'RedisStringsHandler.get() called with',
406
+ key,
407
+ ctx,
408
+ 'cacheEntry is mall formed (missing lastModified)',
409
+ );
333
410
  }
334
411
 
335
- // INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route). See revalidateTag() for more information
336
- //
337
- // 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)
338
- // have been revalidated since the entry was last modified. If any tag was revalidated more recently than the entry's
339
- // lastModified timestamp, then the cached content is considered stale (therefore return null) and should be removed.
340
- for (const tag of combinedTags) {
341
- // Get the last revalidation time for this tag from our revalidatedTagsMap
342
- const revalidationTime = this.revalidatedTagsMap.get(tag);
343
-
344
- // If we have a revalidation time for this tag and it's more recent than when
345
- // this cache entry was last modified, the entry is stale
346
- if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
347
- const redisKey = this.keyPrefix + key;
348
-
349
- // We don't await this cleanup since it can happen asynchronously in the background.
350
- // The cache entry is already considered invalid at this point.
351
- this.client
352
- .unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
353
- .catch((err) => {
354
- // If the first unlink fails, only log the error
355
- // Never implement a retry here as the cache entry will be updated directly after this get request
356
- console.error(
357
- 'Error occurred while unlinking stale data. Error was:',
358
- err,
359
- );
360
- })
361
- .finally(async () => {
362
- // Clean up our tag tracking maps after the Redis key is removed
363
- await this.sharedTagsMap.delete(key);
364
- await this.revalidatedTagsMap.delete(tag);
365
- });
412
+ if (ctx.kind === 'FETCH') {
413
+ const combinedTags = new Set([
414
+ ...(ctx?.softTags || []),
415
+ ...(ctx?.tags || []),
416
+ ]);
366
417
 
367
- debug(
368
- 'green',
369
- 'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
370
- tag,
371
- redisKey,
372
- revalidationTime,
373
- cacheEntry,
374
- );
418
+ if (combinedTags.size === 0) {
419
+ return cacheEntry;
420
+ }
375
421
 
376
- // Return null to indicate no valid cache entry was found
377
- return null;
422
+ // INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route). See revalidateTag() for more information
423
+ //
424
+ // 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)
425
+ // have been revalidated since the entry was last modified. If any tag was revalidated more recently than the entry's
426
+ // lastModified timestamp, then the cached content is considered stale (therefore return null) and should be removed.
427
+ for (const tag of combinedTags) {
428
+ // Get the last revalidation time for this tag from our revalidatedTagsMap
429
+ const revalidationTime = this.revalidatedTagsMap.get(tag);
430
+
431
+ // If we have a revalidation time for this tag and it's more recent than when
432
+ // this cache entry was last modified, the entry is stale
433
+ if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
434
+ const redisKey = this.keyPrefix + key;
435
+
436
+ // We don't await this cleanup since it can happen asynchronously in the background.
437
+ // The cache entry is already considered invalid at this point.
438
+ this.client
439
+ .unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
440
+ .catch((err) => {
441
+ // If the first unlink fails, only log the error
442
+ // Never implement a retry here as the cache entry will be updated directly after this get request
443
+ console.error(
444
+ 'Error occurred while unlinking stale data. Error was:',
445
+ err,
446
+ );
447
+ })
448
+ .finally(async () => {
449
+ // Clean up our tag tracking maps after the Redis key is removed
450
+ await this.sharedTagsMap.delete(key);
451
+ await this.revalidatedTagsMap.delete(tag);
452
+ });
453
+
454
+ debug(
455
+ 'green',
456
+ 'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
457
+ tag,
458
+ redisKey,
459
+ revalidationTime,
460
+ cacheEntry,
461
+ );
462
+
463
+ // Return null to indicate no valid cache entry was found
464
+ return null;
465
+ }
378
466
  }
379
467
  }
380
- }
381
468
 
382
- return cacheEntry;
469
+ return cacheEntry;
470
+ } catch (error) {
471
+ // This catch block is necessary to handle any errors that may occur during:
472
+ // 1. Redis operations (get, unlink)
473
+ // 2. JSON parsing of cache entries
474
+ // 3. Tag validation and cleanup
475
+ // If any error occurs, we return null to indicate no valid cache entry was found,
476
+ // allowing the application to regenerate the content rather than crash
477
+ console.error(
478
+ 'RedisStringsHandler.get() Error occurred while getting cache entry. Returning null so site can continue to serve content while cache is disabled. The original error was:',
479
+ error,
480
+ killContainerOnErrorCount++,
481
+ );
482
+
483
+ if (
484
+ this.killContainerOnErrorThreshold > 0 &&
485
+ killContainerOnErrorCount >= this.killContainerOnErrorThreshold
486
+ ) {
487
+ console.error(
488
+ 'RedisStringsHandler get() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
489
+ error,
490
+ killContainerOnErrorCount,
491
+ );
492
+ this.client.disconnect();
493
+ this.client.quit();
494
+ setTimeout(() => {
495
+ process.exit(1);
496
+ }, 500);
497
+ }
498
+ return null;
499
+ }
383
500
  }
384
501
  public async set(
385
502
  key: string,
@@ -425,178 +542,231 @@ export default class RedisStringsHandler {
425
542
  cacheControl?: { revalidate: 5; expire: undefined }; // Version 15.0.3
426
543
  },
427
544
  ) {
428
- if (
429
- data.kind !== 'APP_ROUTE' &&
430
- data.kind !== 'APP_PAGE' &&
431
- data.kind !== 'FETCH'
432
- ) {
433
- console.warn(
434
- 'RedisStringsHandler.set() called with',
545
+ try {
546
+ if (
547
+ data.kind !== 'APP_ROUTE' &&
548
+ data.kind !== 'APP_PAGE' &&
549
+ data.kind !== 'FETCH'
550
+ ) {
551
+ console.warn(
552
+ 'RedisStringsHandler.set() called with',
553
+ key,
554
+ ctx,
555
+ data,
556
+ ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
557
+ (data as { kind: string })?.kind,
558
+ );
559
+ }
560
+
561
+ await this.assertClientIsReady();
562
+
563
+ if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
564
+ const tags = data.headers['x-next-cache-tags']?.split(',');
565
+ ctx.tags = [...(ctx.tags || []), ...(tags || [])];
566
+ }
567
+
568
+ // Constructing and serializing the value for storing it in redis
569
+ const cacheEntry: CacheEntry = {
570
+ lastModified: Date.now(),
571
+ tags: ctx?.tags || [],
572
+ value: data,
573
+ };
574
+ const serializedCacheEntry = JSON.stringify(cacheEntry, bufferReplacer);
575
+
576
+ // pre seed data into deduplicated get client. This will reduce redis load by not requesting
577
+ // the same value from redis which was just set.
578
+ if (this.redisGetDeduplication) {
579
+ this.redisDeduplicationHandler.seedRequestReturn(
580
+ key,
581
+ serializedCacheEntry,
582
+ );
583
+ }
584
+
585
+ // TODO: implement expiration based on cacheControl.expire argument, -> probably relevant for cacheLife and "use cache" etc.: https://nextjs.org/docs/app/api-reference/functions/cacheLife
586
+ // Constructing the expire time for the cache entry
587
+ const revalidate =
588
+ // For fetch requests in newest versions, the revalidate context property is never used, and instead the revalidate property of the passed-in data is used
589
+ (data.kind === 'FETCH' && data.revalidate) ||
590
+ ctx.revalidate ||
591
+ ctx.cacheControl?.revalidate ||
592
+ (data as { revalidate?: number | false })?.revalidate;
593
+ const expireAt =
594
+ revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
595
+ ? this.estimateExpireAge(revalidate)
596
+ : this.estimateExpireAge(this.defaultStaleAge);
597
+
598
+ // Setting the cache entry in redis
599
+ const options = getTimeoutRedisCommandOptions(this.timeoutMs);
600
+ const setOperation: Promise<string | null> = this.client.set(
601
+ options,
602
+ this.keyPrefix + key,
603
+ serializedCacheEntry,
604
+ {
605
+ EX: expireAt,
606
+ },
607
+ );
608
+
609
+ debug(
610
+ 'blue',
611
+ 'RedisStringsHandler.set() will set the following serializedCacheEntry',
612
+ this.keyPrefix,
435
613
  key,
436
- ctx,
437
614
  data,
438
- ' this cache handler is only designed and tested for kind APP_ROUTE and APP_PAGE and not for kind ',
439
- (data as { kind: string })?.kind,
615
+ ctx,
616
+ serializedCacheEntry?.substring(0, 200),
617
+ expireAt,
440
618
  );
441
- }
442
619
 
443
- await this.assertClientIsReady();
444
-
445
- if (data.kind === 'APP_PAGE' || data.kind === 'APP_ROUTE') {
446
- const tags = data.headers['x-next-cache-tags']?.split(',');
447
- ctx.tags = [...(ctx.tags || []), ...(tags || [])];
448
- }
620
+ // Setting the tags for the cache entry in the sharedTagsMap (locally stored hashmap synced via redis)
621
+ let setTagsOperation: Promise<void> | undefined;
622
+ if (ctx.tags && ctx.tags.length > 0) {
623
+ const currentTags = this.sharedTagsMap.get(key);
624
+ const currentIsSameAsNew =
625
+ currentTags?.length === ctx.tags.length &&
626
+ currentTags.every((v) => ctx.tags!.includes(v)) &&
627
+ ctx.tags.every((v) => currentTags.includes(v));
628
+
629
+ if (!currentIsSameAsNew) {
630
+ setTagsOperation = this.sharedTagsMap.set(
631
+ key,
632
+ structuredClone(ctx.tags) as string[],
633
+ );
634
+ }
635
+ }
449
636
 
450
- // Constructing and serializing the value for storing it in redis
451
- const cacheEntry: CacheEntry = {
452
- lastModified: Date.now(),
453
- tags: ctx?.tags || [],
454
- value: data,
455
- };
456
- const serializedCacheEntry = JSON.stringify(cacheEntry, bufferReplacer);
457
-
458
- // pre seed data into deduplicated get client. This will reduce redis load by not requesting
459
- // the same value from redis which was just set.
460
- if (this.redisGetDeduplication) {
461
- this.redisDeduplicationHandler.seedRequestReturn(
637
+ debug(
638
+ 'blue',
639
+ 'RedisStringsHandler.set() will set the following sharedTagsMap',
462
640
  key,
463
- serializedCacheEntry,
641
+ ctx.tags as string[],
464
642
  );
465
- }
466
643
 
467
- // TODO: implement expiration based on cacheControl.expire argument, -> probably relevant for cacheLife and "use cache" etc.: https://nextjs.org/docs/app/api-reference/functions/cacheLife
468
- // Constructing the expire time for the cache entry
469
- const revalidate = ctx.revalidate || ctx.cacheControl?.revalidate;
470
- const expireAt =
471
- revalidate && Number.isSafeInteger(revalidate) && revalidate > 0
472
- ? this.estimateExpireAge(revalidate)
473
- : this.estimateExpireAge(this.defaultStaleAge);
474
-
475
- // Setting the cache entry in redis
476
- const options = getTimeoutRedisCommandOptions(this.timeoutMs);
477
- const setOperation: Promise<string | null> = this.client.set(
478
- options,
479
- this.keyPrefix + key,
480
- serializedCacheEntry,
481
- {
482
- EX: expireAt,
483
- },
484
- );
485
-
486
- debug(
487
- 'blue',
488
- 'RedisStringsHandler.set() will set the following serializedCacheEntry',
489
- this.keyPrefix,
490
- key,
491
- data,
492
- ctx,
493
- serializedCacheEntry?.substring(0, 200),
494
- expireAt,
495
- );
496
-
497
- // Setting the tags for the cache entry in the sharedTagsMap (locally stored hashmap synced via redis)
498
- let setTagsOperation: Promise<void> | undefined;
499
- if (ctx.tags && ctx.tags.length > 0) {
500
- const currentTags = this.sharedTagsMap.get(key);
501
- const currentIsSameAsNew =
502
- currentTags?.length === ctx.tags.length &&
503
- currentTags.every((v) => ctx.tags!.includes(v)) &&
504
- ctx.tags.every((v) => currentTags.includes(v));
505
-
506
- if (!currentIsSameAsNew) {
507
- setTagsOperation = this.sharedTagsMap.set(
508
- key,
509
- structuredClone(ctx.tags) as string[],
644
+ await Promise.all([setOperation, setTagsOperation]);
645
+ } catch (error) {
646
+ console.error(
647
+ 'RedisStringsHandler.set() Error occurred while setting cache entry. The original error was:',
648
+ error,
649
+ killContainerOnErrorCount++,
650
+ );
651
+ if (
652
+ this.killContainerOnErrorThreshold > 0 &&
653
+ killContainerOnErrorCount >= this.killContainerOnErrorThreshold
654
+ ) {
655
+ console.error(
656
+ 'RedisStringsHandler set() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
657
+ error,
658
+ killContainerOnErrorCount,
510
659
  );
660
+ this.client.disconnect();
661
+ this.client.quit();
662
+ setTimeout(() => {
663
+ process.exit(1);
664
+ }, 500);
511
665
  }
666
+ throw error;
512
667
  }
513
-
514
- debug(
515
- 'blue',
516
- 'RedisStringsHandler.set() will set the following sharedTagsMap',
517
- key,
518
- ctx.tags as string[],
519
- );
520
-
521
- await Promise.all([setOperation, setTagsOperation]);
522
668
  }
523
669
 
524
670
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
525
671
  public async revalidateTag(tagOrTags: string | string[], ...rest: any[]) {
526
- debug(
527
- 'red',
528
- 'RedisStringsHandler.revalidateTag() called with',
529
- tagOrTags,
530
- rest,
531
- );
532
- const tags = new Set([tagOrTags || []].flat());
533
- await this.assertClientIsReady();
534
-
535
- // find all keys that are related to this tag
536
- const keysToDelete: Set<string> = new Set();
537
-
538
- for (const tag of tags) {
539
- // INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route)
540
- //
541
- // Invalidation logic for fetch requests that are related to a invalidated page.
542
- // revalidateTag is called for the page tag (_N_T_...) and the fetch request needs to be invalidated as well
543
- // 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
544
- // 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
545
- // therefore we only mark the page/route as stale here (with help of the revalidatedTagsMap)
546
- // and delete the cache entry of the fetch request on the next request to the get function
547
- if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
548
- const now = Date.now();
549
- debug(
550
- 'red',
551
- 'RedisStringsHandler.revalidateTag() set revalidation time for tag',
552
- tag,
553
- 'to',
554
- now,
555
- );
556
- await this.revalidatedTagsMap.set(tag, now);
672
+ try {
673
+ debug(
674
+ 'red',
675
+ 'RedisStringsHandler.revalidateTag() called with',
676
+ tagOrTags,
677
+ rest,
678
+ );
679
+ const tags = new Set([tagOrTags || []].flat());
680
+ await this.assertClientIsReady();
681
+
682
+ // find all keys that are related to this tag
683
+ const keysToDelete: Set<string> = new Set();
684
+
685
+ for (const tag of tags) {
686
+ // INFO: implicit tags (revalidate of nested fetch in api route/page on revalidatePath call of the page/api route)
687
+ //
688
+ // Invalidation logic for fetch requests that are related to a invalidated page.
689
+ // revalidateTag is called for the page tag (_N_T_...) and the fetch request needs to be invalidated as well
690
+ // 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
691
+ // 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
692
+ // therefore we only mark the page/route as stale here (with help of the revalidatedTagsMap)
693
+ // and delete the cache entry of the fetch request on the next request to the get function
694
+ if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
695
+ const now = Date.now();
696
+ debug(
697
+ 'red',
698
+ 'RedisStringsHandler.revalidateTag() set revalidation time for tag',
699
+ tag,
700
+ 'to',
701
+ now,
702
+ );
703
+ await this.revalidatedTagsMap.set(tag, now);
704
+ }
557
705
  }
558
- }
559
706
 
560
- // Scan the whole sharedTagsMap for keys that are dependent on any of the revalidated tags
561
- for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
562
- if (sharedTags.some((tag) => tags.has(tag))) {
563
- keysToDelete.add(key);
707
+ // Scan the whole sharedTagsMap for keys that are dependent on any of the revalidated tags
708
+ for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
709
+ if (sharedTags.some((tag) => tags.has(tag))) {
710
+ keysToDelete.add(key);
711
+ }
564
712
  }
565
- }
566
713
 
567
- debug(
568
- 'red',
569
- 'RedisStringsHandler.revalidateTag() found',
570
- keysToDelete,
571
- 'keys to delete',
572
- );
714
+ debug(
715
+ 'red',
716
+ 'RedisStringsHandler.revalidateTag() found',
717
+ keysToDelete,
718
+ 'keys to delete',
719
+ );
573
720
 
574
- // exit early if no keys are related to this tag
575
- if (keysToDelete.size === 0) {
576
- return;
577
- }
721
+ // exit early if no keys are related to this tag
722
+ if (keysToDelete.size === 0) {
723
+ return;
724
+ }
578
725
 
579
- // prepare deletion of all keys in redis that are related to this tag
580
- const redisKeys = Array.from(keysToDelete);
581
- const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
582
- const options = getTimeoutRedisCommandOptions(this.timeoutMs);
583
- const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
726
+ // prepare deletion of all keys in redis that are related to this tag
727
+ const redisKeys = Array.from(keysToDelete);
728
+ const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
729
+ const options = getTimeoutRedisCommandOptions(this.timeoutMs);
730
+ const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
584
731
 
585
- // also delete entries from in-memory deduplication cache if they get revalidated
586
- if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
587
- for (const key of keysToDelete) {
588
- this.inMemoryDeduplicationCache.delete(key);
732
+ // also delete entries from in-memory deduplication cache if they get revalidated
733
+ if (this.redisGetDeduplication && this.inMemoryCachingTime > 0) {
734
+ for (const key of keysToDelete) {
735
+ this.inMemoryDeduplicationCache.delete(key);
736
+ }
589
737
  }
590
- }
591
738
 
592
- // prepare deletion of entries from shared tags map if they get revalidated so that the map will not grow indefinitely
593
- const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
739
+ // prepare deletion of entries from shared tags map if they get revalidated so that the map will not grow indefinitely
740
+ const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
594
741
 
595
- // execute keys and tag maps deletion
596
- await Promise.all([deleteKeysOperation, deleteTagsOperation]);
597
- debug(
598
- 'red',
599
- 'RedisStringsHandler.revalidateTag() finished delete operations',
600
- );
742
+ // execute keys and tag maps deletion
743
+ await Promise.all([deleteKeysOperation, deleteTagsOperation]);
744
+ debug(
745
+ 'red',
746
+ 'RedisStringsHandler.revalidateTag() finished delete operations',
747
+ );
748
+ } catch (error) {
749
+ console.error(
750
+ 'RedisStringsHandler.revalidateTag() Error occurred while revalidating tags. The original error was:',
751
+ error,
752
+ killContainerOnErrorCount++,
753
+ );
754
+ if (
755
+ this.killContainerOnErrorThreshold > 0 &&
756
+ killContainerOnErrorCount >= this.killContainerOnErrorThreshold
757
+ ) {
758
+ console.error(
759
+ 'RedisStringsHandler revalidateTag() error threshold reached, disconnecting and exiting (please implement a restart process/container watchdog to handle this error)',
760
+ error,
761
+ killContainerOnErrorCount,
762
+ );
763
+ this.client.disconnect();
764
+ this.client.quit();
765
+ setTimeout(() => {
766
+ process.exit(1);
767
+ }, 500);
768
+ }
769
+ throw error;
770
+ }
601
771
  }
602
772
  }