Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-pumas-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/aws": patch
---

Add a new option to bypass checking the tag cache from an incremental cache get
40 changes: 21 additions & 19 deletions packages/open-next/src/adapters/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { getTagsFromValue, hasBeenRevalidated, writeTags } from "utils/cache";
import { isBinaryContentType } from "../utils/binary";
import { debug, error, warn } from "./logger";

export const SOFT_TAG_PREFIX = "_N_T_/";

function isFetchCache(
options?:
| boolean
Expand Down Expand Up @@ -63,11 +65,9 @@ export default class Cache {

const _tags = [...(tags ?? []), ...(softTags ?? [])];
const _lastModified = cachedEntry.lastModified ?? Date.now();
const _hasBeenRevalidated = await hasBeenRevalidated(
key,
_tags,
cachedEntry,
);
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
? false
: await hasBeenRevalidated(key, _tags, cachedEntry);

if (_hasBeenRevalidated) return null;

Expand All @@ -77,16 +77,18 @@ export default class Cache {
// Then we need to find the path for the given key
const path = softTags?.find(
(tag) =>
tag.startsWith("_N_T_/") &&
tag.startsWith(SOFT_TAG_PREFIX) &&
!tag.endsWith("layout") &&
!tag.endsWith("page"),
);
if (path) {
const hasPathBeenUpdated = await hasBeenRevalidated(
path.replace("_N_T_/", ""),
[],
cachedEntry,
);
const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache
? false
: await hasBeenRevalidated(
path.replace(SOFT_TAG_PREFIX, ""),
[],
cachedEntry,
);
if (hasPathBeenUpdated) {
// In case the path has been revalidated, we don't want to use the fetch cache
return null;
Expand Down Expand Up @@ -118,11 +120,9 @@ export default class Cache {
const meta = cacheData.meta;
const tags = getTagsFromValue(cacheData);
const _lastModified = cachedEntry.lastModified ?? Date.now();
const _hasBeenRevalidated = await hasBeenRevalidated(
key,
tags,
cachedEntry,
);
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
? false
: await hasBeenRevalidated(key, tags, cachedEntry);
if (_hasBeenRevalidated) return null;

const store = globalThis.__openNextAls.getStore();
Expand Down Expand Up @@ -358,11 +358,13 @@ export default class Cache {
}));

// If the tag is a soft tag, we should also revalidate the hard tags
if (tag.startsWith("_N_T_/")) {
if (tag.startsWith(SOFT_TAG_PREFIX)) {
for (const path of paths) {
// We need to find all hard tags for a given path
const _tags = await globalThis.tagCache.getByPath(path);
const hardTags = _tags.filter((t) => !t.startsWith("_N_T_/"));
const hardTags = _tags.filter(
(t) => !t.startsWith(SOFT_TAG_PREFIX),
);
// For every hard tag, we need to find all paths and revalidate them
for (const hardTag of hardTags) {
const _paths = await globalThis.tagCache.getByTag(hardTag);
Expand All @@ -386,7 +388,7 @@ export default class Cache {
new Set(
toInsert
// We need to filter fetch cache key as they are not in the CDN
.filter((t) => t.tag.startsWith("_N_T_/"))
.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX))
.map((t) => `/${t.path}`),
),
);
Expand Down
21 changes: 12 additions & 9 deletions packages/open-next/src/adapters/composable-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,23 @@ export default {
globalThis.tagCache.mode === "nextMode" &&
result.value.tags.length > 0
) {
const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated(
result.value.tags,
result.lastModified,
);
const hasBeenRevalidated = result.shouldBypassTagCache
? false
: 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;
const hasBeenRevalidated = result.shouldBypassTagCache
? false
: (await globalThis.tagCache.getLastModified(
cacheKey,
result.lastModified,
)) === -1;
if (hasBeenRevalidated) return undefined;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/open-next/src/types/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export type CachedFetchValue = {
export type WithLastModified<T> = {
lastModified?: number;
value?: T;
/**
* If set to true, we will not check the tag cache for this entry.
* `revalidateTag` and `revalidatePath` may not work as expected.
*/
shouldBypassTagCache?: boolean;
};

export type CacheEntryType = Extension;
Expand Down
203 changes: 200 additions & 3 deletions packages/tests-unit/tests/adapters/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import Cache from "@opennextjs/aws/adapters/cache.js";
import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/aws/adapters/cache.js";
import { vi } from "vitest";

declare global {
Expand Down Expand Up @@ -531,6 +531,8 @@ describe("CacheHandler", () => {
await cache.revalidateTag("tag");

expect(tagCache.writeTags).not.toHaveBeenCalled();
// Reset the config
globalThis.openNextConfig.dangerous.disableTagCache = false;
});

it("Should call tagCache.writeTags", async () => {
Expand All @@ -551,13 +553,13 @@ describe("CacheHandler", () => {
it("Should call invalidateCdnHandler.invalidatePaths", async () => {
globalThis.tagCache.getByTag.mockResolvedValueOnce(["/path"]);
globalThis.tagCache.getByPath.mockResolvedValueOnce([]);
await cache.revalidateTag("_N_T_/path");
await cache.revalidateTag(`${SOFT_TAG_PREFIX}path`);

expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
expect(tagCache.writeTags).toHaveBeenCalledWith([
{
path: "/path",
tag: "_N_T_/path",
tag: `${SOFT_TAG_PREFIX}path`,
},
]);

Expand Down Expand Up @@ -621,4 +623,199 @@ describe("CacheHandler", () => {
globalThis.tagCache.getPathsByTags = undefined;
});
});

describe("shouldBypassTagCache", () => {
describe("fetch cache", () => {
it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => {
incrementalCache.get.mockResolvedValueOnce({
value: {
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
},
lastModified: Date.now(),
shouldBypassTagCache: true,
});

const result = await cache.get("key", {
kind: "FETCH",
tags: ["tag1"],
});

expect(getFetchCacheSpy).toHaveBeenCalled();
expect(tagCache.getLastModified).not.toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
expect(result).not.toBeNull();
expect(result?.value).toEqual({
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
});
});

it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => {
globalThis.tagCache.mode = "nextMode";
incrementalCache.get.mockResolvedValueOnce({
value: {
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
},
lastModified: Date.now(),
shouldBypassTagCache: false,
});

const result = await cache.get("key", {
kind: "FETCH",
tags: ["tag1"],
});

expect(getFetchCacheSpy).toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
expect(result).not.toBeNull();
});

it("Should not bypass tag cache validation when shouldBypassTagCache is undefined", async () => {
globalThis.tagCache.mode = "nextMode";
tagCache.hasBeenRevalidated.mockResolvedValueOnce(false);
incrementalCache.get.mockResolvedValueOnce({
value: {
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
},
lastModified: Date.now(),
// shouldBypassTagCache not set
});

const result = await cache.get("key", {
kind: "FETCH",
tags: ["tag1"],
});

expect(getFetchCacheSpy).toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
expect(result).not.toBeNull();
});

it("Should bypass path validation when shouldBypassTagCache is true for soft tags", async () => {
incrementalCache.get.mockResolvedValueOnce({
value: {
kind: "FETCH",
data: {
headers: {},
body: "{}",
url: "https://example.com",
status: 200,
},
},
lastModified: Date.now(),
shouldBypassTagCache: true,
});

const result = await cache.get("key", {
kind: "FETCH",
softTags: [`${SOFT_TAG_PREFIX}path`],
});

expect(getFetchCacheSpy).toHaveBeenCalled();
expect(tagCache.getLastModified).not.toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
expect(result).not.toBeNull();
});
});

describe("incremental cache", () => {
it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => {
incrementalCache.get.mockResolvedValueOnce({
value: {
type: "route",
body: "{}",
},
lastModified: Date.now(),
shouldBypassTagCache: true,
});

const result = await cache.get("key", { kindHint: "app" });

expect(getIncrementalCache).toHaveBeenCalled();
expect(tagCache.getLastModified).not.toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
expect(result).not.toBeNull();
expect(result?.value?.kind).toEqual("ROUTE");
});

it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => {
globalThis.tagCache.mode = "nextMode";
incrementalCache.get.mockResolvedValueOnce({
value: {
type: "route",
body: "{}",
},
lastModified: Date.now(),
shouldBypassTagCache: false,
});

const result = await cache.get("key", { kindHint: "app" });

expect(getIncrementalCache).toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
expect(result).not.toBeNull();
});

it("Should return null when tag cache indicates revalidation and shouldBypassTagCache is false", async () => {
globalThis.tagCache.mode = "nextMode";
tagCache.hasBeenRevalidated.mockResolvedValueOnce(true);
incrementalCache.get.mockResolvedValueOnce({
value: {
type: "route",
body: "{}",
},
lastModified: Date.now(),
shouldBypassTagCache: false,
});

const result = await cache.get("key", { kindHint: "app" });

expect(getIncrementalCache).toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
expect(result).toBeNull();
});

it("Should return value when tag cache indicates revalidation but shouldBypassTagCache is true", async () => {
incrementalCache.get.mockResolvedValueOnce({
value: {
type: "route",
body: "{}",
},
lastModified: Date.now(),
shouldBypassTagCache: true,
});

const result = await cache.get("key", { kindHint: "app" });

expect(getIncrementalCache).toHaveBeenCalled();
expect(tagCache.getLastModified).not.toHaveBeenCalled();
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
expect(result).not.toBeNull();
expect(result?.value?.kind).toEqual("ROUTE");
});
});
});
});