Skip to content

Commit 9d0d1d2

Browse files
committed
feat: add new invalidation logic for fetch requests + new tests
1 parent 35885e3 commit 9d0d1d2

File tree

14 files changed

+398
-90
lines changed

14 files changed

+398
-90
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ By accepting and tolerating this eventual consistency, the performance of the ca
156156
1. Run `pnpm lint` to lint the project
157157
1. Run `pnpm format` to format the project
158158
1. Run `pnpm run-dev-server` to test and develop the caching handler using the nextjs integration test project
159+
1. If you make changes to the cache handler, you need to stop `pnpm run-dev-server` and run it again.
159160

160161
## Testing
161162

src/CachedHandler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import RedisStringsHandler, {
22
CreateRedisStringsHandlerOptions,
33
} from './RedisStringsHandler';
4-
import { debug } from './utils/debug';
4+
import { debugVerbose } from './utils/debug';
55

66
let cachedHandler: RedisStringsHandler;
77

@@ -15,19 +15,19 @@ export default class CachedHandler {
1515
get(
1616
...args: Parameters<RedisStringsHandler['get']>
1717
): ReturnType<RedisStringsHandler['get']> {
18-
debug('CachedHandler.get called with', args);
18+
debugVerbose('CachedHandler.get called with', args);
1919
return cachedHandler.get(...args);
2020
}
2121
set(
2222
...args: Parameters<RedisStringsHandler['set']>
2323
): ReturnType<RedisStringsHandler['set']> {
24-
debug('CachedHandler.set called with', args);
24+
debugVerbose('CachedHandler.set called with', args);
2525
return cachedHandler.set(...args);
2626
}
2727
revalidateTag(
2828
...args: Parameters<RedisStringsHandler['revalidateTag']>
2929
): ReturnType<RedisStringsHandler['revalidateTag']> {
30-
debug('CachedHandler.revalidateTag called with', args);
30+
debugVerbose('CachedHandler.revalidateTag called with', args);
3131
return cachedHandler.revalidateTag(...args);
3232
}
3333
resetRequestCache(

src/DeduplicatedRequestHandler.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { debug } from './utils/debug';
1+
import { debug, debugVerbose } from './utils/debug';
22
import { SyncedMap } from './SyncedMap';
33
export class DeduplicatedRequestHandler<
44
T extends (...args: [never, never]) => Promise<K>,
@@ -23,7 +23,7 @@ export class DeduplicatedRequestHandler<
2323
const resultPromise = new Promise<K>((res) => res(value));
2424
this.inMemoryDeduplicationCache.set(key, resultPromise);
2525

26-
debug(
26+
debugVerbose(
2727
'DeduplicatedRequestHandler.seedRequestReturn() seeded result ',
2828
key,
2929
(value as string).substring(0, 200),
@@ -36,28 +36,32 @@ export class DeduplicatedRequestHandler<
3636

3737
// Method to handle deduplicated requests
3838
deduplicatedFunction = (key: string): T => {
39-
debug('DeduplicatedRequestHandler.deduplicatedFunction() called with', key);
39+
debug(
40+
'cyan',
41+
'DeduplicatedRequestHandler.deduplicatedFunction() called with',
42+
key,
43+
);
4044
//eslint-disable-next-line @typescript-eslint/no-this-alias
4145
const self = this;
4246
const dedupedFn = async (...args: [never, never]): Promise<K> => {
4347
// If there's already a pending request with the same key, return it
44-
debug(
48+
debugVerbose(
4549
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn called with',
4650
key,
4751
);
4852
if (
4953
self.inMemoryDeduplicationCache &&
5054
self.inMemoryDeduplicationCache.has(key)
5155
) {
52-
debug(
56+
debugVerbose(
5357
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ',
5458
key,
5559
'found key in inMemoryDeduplicationCache',
5660
);
5761
const res = await self.inMemoryDeduplicationCache
5862
.get(key)!
5963
.then((v) => structuredClone(v));
60-
debug(
64+
debugVerbose(
6165
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ',
6266
key,
6367
'found key in inMemoryDeduplicationCache and served result from there',
@@ -70,7 +74,7 @@ export class DeduplicatedRequestHandler<
7074
const promise = self.fn(...args);
7175
self.inMemoryDeduplicationCache.set(key, promise);
7276

73-
debug(
77+
debugVerbose(
7478
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ',
7579
key,
7680
'did not found key in inMemoryDeduplicationCache. Setting it now and waiting for promise to resolve',
@@ -80,6 +84,7 @@ export class DeduplicatedRequestHandler<
8084
const ts = performance.now();
8185
const result = await promise;
8286
debug(
87+
'cyan',
8388
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ',
8489
key,
8590
'promise resolved (in ',
@@ -91,7 +96,7 @@ export class DeduplicatedRequestHandler<
9196
} finally {
9297
// Once the promise is resolved/rejected and caching timeout is over, remove it from the map
9398
setTimeout(() => {
94-
debug(
99+
debugVerbose(
95100
'DeduplicatedRequestHandler.deduplicatedFunction().dedupedFn ',
96101
key,
97102
'deleting key from inMemoryDeduplicationCache after ',

src/RedisStringsHandler.ts

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ export type CreateRedisStringsHandlerOptions = {
5656
estimateExpireAge?: (staleAge: number) => number;
5757
};
5858

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
5961
const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';
60-
const REVALIDATED_TAGS_KEY = '__revalidated_tags__';
6162

62-
function isImplicitTag(tag: string): boolean {
63-
return tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID);
64-
}
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__';
6566

6667
export function getTimeoutRedisCommandOptions(
6768
timeoutMs: number,
@@ -238,7 +239,7 @@ export default class RedisStringsHandler {
238239
);
239240
}
240241

241-
debug('RedisStringsHandler.get() called with', key, ctx);
242+
debug('green', 'RedisStringsHandler.get() called with', key, ctx);
242243
await this.assertClientIsReady();
243244

244245
const clientGet = this.redisGetDeduplication
@@ -250,6 +251,7 @@ export default class RedisStringsHandler {
250251
);
251252

252253
debug(
254+
'green',
253255
'RedisStringsHandler.get() finished with result (serializedCacheEntry)',
254256
serializedCacheEntry?.substring(0, 200),
255257
);
@@ -264,6 +266,7 @@ export default class RedisStringsHandler {
264266
);
265267

266268
debug(
269+
'green',
267270
'RedisStringsHandler.get() finished with result (cacheEntry)',
268271
JSON.stringify(cacheEntry).substring(0, 200),
269272
);
@@ -307,15 +310,24 @@ export default class RedisStringsHandler {
307310
return cacheEntry;
308311
}
309312

310-
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
313+
// This code checks if any of the cache tags associated with this entry have been revalidated
314+
// since the entry was last modified. If any tag was revalidated more recently than the entry's
315+
// lastModified timestamp, then the cached content is considered stale and should be removed.
311316
for (const tag of combinedTags) {
317+
// Get the last revalidation time for this tag from our revalidatedTagsMap
312318
const revalidationTime = this.revalidatedTagsMap.get(tag);
319+
320+
// If we have a revalidation time for this tag and it's more recent than when
321+
// this cache entry was last modified, the entry is stale
313322
if (revalidationTime && revalidationTime > cacheEntry.lastModified) {
314323
const redisKey = this.keyPrefix + key;
315-
// Do not await here as this can happen in the background while we can already serve the cacheValue
324+
325+
// We don't await this cleanup since it can happen asynchronously in the background.
326+
// The cache entry is already considered invalid at this point.
316327
this.client
317328
.unlink(getTimeoutRedisCommandOptions(this.timeoutMs), redisKey)
318329
.catch((err) => {
330+
// If the first unlink fails, log the error and try one more time
319331
console.error(
320332
'Error occurred while unlinking stale data. Retrying now. Error was:',
321333
err,
@@ -326,9 +338,21 @@ export default class RedisStringsHandler {
326338
);
327339
})
328340
.finally(async () => {
341+
// Clean up our tag tracking maps after the Redis key is removed
329342
await this.sharedTagsMap.delete(key);
330343
await this.revalidatedTagsMap.delete(tag);
331344
});
345+
346+
debug(
347+
'green',
348+
'RedisStringsHandler.get() found revalidation time for tag. Cache entry is stale and will be deleted and "null" will be returned.',
349+
tag,
350+
redisKey,
351+
revalidationTime,
352+
cacheEntry,
353+
);
354+
355+
// Return null to indicate no valid cache entry was found
332356
return null;
333357
}
334358
}
@@ -384,7 +408,7 @@ export default class RedisStringsHandler {
384408
data.kind !== 'FETCH'
385409
) {
386410
console.warn(
387-
'RedisStringsHandler.get() called with',
411+
'RedisStringsHandler.set() called with',
388412
key,
389413
ctx,
390414
data,
@@ -402,15 +426,11 @@ export default class RedisStringsHandler {
402426

403427
// Constructing and serializing the value for storing it in redis
404428
const cacheEntry: CacheEntry = {
405-
value: data,
406429
lastModified: Date.now(),
407430
tags: ctx?.tags || [],
431+
value: data,
408432
};
409433
const serializedCacheEntry = JSON.stringify(cacheEntry, bufferReplacer);
410-
debug(
411-
'RedisStringsHandler.set() will set the following serializedCacheEntry',
412-
serializedCacheEntry?.substring(0, 200),
413-
);
414434

415435
// pre seed data into deduplicated get client. This will reduce redis load by not requesting
416436
// the same value from redis which was just set.
@@ -440,6 +460,15 @@ export default class RedisStringsHandler {
440460
},
441461
);
442462

463+
debug(
464+
'blue',
465+
'RedisStringsHandler.set() will set the following serializedCacheEntry',
466+
this.keyPrefix,
467+
key,
468+
serializedCacheEntry?.substring(0, 200),
469+
expireAt,
470+
);
471+
443472
// Setting the tags for the cache entry in the sharedTagsMap (locally stored hashmap synced via redis)
444473
let setTagsOperation: Promise<void> | undefined;
445474
if (ctx.tags && ctx.tags.length > 0) {
@@ -457,44 +486,102 @@ export default class RedisStringsHandler {
457486
}
458487
}
459488

489+
debug(
490+
'blue',
491+
'RedisStringsHandler.set() will set the following sharedTagsMap',
492+
key,
493+
ctx.tags as string[],
494+
);
495+
460496
await Promise.all([setOperation, setTagsOperation]);
461497
}
462498

463499
// eslint-disable-next-line @typescript-eslint/no-explicit-any
464500
public async revalidateTag(tagOrTags: string | string[], ...rest: any[]) {
465-
debug('RedisStringsHandler.revalidateTag() called with', tagOrTags, rest);
501+
debug(
502+
'red',
503+
'RedisStringsHandler.revalidateTag() called with',
504+
tagOrTags,
505+
rest,
506+
);
466507
const tags = new Set([tagOrTags || []].flat());
467508
await this.assertClientIsReady();
468509

469-
// TODO: check how this revalidatedTagsMap is used or if it can be deleted
510+
// find all keys that are related to this tag
511+
const keysToDelete: Set<string> = new Set();
512+
513+
// TODO right now this code is only tested for calls with revalidatePath. We need to test this code for calls with revalidateTag as well
514+
// a call to revalidatePath will result in revalidateTag(_N_T_...) -> therefore we could check of tagOrTags.startsWith(_N_T_) to only execute this code for revalidatePath calls
515+
470516
for (const tag of tags) {
471-
if (isImplicitTag(tag)) {
517+
// If a page has a fetch request inside. This fetch request needs to be revalidated as well. This is done by the following code
518+
// sharedTags are containing all directly dependent tags. Need to find out the keys for these tags
519+
const sharedTags = this.sharedTagsMap.get(
520+
tag.replace(NEXT_CACHE_IMPLICIT_TAG_ID, ''),
521+
);
522+
for (const sharedTag of sharedTags || []) {
523+
// Implicit tags are not stored in cache therefore we can ignore them and only look at the non-implicit tags
524+
if (!sharedTag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
525+
// For these non-implicit tags we then need to find the keys that are dependent on each of these tags
526+
for (const [
527+
dependentKey,
528+
sharedTagsForDependentKey,
529+
] of this.sharedTagsMap.entries()) {
530+
// We can do so by scanning the whole sharedTagsMap for keys that contain this tag in there sharedTags array
531+
if (sharedTagsForDependentKey.includes(sharedTag)) {
532+
keysToDelete.add(dependentKey);
533+
}
534+
}
535+
}
536+
}
537+
538+
debug(
539+
'red',
540+
'RedisStringsHandler.revalidateTag() directly dependent keys',
541+
tag,
542+
sharedTags?.filter(
543+
(tag) => !tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID),
544+
) || [],
545+
);
546+
547+
// TODO check if this can be deleted with the new logic --> I think yes but tests should be added first
548+
// For Next.js implicit tags (route-based), store the revalidation timestamp
549+
// This is used to track when routes were last invalidated
550+
if (tag.startsWith(NEXT_CACHE_IMPLICIT_TAG_ID)) {
472551
const now = Date.now();
552+
debug(
553+
'red',
554+
'RedisStringsHandler.revalidateTag() set revalidation time for tag',
555+
tag,
556+
'to',
557+
now,
558+
);
473559
await this.revalidatedTagsMap.set(tag, now);
474560
}
475561
}
476562

477-
// find all keys that are related to this tag
478-
const keysToDelete: string[] = [];
563+
// Scan the whole sharedTagsMap for keys that are dependent on any of the revalidated tags
479564
for (const [key, sharedTags] of this.sharedTagsMap.entries()) {
480565
if (sharedTags.some((tag) => tags.has(tag))) {
481-
keysToDelete.push(key);
566+
keysToDelete.add(key);
482567
}
483568
}
484569

485570
debug(
571+
'red',
486572
'RedisStringsHandler.revalidateTag() found',
487573
keysToDelete,
488574
'keys to delete',
489575
);
490576

491577
// exit early if no keys are related to this tag
492-
if (keysToDelete.length === 0) {
578+
if (keysToDelete.size === 0) {
493579
return;
494580
}
495581

496582
// prepare deletion of all keys in redis that are related to this tag
497-
const fullRedisKeys = keysToDelete.map((key) => this.keyPrefix + key);
583+
const redisKeys = Array.from(keysToDelete);
584+
const fullRedisKeys = redisKeys.map((key) => this.keyPrefix + key);
498585
const options = getTimeoutRedisCommandOptions(this.timeoutMs);
499586
const deleteKeysOperation = this.client.unlink(options, fullRedisKeys);
500587

@@ -506,10 +593,13 @@ export default class RedisStringsHandler {
506593
}
507594

508595
// prepare deletion of entries from shared tags map if they get revalidated so that the map will not grow indefinitely
509-
const deleteTagsOperation = this.sharedTagsMap.delete(keysToDelete);
596+
const deleteTagsOperation = this.sharedTagsMap.delete(redisKeys);
510597

511598
// execute keys and tag maps deletion
512599
await Promise.all([deleteKeysOperation, deleteTagsOperation]);
513-
debug('RedisStringsHandler.revalidateTag() finished delete operations');
600+
debug(
601+
'red',
602+
'RedisStringsHandler.revalidateTag() finished delete operations',
603+
);
514604
}
515605
}

0 commit comments

Comments
 (0)