Skip to content

Commit 64ee684

Browse files
authored
Feat composable cache (#843)
* basic implementation for composable cache * updated types * make it work with original mode * add e2e test * lint * patch use cache for ISR * review fix * changeset * move test inside describe
1 parent 3b979a2 commit 64ee684

File tree

26 files changed

+566
-60
lines changed

26 files changed

+566
-60
lines changed

.changeset/sour-pandas-buy.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@opennextjs/aws": minor
3+
---
4+
5+
Introduce support for the composable cache
6+
7+
BREAKING CHANGE: The interface for the Incremental cache has changed. The new interface use a Cache type instead of a boolean to distinguish between the different types of caches. It also includes a new Cache type for the composable cache. The new interface is as follows:
8+
9+
```ts
10+
export type CacheEntryType = "cache" | "fetch" | "composable";
11+
12+
export type IncrementalCache = {
13+
get<CacheType extends CacheEntryType = "cache">(
14+
key: string,
15+
cacheType?: CacheType,
16+
): Promise<WithLastModified<CacheValue<CacheType>> | null>;
17+
set<CacheType extends CacheEntryType = "cache">(
18+
key: string,
19+
value: CacheValue<CacheType>,
20+
isFetch?: CacheType,
21+
): Promise<void>;
22+
delete(key: string): Promise<void>;
23+
name: string;
24+
};
25+
```
26+
27+
NextModeTagCache also get a new function `getLastRevalidated` used for the composable cache:
28+
29+
```ts
30+
getLastRevalidated(tags: string[]): Promise<number>;
31+
```

examples/experimental/next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
1010
experimental: {
1111
ppr: "incremental",
1212
nodeMiddleware: true,
13+
dynamicIO: true,
1314
},
1415
};
1516

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { revalidateTag } from "next/cache";
2+
3+
export function GET() {
4+
revalidateTag("fullyTagged");
5+
return new Response("DONE");
6+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { FullyCachedComponent, ISRComponent } from "@/components/cached";
2+
import { Suspense } from "react";
3+
4+
export default async function Page() {
5+
// Not working for now, need a patch in next to disable full revalidation during ISR revalidation
6+
return (
7+
<div>
8+
<h1>Cache</h1>
9+
<Suspense fallback={<p>Loading...</p>}>
10+
<FullyCachedComponent />
11+
</Suspense>
12+
<Suspense fallback={<p>Loading...</p>}>
13+
<ISRComponent />
14+
</Suspense>
15+
</div>
16+
);
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Suspense } from "react";
2+
3+
export default function Layout({
4+
children,
5+
}: {
6+
children: React.ReactNode;
7+
}) {
8+
return (
9+
<div>
10+
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
11+
</div>
12+
);
13+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { FullyCachedComponent, ISRComponent } from "@/components/cached";
2+
import { headers } from "next/headers";
3+
import { Suspense } from "react";
4+
5+
export default async function Page() {
6+
// To opt into SSR
7+
const _headers = await headers();
8+
return (
9+
<div>
10+
<h1>Cache</h1>
11+
<p>{_headers.get("accept") ?? "No accept headers"}</p>
12+
<Suspense fallback={<p>Loading...</p>}>
13+
<FullyCachedComponent />
14+
</Suspense>
15+
<Suspense fallback={<p>Loading...</p>}>
16+
<ISRComponent />
17+
</Suspense>
18+
</div>
19+
);
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { unstable_cacheLife, unstable_cacheTag } from "next/cache";
2+
3+
export async function FullyCachedComponent() {
4+
"use cache";
5+
unstable_cacheTag("fullyTagged");
6+
return (
7+
<div>
8+
<p data-testid="fullyCached">{Date.now()}</p>
9+
</div>
10+
);
11+
}
12+
13+
export async function ISRComponent() {
14+
"use cache";
15+
unstable_cacheLife({
16+
stale: 1,
17+
revalidate: 5,
18+
});
19+
return (
20+
<div>
21+
<p data-testid="isr">{Date.now()}</p>
22+
</div>
23+
);
24+
}

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export default class Cache {
5757
async getFetchCache(key: string, softTags?: string[], tags?: string[]) {
5858
debug("get fetch cache", { key, softTags, tags });
5959
try {
60-
const cachedEntry = await globalThis.incrementalCache.get(key, true);
60+
const cachedEntry = await globalThis.incrementalCache.get(key, "fetch");
6161

6262
if (cachedEntry?.value === undefined) return null;
6363

@@ -107,7 +107,7 @@ export default class Cache {
107107

108108
async getIncrementalCache(key: string): Promise<CacheHandlerValue | null> {
109109
try {
110-
const cachedEntry = await globalThis.incrementalCache.get(key, false);
110+
const cachedEntry = await globalThis.incrementalCache.get(key, "cache");
111111

112112
if (!cachedEntry?.value) {
113113
return null;
@@ -227,7 +227,7 @@ export default class Cache {
227227
},
228228
revalidate,
229229
},
230-
false,
230+
"cache",
231231
);
232232
break;
233233
}
@@ -248,7 +248,7 @@ export default class Cache {
248248
},
249249
revalidate,
250250
},
251-
false,
251+
"cache",
252252
);
253253
} else {
254254
await globalThis.incrementalCache.set(
@@ -259,7 +259,7 @@ export default class Cache {
259259
json: pageData,
260260
revalidate,
261261
},
262-
false,
262+
"cache",
263263
);
264264
}
265265
break;
@@ -278,12 +278,12 @@ export default class Cache {
278278
},
279279
revalidate,
280280
},
281-
false,
281+
"cache",
282282
);
283283
break;
284284
}
285285
case "FETCH":
286-
await globalThis.incrementalCache.set<true>(key, data, true);
286+
await globalThis.incrementalCache.set(key, data, "fetch");
287287
break;
288288
case "REDIRECT":
289289
await globalThis.incrementalCache.set(
@@ -293,7 +293,7 @@ export default class Cache {
293293
props: data.props,
294294
revalidate,
295295
},
296-
false,
296+
"cache",
297297
);
298298
break;
299299
case "IMAGE":
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache";
2+
import { fromReadableStream, toReadableStream } from "utils/stream";
3+
import { debug } from "./logger";
4+
5+
export default {
6+
async get(cacheKey: string) {
7+
try {
8+
const result = await globalThis.incrementalCache.get(
9+
cacheKey,
10+
"composable",
11+
);
12+
if (!result?.value?.value) {
13+
return undefined;
14+
}
15+
16+
debug("composable cache result", result);
17+
18+
// We need to check if the tags associated with this entry has been revalidated
19+
if (
20+
globalThis.tagCache.mode === "nextMode" &&
21+
result.value.tags.length > 0
22+
) {
23+
const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated(
24+
result.value.tags,
25+
result.lastModified,
26+
);
27+
if (hasBeenRevalidated) return undefined;
28+
} else if (
29+
globalThis.tagCache.mode === "original" ||
30+
globalThis.tagCache.mode === undefined
31+
) {
32+
const hasBeenRevalidated =
33+
(await globalThis.tagCache.getLastModified(
34+
cacheKey,
35+
result.lastModified,
36+
)) === -1;
37+
if (hasBeenRevalidated) return undefined;
38+
}
39+
40+
return {
41+
...result.value,
42+
value: toReadableStream(result.value.value),
43+
};
44+
} catch (e) {
45+
debug("Cannot read composable cache entry");
46+
return undefined;
47+
}
48+
},
49+
50+
async set(cacheKey: string, pendingEntry: Promise<ComposableCacheEntry>) {
51+
const entry = await pendingEntry;
52+
const valueToStore = await fromReadableStream(entry.value);
53+
await globalThis.incrementalCache.set(
54+
cacheKey,
55+
{
56+
...entry,
57+
value: valueToStore,
58+
},
59+
"composable",
60+
);
61+
if (globalThis.tagCache.mode === "original") {
62+
const storedTags = await globalThis.tagCache.getByPath(cacheKey);
63+
const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag));
64+
if (tagsToWrite.length > 0) {
65+
await globalThis.tagCache.writeTags(
66+
tagsToWrite.map((tag) => ({ tag, path: cacheKey })),
67+
);
68+
}
69+
}
70+
},
71+
72+
async refreshTags() {
73+
// We don't do anything for now, do we want to do something here ???
74+
return;
75+
},
76+
async getExpiration(...tags: string[]) {
77+
if (globalThis.tagCache.mode === "nextMode") {
78+
return globalThis.tagCache.getLastRevalidated(tags);
79+
}
80+
// We always return 0 here, original tag cache are handled directly in the get part
81+
// TODO: We need to test this more, i'm not entirely sure that this is working as expected
82+
return 0;
83+
},
84+
async expireTags(...tags: string[]) {
85+
if (globalThis.tagCache.mode === "nextMode") {
86+
return globalThis.tagCache.writeTags(tags);
87+
}
88+
const tagCache = globalThis.tagCache;
89+
const revalidatedAt = Date.now();
90+
// For the original mode, we have more work to do here.
91+
// We need to find all paths linked to to these tags
92+
const pathsToUpdate = await Promise.all(
93+
tags.map(async (tag) => {
94+
const paths = await tagCache.getByTag(tag);
95+
return paths.map((path) => ({
96+
path,
97+
tag,
98+
revalidatedAt,
99+
}));
100+
}),
101+
);
102+
// We need to deduplicate paths, we use a set for that
103+
const setToWrite = new Set<{ path: string; tag: string }>();
104+
for (const entry of pathsToUpdate.flat()) {
105+
setToWrite.add(entry);
106+
}
107+
await globalThis.tagCache.writeTags(Array.from(setToWrite));
108+
},
109+
110+
// This one is necessary for older versions of next
111+
async receiveExpiredTags(...tags: string[]) {
112+
// This function does absolutely nothing
113+
return;
114+
},
115+
} satisfies ComposableCacheHandler;

