Skip to content

Commit b9635d0

Browse files
conico974Nicolas Dorseuil
andauthored
Add shouldBypassTagCache option for incremental caches (opennextjs#944)
* add a new `shouldBypassTagCache` that could be used by incremental caches on get * add unit test * changeset * review * linting --------- Co-authored-by: Nicolas Dorseuil <[email protected]>
1 parent 352e4b2 commit b9635d0

File tree

5 files changed

+243
-31
lines changed

5 files changed

+243
-31
lines changed

.changeset/rich-pumas-shop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
Add a new option to bypass checking the tag cache from an incremental cache get

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

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { getTagsFromValue, hasBeenRevalidated, writeTags } from "utils/cache";
77
import { isBinaryContentType } from "../utils/binary";
88
import { debug, error, warn } from "./logger";
99

10+
export const SOFT_TAG_PREFIX = "_N_T_/";
11+
1012
function isFetchCache(
1113
options?:
1214
| boolean
@@ -63,11 +65,9 @@ export default class Cache {
6365

6466
const _tags = [...(tags ?? []), ...(softTags ?? [])];
6567
const _lastModified = cachedEntry.lastModified ?? Date.now();
66-
const _hasBeenRevalidated = await hasBeenRevalidated(
67-
key,
68-
_tags,
69-
cachedEntry,
70-
);
68+
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
69+
? false
70+
: await hasBeenRevalidated(key, _tags, cachedEntry);
7171

7272
if (_hasBeenRevalidated) return null;
7373

@@ -77,16 +77,18 @@ export default class Cache {
7777
// Then we need to find the path for the given key
7878
const path = softTags?.find(
7979
(tag) =>
80-
tag.startsWith("_N_T_/") &&
80+
tag.startsWith(SOFT_TAG_PREFIX) &&
8181
!tag.endsWith("layout") &&
8282
!tag.endsWith("page"),
8383
);
8484
if (path) {
85-
const hasPathBeenUpdated = await hasBeenRevalidated(
86-
path.replace("_N_T_/", ""),
87-
[],
88-
cachedEntry,
89-
);
85+
const hasPathBeenUpdated = cachedEntry.shouldBypassTagCache
86+
? false
87+
: await hasBeenRevalidated(
88+
path.replace(SOFT_TAG_PREFIX, ""),
89+
[],
90+
cachedEntry,
91+
);
9092
if (hasPathBeenUpdated) {
9193
// In case the path has been revalidated, we don't want to use the fetch cache
9294
return null;
@@ -118,11 +120,9 @@ export default class Cache {
118120
const meta = cacheData.meta;
119121
const tags = getTagsFromValue(cacheData);
120122
const _lastModified = cachedEntry.lastModified ?? Date.now();
121-
const _hasBeenRevalidated = await hasBeenRevalidated(
122-
key,
123-
tags,
124-
cachedEntry,
125-
);
123+
const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache
124+
? false
125+
: await hasBeenRevalidated(key, tags, cachedEntry);
126126
if (_hasBeenRevalidated) return null;
127127

128128
const store = globalThis.__openNextAls.getStore();
@@ -358,11 +358,13 @@ export default class Cache {
358358
}));
359359

360360
// If the tag is a soft tag, we should also revalidate the hard tags
361-
if (tag.startsWith("_N_T_/")) {
361+
if (tag.startsWith(SOFT_TAG_PREFIX)) {
362362
for (const path of paths) {
363363
// We need to find all hard tags for a given path
364364
const _tags = await globalThis.tagCache.getByPath(path);
365-
const hardTags = _tags.filter((t) => !t.startsWith("_N_T_/"));
365+
const hardTags = _tags.filter(
366+
(t) => !t.startsWith(SOFT_TAG_PREFIX),
367+
);
366368
// For every hard tag, we need to find all paths and revalidate them
367369
for (const hardTag of hardTags) {
368370
const _paths = await globalThis.tagCache.getByTag(hardTag);
@@ -386,7 +388,7 @@ export default class Cache {
386388
new Set(
387389
toInsert
388390
// We need to filter fetch cache key as they are not in the CDN
389-
.filter((t) => t.tag.startsWith("_N_T_/"))
391+
.filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX))
390392
.map((t) => `/${t.path}`),
391393
),
392394
);

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,23 @@ export default {
2828
globalThis.tagCache.mode === "nextMode" &&
2929
result.value.tags.length > 0
3030
) {
31-
const hasBeenRevalidated = await globalThis.tagCache.hasBeenRevalidated(
32-
result.value.tags,
33-
result.lastModified,
34-
);
31+
const hasBeenRevalidated = result.shouldBypassTagCache
32+
? false
33+
: await globalThis.tagCache.hasBeenRevalidated(
34+
result.value.tags,
35+
result.lastModified,
36+
);
3537
if (hasBeenRevalidated) return undefined;
3638
} else if (
3739
globalThis.tagCache.mode === "original" ||
3840
globalThis.tagCache.mode === undefined
3941
) {
40-
const hasBeenRevalidated =
41-
(await globalThis.tagCache.getLastModified(
42-
cacheKey,
43-
result.lastModified,
44-
)) === -1;
42+
const hasBeenRevalidated = result.shouldBypassTagCache
43+
? false
44+
: (await globalThis.tagCache.getLastModified(
45+
cacheKey,
46+
result.lastModified,
47+
)) === -1;
4548
if (hasBeenRevalidated) return undefined;
4649
}
4750

packages/open-next/src/types/overrides.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ export type CachedFetchValue = {
9393
export type WithLastModified<T> = {
9494
lastModified?: number;
9595
value?: T;
96+
/**
97+
* If set to true, we will not check the tag cache for this entry.
98+
* `revalidateTag` and `revalidatePath` may not work as expected.
99+
*/
100+
shouldBypassTagCache?: boolean;
96101
};
97102

98103
export type CacheEntryType = Extension;

packages/tests-unit/tests/adapters/cache.test.ts

Lines changed: 200 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import Cache from "@opennextjs/aws/adapters/cache.js";
2+
import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/aws/adapters/cache.js";
33
import { vi } from "vitest";
44

55
declare global {
@@ -531,6 +531,8 @@ describe("CacheHandler", () => {
531531
await cache.revalidateTag("tag");
532532

533533
expect(tagCache.writeTags).not.toHaveBeenCalled();
534+
// Reset the config
535+
globalThis.openNextConfig.dangerous.disableTagCache = false;
534536
});
535537

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

556558
expect(tagCache.writeTags).toHaveBeenCalledTimes(1);
557559
expect(tagCache.writeTags).toHaveBeenCalledWith([
558560
{
559561
path: "/path",
560-
tag: "_N_T_/path",
562+
tag: `${SOFT_TAG_PREFIX}path`,
561563
},
562564
]);
563565

@@ -621,4 +623,199 @@ describe("CacheHandler", () => {
621623
globalThis.tagCache.getPathsByTags = undefined;
622624
});
623625
});
626+
627+
describe("shouldBypassTagCache", () => {
628+
describe("fetch cache", () => {
629+
it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => {
630+
incrementalCache.get.mockResolvedValueOnce({
631+
value: {
632+
kind: "FETCH",
633+
data: {
634+
headers: {},
635+
body: "{}",
636+
url: "https://example.com",
637+
status: 200,
638+
},
639+
},
640+
lastModified: Date.now(),
641+
shouldBypassTagCache: true,
642+
});
643+
644+
const result = await cache.get("key", {
645+
kind: "FETCH",
646+
tags: ["tag1"],
647+
});
648+
649+
expect(getFetchCacheSpy).toHaveBeenCalled();
650+
expect(tagCache.getLastModified).not.toHaveBeenCalled();
651+
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
652+
expect(result).not.toBeNull();
653+
expect(result?.value).toEqual({
654+
kind: "FETCH",
655+
data: {
656+
headers: {},
657+
body: "{}",
658+
url: "https://example.com",
659+
status: 200,
660+
},
661+
});
662+
});
663+
664+
it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => {
665+
globalThis.tagCache.mode = "nextMode";
666+
incrementalCache.get.mockResolvedValueOnce({
667+
value: {
668+
kind: "FETCH",
669+
data: {
670+
headers: {},
671+
body: "{}",
672+
url: "https://example.com",
673+
status: 200,
674+
},
675+
},
676+
lastModified: Date.now(),
677+
shouldBypassTagCache: false,
678+
});
679+
680+
const result = await cache.get("key", {
681+
kind: "FETCH",
682+
tags: ["tag1"],
683+
});
684+
685+
expect(getFetchCacheSpy).toHaveBeenCalled();
686+
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
687+
expect(result).not.toBeNull();
688+
});
689+
690+
it("Should not bypass tag cache validation when shouldBypassTagCache is undefined", async () => {
691+
globalThis.tagCache.mode = "nextMode";
692+
tagCache.hasBeenRevalidated.mockResolvedValueOnce(false);
693+
incrementalCache.get.mockResolvedValueOnce({
694+
value: {
695+
kind: "FETCH",
696+
data: {
697+
headers: {},
698+
body: "{}",
699+
url: "https://example.com",
700+
status: 200,
701+
},
702+
},
703+
lastModified: Date.now(),
704+
// shouldBypassTagCache not set
705+
});
706+
707+
const result = await cache.get("key", {
708+
kind: "FETCH",
709+
tags: ["tag1"],
710+
});
711+
712+
expect(getFetchCacheSpy).toHaveBeenCalled();
713+
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
714+
expect(result).not.toBeNull();
715+
});
716+
717+
it("Should bypass path validation when shouldBypassTagCache is true for soft tags", async () => {
718+
incrementalCache.get.mockResolvedValueOnce({
719+
value: {
720+
kind: "FETCH",
721+
data: {
722+
headers: {},
723+
body: "{}",
724+
url: "https://example.com",
725+
status: 200,
726+
},
727+
},
728+
lastModified: Date.now(),
729+
shouldBypassTagCache: true,
730+
});
731+
732+
const result = await cache.get("key", {
733+
kind: "FETCH",
734+
softTags: [`${SOFT_TAG_PREFIX}path`],
735+
});
736+
737+
expect(getFetchCacheSpy).toHaveBeenCalled();
738+
expect(tagCache.getLastModified).not.toHaveBeenCalled();
739+
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
740+
expect(result).not.toBeNull();
741+
});
742+
});
743+
744+
describe("incremental cache", () => {
745+
it("Should bypass tag cache validation when shouldBypassTagCache is true", async () => {
746+
incrementalCache.get.mockResolvedValueOnce({
747+
value: {
748+
type: "route",
749+
body: "{}",
750+
},
751+
lastModified: Date.now(),
752+
shouldBypassTagCache: true,
753+
});
754+
755+
const result = await cache.get("key", { kindHint: "app" });
756+
757+
expect(getIncrementalCache).toHaveBeenCalled();
758+
expect(tagCache.getLastModified).not.toHaveBeenCalled();
759+
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
760+
expect(result).not.toBeNull();
761+
expect(result?.value?.kind).toEqual("ROUTE");
762+
});
763+
764+
it("Should not bypass tag cache validation when shouldBypassTagCache is false", async () => {
765+
globalThis.tagCache.mode = "nextMode";
766+
incrementalCache.get.mockResolvedValueOnce({
767+
value: {
768+
type: "route",
769+
body: "{}",
770+
},
771+
lastModified: Date.now(),
772+
shouldBypassTagCache: false,
773+
});
774+
775+
const result = await cache.get("key", { kindHint: "app" });
776+
777+
expect(getIncrementalCache).toHaveBeenCalled();
778+
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
779+
expect(result).not.toBeNull();
780+
});
781+
782+
it("Should return null when tag cache indicates revalidation and shouldBypassTagCache is false", async () => {
783+
globalThis.tagCache.mode = "nextMode";
784+
tagCache.hasBeenRevalidated.mockResolvedValueOnce(true);
785+
incrementalCache.get.mockResolvedValueOnce({
786+
value: {
787+
type: "route",
788+
body: "{}",
789+
},
790+
lastModified: Date.now(),
791+
shouldBypassTagCache: false,
792+
});
793+
794+
const result = await cache.get("key", { kindHint: "app" });
795+
796+
expect(getIncrementalCache).toHaveBeenCalled();
797+
expect(tagCache.hasBeenRevalidated).toHaveBeenCalled();
798+
expect(result).toBeNull();
799+
});
800+
801+
it("Should return value when tag cache indicates revalidation but shouldBypassTagCache is true", async () => {
802+
incrementalCache.get.mockResolvedValueOnce({
803+
value: {
804+
type: "route",
805+
body: "{}",
806+
},
807+
lastModified: Date.now(),
808+
shouldBypassTagCache: true,
809+
});
810+
811+
const result = await cache.get("key", { kindHint: "app" });
812+
813+
expect(getIncrementalCache).toHaveBeenCalled();
814+
expect(tagCache.getLastModified).not.toHaveBeenCalled();
815+
expect(tagCache.hasBeenRevalidated).not.toHaveBeenCalled();
816+
expect(result).not.toBeNull();
817+
expect(result?.value?.kind).toEqual("ROUTE");
818+
});
819+
});
820+
});
624821
});

0 commit comments

Comments
 (0)