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