packages/open-next/src/build/compileCache.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,28 @@ import * as buildHelper from "./helper.js";
77
*
88
* @param options Build options.
99
* @param format Output format.
10-
* @returns The path to the compiled file.
10+
* @returns An object containing the paths to the compiled cache and composable cache files.
1111
*/
1212
export function compileCache(
1313
options: buildHelper.BuildOptions,
1414
format: "cjs" | "esm" = "cjs",
1515
) {
1616
const { config } = options;
1717
const ext = format === "cjs" ? "cjs" : "mjs";
18-
const outFile = path.join(options.buildDir, `cache.${ext}`);
18+
const compiledCacheFile = path.join(options.buildDir, `cache.${ext}`);
1919

2020
const isAfter15 = buildHelper.compareSemver(
2121
options.nextVersion,
2222
">=",
2323
"15.0.0",
2424
);
2525

26+
// Normal cache
2627
buildHelper.esbuildSync(
2728
{
2829
external: ["next", "styled-jsx", "react", "@aws-sdk/*"],
2930
entryPoints: [path.join(options.openNextDistDir, "adapters", "cache.js")],
30-
outfile: outFile,
31+
outfile: compiledCacheFile,
3132
target: ["node18"],
3233
format,
3334
banner: {
@@ -44,5 +45,39 @@ export function compileCache(
4445
},
4546
options,
4647
);
47-
return outFile;
48+
49+
const compiledComposableCacheFile = path.join(
50+
options.buildDir,
51+
`composable-cache.${ext}`,
52+
);
53+
54+
// Composable cache
55+
buildHelper.esbuildSync(
56+
{
57+
external: ["next", "styled-jsx", "react", "@aws-sdk/*"],
58+
entryPoints: [
59+
path.join(options.openNextDistDir, "adapters", "composable-cache.js"),
60+
],
61+
outfile: compiledComposableCacheFile,
62+
target: ["node18"],
63+
format,
64+
banner: {
65+
js: [
66+
`globalThis.disableIncrementalCache = ${
67+
config.dangerous?.disableIncrementalCache ?? false
68+
};`,
69+
`globalThis.disableDynamoDBCache = ${
70+
config.dangerous?.disableTagCache ?? false
71+
};`,
72+
`globalThis.isNextAfter15 = ${isAfter15};`,
73+
].join(""),
74+
},
75+
},
76+
options,
77+
);
78+
79+
return {
80+
cache: compiledCacheFile,
81+
composableCache: compiledComposableCacheFile,
82+
};
4883
}

0 commit comments

Comments
 (0)