8000 Feat composable cache by conico974 · Pull Request #843 · opennextjs/opennextjs-aws · GitHub
[go: up one dir, main page]

Skip to content

Feat composable cache #843

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

< 8000 /file-filter>
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/sour-pandas-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@opennextjs/aws": minor
---

Introduce support for the composable cache

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:

```ts
export type CacheEntryType = "cache" | "fetch" | "composable";

export type IncrementalCache = {
get<CacheType extends CacheEntryType = "cache">(
key: string,
cacheType?: CacheType,
): Promise<WithLastModified<CacheValue<CacheType>> | null>;
set<CacheType extends CacheEntryType = "cache">(
key: string,
value: CacheValue<CacheType>,
isFetch?: CacheType,
): Promise<void>;
delete(key: string): Promise<void>;
name: string;
};
```

NextModeTagCache also get a new function `getLastRevalidated` used for the composable cache:

```ts
getLastRevalidated(tags: string[]): Promise<number>;
```
1 change: 1 addition & 0 deletions examples/experimental/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
experimental: {
ppr: "incremental",
nodeMiddleware: true,
dynamicIO: true,
},
};

Expand Down
6 changes: 6 additions & 0 deletions examples/experimental/src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { revalidateTag } from "next/cache";

export function GET() {
revalidateTag("fullyTagged");
return new Response("DONE");
}
17 changes: 17 additions & 0 deletions examples/experimental/src/app/use-cache/isr/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FullyCachedComponent, ISRComponent } from "@/components/cached";
import { Suspense } from "react";

export default async function Page() {
// Not working for now, need a patch in next to disable full revalidation during ISR revalidation
return (
<div>
<h1>Cache</h1>
<Suspense fallback={<p>Loading...</p>}>
<FullyCachedComponent />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<ISRComponent />
</Suspense>
</div>
);
}
13 changes: 13 additions & 0 deletions examples/experimental/src/app/use-cache/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Suspense } from "react";

export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</div>
);
}
20 changes: 20 additions & 0 deletions examples/experimental/src/app/use-cache/ssr/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FullyCachedComponent, ISRComponent } from "@/components/cached";
import { headers } from "next/headers";
import { Suspense } from "react";

export default async function Page() {
// To opt into SSR
const _headers = await headers();
return (
<div>
<h1>Cache</h1>
<p>{_headers.get("accept") ?? "No accept headers"}</p>
<Suspense fallback={<p>Loading...</p>}>
<FullyCachedComponent />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<ISRComponent />
</Suspense>
</div>
);
}
24 changes: 24 additions & 0 deletions examples/experimental/src/components/cached.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { unstable_cacheLife, unstable_cacheTag } from "next/cache";

export async function FullyCachedComponent() {
"use cache";
unstable_cacheTag("fullyTagged");
return (
<div>
<p data-testid="fullyCached">{Date.now()}</p>
</div>
);
}

export async function ISRComponent() {
"use cache";
unstable_cacheLife({
stale: 1,
revalidate: 5,
});
return (
<div>
<p data-testid="isr">{Date.now()}</p>
</div>
);
}
16 changes: 8 additions & 8 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default class Cache {
async getFetchCache(key: string, softTags?: string[], tags?: string[]) {
debug("get fetch cache", { key, softTags, tags });
try {
const cachedEntry = await globalThis.incrementalCache.get(key, true);
const cachedEntry = await globalThis.incrementalCache.get(key, "fetch");

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

Expand Down Expand Up @@ -107,7 +107,7 @@ export default class Cache {

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

if (!cachedEntry?.value) {
return null;
Expand Down Expand Up @@ -227,7 +227,7 @@ export default class Cache {
},
revalidate,
},
false,
"cache",
);
break;
}
Expand All @@ -248,7 +248,7 @@ export default class Cache {
},
revalidate,
},
false,
"cache",
);
} else {
await globalThis.incrementalCache.set(
Expand All @@ -259,7 +259,7 @@ export default class Cache {
json: pageData,
revalidate,
},
false,
"cache",
);
}
break;
Expand All @@ -278,12 +278,12 @@ export default class Cache {
},
revalidate,
},
false,
"cache",
);
break;
}
case "FETCH":
await globalThis.incrementalCache.set<true>(key, data, true);
await globalThis.incrementalCache.set(key, data, "fetch");
break;
case "REDIRECT":
await globalThis.incrementalCache.set(
Expand All @@ -293,7 +293,7 @@ export default class Cache {
props: data.props,
revalidate,
},
false,
"cache",
);
break;
case "IMAGE":
Expand Down
115 changes: 115 additions & 0 deletions packages/open-next/src/adapters/composable-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { ComposableCacheEntry, ComposableCacheHandler } from "types/cache";
import { fromReadableStream, toReadableStream } from "utils/stream";
import { debug } from "./logger";

