diff --git a/.changeset/rich-pumas-shop.md b/.changeset/rich-pumas-shop.md new file mode 100644 index 000000000..f0b15550f --- /dev/null +++ b/.changeset/rich-pumas-shop.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +Add a new option to bypass checking the tag cache from an incremental cache get diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index 6f0945b93..c7a8ef748 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -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 @@ -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; @@ -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; @@ -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(); @@ -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); @@ -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}`), ), ); diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index 0c4fecdc2..3ef5df796 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -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; } diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index e6ea87120..1b893144a 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -93,6 +93,11 @@ export type CachedFetchValue = { export type WithLastModified = { 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; diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index af507200c..0c74226ca 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -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 { @@ -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 () => { @@ -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`, }, ]); @@ -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"); + }); + }); + }); });