Skip to content

Commit b0b1f77

Browse files
authored
Feat: tag cache mode (#717)
* add new tag cache mode * cleanup cache * add automatic invalidation for nextMode tag cache * updated test * changeset * fix linting * review fix * don't check tag cache for page router * refactor updateOnTagsSet * review fix
1 parent c4b0a78 commit b0b1f77

File tree

16 files changed

+482
-140
lines changed

16 files changed

+482
-140
lines changed

.changeset/silver-pets-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": minor
3+
---
4+
5+
introduce a new optional mode for the tag cache

packages/open-next/src/adapters/cache.ts

Lines changed: 95 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,12 @@
1+
import type {
2+
CacheHandlerValue,
3+
IncrementalCacheContext,
4+
IncrementalCacheValue,
5+
} from "types/cache";
6+
import { getTagsFromValue, hasBeenRevalidated } from "utils/cache";
17
import { isBinaryContentType } from "../utils/binary";
28
import { debug, error, warn } from "./logger";
39

4-
interface CachedFetchValue {
5-
kind: "FETCH";
6-
data: {
7-
headers: { [k: string]: string };
8-
body: string;
9-
url: string;
10-
status?: number;
11-
tags?: string[];
12-
};
13-
revalidate: number;
14-
}
15-
16-
interface CachedRedirectValue {
17-
kind: "REDIRECT";
18-
props: Object;
19-
}
20-
21-
interface CachedRouteValue {
22-
kind: "ROUTE" | "APP_ROUTE";
23-
// this needs to be a RenderResult so since renderResponse
24-
// expects that type instead of a string
25-
body: Buffer;
26-
status: number;
27-
headers: Record<string, undefined | string | string[]>;
28-
}
29-
30-
interface CachedImageValue {
31-
kind: "IMAGE";
32-
etag: string;
33-
buffer: Buffer;
34-
extension: string;
35-
isMiss?: boolean;
36-
isStale?: boolean;
37-
}
38-
39-
interface IncrementalCachedPageValue {
40-
kind: "PAGE" | "PAGES";
41-
// this needs to be a string since the cache expects to store
42-
// the string value
43-
html: string;
44-
pageData: Object;
45-
status?: number;
46-
headers?: Record<string, undefined | string>;
47-
}
48-
49-
interface IncrementalCachedAppPageValue {
50-
kind: "APP_PAGE";
51-
// this needs to be a string since the cache expects to store
52-
// the string value
53-
html: string;
54-
rscData: Buffer;
55-
headers?: Record<string, undefined | string | string[]>;
56-
postponed?: string;
57-
status?: number;
58-
}
59-
60-
type IncrementalCacheValue =
61-
| CachedRedirectValue
62-
| IncrementalCachedPageValue
63-
| IncrementalCachedAppPageValue
64-
| CachedImageValue
65-
| CachedFetchValue
66-
| CachedRouteValue;
67-
68-
type IncrementalCacheContext = {
69-
revalidate?: number | false | undefined;
70-
fetchCache?: boolean | undefined;
71-
fetchUrl?: string | undefined;
72-
fetchIdx?: number | undefined;
73-
tags?: string[] | undefined;
74-
};
75-
76-
interface CacheHandlerValue {
77-
lastModified?: number;
78-
age?: number;
79-
cacheState?: string;
80-
value: IncrementalCacheValue | null;
81-
}
82-
8310
function isFetchCache(
8411
options?:
8512
| boolean
@@ -134,14 +61,15 @@ export default class Cache {
13461

13562
if (cachedEntry?.value === undefined) return null;
13663

137-
const _lastModified = await globalThis.tagCache.getLastModified(
64+
const _tags = [...(tags ?? []), ...(softTags ?? [])];
65+
const _lastModified = cachedEntry.lastModified ?? Date.now();
66+
const _hasBeenRevalidated = await hasBeenRevalidated(
13867
key,
139-
cachedEntry?.lastModified,
68+
_tags,
69+
cachedEntry,
14070
);
141-
if (_lastModified === -1) {
142-
// If some tags are stale we need to force revalidation
143-
return null;
144-
}
71+
72+
if (_hasBeenRevalidated) return null;
14573

14674
// For cases where we don't have tags, we need to ensure that the soft tags are not being revalidated
14775
// We only need to check for the path as it should already contain all the tags
@@ -154,11 +82,12 @@ export default class Cache {
15482
!tag.endsWith("page"),
15583
);
15684
if (path) {
157-
const pathLastModified = await globalThis.tagCache.getLastModified(
85+
const hasPathBeenUpdated = await hasBeenRevalidated(
15886
path.replace("_N_T_/", ""),
159-
cachedEntry.lastModified,
87+
[],
88+
cachedEntry,
16089
);
161-
if (pathLastModified === -1) {
90+
if (hasPathBeenUpdated) {
16291
// In case the path has been revalidated, we don't want to use the fetch cache
16392
return null;
16493
}
@@ -184,20 +113,23 @@ export default class Cache {
184113
return null;
185114
}
186115

187-
const meta = cachedEntry.value.meta;
188-
const _lastModified = await globalThis.tagCache.getLastModified(
116+
const cacheData = cachedEntry.value;
117+
118+
const meta = cacheData.meta;
119+
const tags = getTagsFromValue(cacheData);
120+
const _lastModified = cachedEntry.lastModified ?? Date.now();
121+
const _hasBeenRevalidated = await hasBeenRevalidated(
189122
key,
190-
cachedEntry?.lastModified,
123+
tags,
124+
cachedEntry,
191125
);
192-
if (_lastModified === -1) {
193-
// If some tags are stale we need to force revalidation
194-
return null;
195-
}
196-
const cacheData = cachedEntry?.value;
126+
if (_hasBeenRevalidated) return null;
127+
197128
const store = globalThis.__openNextAls.getStore();
198129
if (store) {
199130
store.lastModified = _lastModified;
200131
}
132+
201133
if (cacheData?.type === "route") {
202134
return {
203135
lastModified: _lastModified,
@@ -363,32 +295,8 @@ export default class Cache {
363295
break;
364296
}
365297
}
366-
// Write derivedTags to dynamodb
367-
// If we use an in house version of getDerivedTags in build we should use it here instead of next's one
368-
const derivedTags: string[] =
369-
data?.kind === "FETCH"
370-
? (ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility
371-
: data?.kind === "PAGE"
372-
? (data.headers?.["x-next-cache-tags"]?.split(",") ?? [])
373-
: [];
374-
debug("derivedTags", derivedTags);
375-
// Get all tags stored in dynamodb for the given key
376-
// If any of the derived tags are not stored in dynamodb for the given key, write them
377-
const storedTags = await globalThis.tagCache.getByPath(key);
378-
const tagsToWrite = derivedTags.filter(
379-
(tag) => !storedTags.includes(tag),
380-
);
381-
if (tagsToWrite.length > 0) {
382-
await globalThis.tagCache.writeTags(
383-
tagsToWrite.map((tag) => ({
384-
path: key,
385-
tag: tag,
386-
// In case the tags are not there we just need to create them
387-
// but we don't want them to return from `getLastModified` as they are not stale
388-
revalidatedAt: 1,
389-
})),
390-
);
391-
}
298+
299+
await this.updateTagsOnSet(key, data, ctx);
392300
debug("Finished setting cache");
393301
} catch (e) {
394302
error("Failed to set cache", e);
@@ -405,6 +313,29 @@ export default class Cache {
405313
}
406314
try {
407315
const _tags = Array.isArray(tags) ? tags : [tags];
316+
if (globalThis.tagCache.mode === "nextMode") {
317+
const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? [];
318+
319+
await globalThis.tagCache.writeTags(_tags);
320+
if (paths.length > 0) {
321+
// TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it
322+
// It also means that we'll need to provide the tags used in every request to the wrapper or converter.
323+
await globalThis.cdnInvalidationHandler.invalidatePaths(
324+
paths.map((path) => ({
325+
initialPath: path,
326+
rawPath: path,
327+
resolvedRoutes: [
328+
{
329+
route: path,
330+
// TODO: ideally here we should check if it's an app router page or route
331+
type: "app",
332+
},
333+
],
334+
})),
335+
);
336+
}
337+
return;
338+
}
408339
for (const tag of _tags) {
409340
debug("revalidateTag", tag);
410341
// Find all keys with the given tag
@@ -468,4 +399,46 @@ export default class Cache {
468399
error("Failed to revalidate tag", e);
469400
}
470401
}
402+
403+
// TODO: We should delete/update tags in this method
404+
// This will require an update to the tag cache interface
405+
private async updateTagsOnSet(
406+
key: string,
407+
data?: IncrementalCacheValue,
408+
ctx?: IncrementalCacheContext,
409+
) {
410+
if (
411+
globalThis.openNextConfig.dangerous?.disableTagCache ||
412+
globalThis.tagCache.mode === "nextMode" ||
413+
// Here it means it's a delete
414+
!data
415+
) {
416+
return;
417+
}
418+
// Write derivedTags to the tag cache
419+
// If we use an in house version of getDerivedTags in build we should use it here instead of next's one
420+
const derivedTags: string[] =
421+
data?.kind === "FETCH"
422+
? (ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility
423+
: data?.kind === "PAGE"
424+
? (data.headers?.["x-next-cache-tags"]?.split(",") ?? [])
425+
: [];
426+
debug("derivedTags", derivedTags);
427+
428+
// Get all tags stored in dynamodb for the given key
429+
// If any of the derived tags are not stored in dynamodb for the given key, write them
430+
const storedTags = await globalThis.tagCache.getByPath(key);
431+
const tagsToWrite = derivedTags.filter((tag) => !storedTags.includes(tag));
432+
if (tagsToWrite.length > 0) {
433+
await globalThis.tagCache.writeTags(
434+
tagsToWrite.map((tag) => ({
435+
path: key,
436+
tag: tag,
437+
// In case the tags are not there we just need to create them
438+
// but we don't want them to return from `getLastModified` as they are not stale
439+
revalidatedAt: 1,
440+
})),
441+
);
442+
}
443+
}
471444
}

packages/open-next/src/adapters/dynamo-provider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ async function defaultHandler(
4747
async function insert(
4848
requestType: InitializationFunctionEvent["requestType"],
4949
): Promise<InitializationFunctionEvent> {
50+
// If it is in nextMode, we don't need to do anything
51+
if (tagCache.mode === "nextMode") {
52+
return {
53+
type: "initializationFunction",
54+
requestType,
55+
resourceId: PHYSICAL_RESOURCE_ID,
56+
};
57+
}
5058
const file = readFileSync("dynamodb-cache.json", "utf8");
5159

5260
const data: DataType[] = JSON.parse(file);

packages/open-next/src/core/routing/cacheInterceptor.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import type { InternalEvent, InternalResult } from "types/open-next";
55
import type { CacheValue } from "types/overrides";
66
import { emptyReadableStream, toReadableStream } from "utils/stream";
77

8-
import { debug } from "../../adapters/logger.js";
9-
import { localizePath } from "./i18n/index.js";
10-
import { generateMessageGroupId } from "./queue.js";
8+
import { getTagsFromValue, hasBeenRevalidated } from "utils/cache";
9+
import { debug } from "../../adapters/logger";
10+
import { localizePath } from "./i18n";
11+
import { generateMessageGroupId } from "./queue";
1112

1213
const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
1314
const CACHE_ONE_MONTH = 60 * 60 * 24 * 30;
@@ -161,15 +162,15 @@ export async function cacheInterceptor(
161162
if (!cachedData?.value) {
162163
return event;
163164
}
164-
165-
if (cachedData?.value?.type === "app") {
166-
// We need to check the tag cache now
167-
const _lastModified = await globalThis.tagCache.getLastModified(
165+
// We need to check the tag cache now
166+
if (cachedData.value?.type === "app") {
167+
const tags = getTagsFromValue(cachedData.value);
168+
const _hasBeenRevalidated = await hasBeenRevalidated(
168169
localizedPath,
169-
cachedData.lastModified,
170+
tags,
171+
cachedData,
170172
);
171-
if (_lastModified === -1) {
172-
// If some tags are stale we need to force revalidation
173+
if (_hasBeenRevalidated) {
173174
return event;
174175
}
175176
}

packages/open-next/src/overrides/incrementalCache/multi-tier-ddb-s3.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const maxCacheSize = process.env.OPEN_NEXT_LOCAL_CACHE_SIZE
1414
: 1000;
1515

1616
const localCache = new LRUCache<{
17-
value: CacheValue<false>;
17+
value: CacheValue<any>;
1818
lastModified: number;
1919
}>(maxCacheSize);
2020

packages/open-next/src/overrides/tagCache/dummy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TagCache } from "types/overrides";
33
// We don't want to throw error on this one because we might use it when we don't need tag cache
44
const dummyTagCache: TagCache = {
55
name: "dummy",
6+
mode: "original",
67
getByPath: async () => {
78
return [];
89
},

packages/open-next/src/overrides/tagCache/dynamodb-lite.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) {
6666
}
6767

6868
const tagCache: TagCache = {
69+
mode: "original",
6970
async getByPath(path) {
7071
try {
7172
if (globalThis.openNextConfig.dangerous?.disableTagCache) {

0 commit comments

Comments
 (0)