export default {
async get(cacheKey: string) {
try {
const result = await globalThis.incrementalCache.get(
cacheKey,
"composable",
);
if (!result?.value?.value) {
return undefined;
}

debug("composable cache result", result);

// We need to check if the tags associated with this entry has been revalidated
if (
globalThis.tagCache.mode === "nextMode" &&
result.value.tags.length > 0
) {
const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated(
result.value.tags,
result.lastModified,
);
if (hasBeenRevalidated) return undefined;
} else if (
globalThis.tagCache.mode === "original" ||
globalThis.tagCache.mode === undefined
) {
const hasBeenRevalidated =
(await globalThis.tagCache.getLastModified(
cacheKey,
result.lastModified,
)) === -1;
if (hasBeenRevalidated) return undefined;
}

return {
...result.value,
value: toReadableStream(result.value.value),
};
} catch (e) {
debug("Cannot read composable cache entry");
return undefined;
}
},

async set(cacheKey: string, pendingEntry: Promise<ComposableCacheEntry>) {
const entry = await pendingEntry;
const valueToStore = await fromReadableStream(entry.value);
await globalThis.incrementalCache.set(
cacheKey,
{
...entry,
value: valueToStore,
},
"composable",
);
if (globalThis.tagCache.mode === "original") {
const storedTags = await globalThis.tagCache.getByPath(cacheKey);
const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag));
if (tagsToWrite.length > 0) {
await globalThis.tagCache.writeTags(
tagsToWrite.map((tag) => ({ tag, path: cacheKey })),
);
}
}
},

async refreshTags() {
// We don't do anything for now, do we want to do something here ???
return;
},
async getExpiration(...tags: string[]) {
if (globalThis.tagCache.mode === "nextMode") {
return globalThis.tagCache.getLastRevalidated(tags);
}
// We always return 0 here, original tag cache are handled directly in the get part
// TODO: We need to test this more, i'm not entirely sure that this is working as expected
return 0;
},
async expireTags(...tags: string[]) {
if (globalThis.tagCache.mode === "nextMode") {
return globalThis.tagCache.writeTags(tags);
}
const tagCache = globalThis.tagCache;
const revalidatedAt = Date.now();
// For the original mode, we have more work to do here.
// We need to find all paths linked to to these tags
const pathsToUpdate = await Promise.all(
tags.map(async (tag) => {
const paths = await tagCache.getByTag(tag);
return paths.map((path) => ({
path,
tag,
revalidatedAt,
}));
}),
);
// We need to deduplicate paths, we use a set for that
const setToWrite = new Set<{ path: string; tag: string }>();
for (const entry of pathsToUpdate.flat()) {
setToWrite.add(entry);
}
await globalThis.tagCache.writeTags(Array.from(setToWrite));
},

// This one is necessary for older versions of next
async receiveExpiredTags(...tags: string[]) {
// This function does absolutely nothing
return;
},
} satisfies ComposableCacheHandler;
43 changes: 39 additions & 4 deletions packages/open-next/src/build/compileCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,28 @@ import * as buildHelper from "./helper.js";
*
* @param options Build options.
* @param format Output format.
* @returns The path to the compiled file.
* @returns An object containing the paths to the compiled cache and composable cache files.
*/
export function compileCache(
options: buildHelper.BuildOptions,
format: "cjs" | "esm" = "cjs",
) {
const { config } = options;
const ext = format === "cjs" ? "cjs" : "mjs";
const outFile = path.join(options.buildDir, `cache.${ext}`);
const compiledCacheFile = path.join(options.buildDir, `cache.${ext}`);

const isAfter15 = buildHelper.compareSemver(
options.nextVersion,
">=",
"15.0.0",
);

// Normal cache
buildHelper.esbuildSync(
{
external: ["next", "styled-jsx", "react", "@aws-sdk/*"],
entryPoints: [path.join(options.openNextDistDir, "adapters", "cache.js")],
outfile: outFile,
outfile: compiledCacheFile,
target: ["node18"],
format,
banner: {
Expand All @@ -44,5 +45,39 @@ export function compileCache(
},
options,
);
return outFile;

const compiledComposableCacheFile = path.join(
options.buildDir,
`composable-cache.${ext}`,
);

// Composable cache
buildHelper.esbuildSync(
{
external: ["next", "styled-jsx", "react", "@aws-sdk/*"],
entryPoints: [
path.join(options.openNextDistDir, "adapters", "composable-cache.js"),
],
outfile: compiledComposableCacheFile,
target: ["node18"],
format,
banner: {
js: [
`globalThis.disableIncrementalCache = ${
config.dangerous?.disableIncrementalCache ?? false
};`,
`globalThis.disableDynamoDBCache = ${
config.dangerous?.disableTagCache ?? false
};`,
`globalThis.isNextAfter15 = ${isAfter15};`,
].join(""),
},
},
options,
);

return {
cache: compiledCacheFile,
composableCache: compiledComposableCacheFile,
};
}
Loading
Loading
0