diff --git a/.changeset/port-pr-1122.md b/.changeset/port-pr-1122.md new file mode 100644 index 00000000..ff885c93 --- /dev/null +++ b/.changeset/port-pr-1122.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/aws": patch +--- + +Ported PR #1122 from source repository - Add support for swr with revalidateTag + +https://github.com/opennextjs/opennextjs-aws/pull/1122 diff --git a/.changeset/port-pr-1142.md b/.changeset/port-pr-1142.md index 19aeb7f0..33390f39 100644 --- a/.changeset/port-pr-1142.md +++ b/.changeset/port-pr-1142.md @@ -1,7 +1,9 @@ --- -"@opennextjs/cloudflare": patch +"@opennextjs/aws": patch --- Ported PR #1142 from source repository -https://github.com/opennextjs/opennextjs-cloudflare/pull/1142 +Enhance cache stale checking logic - Fix cache interceptor isStale handling for Next 16+, fix next mode tag cache not retrieving stale data properly + +https://github.com/opennextjs/opennextjs-aws/pull/1142 diff --git a/.changeset/port-pr-1168.md b/.changeset/port-pr-1168.md new file mode 100644 index 00000000..e759a62b --- /dev/null +++ b/.changeset/port-pr-1168.md @@ -0,0 +1,9 @@ +--- +"@opennextjs/cloudflare": minor +--- + +Ported PR #1168 from source repository + +Add support for SWR (stale-while-revalidate) in `revalidateTag` + +https://github.com/opennextjs/opennextjs-cloudflare/pull/1168 diff --git a/.changeset/port-pr-1193.md b/.changeset/port-pr-1193.md new file mode 100644 index 00000000..a004713f --- /dev/null +++ b/.changeset/port-pr-1193.md @@ -0,0 +1,9 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Ported PR #1193 from source repository + +Fix tag cache stale logic and update tests + +https://github.com/opennextjs/opennextjs-cloudflare/pull/1193 diff --git a/.changeset/port-pr-1200.md b/.changeset/port-pr-1200.md new file mode 100644 index 00000000..b7360ac3 --- /dev/null +++ b/.changeset/port-pr-1200.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Ported PR #1200 from source repository + +https://github.com/opennextjs/opennextjs-cloudflare/pull/1200 diff --git a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts index 9dd00e82..8e22cd1a 100644 --- a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts @@ -37,7 +37,81 @@ describe("DOShardedTagCache class", () => { // @ts-expect-error - testing private method expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled(); expect(cache.sql.exec).toHaveBeenCalledWith( - `CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)` + `CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER, stale INTEGER, expire INTEGER DEFAULT NULL)` ); + expect(cache.sql.exec).toHaveBeenCalledWith( + `ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER DEFAULT NULL` + ); + }); + + describe("getTagData", () => { + it("should return an empty object for empty tags", async () => { + const cache = createDOShardedTagCache(); + const result = await cache.getTagData([]); + expect(result).toEqual({}); + expect(cache.sql.exec).not.toHaveBeenCalledWith(expect.stringContaining("SELECT"), expect.anything()); + }); + + it("should query all columns and return a record", async () => { + const cache = createDOShardedTagCache(); + vi.mocked(cache.sql.exec).mockReturnValueOnce({ + toArray: () => [ + { tag: "tag1", revalidatedAt: 1000, stale: 1000, expire: null }, + { tag: "tag2", revalidatedAt: 2000, stale: 1500, expire: 9999 }, + ], + } as ReturnType); + const result = await cache.getTagData(["tag1", "tag2"]); + expect(result).toEqual({ + tag1: { revalidatedAt: 1000, stale: 1000, expire: null }, + tag2: { revalidatedAt: 2000, stale: 1500, expire: 9999 }, + }); + }); + + it("should return empty object on SQL error", async () => { + const cache = createDOShardedTagCache(); + vi.mocked(cache.sql.exec).mockImplementationOnce(() => { + throw new Error("sql error"); + }); + const result = await cache.getTagData(["tag1"]); + expect(result).toEqual({}); + }); + }); + + describe("writeTags", () => { + it("should write string tags using the old format (backward compat)", async () => { + const cache = createDOShardedTagCache(); + await cache.writeTags(["tag1", "tag2"], 1000); + expect(cache.sql.exec).toHaveBeenCalledWith( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`, + "tag1", + 1000, + 1000 + ); + expect(cache.sql.exec).toHaveBeenCalledWith( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`, + "tag2", + 1000, + 1000 + ); + }); + + it("should write object tags using stale and expire", async () => { + const cache = createDOShardedTagCache(); + await cache.writeTags([{ tag: "tag1", stale: 5000, expire: 9999 }]); + expect(cache.sql.exec).toHaveBeenCalledWith( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`, + "tag1", + 5000, + 5000, + 9999 + ); + }); + + it("should return early for empty tags", async () => { + const cache = createDOShardedTagCache(); + const execCallsBeforeCreate = vi.mocked(cache.sql.exec).mock.calls.length; + await cache.writeTags([]); + expect(vi.mocked(cache.sql.exec).mock.calls.length).toBe(execCallsBeforeCreate); + }); }); }); diff --git a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts index cca2d1f2..b3e7dd42 100644 --- a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts @@ -2,6 +2,12 @@ import { DurableObject } from "cloudflare:workers"; import { debugCache } from "../overrides/internal.js"; +export type TagData = { + revalidatedAt: number; + stale: number | null; + expire: number | null; +}; + export class DOShardedTagCache extends DurableObject { sql: SqlStorage; @@ -9,62 +15,95 @@ export class DOShardedTagCache extends DurableObject { super(state, env); this.sql = state.storage.sql; state.blockConcurrencyWhile(async () => { - this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)`); + this.sql.exec( + `CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER, stale INTEGER, expire INTEGER DEFAULT NULL)` + ); + try { + this.sql.exec( + `ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER DEFAULT NULL` + ); + } catch { + // The ALTER TABLE statement fails if the columns already exist. + } }); } - async getLastRevalidated(tags: string[]): Promise { + async getTagData(tags: string[]): Promise> { + if (tags.length === 0) return {}; try { const result = this.sql .exec( - `SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, + `SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, ...tags ) .toArray(); - const timeMs = (result[0]?.time ?? 0) as number; - debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`); - return timeMs; + debugCache("DOShardedTagCache", `getTagData tags=${tags} -> ${result.length} results`); + return Object.fromEntries( + result.map((row) => [ + row.tag as string, + { + revalidatedAt: (row.revalidatedAt ?? 0) as number, + stale: (row.stale ?? null) as number | null, + expire: (row.expire ?? null) as number | null, + }, + ]) + ); } catch (e) { console.error(e); - // By default we don't want to crash here, so we return 0 - return 0; + return {}; } } - async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { - const revalidated = - this.sql - .exec( - `SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1`, - ...tags, - lastModified ?? Date.now() - ) - .toArray().length > 0; + async getLastRevalidated(tags: string[]): Promise { + const data = await this.getTagData(tags); + const values = Object.values(data); + const timeMs = values.length === 0 ? 0 : Math.max(...values.map(({ revalidatedAt }) => revalidatedAt)); + debugCache("DOShardedTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`); + return timeMs; + } + async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { + const data = await this.getTagData(tags); + const lastModifiedOrNowMs = lastModified ?? Date.now(); + const revalidated = Object.values(data).some(({ revalidatedAt }) => revalidatedAt > lastModifiedOrNowMs); debugCache("DOShardedTagCache", `hasBeenRevalidated tags=${tags} -> revalidated=${revalidated}`); return revalidated; } - async writeTags(tags: string[], lastModified: number): Promise { - debugCache("DOShardedTagCache", `writeTags tags=${tags} time=${lastModified}`); - - tags.forEach((tag) => { - this.sql.exec( - `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, - tag, - lastModified - ); - }); + async getRevalidationTimes(tags: string[]): Promise> { + const data = await this.getTagData(tags); + return Object.fromEntries(Object.entries(data).map(([tag, { revalidatedAt }]) => [tag, revalidatedAt])); } - async getRevalidationTimes(tags: string[]): Promise> { - const result = this.sql - .exec( - `SELECT tag, revalidatedAt FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})`, - ...tags - ) - .toArray(); - return Object.fromEntries(result.map((row) => [row.tag, row.revalidatedAt])); + async writeTags( + tags: Array, + lastModified?: number + ): Promise { + if (tags.length === 0) return; + const nowMs = lastModified ?? Date.now(); + debugCache("DOShardedTagCache", `writeTags tags=${JSON.stringify(tags)} time=${nowMs}`); + + if (typeof tags[0] === "string") { + for (const tag of tags as string[]) { + this.sql.exec( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale) VALUES (?, ?, ?)`, + tag, + nowMs, + nowMs + ); + } + } else { + for (const entry of tags as Array<{ tag: string; stale?: number; expire?: number | null }>) { + const staleValue = entry.stale ?? nowMs; + this.sql.exec( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`, + entry.tag, + staleValue, + staleValue, + entry.expire ?? null + ); + } + } } } diff --git a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts index d6537c11..447cd68c 100644 --- a/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts +++ b/packages/cloudflare/src/api/overrides/incremental-cache/regional-cache.ts @@ -1,4 +1,5 @@ import { error } from "@opennextjs/aws/adapters/logger.js"; +import { compareSemver } from "@opennextjs/aws/build/helper.js"; import { CacheEntryType, CacheValue, @@ -31,21 +32,33 @@ type Options = { defaultLongLivedTtlSec?: number; /** - * Whether the regional cache entry should be updated in the background or not when it experiences - * a cache hit. + * Whether the regional cache entry should be updated in the background on regional cache hits. * - * @default `true` in `long-lived` mode when cache purge is not used, `false` otherwise. + * NOTE: Use the default value unless you know what you are doing. It is set to: + * - Next < 16: + * `true` in `long-lived` mode when cache purge is not used, `false` otherwise. + * - Next >= 16: + * `!bypassTagCacheOnCacheHit` */ shouldLazilyUpdateOnCacheHit?: boolean; /** - * Whether on cache hits the tagCache should be skipped or not. Skipping the tagCache allows requests to be - * handled faster, + * Whether the tagCache should be skipped on regional cache hits. * - * Note: When this is enabled, make sure that the cache gets purged - * either by enabling the auto cache purging feature or manually. + * Note: + * - Skipping the tagCache allows requests to be handled faster + * - When `true`, make sure the cache gets purged + * either by enabling the auto cache purging feature or manually * - * @default `true` if the auto cache purging is enabled, `false` otherwise. + * `true` is not compatible with SWR types of revalidateTag + * i.e. on Next 16+, anything different than `revalidateTag("tag", { expire: 0 })`. + * That's why the default is `false` for Next 16+ which uses SWR by default. + * + * NOTE: Use the default value unless you know what you are doing. It is set to: + * - Next < 16: + * `true` if the auto cache purging is enabled, `false` otherwise. + * - Next >= 16: + * `false` */ bypassTagCacheOnCacheHit?: boolean; }; @@ -78,17 +91,33 @@ class RegionalCache implements IncrementalCache { private opts: Options ) { this.name = this.store.name; - // `shouldLazilyUpdateOnCacheHit` is not needed when cache purge is enabled. - this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled(); - } - get #bypassTagCacheOnCacheHit(): boolean { - if (this.opts.bypassTagCacheOnCacheHit !== undefined) { - return this.opts.bypassTagCacheOnCacheHit; + // `globalThis.nextVersion` is only defined at runtime but not when the Open Next build runs. + // The options do no matter at build time so we can skip setting them. + const { nextVersion } = globalThis; + if (nextVersion) { + if (compareSemver(nextVersion, "<", "16")) { + // Next < 16 + this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived" && !isPurgeCacheEnabled(); + this.opts.bypassTagCacheOnCacheHit ??= isPurgeCacheEnabled(); + } else { + // Next >= 16 + this.opts.bypassTagCacheOnCacheHit ??= false; + if (this.opts.bypassTagCacheOnCacheHit) { + debugCache( + "RegionalCache", + `bypassTagCacheOnCacheHit is not recommended for Next 16+ as it is not compatible with SWR tags. Make sure to always use \`revalidateTag\` with \`{ expire: 0 }\` if you want to bypass the tag cache.` + ); + } + this.opts.shouldLazilyUpdateOnCacheHit ??= !this.opts.bypassTagCacheOnCacheHit; + if (this.opts.shouldLazilyUpdateOnCacheHit !== !this.opts.bypassTagCacheOnCacheHit) { + debugCache( + "RegionalCache", + `\`shouldLazilyUpdateOnCacheHit\` and \`bypassTagCacheOnCacheHit\` are mutually exclusive for Next 16+.` + ); + } + } } - - // When `bypassTagCacheOnCacheHit` is not set, we default to whether the automatic cache purging is enabled or not - return isPurgeCacheEnabled(); } async get( @@ -123,7 +152,7 @@ class RegionalCache implements IncrementalCache { return { ...responseJson, - shouldBypassTagCache: this.#bypassTagCacheOnCacheHit, + shouldBypassTagCache: this.opts.bypassTagCacheOnCacheHit, }; } diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts index 9f531d7c..5bd6aad8 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.spec.ts @@ -1,8 +1,5 @@ -/** - * Author: Copilot (Claude Sonnet 4) - */ import { error } from "@opennextjs/aws/adapters/logger.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getCloudflareContext } from "../../cloudflare-context.js"; import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js"; @@ -33,22 +30,17 @@ describe("D1NextModeTagCache", () => { }; let mockPrepare: ReturnType; let mockBind: ReturnType; - let mockRun: ReturnType; let mockRaw: ReturnType; let mockBatch: ReturnType; beforeEach(() => { vi.clearAllMocks(); - // Setup mock database - mockRun = vi.fn(); - mockRaw = vi.fn(); - mockBind = vi.fn().mockReturnThis(); - mockPrepare = vi.fn().mockReturnValue({ - bind: mockBind, - run: mockRun, - raw: mockRaw, - }); + // Setup mock database. + // All read methods now use .raw() (via #resolveTagValues) to fetch full rows. + mockRaw = vi.fn().mockResolvedValue([]); + mockBind = vi.fn().mockReturnValue({ raw: mockRaw }); + mockPrepare = vi.fn().mockReturnValue({ bind: mockBind }); mockBatch = vi.fn(); mockDb = { @@ -70,12 +62,19 @@ describe("D1NextModeTagCache", () => { }, }; + // Ensure __openNextAls is not set (tests that need it will set it up explicitly) + (globalThis as Record).__openNextAls = undefined; + // Reset environment variables vi.unstubAllEnvs(); tagCache = new D1NextModeTagCache(); }); + afterEach(() => { + (globalThis as Record).__openNextAls = undefined; + }); + describe("constructor and properties", () => { it("should have correct mode and name", () => { expect(tagCache.mode).toBe("nextMode"); @@ -106,26 +105,31 @@ describe("D1NextModeTagCache", () => { expect(debugCache).toHaveBeenCalledWith("No D1 database found"); }); + it("should return 0 for empty tags", async () => { + const result = await tagCache.getLastRevalidated([]); + expect(result).toBe(0); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + it("should return the maximum revalidation time for given tags", async () => { - const mockTime = 1234567890; - mockRun.mockResolvedValue({ - results: [{ time: mockTime }], - }); + // D1 returns rows as arrays: [tag, revalidatedAt, stale, expire] + mockRaw.mockResolvedValue([ + [`${FALLBACK_BUILD_ID}/tag1`, 1000, 1000, null], + [`${FALLBACK_BUILD_ID}/tag2`, 2000, 2000, null], + ]); const tags = ["tag1", "tag2"]; const result = await tagCache.getLastRevalidated(tags); - expect(result).toBe(mockTime); + expect(result).toBe(2000); expect(mockPrepare).toHaveBeenCalledWith( - "SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (?, ?)" + "SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (?, ?)" ); expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag1`, `${FALLBACK_BUILD_ID}/tag2`); }); it("should return 0 when no results are found", async () => { - mockRun.mockResolvedValue({ - results: [], - }); + mockRaw.mockResolvedValue([]); const result = await tagCache.getLastRevalidated(["tag1"]); @@ -134,7 +138,7 @@ describe("D1NextModeTagCache", () => { it("should return 0 when database query throws an error", async () => { const mockError = new Error("Database error"); - mockRun.mockRejectedValue(mockError); + mockRaw.mockRejectedValue(mockError); const result = await tagCache.getLastRevalidated(["tag1"]); @@ -146,9 +150,7 @@ describe("D1NextModeTagCache", () => { const customBuildId = "custom-build-id"; vi.stubEnv("NEXT_BUILD_ID", customBuildId); - mockRun.mockResolvedValue({ - results: [{ time: 123 }], - }); + mockRaw.mockResolvedValue([[`${customBuildId}/tag1`, 123, 123, null]]); await tagCache.getLastRevalidated(["tag1"]); @@ -178,26 +180,46 @@ describe("D1NextModeTagCache", () => { expect(result).toBe(false); }); - it("should return true when tags have been revalidated after lastModified", async () => { - mockRaw.mockResolvedValue([{ "1": 1 }]); // Non-empty result + it("should return true when revalidatedAt > lastModified and expire is null", async () => { + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 2000, 2000, null]]); - const tags = ["tag1", "tag2"]; - const lastModified = 1000; - const result = await tagCache.hasBeenRevalidated(tags, lastModified); + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); expect(result).toBe(true); - expect(mockPrepare).toHaveBeenCalledWith( - "SELECT 1 FROM revalidations WHERE tag IN (?, ?) AND revalidatedAt > ? LIMIT 1" - ); - expect(mockBind).toHaveBeenCalledWith( - `${FALLBACK_BUILD_ID}/tag1`, - `${FALLBACK_BUILD_ID}/tag2`, - lastModified - ); + }); + + it("should return false when revalidatedAt <= lastModified", async () => { + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 500, null]]); + + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return true when expire <= now and expire > lastModified", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + // expire=1500, lastModified=1000: expire <= now (1500 <= 2000) && expire > lastModified (1500 > 1000) + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 500, 1500]]); + + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return false when expire > now (not yet expired)", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + // expire=3000: expire <= now (3000 <= 2000) is false + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 500, 3000]]); + + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); + + expect(result).toBe(false); }); it("should return false when no tags have been revalidated", async () => { - mockRaw.mockResolvedValue([]); // Empty result + mockRaw.mockResolvedValue([]); const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); @@ -205,13 +227,14 @@ describe("D1NextModeTagCache", () => { }); it("should use current time as default when lastModified is not provided", async () => { - const currentTime = Date.now(); - vi.spyOn(Date, "now").mockReturnValue(currentTime); - mockRaw.mockResolvedValue([]); + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + // revalidatedAt=1500 is not > now=2000 when lastModified defaults to now + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 1500, 1500, null]]); - await tagCache.hasBeenRevalidated(["tag1"]); + const result = await tagCache.hasBeenRevalidated(["tag1"]); - expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag1`, currentTime); + expect(result).toBe(false); }); it("should return false when database query throws an error", async () => { @@ -226,6 +249,15 @@ describe("D1NextModeTagCache", () => { }); describe("writeTags", () => { + beforeEach(() => { + // writeTags uses .bind().run() pattern for batch, so override the mock chain + mockBind = vi.fn().mockReturnThis(); + mockPrepare = vi.fn().mockReturnValue({ + bind: mockBind, + }); + mockDb.prepare = mockPrepare; + }); + it("should do nothing when cache is disabled", async () => { ( globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } @@ -274,12 +306,25 @@ describe("D1NextModeTagCache", () => { // Verify the prepared statements were created correctly expect(mockPrepare).toHaveBeenCalledTimes(2); expect(mockPrepare).toHaveBeenCalledWith( - "INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)" + "INSERT INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)" ); expect(purgeCacheByTags).toHaveBeenCalledWith(tags); }); + it("should write object tags with explicit stale and expire", async () => { + const currentTime = 1000; + vi.spyOn(Date, "now").mockReturnValue(currentTime); + + await tagCache.writeTags([{ tag: "tag1", stale: 500, expire: 9999 }]); + + expect(mockPrepare).toHaveBeenCalledWith( + "INSERT INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)" + ); + expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag1`, 500, 500, 9999); + expect(purgeCacheByTags).toHaveBeenCalledWith(["tag1"]); + }); + it("should handle single tag", async () => { const currentTime = Date.now(); vi.spyOn(Date, "now").mockReturnValue(currentTime); @@ -296,6 +341,211 @@ describe("D1NextModeTagCache", () => { }); }); + describe("isStale", () => { + beforeEach(() => { + mockBind = vi.fn().mockReturnValue({ raw: mockRaw }); + mockPrepare = vi.fn().mockReturnValue({ bind: mockBind }); + mockDb.prepare = mockPrepare; + }); + + it("should return false when cache is disabled", async () => { + ( + globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } + ).openNextConfig!.dangerous!.disableTagCache = true; + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it("should return false when no database is available", async () => { + vi.mocked(getCloudflareContext).mockReturnValue({ + env: {}, + } as ReturnType); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when tags array is empty", async () => { + const result = await tagCache.isStale([], 1000); + expect(result).toBe(false); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it("should return true when stale > lastModified and expire is null", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 1500, 1500, null]]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return true when stale > lastModified and expire > now", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 1500, 1500, 3000]]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return false when stale <= lastModified", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 500, null]]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 500, 1500, null]]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when expire <= now (tag expired)", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + // stale=1500 > lastModified=1000, but expire=1999 <= now=2000 + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 1500, 1500, 1999]]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when D1 value is null (tag not found)", async () => { + mockRaw.mockResolvedValue([]); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when database query throws an error", async () => { + mockRaw.mockRejectedValue(new Error("db error")); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + expect(error).toHaveBeenCalled(); + }); + }); + + describe("requestCache", () => { + /** + * Creates a mock ALS store with a requestCache that uses a simple Map-based getOrCreate. + */ + function setupRequestCache() { + const caches = new Map>(); + const store = { + requestCache: { + getOrCreate(namespace: string): Map { + if (!caches.has(namespace)) { + caches.set(namespace, new Map()); + } + return caches.get(namespace)! as Map; + }, + }, + }; + (globalThis as Record).__openNextAls = { + getStore: () => store, + }; + return caches; + } + + it("should not query D1 for tags already in the request cache", async () => { + setupRequestCache(); + + // First call populates the request cache + mockRaw.mockResolvedValueOnce([[`${FALLBACK_BUILD_ID}/tag1`, 2000, 2000, null]]); + await tagCache.getLastRevalidated(["tag1"]); + expect(mockPrepare).toHaveBeenCalledTimes(1); + + // Second call for the same tag should not query D1 again + mockPrepare.mockClear(); + const result = await tagCache.getLastRevalidated(["tag1"]); + + expect(result).toBe(2000); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it("should share the request cache across methods", async () => { + setupRequestCache(); + + // getLastRevalidated populates the cache + mockRaw.mockResolvedValueOnce([[`${FALLBACK_BUILD_ID}/tag1`, 2000, 2000, null]]); + await tagCache.getLastRevalidated(["tag1"]); + expect(mockPrepare).toHaveBeenCalledTimes(1); + + // hasBeenRevalidated for the same tag should not query D1 + mockPrepare.mockClear(); + const result = await tagCache.hasBeenRevalidated(["tag1"], 1000); + + expect(result).toBe(true); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it("should query D1 only for uncached tags in a mixed request", async () => { + setupRequestCache(); + + // First call caches tag1 + mockRaw.mockResolvedValueOnce([[`${FALLBACK_BUILD_ID}/tag1`, 2000, 2000, null]]); + await tagCache.getLastRevalidated(["tag1"]); + + // Second call with tag1 (cached) and tag2 (uncached) + mockPrepare.mockClear(); + mockRaw.mockResolvedValueOnce([[`${FALLBACK_BUILD_ID}/tag2`, 3000, 3000, null]]); + const result = await tagCache.getLastRevalidated(["tag1", "tag2"]); + + expect(result).toBe(3000); + // Should only query for tag2 + expect(mockPrepare).toHaveBeenCalledTimes(1); + expect(mockBind).toHaveBeenCalledWith(`${FALLBACK_BUILD_ID}/tag2`); + }); + + it("should cache null for tags not found in D1", async () => { + setupRequestCache(); + + // First call: tag1 not found in D1 + mockRaw.mockResolvedValueOnce([]); + await tagCache.getLastRevalidated(["tag1"]); + expect(mockPrepare).toHaveBeenCalledTimes(1); + + // Second call: should not re-query D1 for tag1 (cached as null) + mockPrepare.mockClear(); + const result = await tagCache.getLastRevalidated(["tag1"]); + + expect(result).toBe(0); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it("should work without __openNextAls (no request cache)", async () => { + // __openNextAls is undefined (set in beforeEach) + mockRaw.mockResolvedValue([[`${FALLBACK_BUILD_ID}/tag1`, 2000, 2000, null]]); + + const result1 = await tagCache.getLastRevalidated(["tag1"]); + const result2 = await tagCache.getLastRevalidated(["tag1"]); + + expect(result1).toBe(2000); + expect(result2).toBe(2000); + // Without request cache, D1 is queried both times + expect(mockPrepare).toHaveBeenCalledTimes(2); + }); + }); + describe("getCacheKey", () => { it("should generate cache key with build ID and tag", () => { const key = "test-tag"; @@ -335,8 +585,6 @@ describe("D1NextModeTagCache", () => { }); it("should return fallback build ID when NEXT_BUILD_ID is not set", () => { - // Environment variables are cleared by vi.unstubAllEnvs() in beforeEach - const buildId = (tagCache as unknown as { getBuildId: () => string }).getBuildId(); expect(buildId).toBe(FALLBACK_BUILD_ID); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts index c13225b7..8cd1815e 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/d1-next-tag-cache.ts @@ -1,5 +1,5 @@ import { error } from "@opennextjs/aws/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; @@ -8,6 +8,8 @@ export const NAME = "d1-next-mode-tag-cache"; export const BINDING_NAME = "NEXT_TAG_CACHE_D1"; +type D1TagValue = { revalidatedAt: number; stale: number | null; expire: number | null }; + export class D1NextModeTagCache implements NextModeTagCache { readonly mode = "nextMode" as const; readonly name = NAME; @@ -18,19 +20,15 @@ export class D1NextModeTagCache implements NextModeTagCache { return 0; } try { - const result = await db - .prepare( - `SELECT MAX(revalidatedAt) AS time FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")})` - ) - .bind(...tags.map((tag) => this.getCacheKey(tag))) - .run(); + const result = await this.#resolveTagValues(tags, db); - const timeMs = (result.results[0]?.time ?? 0) as number; + const revalidations = [...result.values()] + .filter((v): v is D1TagValue => v != null) + .map((v) => v.revalidatedAt); + const timeMs = revalidations.length === 0 ? 0 : Math.max(...revalidations); debugCache("D1NextModeTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`); return timeMs; } catch (e) { - // By default we don't want to crash here, so we return false - // We still log the error though so we can debug it error(e); return 0; } @@ -42,14 +40,16 @@ export class D1NextModeTagCache implements NextModeTagCache { return false; } try { - const result = await db - .prepare( - `SELECT 1 FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ? LIMIT 1` - ) - .bind(...tags.map((tag) => this.getCacheKey(tag)), lastModified ?? Date.now()) - .raw(); + const now = Date.now(); + const result = await this.#resolveTagValues(tags, db); + + const revalidated = [...result.values()].some((v) => { + if (v == null) return false; + const { revalidatedAt, expire } = v; + if (expire != null) return expire <= now && expire > (lastModified ?? 0); + return revalidatedAt > (lastModified ?? now); + }); - const revalidated = result.length > 0; debugCache( "D1NextModeTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${revalidated}` @@ -57,32 +57,102 @@ export class D1NextModeTagCache implements NextModeTagCache { return revalidated; } catch (e) { error(e); - // By default we don't want to crash here, so we return false - // We still log the error though so we can debug it return false; } } - async writeTags(tags: string[]): Promise { + async writeTags(tags: NextModeTagCacheWriteInput[]): Promise { const { isDisabled, db } = this.getConfig(); if (isDisabled || tags.length === 0) return Promise.resolve(); const nowMs = Date.now(); await db.batch( - tags.map((tag) => - db - .prepare(`INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`) - .bind(this.getCacheKey(tag), nowMs) - ) + tags.map((tag) => { + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? nowMs : (tag.stale ?? nowMs); + const expire = typeof tag === "string" ? null : (tag.expire ?? null); + return db + .prepare(`INSERT INTO revalidations (tag, revalidatedAt, stale, expire) VALUES (?, ?, ?, ?)`) + .bind(this.getCacheKey(tagStr), stale, stale, expire); + }) ); - debugCache("D1NextModeTagCache", `writeTags tags=${tags} time=${nowMs}`); + const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag)); + debugCache("D1NextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`); // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 if (isPurgeCacheEnabled()) { - await purgeCacheByTags(tags); + await purgeCacheByTags(tagStrings); + } + } + + async isStale(tags: string[], lastModified?: number): Promise { + const { isDisabled, db } = this.getConfig(); + if (isDisabled || tags.length === 0) { + return false; + } + try { + const now = Date.now(); + const result = await this.#resolveTagValues(tags, db); + + const isStale = [...result.values()].some((v) => { + if (v == null) return false; + const { revalidatedAt, stale, expire } = v; + const lastModifiedOrNow = lastModified ?? now; + const isInStaleWindow = + stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale; + if (!isInStaleWindow) return false; + return expire == null || expire > now; + }); + + debugCache("D1NextModeTagCache", `isStale tags=${tags} at=${lastModified} -> ${isStale}`); + return isStale; + } catch (e) { + error(e); + return false; + } + } + + async #resolveTagValues(tags: string[], db: D1Database): Promise> { + const result = new Map(); + const uncachedTags: string[] = []; + + const itemsCache = this.getItemsCache(); + + for (const tag of tags) { + if (itemsCache?.has(tag)) { + result.set(tag, itemsCache.get(tag) ?? null); + } else { + uncachedTags.push(tag); + } + } + + if (uncachedTags.length > 0) { + const rows = await db + .prepare( + `SELECT tag, revalidatedAt, stale, expire FROM revalidations WHERE tag IN (${uncachedTags.map(() => "?").join(", ")})` + ) + .bind(...uncachedTags.map((tag) => this.getCacheKey(tag))) + .raw(); + + const rowsByKey = new Map(rows.map((row) => [row[0] as string, row])); + + for (const tag of uncachedTags) { + const row = rowsByKey.get(this.getCacheKey(tag)); + const value: D1TagValue | null = row + ? { + revalidatedAt: (row[1] as number) ?? 0, + stale: (row[2] as number) ?? null, + expire: (row[3] as number) ?? null, + } + : null; + itemsCache?.set(tag, value); + result.set(tag, value); + } } + + return result; } private getConfig() { @@ -107,6 +177,11 @@ export class D1NextModeTagCache implements NextModeTagCache { protected getBuildId() { return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID; } + + protected getItemsCache() { + const store = globalThis.__openNextAls?.getStore(); + return store?.requestCache.getOrCreate("d1-nextMode:tagItems"); + } } export default new D1NextModeTagCache(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts index 15ea2a49..ae71f535 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.spec.ts @@ -5,11 +5,11 @@ import shardedDOTagCache, { AVAILABLE_REGIONS, DOId } from "./do-sharded-tag-cac const hasBeenRevalidatedMock = vi.fn(); const writeTagsMock = vi.fn(); const idFromNameMock = vi.fn(); -const getRevalidationTimesMock = vi.fn(); +const getTagDataMock = vi.fn(); const getMock = vi.fn().mockReturnValue({ hasBeenRevalidated: hasBeenRevalidatedMock, writeTags: writeTagsMock, - getRevalidationTimes: getRevalidationTimesMock, + getTagData: getTagDataMock, }); const waitUntilMock = vi.fn().mockImplementation(async (fn) => fn()); globalThis.continent = undefined; @@ -213,55 +213,57 @@ describe("DOShardedTagCache", () => { expect(idFromNameMock).not.toHaveBeenCalled(); }); - it("should return false if stub return false", async () => { + it("should return false if stub returns no recently revalidated data", async () => { const cache = shardedDOTagCache(); cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); - hasBeenRevalidatedMock.mockImplementationOnce(() => false); + getTagDataMock.mockResolvedValueOnce({}); const result = await cache.hasBeenRevalidated(["tag1"], 123456); expect(cache.getFromRegionalCache).toHaveBeenCalled(); expect(idFromNameMock).toHaveBeenCalled(); - expect(hasBeenRevalidatedMock).toHaveBeenCalled(); + expect(getTagDataMock).toHaveBeenCalled(); expect(result).toBe(false); }); - it("should return true if stub return true", async () => { + it("should return true if stub returns revalidated data", async () => { const cache = shardedDOTagCache(); cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); - hasBeenRevalidatedMock.mockImplementationOnce(() => true); + getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 123457, stale: null, expire: null } }); const result = await cache.hasBeenRevalidated(["tag1"], 123456); expect(cache.getFromRegionalCache).toHaveBeenCalled(); expect(idFromNameMock).toHaveBeenCalled(); - expect(hasBeenRevalidatedMock).toHaveBeenCalledWith(["tag1"], 123456); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); expect(result).toBe(true); }); it("should return false if it throws", async () => { const cache = shardedDOTagCache(); cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); - hasBeenRevalidatedMock.mockImplementationOnce(() => { + getTagDataMock.mockImplementationOnce(() => { throw new Error("error"); }); const result = await cache.hasBeenRevalidated(["tag1"], 123456); expect(cache.getFromRegionalCache).toHaveBeenCalled(); expect(idFromNameMock).toHaveBeenCalled(); - expect(hasBeenRevalidatedMock).toHaveBeenCalled(); + expect(getTagDataMock).toHaveBeenCalled(); expect(result).toBe(false); }); it("Should return from the cache if it was found there", async () => { const cache = shardedDOTagCache(); - cache.getFromRegionalCache = vi.fn().mockReturnValueOnce([{ tag: "tag1", time: 1234567 }]); + cache.getFromRegionalCache = vi + .fn() + .mockReturnValueOnce([{ tag: "tag1", revalidatedAt: 1234567, stale: null, expire: null }]); const result = await cache.hasBeenRevalidated(["tag1"], 123456); expect(result).toBe(true); expect(idFromNameMock).not.toHaveBeenCalled(); - expect(hasBeenRevalidatedMock).not.toHaveBeenCalled(); + expect(getTagDataMock).not.toHaveBeenCalled(); }); it("should try to put the result in the cache if it was not revalidated", async () => { const cache = shardedDOTagCache(); cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); cache.putToRegionalCache = vi.fn(); - hasBeenRevalidatedMock.mockImplementationOnce(() => false); + getTagDataMock.mockResolvedValueOnce({}); const result = await cache.hasBeenRevalidated(["tag1"], 123456); expect(result).toBe(false); @@ -269,13 +271,103 @@ describe("DOShardedTagCache", () => { expect(cache.putToRegionalCache).toHaveBeenCalled(); }); - it("should call all the durable object instance", async () => { + it("should only read tag data once on a regional-cache miss", async () => { + const putMock = vi.fn(); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + match: vi.fn().mockResolvedValue(null), + put: putMock, + }), + }; + const cache = shardedDOTagCache({ baseShardSize: 4, regionalCache: true }); + cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); + getTagDataMock.mockResolvedValueOnce({ + tag1: { revalidatedAt: 123455, stale: null, expire: null }, + }); + + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + + expect(result).toBe(false); + expect(getTagDataMock).toHaveBeenCalledTimes(1); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); + expect(putMock).toHaveBeenCalledTimes(1); + expect(putMock).toHaveBeenCalledWith( + "http://local.cache/shard/tag-hard;shard-1?tag=tag1", + expect.any(Response) + ); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should call all the durable object instances", async () => { const cache = shardedDOTagCache(); cache.getFromRegionalCache = vi.fn().mockResolvedValue([]); + getTagDataMock.mockResolvedValue({}); const result = await cache.hasBeenRevalidated(["tag1", "tag2"], 123456); expect(result).toBe(false); expect(idFromNameMock).toHaveBeenCalledTimes(2); - expect(hasBeenRevalidatedMock).toHaveBeenCalledTimes(2); + expect(getTagDataMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("isStale", () => { + beforeEach(() => { + globalThis.openNextConfig = { + dangerous: { disableTagCache: false }, + }; + }); + + it("should return false if the cache is disabled", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + const cache = shardedDOTagCache(); + expect(await cache.isStale(["tag1"])).toBe(false); + expect(idFromNameMock).not.toHaveBeenCalled(); + }); + + it("should return false when there are no tags", async () => { + const cache = shardedDOTagCache(); + expect(await cache.isStale([])).toBe(false); + }); + + it("should return false when stub returns no stale data", async () => { + const cache = shardedDOTagCache(); + cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); + getTagDataMock.mockResolvedValueOnce({}); + expect(await cache.isStale(["tag1"], 123456)).toBe(false); + }); + + it("should return true when stub returns stale data (no expire)", async () => { + const cache = shardedDOTagCache(); + cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); + getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 200, stale: 200, expire: null } }); + expect(await cache.isStale(["tag1"], 100)).toBe(true); + }); + + it("should return false when stale <= lastModified", async () => { + const cache = shardedDOTagCache(); + cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); + getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 100, stale: 100, expire: null } }); + expect(await cache.isStale(["tag1"], 200)).toBe(false); + }); + + it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => { + const cache = shardedDOTagCache(); + cache.getFromRegionalCache = vi.fn().mockResolvedValueOnce([]); + getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 100, stale: 300, expire: null } }); + expect(await cache.isStale(["tag1"], 200)).toBe(false); + }); + + it("should return from regional cache if available", async () => { + vi.useFakeTimers(); + vi.setSystemTime(500); + const cache = shardedDOTagCache(); + cache.getFromRegionalCache = vi + .fn() + .mockReturnValueOnce([{ tag: "tag1", revalidatedAt: 200, stale: 200, expire: null }]); + expect(await cache.isStale(["tag1"], 100)).toBe(true); + expect(idFromNameMock).not.toHaveBeenCalled(); + vi.useRealTimers(); }); }); @@ -305,7 +397,7 @@ describe("DOShardedTagCache", () => { await cache.writeTags(["tag1"]); expect(idFromNameMock).toHaveBeenCalled(); expect(writeTagsMock).toHaveBeenCalled(); - expect(writeTagsMock).toHaveBeenCalledWith(["tag1"], 1000); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "tag1", stale: 1000, expire: undefined }]); }); it("should write the tags to the cache for multiple shards", async () => { @@ -313,8 +405,14 @@ describe("DOShardedTagCache", () => { await cache.writeTags(["tag1", "tag2"]); expect(idFromNameMock).toHaveBeenCalledTimes(2); expect(writeTagsMock).toHaveBeenCalledTimes(2); - expect(writeTagsMock).toHaveBeenCalledWith(["tag1"], 1000); - expect(writeTagsMock).toHaveBeenCalledWith(["tag2"], 1000); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "tag1", stale: 1000, expire: undefined }]); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "tag2", stale: 1000, expire: undefined }]); + }); + + it("should write object tags with stale and expire", async () => { + const cache = shardedDOTagCache(); + await cache.writeTags([{ tag: "tag1", stale: 500, expire: 9999 }]); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "tag1", stale: 500, expire: 9999 }]); }); it('should write to all the replicated shards if "generateAllReplicas" is true', async () => { @@ -325,8 +423,8 @@ describe("DOShardedTagCache", () => { await cache.writeTags(["tag1", "_N_T_/tag1"]); expect(idFromNameMock).toHaveBeenCalledTimes(6); expect(writeTagsMock).toHaveBeenCalledTimes(6); - expect(writeTagsMock).toHaveBeenCalledWith(["tag1"], 1000); - expect(writeTagsMock).toHaveBeenCalledWith(["_N_T_/tag1"], 1000); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "tag1", stale: 1000, expire: undefined }]); + expect(writeTagsMock).toHaveBeenCalledWith([{ tag: "_N_T_/tag1", stale: 1000, expire: undefined }]); }); it("should call deleteRegionalCache", async () => { @@ -338,7 +436,6 @@ describe("DOShardedTagCache", () => { doId: expect.objectContaining({ key: "tag-hard;shard-1;replica-1" }), tags: ["tag1"], }); - // expect(cache.deleteRegionalCache).toHaveBeenCalledWith("tag-hard;shard-1;replica-1", ["tag1"]); }); }); @@ -388,7 +485,28 @@ describe("DOShardedTagCache", () => { }); const cacheResult = await cache.getFromRegionalCache({ doId, tags: ["tag1"] }); expect(cacheResult.length).toBe(1); - expect(cacheResult[0]).toEqual({ tag: "tag1", time: 1234567 }); + // "1234567" is a plain number (old format) → backward-compat parse + expect(cacheResult[0]).toEqual({ tag: "tag1", revalidatedAt: 1234567, stale: 1234567, expire: null }); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + + it("should parse new JSON object format from the cache", async () => { + const stored = JSON.stringify({ revalidatedAt: 1000, stale: 500, expire: 9999 }); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + match: vi.fn().mockResolvedValue(new Response(stored)), + }), + }; + const cache = shardedDOTagCache({ baseShardSize: 4, regionalCache: true }); + const doId = new DOId({ + baseShardId: "shard-1", + numberOfReplicas: 1, + shardType: "hard", + }); + const cacheResult = await cache.getFromRegionalCache({ doId, tags: ["tag1"] }); + expect(cacheResult[0]).toEqual({ tag: "tag1", revalidatedAt: 1000, stale: 500, expire: 9999 }); // @ts-expect-error - Defined on cloudfare context globalThis.caches = undefined; }); @@ -403,7 +521,7 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); - expect(getRevalidationTimesMock).not.toHaveBeenCalled(); + expect(getTagDataMock).not.toHaveBeenCalled(); }); it("should put the tags in the regional cache if the tags exists in the DO", async () => { @@ -421,11 +539,11 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); - getRevalidationTimesMock.mockResolvedValueOnce({ tag1: 123456 }); + getTagDataMock.mockResolvedValueOnce({ tag1: { revalidatedAt: 123456, stale: null, expire: null } }); await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); - expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); expect(putMock).toHaveBeenCalledWith( "http://local.cache/shard/tag-hard;shard-1?tag=tag1", expect.any(Response) @@ -449,11 +567,11 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); - getRevalidationTimesMock.mockResolvedValueOnce({}); + getTagDataMock.mockResolvedValueOnce({}); await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); - expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); expect(putMock).not.toHaveBeenCalled(); // @ts-expect-error - Defined on cloudfare context globalThis.caches = undefined; @@ -474,11 +592,14 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); - getRevalidationTimesMock.mockResolvedValueOnce({ tag1: 123456, tag2: 654321 }); + getTagDataMock.mockResolvedValueOnce({ + tag1: { revalidatedAt: 123456, stale: null, expire: null }, + tag2: { revalidatedAt: 654321, stale: null, expire: null }, + }); await cache.putToRegionalCache({ doId, tags: ["tag1", "tag2"] }, getMock()); - expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1", "tag2"]); expect(putMock).toHaveBeenCalledWith( "http://local.cache/shard/tag-hard;shard-1?tag=tag1", expect.any(Response) @@ -510,11 +631,11 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); - getRevalidationTimesMock.mockResolvedValueOnce({}); + getTagDataMock.mockResolvedValueOnce({}); await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); - expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); expect(putMock).toHaveBeenCalledWith( "http://local.cache/shard/tag-hard;shard-1?tag=tag1", expect.any(Response) @@ -542,11 +663,11 @@ describe("DOShardedTagCache", () => { shardType: "hard", }); - getRevalidationTimesMock.mockResolvedValueOnce({}); + getTagDataMock.mockResolvedValueOnce({}); await cache.putToRegionalCache({ doId, tags: ["tag1"] }, getMock()); - expect(getRevalidationTimesMock).toHaveBeenCalledWith(["tag1"]); + expect(getTagDataMock).toHaveBeenCalledWith(["tag1"]); expect(putMock).not.toHaveBeenCalled(); // @ts-expect-error - Defined on cloudfare context globalThis.caches = undefined; @@ -579,15 +700,16 @@ describe("DOShardedTagCache", () => { throw new Error("error"); }); const spiedFn = vi.spyOn(cache, "performWriteTagsWithRetry"); + const tags = [{ tag: "tag1", stale: 1000 }]; const doId = new DOId({ baseShardId: "shard-1", numberOfReplicas: 1, shardType: "hard", }); - await cache.performWriteTagsWithRetry(doId, ["tag1"], Date.now()); + await cache.performWriteTagsWithRetry(doId, tags); expect(writeTagsMock).toHaveBeenCalledTimes(2); expect(spiedFn).toHaveBeenCalledTimes(2); - expect(spiedFn).toHaveBeenCalledWith(doId, ["tag1"], 1000, 1); + expect(spiedFn).toHaveBeenCalledWith(doId, tags, 1); expect(sendDLQMock).not.toHaveBeenCalled(); vi.useRealTimers(); @@ -600,20 +722,17 @@ describe("DOShardedTagCache", () => { writeTagsMock.mockImplementationOnce(() => { throw new Error("error"); }); - const spiedFn = vi.spyOn(cache, "performWriteTagsWithRetry"); + const tags = [{ tag: "tag1", stale: 1000 }]; await cache.performWriteTagsWithRetry( new DOId({ baseShardId: "shard-1", numberOfReplicas: 1, shardType: "hard" }), - ["tag1"], - Date.now(), + tags, 3 ); expect(writeTagsMock).toHaveBeenCalledTimes(1); - expect(spiedFn).toHaveBeenCalledTimes(1); expect(sendDLQMock).toHaveBeenCalledWith({ failingShardId: "tag-hard;shard-1;replica-1", - failingTags: ["tag1"], - lastModified: 1000, + failingTags: tags, }); vi.useRealTimers(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index 4751d259..734cfcfd 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -1,13 +1,17 @@ import { debug, error } from "@opennextjs/aws/adapters/logger.js"; import { generateShardId } from "@opennextjs/aws/core/routing/queue.js"; -import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js"; import { IgnorableError } from "@opennextjs/aws/utils/error.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import type { OpenNextConfig } from "../../config.js"; +import type { TagData } from "../../durable-objects/sharded-tag-cache.js"; import { DOShardedTagCache } from "../../durable-objects/sharded-tag-cache.js"; import { debugCache, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; +type NormalizedTagInput = { tag: string; stale?: number; expire?: number | null }; +type CachedTagValue = { tag: string } & TagData; + export const DEFAULT_WRITE_RETRIES = 3; export const DEFAULT_NUM_SHARDS = 4; export const NAME = "do-sharded-tag-cache"; @@ -133,124 +137,95 @@ class ShardedDOTagCache implements NextModeTagCache { if (isDisabled || tags.length === 0) { return 0; } - const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests + const deduplicatedTags = Array.from(new Set(tags)); try { - const shardedTagGroups = this.groupTagsByDO({ tags: deduplicatedTags }); - const shardedTagRevalidationOutcomes = await Promise.all( - shardedTagGroups.map(async ({ doId, tags }) => { - const cachedValue = await this.getFromRegionalCache({ doId, tags }); - // If all the value were found in the regional cache, we can just return the max value - if (cachedValue.length === tags.length) { - const timeMs = Math.max(...cachedValue.map((item) => item.time as number)); - debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs} (regional cache)`); - return timeMs; - } - // Otherwise we need to check the durable object on the ones that were not found in the cache - const filteredTags = deduplicatedTags.filter( - (tag) => !cachedValue.some((item) => item.tag === tag) - ); - - const stub = this.getDurableObjectStub(doId); - const lastRevalidated = await stub.getLastRevalidated(filteredTags); - - const timeMs = Math.max(...cachedValue.map((item) => item.time), lastRevalidated); - - // We then need to populate the regional cache with the missing tags - getCloudflareContext().ctx.waitUntil(this.putToRegionalCache({ doId, tags }, stub)); - - debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`); - return timeMs; - }) + const tagData = await this.#resolveTagData(deduplicatedTags); + const timeMs = Math.max( + 0, + ...[...tagData.values()].filter((d): d is TagData => d != null).map((d) => d.revalidatedAt) ); - return Math.max(...shardedTagRevalidationOutcomes); + debugCache("ShardedDOTagCache", `getLastRevalidated tags=${tags} -> ${timeMs}`); + return timeMs; } catch (e) { error("Error while checking revalidation", e); return 0; } } - /** - * This function checks if the tags have been revalidated - * It is never supposed to throw and in case of error, it will return false - * @param tags - * @param lastModified default to `Date.now()` - * @returns - */ public async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { const { isDisabled } = this.getConfig(); if (isDisabled || tags.length === 0) { return false; } try { - const shardedTagGroups = this.groupTagsByDO({ tags }); - const shardedTagRevalidationOutcomes = await Promise.all( - shardedTagGroups.map(async ({ doId, tags }) => { - const cachedValue = await this.getFromRegionalCache({ doId, tags }); - - // If one of the cached values is newer than the lastModified, we can return true - const cacheHasBeenRevalidated = cachedValue.some((cachedValue) => { - return (cachedValue.time ?? 0) > (lastModified ?? Date.now()); - }); - - if (cacheHasBeenRevalidated) { - debugCache( - "ShardedDOTagCache", - `hasBeenRevalidated tags=${tags} at=${lastModified} -> true (regional cache)` - ); - - return true; - } - const stub = this.getDurableObjectStub(doId); - const _hasBeenRevalidated = await stub.hasBeenRevalidated(tags, lastModified); - - const remainingTags = tags.filter((tag) => !cachedValue.some((item) => item.tag === tag)); - if (remainingTags.length > 0) { - // We need to put the missing tags in the regional cache - getCloudflareContext().ctx.waitUntil( - this.putToRegionalCache({ doId, tags: remainingTags }, stub) - ); - } - - debugCache( - "ShardedDOTagCache", - `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${_hasBeenRevalidated}` - ); - - return _hasBeenRevalidated; - }) - ); - return shardedTagRevalidationOutcomes.some((result) => result); + const now = Date.now(); + const tagData = await this.#resolveTagData(tags); + const result = [...tagData.values()].some((data) => { + if (data == null) return false; + const { revalidatedAt, expire } = data; + if (expire != null) return expire <= now && expire > (lastModified ?? 0); + return revalidatedAt > (lastModified ?? now); + }); + debugCache("ShardedDOTagCache", `hasBeenRevalidated tags=${tags} at=${lastModified} -> ${result}`); + return result; } catch (e) { error("Error while checking revalidation", e); return false; } } - /** - * This function writes the tags to the cache - * Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated - * @param tags - * @returns - */ - public async writeTags(tags: string[]): Promise { + public async isStale(tags: string[], lastModified?: number): Promise { + const { isDisabled } = this.getConfig(); + if (isDisabled || tags.length === 0) { + return false; + } + try { + const now = Date.now(); + const tagData = await this.#resolveTagData(tags); + const result = [...tagData.values()].some((data) => { + if (data == null) return false; + const { revalidatedAt, stale, expire } = data; + const lastModifiedOrNow = lastModified ?? now; + const isInStaleWindow = + stale != null && revalidatedAt > lastModifiedOrNow && lastModifiedOrNow <= stale; + if (!isInStaleWindow) return false; + return expire == null || expire > now; + }); + debugCache("ShardedDOTagCache", `isStale tags=${tags} at=${lastModified} -> ${result}`); + return result; + } catch (e) { + error("Error while checking stale", e); + return false; + } + } + + public async writeTags(tags: NextModeTagCacheWriteInput[]): Promise { const { isDisabled } = this.getConfig(); if (isDisabled) return; - // We want to use the same revalidation time for all tags const nowMs = Date.now(); - debugCache("ShardedDOTagCache", `writeTags tags=${tags} time=${nowMs}`); + const normalized: NormalizedTagInput[] = tags.map((tag) => + typeof tag === "string" + ? { tag, stale: nowMs, expire: undefined } + : { tag: tag.tag, stale: tag.stale ?? nowMs, expire: tag.expire } + ); + + const tagStrings = normalized.map((t) => t.tag); + debugCache("ShardedDOTagCache", `writeTags tags=${tagStrings} time=${nowMs}`); - const shardedTagGroups = this.groupTagsByDO({ tags, generateAllReplicas: true }); + const tagMap = new Map(normalized.map((t) => [t.tag, t])); + const shardedTagGroups = this.groupTagsByDO({ tags: tagStrings, generateAllReplicas: true }); await Promise.all( - shardedTagGroups.map(async ({ doId, tags }) => { - await this.performWriteTagsWithRetry(doId, tags, nowMs); + shardedTagGroups.map(async ({ doId, tags: shardTags }) => { + const shardNormalized = shardTags.map((t) => tagMap.get(t)!); + await this.performWriteTagsWithRetry(doId, shardNormalized); }) ); // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 if (isPurgeCacheEnabled()) { - await purgeCacheByTags(tags); + await purgeCacheByTags(tagStrings); } } @@ -258,13 +233,13 @@ class ShardedDOTagCache implements NextModeTagCache { * The following methods are public only because they are accessed from the tests */ - public async performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber = 0) { + public async performWriteTagsWithRetry(doId: DOId, tags: NormalizedTagInput[], retryNumber = 0) { try { const stub = this.getDurableObjectStub(doId); - await stub.writeTags(tags, lastModified); + await stub.writeTags(tags); // Depending on the shards and the tags, deleting from the regional cache will not work for every tag // We also need to delete both cache - await this.deleteRegionalCache({ doId, tags }); + await Promise.all([this.deleteRegionalCache({ doId, tags: tags.map((t) => t.tag) })]); } catch (e) { error("Error while writing tags", e); if (retryNumber >= this.maxWriteRetries) { @@ -273,11 +248,10 @@ class ShardedDOTagCache implements NextModeTagCache { await getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED_DLQ?.send({ failingShardId: doId.key, failingTags: tags, - lastModified, }); return; } - await this.performWriteTagsWithRetry(doId, tags, lastModified, retryNumber + 1); + await this.performWriteTagsWithRetry(doId, tags, retryNumber + 1); } } @@ -292,12 +266,7 @@ class ShardedDOTagCache implements NextModeTagCache { return this.localCache; } - /** - * Get the last revalidation time for the tags from the regional cache - * If the cache is not enabled, it will return an empty array - * @returns An array of objects with the tag and the last revalidation time - */ - public async getFromRegionalCache(opts: CacheTagKeyOptions) { + public async getFromRegionalCache(opts: CacheTagKeyOptions): Promise { try { if (!this.opts.regionalCache) return []; const cache = await this.getCacheInstance(); @@ -308,42 +277,63 @@ class ShardedDOTagCache implements NextModeTagCache { if (!cachedResponse) return null; const cachedText = await cachedResponse.text(); try { - return { tag, time: parseInt(cachedText, 10) }; + const parsed: unknown = JSON.parse(cachedText); + if (typeof parsed === "number") { + // Backward compat: old format stored a plain revalidatedAt number + return { + tag, + revalidatedAt: parsed, + stale: parsed as number | null, + expire: null as number | null, + }; + } + const data = parsed as TagData; + return { + tag, + revalidatedAt: data.revalidatedAt ?? 0, + stale: data.stale ?? null, + expire: data.expire ?? null, + }; } catch (e) { debugCache("Error while parsing cached value", e); return null; } }) ); - return result.filter((item) => item !== null); + return result.filter((item): item is CachedTagValue => item !== null); } catch (e) { error("Error while fetching from regional cache", e); return []; } } - public async putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub) { + public async putToRegionalCache( + optsKey: CacheTagKeyOptions, + stub: DurableObjectStub, + prefetchedTagData?: Record + ) { if (!this.opts.regionalCache) return; const cache = await this.getCacheInstance(); if (!cache) return; const tags = optsKey.tags; - const tagsLastRevalidated = await stub.getRevalidationTimes(tags); + const tagData = prefetchedTagData ?? (await stub.getTagData(tags)); await Promise.all( tags.map(async (tag) => { - let lastRevalidated = tagsLastRevalidated[tag]; - if (lastRevalidated === undefined) { + let data = tagData[tag]; + if (data === undefined) { if (this.opts.regionalCacheDangerouslyPersistMissingTags) { - lastRevalidated = 0; // If the tag is not found, we set it to 0 as it means it has never been revalidated before. + // Tag not found: store a sentinel (never revalidated) + data = { revalidatedAt: 0, stale: null, expire: null }; } else { - debugCache("Tag not found in revalidation times", { tag, optsKey }); - return; // If the tag is not found, we skip it + debugCache("Tag not found in tag data", { tag, optsKey }); + return; } } const cacheKey = this.getCacheUrlKey(optsKey.doId, tag); - debugCache("Putting to regional cache", { cacheKey, lastRevalidated }); + debugCache("Putting to regional cache", { cacheKey, data }); await cache.put( cacheKey, - new Response(lastRevalidated.toString(), { + new Response(JSON.stringify(data), { status: 200, headers: { "cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}`, @@ -425,6 +415,72 @@ class ShardedDOTagCache implements NextModeTagCache { // Private methods + /** + * Fetches tag data for the given tags by consulting the regional cache first and falling back + * to Durable Object stubs for any misses. Returns a map of tag → TagData (null for tags not found). + */ + async #fetchTagDataFromShards(tags: string[]): Promise> { + const result = new Map(); + const shardedTagGroups = this.groupTagsByDO({ tags }); + + await Promise.all( + shardedTagGroups.map(async ({ doId, tags: shardTags }) => { + const cachedValues = await this.getFromRegionalCache({ doId, tags: shardTags }); + for (const { tag, revalidatedAt, stale, expire } of cachedValues) { + result.set(tag, { revalidatedAt, stale, expire }); + } + + const cachedTagNames = new Set(cachedValues.map(({ tag }) => tag)); + const remainingTags = shardTags.filter((tag) => !cachedTagNames.has(tag)); + if (remainingTags.length === 0) return; + + const stub = this.getDurableObjectStub(doId); + const tagData = await stub.getTagData(remainingTags); + for (const tag of remainingTags) { + result.set(tag, tagData[tag] ?? null); + } + + getCloudflareContext().ctx.waitUntil( + this.putToRegionalCache({ doId, tags: remainingTags }, stub, tagData) + ); + }) + ); + + return result; + } + + /** + * Resolves tag data from the per-request in-memory cache, falling back to + * `#fetchTagDataFromShards` for any misses. Results are stored back so repeated + * calls within the same request avoid duplicate shard fetches. + */ + async #resolveTagData(tags: string[]): Promise> { + const store = globalThis.__openNextAls?.getStore(); + const itemsCache = store?.requestCache.getOrCreate("do-sharded:tagItems"); + + const result = new Map(); + const uncachedTags: string[] = []; + + for (const tag of tags) { + if (itemsCache?.has(tag)) { + result.set(tag, itemsCache.get(tag) ?? null); + } else { + uncachedTags.push(tag); + } + } + + if (uncachedTags.length > 0) { + const fetched = await this.#fetchTagDataFromShards(uncachedTags); + for (const tag of uncachedTags) { + const value = fetched.get(tag) ?? null; + itemsCache?.set(tag, value); + result.set(tag, value); + } + } + + return result; + } + private getDurableObjectStub(doId: DOId) { const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED; if (!durableObject) throw new IgnorableError("No durable object binding for cache revalidation"); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts index 9d8090f9..451853e2 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.spec.ts @@ -97,8 +97,8 @@ describe("KVNextModeTagCache", () => { const mockTime = 1234567890; mockGet.mockResolvedValue( new Map([ - ["tag1", mockTime], - ["tag2", mockTime - 100], + [`${FALLBACK_BUILD_ID}/tag1`, mockTime], + [`${FALLBACK_BUILD_ID}/tag2`, mockTime - 100], ]) ); @@ -112,7 +112,7 @@ describe("KVNextModeTagCache", () => { }); it("should return 0 when no results are found", async () => { - mockGet.mockResolvedValue(new Map([["tag1", null]])); + mockGet.mockResolvedValue(new Map([[`${FALLBACK_BUILD_ID}/tag1`, null]])); const result = await tagCache.getLastRevalidated(["tag1"]); @@ -133,7 +133,7 @@ describe("KVNextModeTagCache", () => { const customBuildId = "custom-build-id"; vi.stubEnv("NEXT_BUILD_ID", customBuildId); - mockGet.mockResolvedValue(new Map([["tag1", null]])); + mockGet.mockResolvedValue(new Map([[`${customBuildId}/tag1`, null]])); await tagCache.getLastRevalidated(["tag1"]); @@ -166,8 +166,8 @@ describe("KVNextModeTagCache", () => { it("should return true when tags have been revalidated after lastModified", async () => { mockGet.mockResolvedValue( new Map([ - ["tag1", 1000], - ["tag2", null], + [`${FALLBACK_BUILD_ID}/tag1`, 1000], + [`${FALLBACK_BUILD_ID}/tag2`, null], ]) ); @@ -181,8 +181,8 @@ describe("KVNextModeTagCache", () => { it("should return false when no tags have been revalidated", async () => { mockGet.mockResolvedValue( new Map([ - ["tag1", null], - ["tag2", null], + [`${FALLBACK_BUILD_ID}/tag1`, null], + [`${FALLBACK_BUILD_ID}/tag2`, null], ]) ); @@ -257,6 +257,147 @@ describe("KVNextModeTagCache", () => { expect(purgeCacheByTags).toHaveBeenCalledWith(["single-tag"]); }); + + it("should write object tags as JSON to KV", async () => { + const currentTime = 1000; + vi.spyOn(Date, "now").mockReturnValue(currentTime); + + await tagCache.writeTags([{ tag: "tag1", stale: 500, expire: 9999 }]); + + expect(mockPut).toHaveBeenCalledWith( + "fallback-build-id/tag1", + JSON.stringify({ revalidatedAt: 500, stale: 500, expire: 9999 }) + ); + expect(purgeCacheByTags).toHaveBeenCalledWith(["tag1"]); + }); + + it("should default stale to Date.now() for object tags without stale", async () => { + const currentTime = 1000; + vi.spyOn(Date, "now").mockReturnValue(currentTime); + + await tagCache.writeTags([{ tag: "tag1" }]); + + expect(mockPut).toHaveBeenCalledWith( + "fallback-build-id/tag1", + JSON.stringify({ revalidatedAt: 1000, stale: 1000, expire: null }) + ); + expect(purgeCacheByTags).toHaveBeenCalledWith(["tag1"]); + }); + }); + + describe("isStale", () => { + it("should return false when cache is disabled", async () => { + ( + globalThis as { openNextConfig?: { dangerous?: { disableTagCache?: boolean } } } + ).openNextConfig!.dangerous!.disableTagCache = true; + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("should return false when no KV is available", async () => { + vi.mocked(getCloudflareContext).mockReturnValue({ + env: {}, + } as ReturnType); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when tags array is empty", async () => { + const result = await tagCache.isStale([], 1000); + expect(result).toBe(false); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("should return true when stale > lastModified and expire is null", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockGet.mockResolvedValue( + new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 1500, stale: 1500, expire: null }]]) + ); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return true when stale > lastModified and expire > now", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockGet.mockResolvedValue( + new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 1500, stale: 1500, expire: 3000 }]]) + ); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return false when stale <= lastModified", async () => { + mockGet.mockResolvedValue( + new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 500, stale: 500, expire: null }]]) + ); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when revalidatedAt <= lastModified even if stale > lastModified", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockGet.mockResolvedValue( + new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 500, stale: 1500, expire: null }]]) + ); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when expire <= now (tag expired)", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + mockGet.mockResolvedValue( + new Map([[`${FALLBACK_BUILD_ID}/tag1`, { revalidatedAt: 1500, stale: 1500, expire: 1999 }]]) + ); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should return false when KV value is null", async () => { + mockGet.mockResolvedValue(new Map([[`${FALLBACK_BUILD_ID}/tag1`, null]])); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + }); + + it("should handle backward compat: plain number value uses that as stale", async () => { + const now = 2000; + vi.spyOn(Date, "now").mockReturnValue(now); + // Old format: plain number — treated as stale = revalidatedAt + mockGet.mockResolvedValue(new Map([[`${FALLBACK_BUILD_ID}/tag1`, 1500]])); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(true); + }); + + it("should return false when KV get throws an error", async () => { + mockGet.mockRejectedValue(new Error("kv error")); + + const result = await tagCache.isStale(["tag1"], 1000); + + expect(result).toBe(false); + expect(error).toHaveBeenCalled(); + }); }); describe("getCacheKey", () => { diff --git a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts index abd64937..d5135437 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/kv-next-tag-cache.ts @@ -1,5 +1,5 @@ import { error } from "@opennextjs/aws/adapters/logger.js"; -import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js"; import { getCloudflareContext } from "../../cloudflare-context.js"; import { debugCache, FALLBACK_BUILD_ID, isPurgeCacheEnabled, purgeCacheByTags } from "../internal.js"; @@ -8,6 +8,29 @@ export const NAME = "kv-next-mode-tag-cache"; export const BINDING_NAME = "NEXT_TAG_CACHE_KV"; +type KVTagValue = + // Old format (=v1.19): a JSON object with full tag data. + // - revalidatedAt: timestamp in ms of the last revalidation + // - stale: timestamp in ms when the tag becomes stale + // - expire: timestamp in ms when the tag expires + | { revalidatedAt: number; stale?: number | null; expire?: number | null }; + +function getRevalidatedAt(value: KVTagValue): number { + return typeof value === "number" ? value : (value.revalidatedAt ?? 0); +} + +function getStale(value: KVTagValue): number | null { + // Backward compat: old format stored a plain number meaning revalidatedAt = stale + return typeof value === "number" ? value : (value.stale ?? null); +} + +function getExpire(value: KVTagValue): number | null { + return typeof value === "number" ? null : (value.expire ?? null); +} + /** * Tag Cache based on a KV namespace * @@ -25,28 +48,17 @@ export class KVNextModeTagCache implements NextModeTagCache { readonly name = NAME; async getLastRevalidated(tags: string[]): Promise { - const timeMs = await this.#getLastRevalidated(tags); - debugCache("KVNextModeTagCache", `getLastRevalidated tags=${tags} -> time=${timeMs}`); - return timeMs; - } - - /** - * Implementation of `getLastRevalidated`. - * - * This implementation is separated so that `hasBeenRevalidated` do not include logs from `getLastRevalidated`. - */ - async #getLastRevalidated(tags: string[]): Promise { const kv = this.getKv(); if (!kv || tags.length === 0) { return 0; } try { - const keys = tags.map((tag) => this.getCacheKey(tag)); - // Use the `json` type to get back numbers/null - const result: Map = await kv.get(keys, { type: "json" }); + const result = await this.#resolveTagValues(tags, kv); - const revalidations = [...result.values()].filter((v) => v != null); + const revalidations = [...result.values()] + .filter((v): v is KVTagValue => v != null) + .map(getRevalidatedAt); return revalidations.length === 0 ? 0 : Math.max(...revalidations); } catch (e) { // By default we don't want to crash here, so we return false @@ -56,16 +68,64 @@ export class KVNextModeTagCache implements NextModeTagCache { } } + /** + * Resolves tag values from the per-request in-memory cache, falling back to KV for any misses. + * Results are stored back into the request cache so repeated calls within the same request + * avoid duplicate KV fetches. + */ + async #resolveTagValues(tags: string[], kv: KVNamespace): Promise> { + const result = new Map(); + const uncachedTags: string[] = []; + + const itemsCache = this.getItemsCache(); + + for (const tag of tags) { + if (itemsCache?.has(tag)) { + result.set(tag, itemsCache.get(tag) ?? null); + } else { + uncachedTags.push(tag); + } + } + + if (uncachedTags.length > 0) { + const kvKeys = uncachedTags.map((tag) => this.getCacheKey(tag)); + const fetched: Map = await kv.get(kvKeys, { type: "json" }); + for (const tag of uncachedTags) { + const value = fetched.get(this.getCacheKey(tag)) ?? null; + itemsCache?.set(tag, value); + result.set(tag, value); + } + } + + return result; + } + async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { - const revalidated = (await this.#getLastRevalidated(tags)) > (lastModified ?? Date.now()); - debugCache( - "KVNextModeTagCache", - `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}` - ); - return revalidated; + const kv = this.getKv(); + if (!kv || tags.length === 0) { + return false; + } + try { + const now = Date.now(); + const result = await this.#resolveTagValues(tags, kv); + const revalidated = [...result.values()].some((v) => { + if (v == null) return false; + const expire = getExpire(v); + if (expire != null) return expire <= now && expire > (lastModified ?? 0); + return getRevalidatedAt(v) > (lastModified ?? now); + }); + debugCache( + "KVNextModeTagCache", + `hasBeenRevalidated tags=${tags} lastModified=${lastModified} -> ${revalidated}` + ); + return revalidated; + } catch (e) { + error(e); + return false; + } } - async writeTags(tags: string[]): Promise { + async writeTags(tags: NextModeTagCacheWriteInput[]): Promise { const kv = this.getKv(); if (!kv || tags.length === 0) { return Promise.resolve(); @@ -75,15 +135,50 @@ export class KVNextModeTagCache implements NextModeTagCache { await Promise.all( tags.map(async (tag) => { - await kv.put(this.getCacheKey(tag), String(nowMs)); + if (typeof tag === "string") { + // Old format: store plain number string for backward compat + await kv.put(this.getCacheKey(tag), String(nowMs)); + } else { + const stale = tag.stale ?? nowMs; + const value: KVTagValue = { revalidatedAt: stale, stale, expire: tag.expire ?? null }; + await kv.put(this.getCacheKey(tag.tag), JSON.stringify(value)); + } }) ); - debugCache("KVNextModeTagCache", `writeTags tags=${tags} time=${nowMs}`); + const tagStrings = tags.map((t) => (typeof t === "string" ? t : t.tag)); + debugCache("KVNextModeTagCache", `writeTags tags=${tagStrings} time=${nowMs}`); // TODO: See https://github.com/opennextjs/opennextjs-aws/issues/986 if (isPurgeCacheEnabled()) { - await purgeCacheByTags(tags); + await purgeCacheByTags(tagStrings); + } + } + + async isStale(tags: string[], lastModified?: number): Promise { + const kv = this.getKv(); + if (!kv || tags.length === 0) return false; + + try { + const now = Date.now(); + const result = await this.#resolveTagValues(tags, kv); + + const isStale = [...result.values()].some((v) => { + if (v == null) return false; + const stale = getStale(v); + const expire = getExpire(v); + const lastModifiedOrNow = lastModified ?? now; + const isInStaleWindow = + stale != null && getRevalidatedAt(v) > lastModifiedOrNow && lastModifiedOrNow <= stale; + if (!isInStaleWindow) return false; + return expire == null || expire > now; + }); + + debugCache("KVNextModeTagCache", `isStale tags=${tags} lastModified=${lastModified} -> ${isStale}`); + return isStale; + } catch (e) { + error(e); + return false; } } @@ -112,6 +207,11 @@ export class KVNextModeTagCache implements NextModeTagCache { protected getBuildId() { return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID; } + + protected getItemsCache() { + const store = globalThis.__openNextAls?.getStore(); + return store?.requestCache.getOrCreate("kv-nextMode:tagItems"); + } } export default new KVNextModeTagCache(); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts index c66f68e0..85b7729c 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -1,4 +1,4 @@ -import { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; +import type { NextModeTagCache, NextModeTagCacheWriteInput } from "@opennextjs/aws/types/overrides.js"; interface WithFilterOptions { /** @@ -11,7 +11,7 @@ interface WithFilterOptions { * @param tag The tag to filter. * @returns true if the tag should be forwarded, false otherwise. */ - filterFn: (tag: string) => boolean; + filterFn: (tag: string | NextModeTagCacheWriteInput) => boolean; } /** @@ -45,8 +45,20 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT } return tagCache.hasBeenRevalidated(filteredTags, lastModified); }, + isStale: tagCache.isStale + ? async (tags, lastModified) => { + const filteredTags = tags.filter(filterFn); + if (filteredTags.length === 0) { + return false; + } + return tagCache.isStale!(filteredTags, lastModified); + } + : undefined, writeTags: async (tags) => { - const filteredTags = tags.filter(filterFn); + const filteredTags = (tags as NextModeTagCacheWriteInput[]).filter((t) => { + const tagStr = typeof t === "string" ? t : t.tag; + return filterFn(tagStr); + }); if (filteredTags.length === 0) { return; } @@ -60,6 +72,9 @@ export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeT * This is used to filter out internal soft tags. * Can be used if `revalidatePath` is not used. */ -export function softTagFilter(tag: string): boolean { - return !tag.startsWith("_N_T_"); +export function softTagFilter(tag: string | NextModeTagCacheWriteInput): boolean { + if (typeof tag === "string") { + return !tag.startsWith("_N_T_"); + } + return !tag.tag.startsWith("_N_T_"); } diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index 496b6e5c..430cc992 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -363,7 +363,7 @@ function populateD1TagCache( [ "d1 execute", D1_TAG_BINDING_NAME, - `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`, + `--command "CREATE TABLE IF NOT EXISTS revalidations (tag TEXT NOT NULL, revalidatedAt INTEGER NOT NULL, stale INTEGER, expire INTEGER default NULL, UNIQUE(tag) ON CONFLICT REPLACE);"`, `--preview ${populateCacheOptions.shouldUsePreviewId}`, ], { @@ -379,6 +379,25 @@ function populateD1TagCache( process.exit(1); } + // Schema migration: add `stale` and `expire` columns (idempotent, safe for existing deployments). + // The columns were added in v1.19 to support SWR. + // These commands are intentionally non-throwing — they fail harmlessly if the columns already exist. + runWrangler( + buildOpts, + [ + "d1 execute", + D1_TAG_BINDING_NAME, + `--command "ALTER TABLE revalidations ADD COLUMN stale INTEGER; ALTER TABLE revalidations ADD COLUMN expire INTEGER default NULL"`, + `--preview ${populateCacheOptions.shouldUsePreviewId}`, + ], + { + target: populateCacheOptions.target, + environment: populateCacheOptions.environment, + configPath: populateCacheOptions.wranglerConfigPath, + logging: "error", + } + ); + logger.info("\nSuccessfully created D1 table"); } diff --git a/packages/open-next/src/adapters/cache.ts b/packages/open-next/src/adapters/cache.ts index 0ac93ae6..3d63910d 100644 --- a/packages/open-next/src/adapters/cache.ts +++ b/packages/open-next/src/adapters/cache.ts @@ -1,7 +1,8 @@ import type { CacheHandlerValue, IncrementalCacheContext, IncrementalCacheValue } from "@/types/cache"; -import { getTagsFromValue, hasBeenRevalidated, writeTags } from "@/utils/cache"; +import { getTagsFromValue, hasBeenRevalidated, isStale, writeTags } from "@/utils/cache"; import { isBinaryContentType } from "../utils/binary"; +import { compareSemver } from "../utils/semver"; import { debug, error, warn } from "./logger"; @@ -67,8 +68,10 @@ export default class Cache { } } + const _isStale = cachedEntry.shouldBypassTagCache ? false : await isStale(key, _tags, _lastModified); + return { - lastModified: _lastModified, + lastModified: _isStale ? 1 : _lastModified, value: cachedEntry.value, } as CacheHandlerValue; } catch (e) { @@ -90,22 +93,25 @@ export default class Cache { const meta = cacheData.meta; const tags = getTagsFromValue(cacheData); - const _lastModified = cachedEntry.lastModified ?? Date.now(); + let _lastModified = cachedEntry.lastModified ?? Date.now(); const _hasBeenRevalidated = cachedEntry.shouldBypassTagCache ? false : await hasBeenRevalidated(key, tags, cachedEntry); if (_hasBeenRevalidated) return null; + const _isStale = cachedEntry.shouldBypassTagCache ? false : await isStale(key, tags, _lastModified); + const store = globalThis.__openNextAls.getStore(); if (store) { - store.lastModified = _lastModified; + store.lastModified = _isStale ? 1 : _lastModified; + _lastModified = store.lastModified; } if (cacheData?.type === "route") { return { lastModified: _lastModified, value: { - kind: "APP_ROUTE", + kind: compareSemver(globalThis.nextVersion, ">=", "15.0.0") ? "APP_ROUTE" : "ROUTE", body: Buffer.from( cacheData.body ?? Buffer.alloc(0), isBinaryContentType(String(meta?.headers?.["content-type"])) ? "base64" : "utf8" @@ -116,7 +122,7 @@ export default class Cache { } as CacheHandlerValue; } if (cacheData?.type === "page" || cacheData?.type === "app") { - if (cacheData?.type === "app") { + if (compareSemver(globalThis.nextVersion, ">=", "15.0.0") && cacheData?.type === "app") { const segmentData = new Map(); if (cacheData.segmentData) { for (const [segmentPath, segmentContent] of Object.entries(cacheData.segmentData ?? {})) { @@ -139,9 +145,9 @@ export default class Cache { return { lastModified: _lastModified, value: { - kind: "PAGES", + kind: compareSemver(globalThis.nextVersion, ">=", "15.0.0") ? "PAGES" : "PAGE", html: cacheData.html, - pageData: cacheData.json, + pageData: cacheData.type === "page" ? cacheData.json : cacheData.rsc, status: meta?.status, headers: meta?.headers, }, @@ -285,7 +291,7 @@ export default class Cache { } } - public async revalidateTag(tags: string | string[]) { + public async revalidateTag(tags: string | string[], durations?: { expire?: number }) { const config = globalThis.openNextConfig.dangerous; if (config?.disableTagCache || config?.disableIncrementalCache) { return; @@ -299,7 +305,22 @@ export default class Cache { if (globalThis.tagCache.mode === "nextMode") { const paths = (await globalThis.tagCache.getPathsByTags?.(_tags)) ?? []; - await writeTags(_tags); + const now = Date.now(); + const tagsToWrite = _tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { + tag, + expire: now, + }; + }); + + await writeTags(tagsToWrite); if (paths.length > 0) { // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it // It also means that we'll need to provide the tags used in every request to the wrapper or converter. @@ -326,10 +347,21 @@ export default class Cache { // Find all keys with the given tag const paths = await globalThis.tagCache.getByTag(tag); debug("Items", paths); - const toInsert = paths.map((path) => ({ - path, - tag, - })); + const now = Date.now(); + const toInsert = paths.map((path) => { + const baseEntry = { path, tag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { + ...baseEntry, + expire: now, + }; + }); // If the tag is a soft tag, we should also revalidate the hard tags if (tag.startsWith(SOFT_TAG_PREFIX)) { @@ -342,10 +374,20 @@ export default class Cache { const _paths = await globalThis.tagCache.getByTag(hardTag); debug({ hardTag, _paths }); toInsert.push( - ..._paths.map((path) => ({ - path, - tag: hardTag, - })) + ..._paths.map((path) => { + const baseEntry = { path, tag: hardTag }; + if (durations) { + return { + ...baseEntry, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { + ...baseEntry, + expire: now, + }; + }) ); } } diff --git a/packages/open-next/src/adapters/composable-cache.ts b/packages/open-next/src/adapters/composable-cache.ts index a6fb19c3..ad071d7e 100644 --- a/packages/open-next/src/adapters/composable-cache.ts +++ b/packages/open-next/src/adapters/composable-cache.ts @@ -1,6 +1,6 @@ import type { ComposableCacheEntry, ComposableCacheHandler } from "@/types/cache"; -import type { CacheValue } from "@/types/overrides"; -import { writeTags } from "@/utils/cache"; +import type { CacheValue, OriginalTagCache } from "@/types/overrides"; +import { isStale, writeTags } from "@/utils/cache"; import { fromReadableStream, toReadableStream } from "@/utils/stream"; import { debug } from "./logger"; @@ -28,21 +28,39 @@ export default { debug("composable cache result", result); - // We need to check if the tags associated with this entry has been revalidated + let revalidate = result.value.revalidate; if (globalThis.tagCache.mode === "nextMode" && result.value.tags.length > 0) { + // We need to check if the tags associated with this entry has been revalidated const hasBeenRevalidated = result.shouldBypassTagCache ? false : await globalThis.tagCache.hasBeenRevalidated(result.value.tags, result.lastModified); if (hasBeenRevalidated) return undefined; + + // Check if tags are stale – entry is valid but needs background revalidation + const isCacheStale = result.shouldBypassTagCache + ? false + : await isStale(cacheKey, result.value.tags, result.lastModified); + if (isCacheStale) { + revalidate = -1; + } } else if (globalThis.tagCache.mode === "original" || globalThis.tagCache.mode === undefined) { const hasBeenRevalidated = result.shouldBypassTagCache ? false : (await globalThis.tagCache.getLastModified(cacheKey, result.lastModified)) === -1; if (hasBeenRevalidated) return undefined; + + // Check if tags are stale – entry is valid but needs background revalidation + const isCacheStale = result.shouldBypassTagCache + ? false + : await isStale(cacheKey, result.value.tags, result.lastModified); + if (isCacheStale) { + revalidate = -1; + } } return { ...result.value, + revalidate, value: toReadableStream(result.value.value), }; } catch (e) { @@ -132,4 +150,65 @@ export default { // This function does absolutely nothing return; }, + + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * Mirrors the logic in `Cache.revalidateTag` but without CDN invalidation + * since composable cache keys are not URL paths. + * + * When `durations` is provided, marks tags as stale immediately and optionally + * sets an expiry timestamp. When omitted, immediately expires tags (no grace period). + * durations.expire is in seconds, but we convert it to milliseconds for storage and comparison. + */ + async updateTags(tags: string[], durations?: { expire?: number }) { + const config = globalThis.openNextConfig.dangerous; + if (config?.disableTagCache || config?.disableIncrementalCache) { + return; + } + if (tags.length === 0) { + return; + } + try { + const now = Date.now(); + if (globalThis.tagCache.mode === "nextMode") { + const tagsToWrite = tags.map((tag) => { + if (durations) { + return { + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + // Default: immediate expiry, no grace period + return { tag, expire: now }; + }); + await writeTags(tagsToWrite); + } else { + // Original mode: resolve tag → path mappings first + const originalTagCache = globalThis.tagCache as OriginalTagCache; + const pathsPerTag = await Promise.all( + tags.map(async (tag) => { + const paths = await originalTagCache.getByTag(tag); + return paths.map((path: string) => { + if (durations) { + return { + path, + tag, + stale: now, + expire: durations.expire !== undefined ? now + durations.expire * 1000 : undefined, + }; + } + return { path, tag, expire: now }; + }); + }) + ); + const toWrite = pathsPerTag.flat(); + if (toWrite.length > 0) { + await writeTags(toWrite); + } + } + } catch (e) { + debug("Failed to update tags", e); + } + }, } satisfies ComposableCacheHandler; diff --git a/packages/open-next/src/adapters/dynamo-provider.ts b/packages/open-next/src/adapters/dynamo-provider.ts index ed5a7dee..703a1516 100644 --- a/packages/open-next/src/adapters/dynamo-provider.ts +++ b/packages/open-next/src/adapters/dynamo-provider.ts @@ -16,6 +16,18 @@ type DataType = { revalidatedAt: { N: string; }; + /** + * The time at which the tag should be considered stale, in milliseconds since epoch. + */ + stale?: { + N: string; + }; + /** + * The time at which the tag should expire, in milliseconds since epoch. + */ + expire?: { + N: string; + }; }; export interface InitializationFunctionEvent { @@ -59,6 +71,8 @@ async function insert( tag: item.tag.S, path: item.path.S, revalidatedAt: Number.parseInt(item.revalidatedAt.N), + ...(item.stale && { stale: Number.parseInt(item.stale.N) }), + ...(item.expire && { expire: Number.parseInt(item.expire.N) }), })); await tagCache.writeTags(parsedData); diff --git a/packages/open-next/src/build/compileCache.ts b/packages/open-next/src/build/compileCache.ts index 553bcbf5..5ee1fd51 100644 --- a/packages/open-next/src/build/compileCache.ts +++ b/packages/open-next/src/build/compileCache.ts @@ -26,6 +26,7 @@ export function compileCache(options: buildHelper.BuildOptions, format: "cjs" | js: [ `globalThis.disableIncrementalCache = ${config.dangerous?.disableIncrementalCache ?? false};`, `globalThis.disableDynamoDBCache = ${config.dangerous?.disableTagCache ?? false};`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }, @@ -46,6 +47,7 @@ export function compileCache(options: buildHelper.BuildOptions, format: "cjs" | js: [ `globalThis.disableIncrementalCache = ${config.dangerous?.disableIncrementalCache ?? false};`, `globalThis.disableDynamoDBCache = ${config.dangerous?.disableTagCache ?? false};`, + `globalThis.nextVersion = "${options.nextVersion}";`, ].join(""), }, }, diff --git a/packages/open-next/src/build/helper.ts b/packages/open-next/src/build/helper.ts index 159495b9..360ad2c0 100644 --- a/packages/open-next/src/build/helper.ts +++ b/packages/open-next/src/build/helper.ts @@ -256,62 +256,8 @@ export function getNextVersion(appPath: string): string { return version.split("-")[0]; } -export type SemverOp = "=" | ">=" | "<=" | ">" | "<"; - -/** - * Compare two semver versions. - * - * @param v1 - First version. Can be "latest", otherwise it should be a valid semver version in the format of `major.minor.patch`. Usually is the next version from the package.json without canary suffix. If minor or patch are missing, they are considered 0. - * @param v2 - Second version. Should not be "latest", it should be a valid semver version in the format of `major.minor.patch`. If minor or patch are missing, they are considered 0. - * @example - * compareSemver("2.0.0", ">=", "1.0.0") === true - */ -export function compareSemver(v1: string, operator: SemverOp, v2: string): boolean { - // - = 0 when versions are equal - // - > 0 if v1 > v2 - // - < 0 if v2 > v1 - let versionDiff = 0; - if (v1 === "latest") { - versionDiff = 1; - } else { - if (/^[^\d]/.test(v1)) { - // oxlint-disable-next-line no-param-reassign - v1 = v1.substring(1); - } - if (/^[^\d]/.test(v2)) { - // oxlint-disable-next-line no-param-reassign - v2 = v2.substring(1); - } - const [major1, minor1 = 0, patch1 = 0] = v1.split(".").map(Number); - const [major2, minor2 = 0, patch2 = 0] = v2.split(".").map(Number); - if (Number.isNaN(major1) || Number.isNaN(major2)) { - throw new Error("The major version is required."); - } - - if (major1 !== major2) { - versionDiff = major1 - major2; - } else if (minor1 !== minor2) { - versionDiff = minor1 - minor2; - } else if (patch1 !== patch2) { - versionDiff = patch1 - patch2; - } - } - - switch (operator) { - case "=": - return versionDiff === 0; - case ">=": - return versionDiff >= 0; - case "<=": - return versionDiff <= 0; - case ">": - return versionDiff > 0; - case "<": - return versionDiff < 0; - default: - throw new Error(`Unsupported operator: ${operator}`); - } -} +export type { SemverOp } from "../utils/semver.js"; +export { compareSemver } from "../utils/semver.js"; export function copyOpenNextConfig(inputDir: string, outputDir: string, isEdge = false) { // Copy open-next.config.mjs diff --git a/packages/open-next/src/build/patch/patches/patchNextServer.ts b/packages/open-next/src/build/patch/patches/patchNextServer.ts index c3b99d43..2f39ad17 100644 --- a/packages/open-next/src/build/patch/patches/patchNextServer.ts +++ b/packages/open-next/src/build/patch/patches/patchNextServer.ts @@ -35,6 +35,20 @@ fix: '{return null;}' `; +export const provideInternalWaitUntil = ` +rule: + kind: return_statement + inside: + kind: method_definition + stopBy: end + has: + kind: property_identifier + regex: getInternalWaitUntil + +fix: + return globalThis.__openNextAls.getStore()?.waitUntil; +`; + // Make `handleNextImageRequest` a no-op to avoid pulling `sharp` // Applies wherever this constructor pattern is matched export const emptyHandleNextImageRequestRule = ` @@ -128,6 +142,12 @@ export const patchNextServer: CodePatcher = { contentFilter: /getMiddlewareManifest/, patchCode: createPatchCode(removeMiddlewareManifestRule), }, + { + versions: ">=16.0.0", + pathFilter, + contentFilter: /getInternalWaitUntil/, + patchCode: createPatchCode(provideInternalWaitUntil), + }, ...babelPatches, ], }; diff --git a/packages/open-next/src/core/routing/cacheInterceptor.ts b/packages/open-next/src/core/routing/cacheInterceptor.ts index 271b123f..5362e309 100644 --- a/packages/open-next/src/core/routing/cacheInterceptor.ts +++ b/packages/open-next/src/core/routing/cacheInterceptor.ts @@ -4,7 +4,7 @@ import { NextConfig, PrerenderManifest } from "@/config/index"; import type { InternalEvent, InternalResult, MiddlewareEvent, PartialResult } from "@/types/open-next"; import type { CacheValue } from "@/types/overrides"; import { isBinaryContentType } from "@/utils/binary"; -import { getTagsFromValue, hasBeenRevalidated } from "@/utils/cache"; +import { getTagsFromValue, hasBeenRevalidated, isStale } from "@/utils/cache"; import { emptyReadableStream, toReadableStream } from "@/utils/stream"; import { debug, error } from "../../adapters/logger"; @@ -35,7 +35,8 @@ async function computeCacheControl( body: string, host: string, revalidate?: number | false, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ) { let finalRevalidate = CACHE_ONE_YEAR; @@ -60,19 +61,25 @@ async function computeCacheControl( etag, }; } - if (finalRevalidate !== CACHE_ONE_YEAR) { - const sMaxAge = Math.max(finalRevalidate - age, 1); + // SSG uses one year cache + const isSSG = finalRevalidate === CACHE_ONE_YEAR; + const remainingTtl = Math.max(finalRevalidate - age, 1); + + const isStaleFromTime = !isSSG && remainingTtl === 1; + const _isStale = isStaleFromTime || isStaleFromTagCache; + + if (!isSSG || isStaleFromTagCache) { + const sMaxAge = isStaleFromTagCache ? 1 : remainingTtl; debug("sMaxAge", { finalRevalidate, age, lastModified, revalidate, + isStaleFromTagCache, }); - const isStale = sMaxAge === 1; - if (isStale) { + if (_isStale) { let url = NextConfig.trailingSlash ? `${path}/` : path; if (NextConfig.basePath) { - // We need to add the basePath to the url url = `${NextConfig.basePath}${url}`; } await globalThis.queue.send({ @@ -88,7 +95,7 @@ async function computeCacheControl( } return { "cache-control": `s-maxage=${sMaxAge}, stale-while-revalidate=${CACHE_ONE_MONTH}`, - "x-opennext-cache": isStale ? "STALE" : "HIT", + "x-opennext-cache": _isStale ? "STALE" : "HIT", etag, }; } @@ -165,7 +172,8 @@ async function generateResult( event: MiddlewareEvent, localizedPath: string, cachedValue: CacheValue<"cache">, - lastModified?: number + lastModified?: number, + isStaleFromTagCache = false ): Promise { debug("Returning result from experimental cache"); let body = ""; @@ -232,7 +240,8 @@ async function generateResult( body, event.headers.host, cachedValue.revalidate, - lastModified + lastModified, + isStaleFromTagCache ); return { type: "core", @@ -346,10 +355,9 @@ export async function cacheInterceptor( if (!cachedData?.value) { return event; } + const tags = getTagsFromValue(cachedData.value); // We need to check the tag cache now if (cachedData.value?.type === "app" || cachedData.value?.type === "route") { - const tags = getTagsFromValue(cachedData.value); - const _hasBeenRevalidated = cachedData.shouldBypassTagCache ? false : await hasBeenRevalidated(localizedPath, tags, cachedData); @@ -358,18 +366,25 @@ export async function cacheInterceptor( return event; } } + + // Check if the cache entry is stale (valid but needs background revalidation) + const _isStale = cachedData.shouldBypassTagCache + ? false + : await isStale(localizedPath, tags, cachedData.lastModified ?? Date.now()); + const host = event.headers.host; switch (cachedData?.value?.type) { case "app": case "page": - return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified); + return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified, _isStale); case "redirect": { const cacheControl = await computeCacheControl( localizedPath, "", host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + _isStale ); return { type: "core", @@ -388,7 +403,8 @@ export async function cacheInterceptor( cachedData.value.body, host, cachedData.value.revalidate, - cachedData.lastModified + cachedData.lastModified, + _isStale ); const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"])); diff --git a/packages/open-next/src/overrides/tagCache/dummy.ts b/packages/open-next/src/overrides/tagCache/dummy.ts index ac44b532..f3ca177f 100644 --- a/packages/open-next/src/overrides/tagCache/dummy.ts +++ b/packages/open-next/src/overrides/tagCache/dummy.ts @@ -16,6 +16,9 @@ const dummyTagCache: TagCache = { writeTags: async () => { return; }, + isStale: async (_path: string) => { + return false; + }, }; export default dummyTagCache; diff --git a/packages/open-next/src/overrides/tagCache/dynamodb-lite.ts b/packages/open-next/src/overrides/tagCache/dynamodb-lite.ts index 8261484f..6f8d8759 100644 --- a/packages/open-next/src/overrides/tagCache/dynamodb-lite.ts +++ b/packages/open-next/src/overrides/tagCache/dynamodb-lite.ts @@ -57,12 +57,22 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { - return { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const obj: Record = { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; + return obj; } const tagCache: OriginalTagCache = { @@ -73,6 +83,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -94,7 +109,9 @@ const tagCache: OriginalTagCache = { const tags = Items?.map((item) => item.tag?.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag: string) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -106,6 +123,11 @@ const tagCache: OriginalTagCache = { return []; } const { CACHE_DYNAMO_TABLE, NEXT_BUILD_ID } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -122,10 +144,10 @@ const tagCache: OriginalTagCache = { throw new RecoverableError(`Failed to get by tag: ${result.status}`); } const { Items } = (await result.json()) as DynamoDBResponse; - return ( - // We need to remove the buildId from the path - Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map((item) => item.path?.S?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -137,6 +159,12 @@ const tagCache: OriginalTagCache = { return lastModified ?? Date.now(); } const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getLastModified"); + const cacheKey = `${key}:${lastModified ?? 0}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey)!; + } const result = await awsFetch( JSON.stringify({ TableName: CACHE_DYNAMO_TABLE, @@ -157,14 +185,83 @@ const tagCache: OriginalTagCache = { } const revalidatedTags = ((await result.json()) as DynamoDBResponse).Items ?? []; debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const hasExpiredTag = revalidatedTags.some((item: any) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const nonExpiredRevalidatedTags = revalidatedTags.filter((item: any) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) === Number.parseInt(item.revalidatedAt.N); + } + return true; + }); + // If we have revalidated tags or expired tags we return -1 to force revalidation + const resultValue = + nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + cache?.set(cacheKey, resultValue); + return resultValue; } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, - async writeTags(tags: { tag: string; path: string; revalidatedAt?: number }[]) { + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const { CACHE_DYNAMO_TABLE } = process.env; + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems"); + const cacheKey = `${key}:${lastModified ?? 0}`; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + let items: any[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await awsFetch( + JSON.stringify({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + if (result.status !== 200) { + throw new RecoverableError(`Failed to check stale tags: ${result.status}`); + } + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + items = ((await result.json()) as any).Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, + async writeTags( + tags: { tag: string; path: string; revalidatedAt?: number; stale?: number; expire?: number }[] + ) { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -175,7 +272,7 @@ const tagCache: OriginalTagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts index 93afbaf6..eb060081 100644 --- a/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts +++ b/packages/open-next/src/overrides/tagCache/dynamodb-nextMode.ts @@ -59,15 +59,91 @@ function buildDynamoKey(key: string) { // We use the same key for both path and tag // That's mostly for compatibility reason so that it's easier to use this with existing infra // FIXME: Allow a simpler object without an unnecessary path key -function buildDynamoObject(tag: string, revalidatedAt?: number) { - return { +function buildDynamoObject(tag: string, revalidatedAt?: number, stale?: number, expire?: number) { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const obj: Record = { path: { S: buildDynamoKey(tag) }, tag: { S: buildDynamoKey(tag) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; + return obj; } // This implementation does not support automatic invalidation of paths by the cdn + +/** + * Checks the items cache for each tag. Returns tags not yet cached and whether + * a positive result was already found among the cached ones. + */ +function checkItemsCache( + tags: string[], + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + itemsCache: Map | undefined, + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + compute: (item: any) => boolean +): { uncachedTags: string[]; hasMatch: boolean } { + const uncachedTags: string[] = []; + let hasMatch = false; + for (const tag of tags) { + if (itemsCache?.has(tag)) { + if (compute(itemsCache.get(tag))) hasMatch = true; + } else { + uncachedTags.push(tag); + } + } + return { uncachedTags, hasMatch }; +} + +/** + * Fetches uncached tags from DynamoDB via BatchGetItem, populates the items + * cache (storing null for absent tags), and returns whether any tag matched. + */ +async function fetchAndCacheItems( + uncachedTags: string[], + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + itemsCache: Map | undefined, + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + compute: (item: any) => boolean +): Promise { + const { CACHE_DYNAMO_TABLE } = process.env; + const response = await awsFetch( + JSON.stringify({ + RequestItems: { + [CACHE_DYNAMO_TABLE ?? ""]: { + Keys: uncachedTags.map((tag) => ({ + path: { S: buildDynamoKey(tag) }, + tag: { S: buildDynamoKey(tag) }, + })), + }, + }, + }), + "query" + ); + if (response.status !== 200) { + throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); + } + const { Responses } = await response.json(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const responseItems: any[] = Responses?.[CACHE_DYNAMO_TABLE ?? ""] ?? []; + + // Build a lookup map: DynamoDB key -> item + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const responseByKey = new Map(); + for (const item of responseItems) { + responseByKey.set(item.tag.S, item); + } + + let hasMatch = false; + for (const tag of uncachedTags) { + const item = responseByKey.get(buildDynamoKey(tag)) ?? null; + itemsCache?.set(tag, item); + if (compute(item)) hasMatch = true; + } + return hasMatch; +} + export default { name: "ddb-nextMode", mode: "nextMode", @@ -84,38 +160,64 @@ export default { "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" ); } - const { CACHE_DYNAMO_TABLE } = process.env; - // It's unlikely that we will have more than 100 items to query - // If that's the case, you should not use this tagCache implementation - const response = await awsFetch( - JSON.stringify({ - RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: { - Keys: tags.map((tag) => ({ - path: { S: buildDynamoKey(tag) }, - tag: { S: buildDynamoKey(tag) }, - })), - }, - }, - }), - "query" - ); - if (response.status !== 200) { - throw new RecoverableError(`Failed to query dynamo item: ${response.status}`); - } - // Now we need to check for every item if lastModified is greater than the revalidatedAt - const { Responses } = (await response.json()) as DynamoDBBatchGetResponse; - if (!Responses) { + + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + const now = Date.now(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const compute = (item: any): boolean => { + if (!item) return false; + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + if (expiry <= now && expiry > (lastModified ?? 0)) return true; + } + return Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("retrieved tags for hasBeenRevalidated", tags); + return result; + }, + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { return false; } - const revalidatedTags = - Responses?.[CACHE_DYNAMO_TABLE ?? ""]?.filter( - (item) => Number.parseInt(item.revalidatedAt.N) > (lastModified ?? 0) - ) ?? []; - debug("retrieved tags", revalidatedTags); - return revalidatedTags.length > 0; + if (tags.length === 0) return false; + if (tags.length > 100) { + throw new RecoverableError( + "Cannot query more than 100 tags at once. You should not be using this tagCache implementation for this amount of tags" + ); + } + + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const itemsCache = store?.requestCache.getOrCreate("ddb-nextMode:tagItems"); + + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const compute = (item: any): boolean => { + if (!item?.stale?.N) return false; + const revalidatedAt = Number.parseInt(item.revalidatedAt?.N ?? "0"); + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return revalidatedAt > (lastModified ?? 0) && Number.parseInt(item.stale.N) >= (lastModified ?? 0); + }; + + const { uncachedTags, hasMatch } = checkItemsCache(tags, itemsCache, compute); + if (hasMatch) return true; + if (uncachedTags.length === 0) return false; + + const result = await fetchAndCacheItems(uncachedTags, itemsCache, compute); + debug("isStale result:", result); + return result; }, - writeTags: async (tags: string[]) => { + writeTags: async (tags) => { try { const { CACHE_DYNAMO_TABLE } = process.env; if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -123,13 +225,18 @@ export default { } const dataChunks = chunk(tags, MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT).map((Items) => ({ RequestItems: { - [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => ({ - PutRequest: { - Item: { - ...buildDynamoObject(tag), + [CACHE_DYNAMO_TABLE ?? ""]: Items.map((tag) => { + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expiry = typeof tag === "string" ? undefined : tag.expire; + return { + PutRequest: { + Item: { + ...buildDynamoObject(tagStr, undefined, stale, expiry), + }, }, - }, - })), + }; + }), }, })); const toInsert = chunk(dataChunks, getDynamoBatchWriteCommandConcurrency()); diff --git a/packages/open-next/src/overrides/tagCache/dynamodb.ts b/packages/open-next/src/overrides/tagCache/dynamodb.ts index 8a69f64c..e5dcbe34 100644 --- a/packages/open-next/src/overrides/tagCache/dynamodb.ts +++ b/packages/open-next/src/overrides/tagCache/dynamodb.ts @@ -28,12 +28,22 @@ function buildDynamoKey(key: string) { return path.posix.join(NEXT_BUILD_ID ?? "", key); } -function buildDynamoObject(path: string, tags: string, revalidatedAt?: number) { - return { +function buildDynamoObject( + path: string, + tags: string, + revalidatedAt?: number, + stale?: number, + expire?: number +) { + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const obj: Record = { path: { S: buildDynamoKey(path) }, tag: { S: buildDynamoKey(tags) }, revalidatedAt: { N: `${revalidatedAt ?? Date.now()}` }, + ...(stale !== undefined ? { stale: { N: `${stale}` } } : {}), + ...(expire !== undefined ? { expire: { N: `${expire}` } } : {}), }; + return obj; } const tagCache: TagCache = { @@ -43,6 +53,12 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const cache = store?.requestCache.getOrCreate("dynamoDb:getByPath"); + if (cache?.has(path)) { + return cache.get(path)!; + } const result = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -59,7 +75,9 @@ const tagCache: TagCache = { const tags = result.Items?.map((item) => item.tag.S ?? "") ?? []; debug("tags for path", path, tags); // We need to remove the buildId from the path - return tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + const resultTags = tags.map((tag) => tag.replace(`${NEXT_BUILD_ID}/`, "")); + cache?.set(path, resultTags); + return resultTags; } catch (e) { error("Failed to get tags by path", e); return []; @@ -70,6 +88,11 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return []; } + const store = globalThis.__openNextAls.getStore(); + const cache = store?.requestCache.getOrCreate("dynamoDb:getByTag"); + if (cache?.has(tag)) { + return cache.get(tag)!; + } const { Items } = await dynamoClient.send( new QueryCommand({ TableName: CACHE_DYNAMO_TABLE, @@ -82,10 +105,10 @@ const tagCache: TagCache = { }, }) ); - return ( - // We need to remove the buildId from the path - Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? [] - ); + // We need to remove the buildId from the path + const paths = Items?.map(({ path: { S: key } }) => key?.replace(`${NEXT_BUILD_ID}/`, "") ?? "") ?? []; + cache?.set(tag, paths); + return paths; } catch (e) { error("Failed to get by tag", e); return []; @@ -96,30 +119,99 @@ const tagCache: TagCache = { if (globalThis.openNextConfig.dangerous?.disableTagCache) { return lastModified ?? Date.now(); } - const result = await dynamoClient.send( - new QueryCommand({ - TableName: CACHE_DYNAMO_TABLE, - IndexName: "revalidate", - KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", - ExpressionAttributeNames: { - "#key": "path", - "#revalidatedAt": "revalidatedAt", - }, - ExpressionAttributeValues: { - ":key": { S: buildDynamoKey(key) }, - ":lastModified": { N: String(lastModified ?? 0) }, - }, - }) - ); - const revalidatedTags = result.Items ?? []; + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems"); + const cacheKey = `${key}:${lastModified ?? 0}`; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + let revalidatedTags: any[]; + if (itemsCache?.has(cacheKey)) { + revalidatedTags = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + revalidatedTags = result.Items ?? []; + itemsCache?.set(cacheKey, revalidatedTags); + } debug("revalidatedTags", revalidatedTags); - // If we have revalidated tags we return -1 to force revalidation - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = revalidatedTags.some((item) => { + if (item.expire?.N) { + const expiry = Number.parseInt(item.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + // Exclude expired tags from the revalidated count — they are handled + // separately via hasExpiredTag above. + const nonExpiredRevalidatedTags = revalidatedTags.filter((item) => { + if (item.expire?.N) { + return Number.parseInt(item.expire.N) > now; + } + return true; + }); + + // If we have revalidated tags or expired tags we return -1 to force revalidation + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); } catch (e) { error("Failed to get revalidated tags", e); return lastModified ?? Date.now(); } }, + async isStale(key: string, lastModified?: number) { + try { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const store = globalThis.__openNextAls.getStore(); + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + const itemsCache = store?.requestCache.getOrCreate("dynamoDb:revalidateQueryItems"); + const cacheKey = `${key}:${lastModified ?? 0}`; + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + let items: any[]; + if (itemsCache?.has(cacheKey)) { + items = itemsCache.get(cacheKey)!; + } else { + const result = await dynamoClient.send( + new QueryCommand({ + TableName: CACHE_DYNAMO_TABLE, + IndexName: "revalidate", + KeyConditionExpression: "#key = :key AND #revalidatedAt > :lastModified", + ExpressionAttributeNames: { + "#key": "path", + "#revalidatedAt": "revalidatedAt", + }, + ExpressionAttributeValues: { + ":key": { S: buildDynamoKey(key) }, + ":lastModified": { N: String(lastModified ?? 0) }, + }, + }) + ); + items = result.Items ?? []; + itemsCache?.set(cacheKey, items); + } + debug("isStale items", key, items); + return items.length > 0; + } catch (e) { + error("Failed to check stale tags", e); + return false; + } + }, async writeTags(tags) { try { if (globalThis.openNextConfig.dangerous?.disableTagCache) { @@ -130,7 +222,7 @@ const tagCache: TagCache = { [CACHE_DYNAMO_TABLE ?? ""]: Items.map((Item) => ({ PutRequest: { Item: { - ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt), + ...buildDynamoObject(Item.path, Item.tag, Item.revalidatedAt, Item.stale, Item.expire), }, }, })), diff --git a/packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts b/packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts index 49ccb498..591ccb1a 100644 --- a/packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts +++ b/packages/open-next/src/overrides/tagCache/fs-dev-nextMode.ts @@ -2,7 +2,7 @@ import type { NextModeTagCache } from "@/types/overrides"; import { debug } from "../../adapters/logger"; -const tagsMap = new Map(); +const tagsMap = new Map(); export default { name: "fs-dev-nextMode", @@ -15,9 +15,9 @@ export default { let lastRevalidated = 0; tags.forEach((tag) => { - const tagTime = tagsMap.get(tag); - if (tagTime && tagTime > lastRevalidated) { - lastRevalidated = tagTime; + const tagData = tagsMap.get(tag); + if (tagData && tagData.revalidatedAt > lastRevalidated) { + lastRevalidated = tagData.revalidatedAt; } }); @@ -29,15 +29,44 @@ export default { return false; } + const now = Date.now(); const hasRevalidatedTag = tags.some((tag) => { - const tagRevalidatedAt = tagsMap.get(tag); - return tagRevalidatedAt ? tagRevalidatedAt > (lastModified ?? 0) : false; + const tagData = tagsMap.get(tag); + if (!tagData) { + return false; + } + + // Check if tag has expired + if (typeof tagData.expire === "number") { + const isExpired = tagData.expire <= now && tagData.expire > (lastModified ?? 0); + return isExpired; + } + + // Check if tag has been revalidated + return tagData.revalidatedAt > (lastModified ?? 0); }); debug("hasBeenRevalidated result:", hasRevalidatedTag); return hasRevalidatedTag; }, - writeTags: async (tags: string[]) => { + isStale: async (tags: string[], lastModified?: number) => { + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + const hasStaleTag = tags.some((tag) => { + const tagData = tagsMap.get(tag); + if (!tagData || typeof tagData.stale !== "number") { + return false; + } + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return tagData.revalidatedAt > (lastModified ?? 0) && tagData.stale >= (lastModified ?? 0); + }); + debug("isStale result:", hasStaleTag); + return hasStaleTag; + }, + writeTags: async (tags) => { if (globalThis.openNextConfig.dangerous?.disableTagCache || tags.length === 0) { return; } @@ -45,7 +74,14 @@ export default { debug("writeTags", { tags: tags }); tags.forEach((tag) => { - tagsMap.set(tag, Date.now()); + const tagStr = typeof tag === "string" ? tag : tag.tag; + const stale = typeof tag === "string" ? undefined : tag.stale; + const expire = typeof tag === "string" ? undefined : tag.expire; + tagsMap.set(tagStr, { + revalidatedAt: Date.now(), + stale, + expire, + }); }); debug("writeTags completed, written", tags.length, "tags"); diff --git a/packages/open-next/src/overrides/tagCache/fs-dev.ts b/packages/open-next/src/overrides/tagCache/fs-dev.ts index 932eb216..d854c045 100644 --- a/packages/open-next/src/overrides/tagCache/fs-dev.ts +++ b/packages/open-next/src/overrides/tagCache/fs-dev.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +import type { TagCacheMetaFile } from "@/types/cache"; import type { TagCache } from "@/types/overrides"; import { getMonorepoRelativePath } from "@/utils/normalize-path"; @@ -11,6 +12,8 @@ let tags = JSON.parse(tagContent) as { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; + stale?: { N: string }; + expire?: { N: string }; }[]; const { NEXT_BUILD_ID } = process.env; @@ -33,22 +36,52 @@ const tagCache: TagCache = { .map((tagEntry) => tagEntry.path.S.replace(`${NEXT_BUILD_ID}/`, "")); }, getLastModified: async (path: string, lastModified?: number) => { - const revalidatedTags = tags.filter( + // Check if any tag has expired + const now = Date.now(); + const hasExpiredTag = tags.some((tagPathMapping) => { + if (tagPathMapping.path.S === buildKey(path) && tagPathMapping.expire?.N) { + const expiry = Number.parseInt(tagPathMapping.expire.N); + return expiry <= now && expiry > (lastModified ?? 0); + } + return false; + }); + + const nonExpiredRevalidatedTags = tags.filter( (tagPathMapping) => tagPathMapping.path.S === buildKey(path) && - Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) + Number.parseInt(tagPathMapping.revalidatedAt.N) > (lastModified ?? 0) && + (!tagPathMapping.expire?.N || Number.parseInt(tagPathMapping.expire.N) > now) ); - return revalidatedTags.length > 0 ? -1 : (lastModified ?? Date.now()); + + return nonExpiredRevalidatedTags.length > 0 || hasExpiredTag ? -1 : (lastModified ?? Date.now()); + }, + isStale: async (path: string, lastModified?: number) => { + return tags.some((entry) => { + if (entry.path.S !== buildKey(path)) return false; + if (!entry.stale?.N) return false; + // A tag is stale when both its stale timestamp and its revalidatedAt are newer than the page. + // revalidatedAt > lastModified ensures the revalidation that set this stale window happened + // after the page was generated, preventing a stale signal from a previous ISR cycle. + return ( + Number.parseInt(entry.revalidatedAt.N) > (lastModified ?? 0) && + Number.parseInt(entry.stale.N) > (lastModified ?? 0) + ); + }); }, writeTags: async (newTags) => { const newTagsSet = new Set(newTags.map(({ tag, path }) => `${buildKey(tag)}-${buildKey(path)}`)); const unchangedTags = tags.filter(({ tag, path }) => !newTagsSet.has(`${tag.S}-${path.S}`)); tags = unchangedTags.concat( - newTags.map((item) => ({ - tag: { S: buildKey(item.tag) }, - path: { S: buildKey(item.path) }, - revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, - })) + newTags.map((item) => { + const tagEntry: TagCacheMetaFile = { + tag: { S: buildKey(item.tag) }, + path: { S: buildKey(item.path) }, + revalidatedAt: { N: `${item.revalidatedAt ?? Date.now()}` }, + ...(item.stale !== undefined ? { stale: { N: `${item.stale}` } } : undefined), + ...(item.expire !== undefined ? { expire: { N: `${item.expire}` } } : undefined), + }; + return tagEntry; + }) ); }, }; diff --git a/packages/open-next/src/types/cache.ts b/packages/open-next/src/types/cache.ts index db5b63f1..cd72be9f 100644 --- a/packages/open-next/src/types/cache.ts +++ b/packages/open-next/src/types/cache.ts @@ -99,6 +99,14 @@ export type TagCacheMetaFile = { tag: { S: string }; path: { S: string }; revalidatedAt: { N: string }; + /** + * The time at which the tag should be considered stale, in milliseconds since epoch. Optional, if not set the tag will never be stale. + */ + stale?: { N: string }; + /** + * The time at which the tag should expire, in milliseconds since epoch. Optional, if not set the tag will never expire. + */ + expire?: { N: string }; }; // Cache context since vercel/next.js#76207 @@ -172,4 +180,10 @@ export interface ComposableCacheHandler { * This function is only there for older versions and do nothing */ receiveExpiredTags(...tags: string[]): Promise; + /** + * Added in Next.js 16. Updates tags with optional stale/expire durations. + * When durations is provided, marks tags as stale immediately and sets expiry; + * when omitted, immediately expires tags (same as expireTags). + */ + updateTags(tags: string[], durations?: { expire?: number }): Promise; } diff --git a/packages/open-next/src/types/global.ts b/packages/open-next/src/types/global.ts index 289fe144..987330ca 100644 --- a/packages/open-next/src/types/global.ts +++ b/packages/open-next/src/types/global.ts @@ -11,6 +11,7 @@ import type { } from "@/types/overrides"; import type { DetachedPromiseRunner } from "../utils/promise"; +import type { RequestCache } from "../utils/requestCache"; import type { i18nConfig } from "./next-types.js"; import type { OpenNextConfig, WaitUntil } from "./open-next"; @@ -68,6 +69,8 @@ interface OpenNextRequestContext { waitUntil?: WaitUntil; /** We use this to deduplicate write of the tags*/ writtenTags: Set; + /** Per-request in-memory cache. Overrides can use this to store data scoped to the current request. */ + requestCache: RequestCache; } declare global { @@ -188,6 +191,13 @@ declare global { */ var AsyncLocalStorage: typeof NodeAsyncLocalStorage; + /** + * The Next.js version of the application. + * Only available in the cache adapter. + * Defined in the esbuild banner for the cache adapter. + */ + var nextVersion: string; + /** * The version of the Open Next runtime. * Available everywhere. diff --git a/packages/open-next/src/types/overrides.ts b/packages/open-next/src/types/overrides.ts index 84589959..052dcec7 100644 --- a/packages/open-next/src/types/overrides.ts +++ b/packages/open-next/src/types/overrides.ts @@ -153,21 +153,55 @@ Cons : - One page request (i.e. GET request) could require to check a lot of tags (And some of them multiple time when used with the fetch cache) - Almost impossible to do automatic cdn revalidation by itself */ + +export type NextModeTagCacheWriteInput = + | string + | { + tag: string; + /** + * Timestamp in milliseconds since epoch after which the tag should be considered stale. + */ + stale?: number; + /** + * Timestamp in milliseconds since epoch after which the tag should be considered expired. + */ + expire?: number; + }; + export type NextModeTagCache = BaseTagCache & { mode: "nextMode"; // Necessary for the composable cache getLastRevalidated(tags: string[]): Promise; hasBeenRevalidated(tags: string[], lastModified?: number): Promise; - writeTags(tags: string[]): Promise; + writeTags(tags: NextModeTagCacheWriteInput[]): Promise; // Optional method to get paths by tags // It is used to automatically invalidate paths in the CDN getPathsByTags?: (tags: string[]) => Promise; + /** + * Optional method to check if any tag has become stale (but not yet expired). + * There are three possible states for a cache entry: + * - **Fresh**: no tag has been revalidated since `lastModified` → returns `false`. + * - **Stale**: at least one tag was revalidated after `lastModified` but has not yet expired → + * returns `true`. The cache entry is still served but `revalidate` is set to `1` to trigger + * background revalidation. + * - **Expired**: at least one tag has fully expired → handled by `hasBeenRevalidated` returning + * `true`. This method is only called when `hasBeenRevalidated` returned `false`. + */ + isStale?(tags: string[], lastModified?: number): Promise; }; export interface OriginalTagCacheWriteInput { tag: string; path: string; revalidatedAt?: number; + /** + * Timestamp in milliseconds since epoch after which the tag/path combination should be considered stale. + */ + stale?: number; + /** + * Timestamp in milliseconds since epoch after which the tag/path combination should be considered expired. + */ + expire?: number; } /** @@ -195,6 +229,17 @@ export type OriginalTagCache = BaseTagCache & { getByPath(path: string): Promise; getLastModified(path: string, lastModified?: number): Promise; writeTags(tags: OriginalTagCacheWriteInput[]): Promise; + /** + * Optional method to check if any tag entry for the given path has become stale (but not yet expired). + * There are three possible states for a cache entry: + * - **Fresh**: no tag/path combination has been revalidated since `lastModified` → returns `false`. + * - **Stale**: at least one tag/path combination was revalidated after `lastModified` but has not + * yet expired → returns `true`. The cache entry is still served but `revalidate` is set to `1` + * to trigger background revalidation. + * - **Expired**: at least one tag/path combination has fully expired → handled by `getLastModified` + * returning `-1`. This method is only called when the entry is not fully expired. + */ + isStale?(path: string, lastModified?: number): Promise; }; export type TagCache = NextModeTagCache | OriginalTagCache; @@ -238,7 +283,7 @@ export type Warmer = BaseOverride & { }; export type ImageLoader = BaseOverride & { - load: (url: string) => Promise<{ + load: (path: string) => Promise<{ body?: Readable; contentType?: string; cacheControl?: string; diff --git a/packages/open-next/src/utils/cache.ts b/packages/open-next/src/utils/cache.ts index 090bc247..cbfb7fd6 100644 --- a/packages/open-next/src/utils/cache.ts +++ b/packages/open-next/src/utils/cache.ts @@ -1,12 +1,48 @@ import type { CacheEntryType, CacheValue, + NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, WithLastModified, } from "@/types/overrides"; import { debug } from "../adapters/logger"; +import { compareSemver } from "./semver"; + +/** + * + * @param key The key for that specific cache entry + * @param tags Array of tags associated with that cache entry + * @param lastModified Time of the last update to the cache entry + * @returns A boolean indicating whether the cache entry has become stale - + * A cache entry is considered stale if at least one of its associated tags has been revalidated since the `lastModified` time, but none of them has expired yet. + * In this case, the cache entry is still valid and can be served, but it should trigger a background revalidation to update the cache. + */ +export async function isStale(key: string, tags: string[], lastModified?: number): Promise { + // SWR for revalidateTag has been implemented starting from Next.js 16 + if (!compareSemver(globalThis.nextVersion, ">=", "16.0.0")) { + return false; + } + if (globalThis.openNextConfig.dangerous?.disableTagCache) { + return false; + } + if (globalThis.tagCache.mode === "nextMode") { + return tags.length === 0 ? false : ((await globalThis.tagCache.isStale?.(tags, lastModified)) ?? false); + } + return (await globalThis.tagCache.isStale?.(key, lastModified)) ?? false; +} + +/** + * @param key The key for that specific cache entry + * @param tags Array of tags associated with that cache entry + * @param cacheEntry The cache entry with its last modified time and value + * @returns A boolean indicating whether the cache entry has been revalidated - + * A cache entry is considered revalidated if at least one of its associated tags has been revalidated + * after the entry's `lastModified` time, meaning the cached data is stale and must be re-fetched. + * For Next 16+ you need {@link isStale}, to know if a revalidated entry is stale (valid but needs background revalidation) or expired (needs to be re-fetched immediately). + * Without it, we consider all revalidated entries as expired, which means that they will be re-fetched immediately without a chance to be served stale. + */ export async function hasBeenRevalidated( key: string, tags: string[], @@ -46,17 +82,24 @@ export function getTagsFromValue(value?: CacheValue<"cache">) { } } -function getTagKey(tag: string | OriginalTagCacheWriteInput): string { +function getTagKey(tag: string | OriginalTagCacheWriteInput | NextModeTagCacheWriteInput): string { if (typeof tag === "string") { return tag; } - return JSON.stringify({ - tag: tag.tag, - path: tag.path, - }); + // For OriginalTagCacheWriteInput, include path in the key + if ("path" in tag) { + return JSON.stringify({ + tag: tag.tag, + path: tag.path, + }); + } + // For NextModeTagCacheWriteInput, just use the tag + return tag.tag; } -export async function writeTags(tags: (string | OriginalTagCacheWriteInput)[]): Promise { +export async function writeTags( + tags: (string | OriginalTagCacheWriteInput | NextModeTagCacheWriteInput)[] +): Promise { const store = globalThis.__openNextAls.getStore(); debug("Writing tags", tags, store); if (!store || globalThis.openNextConfig.dangerous?.disableTagCache) { diff --git a/packages/open-next/src/utils/promise.ts b/packages/open-next/src/utils/promise.ts index 00602ce0..e3a2e158 100644 --- a/packages/open-next/src/utils/promise.ts +++ b/packages/open-next/src/utils/promise.ts @@ -2,6 +2,8 @@ import type { WaitUntil } from "@/types/open-next"; import { debug, error } from "../adapters/logger"; +import { RequestCache } from "./requestCache"; + /** * A `Promise.withResolvers` implementation that exposes the `resolve` and * `reject` functions on a `Promise`. @@ -120,6 +122,7 @@ export function runWithOpenNextRequestContext( isISRRevalidation, waitUntil, writtenTags: new Set(), + requestCache: new RequestCache(), }, async () => { provideNextAfterProvider(); diff --git a/packages/open-next/src/utils/requestCache.ts b/packages/open-next/src/utils/requestCache.ts new file mode 100644 index 00000000..c9ddb5bc --- /dev/null +++ b/packages/open-next/src/utils/requestCache.ts @@ -0,0 +1,12 @@ +export class RequestCache { + private _caches = new Map>(); + + getOrCreate(key: string): Map { + let cache = this._caches.get(key) as Map | undefined; + if (!cache) { + cache = new Map(); + this._caches.set(key, cache); + } + return cache; + } +} diff --git a/packages/open-next/src/utils/semver.ts b/packages/open-next/src/utils/semver.ts new file mode 100644 index 00000000..b6c2a913 --- /dev/null +++ b/packages/open-next/src/utils/semver.ts @@ -0,0 +1,43 @@ +export type SemverOp = "=" | ">=" | "<=" | ">" | "<"; + +export function compareSemver(v1: string, operator: SemverOp, v2: string): boolean { + let versionDiff = 0; + if (v1 === "latest") { + versionDiff = 1; + } else { + if (/^[^\d]/.test(v1)) { + v1 = v1.substring(1); + } + if (/^[^\d]/.test(v2)) { + v2 = v2.substring(1); + } + const [major1, minor1 = 0, patch1 = 0] = v1.split(".").map(Number); + const [major2, minor2 = 0, patch2 = 0] = v2.split(".").map(Number); + if (Number.isNaN(major1) || Number.isNaN(major2)) { + throw new Error("The major version is required."); + } + + if (major1 !== major2) { + versionDiff = major1 - major2; + } else if (minor1 !== minor2) { + versionDiff = minor1 - minor2; + } else if (patch1 !== patch2) { + versionDiff = patch1 - patch2; + } + } + + switch (operator) { + case "=": + return versionDiff === 0; + case ">=": + return versionDiff >= 0; + case "<=": + return versionDiff <= 0; + case ">": + return versionDiff > 0; + case "<": + return versionDiff < 0; + default: + throw new Error(`Unsupported operator: ${operator}`); + } +} diff --git a/packages/tests-unit/package.json b/packages/tests-unit/package.json index 10b2d437..45351d75 100644 --- a/packages/tests-unit/package.json +++ b/packages/tests-unit/package.json @@ -12,6 +12,7 @@ "@opennextjs/aws": "workspace:*" }, "devDependencies": { + "@aws-sdk/client-dynamodb": "3.984.0", "@types/testing-library__jest-dom": "^5.14.9", "@vitest/coverage-v8": "^2.1.3", "diff": "^8.0.2", diff --git a/packages/tests-unit/tests/adapters/cache.test.ts b/packages/tests-unit/tests/adapters/cache.test.ts index fa739380..ac99ab27 100644 --- a/packages/tests-unit/tests/adapters/cache.test.ts +++ b/packages/tests-unit/tests/adapters/cache.test.ts @@ -1,11 +1,11 @@ import Cache, { SOFT_TAG_PREFIX } from "@opennextjs/aws/adapters/cache.js"; -import { type Mock, vi } from "vitest"; +import { type Mock, vi, describe, it, expect, beforeEach } from "vitest"; declare global { var openNextConfig: { dangerous: { disableIncrementalCache?: boolean; disableTagCache?: boolean }; }; - var isNextAfter15: boolean; + var nextVersion: string; } describe("CacheHandler", () => { @@ -37,6 +37,7 @@ describe("CacheHandler", () => { getByPath: vi.fn(), getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), writeTags: vi.fn(), + isStale: vi.fn().mockResolvedValue(false), getPathsByTags: undefined as Mock | undefined, }; globalThis.tagCache = tagCache; @@ -48,18 +49,21 @@ describe("CacheHandler", () => { globalThis.cdnInvalidationHandler = invalidateCdnHandler; globalThis.__openNextAls = { - getStore: vi.fn().mockReturnValue({ + getStore: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset the getStore mock to return a fresh set for each test + (globalThis.__openNextAls.getStore as Mock).mockReturnValue({ pendingPromiseRunner: { withResolvers: vi.fn().mockReturnValue({ resolve: vi.fn(), }), }, writtenTags: new Set(), - }), - }; - - beforeEach(() => { - vi.clearAllMocks(); + }); cache = new Cache(); @@ -68,7 +72,7 @@ describe("CacheHandler", () => { disableIncrementalCache: false, }, }; - globalThis.isNextAfter15 = false; + globalThis.nextVersion = "15.0.0"; tagCache.mode = "original"; tagCache.getPathsByTags = undefined; }); @@ -118,6 +122,10 @@ describe("CacheHandler", () => { }); describe("next15", () => { + beforeEach(() => { + globalThis.nextVersion = "15.0.0"; + }); + it("Should retrieve cache from fetch cache when hint is fetch", async () => { await cache.get("key", { kind: "FETCH" }); @@ -460,6 +468,86 @@ describe("CacheHandler", () => { expect(result).toBeNull(); }); }); + + describe("stale tags", () => { + beforeEach(() => { + globalThis.nextVersion = "16.0.0"; + }); + + it("Should return lastModified as 1 when tag is stale", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(true); + const cachedLastModified = Date.now(); + incrementalCache.get.mockResolvedValueOnce({ + value: { + kind: "FETCH", + data: { + headers: {}, + body: "{}", + url: "https://example.com", + status: 200, + }, + }, + lastModified: cachedLastModified, + }); + + const result = await cache.get("key", { + kind: "FETCH", + tags: ["tag1"], + }); + + expect(result).not.toBeNull(); + expect(result?.lastModified).toEqual(1); + }); + + it("Should return original lastModified when tag is not stale", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(false); + const cachedLastModified = Date.now(); + incrementalCache.get.mockResolvedValueOnce({ + value: { + kind: "FETCH", + data: { + headers: {}, + body: "{}", + url: "https://example.com", + status: 200, + }, + }, + lastModified: cachedLastModified, + }); + + const result = await cache.get("key", { + kind: "FETCH", + tags: ["tag1"], + }); + + expect(result).not.toBeNull(); + expect(result?.lastModified).toEqual(cachedLastModified); + }); + + it("Should not call isStale 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, + }); + + await cache.get("key", { kind: "FETCH", tags: ["tag1"] }); + + expect(tagCache.isStale).not.toHaveBeenCalled(); + }); + }); }); describe("set", () => { @@ -698,6 +786,7 @@ describe("CacheHandler", () => { { path: "/path", tag: "tag", + expire: expect.any(Number), }, ]); }); @@ -712,6 +801,7 @@ describe("CacheHandler", () => { { path: "/path", tag: `${SOFT_TAG_PREFIX}path`, + expire: expect.any(Number), }, ]); @@ -727,6 +817,7 @@ describe("CacheHandler", () => { { path: "123456", tag: "tag", + expire: expect.any(Number), }, ]); @@ -738,7 +829,10 @@ describe("CacheHandler", () => { await cache.revalidateTag(["tag1", "tag2"]); expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag1", "tag2"]); + expect(tagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", expire: expect.any(Number) }, + { tag: "tag2", expire: expect.any(Number) }, + ]); expect(invalidateCdnHandler.invalidatePaths).not.toHaveBeenCalled(); }); @@ -756,7 +850,7 @@ describe("CacheHandler", () => { await cache.revalidateTag("tag"); expect(tagCache.writeTags).toHaveBeenCalledTimes(1); - expect(tagCache.writeTags).toHaveBeenCalledWith(["tag"]); + expect(tagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag", expire: expect.any(Number) }]); expect(invalidateCdnHandler.invalidatePaths).toHaveBeenCalledWith([ { initialPath: "/path", diff --git a/packages/tests-unit/tests/adapters/composable-cache.test.ts b/packages/tests-unit/tests/adapters/composable-cache.test.ts index 6176a23b..5857d792 100644 --- a/packages/tests-unit/tests/adapters/composable-cache.test.ts +++ b/packages/tests-unit/tests/adapters/composable-cache.test.ts @@ -1,6 +1,6 @@ import ComposableCache from "@opennextjs/aws/adapters/composable-cache"; import { fromReadableStream, toReadableStream } from "@opennextjs/aws/utils/stream"; -import { vi } from "vitest"; +import { vi, describe, it, expect, beforeEach } from "vitest"; describe("Composable cache handler", () => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); @@ -29,6 +29,7 @@ describe("Composable cache handler", () => { name: "mock", mode: "original" as string | undefined, hasBeenRevalidated: vi.fn(), + isStale: vi.fn().mockResolvedValue(false), getByTag: vi.fn().mockResolvedValue(["path1", "path2"]), getByPath: vi.fn().mockResolvedValue(["tag1"]), getLastModified: vi.fn().mockResolvedValue(new Date("2024-01-02T00:00:00Z").getTime()), @@ -64,6 +65,7 @@ describe("Composable cache handler", () => { disableTagCache: false, }, }; + globalThis.nextVersion = "16.0.0"; }); describe("get", () => { @@ -169,6 +171,61 @@ describe("Composable cache handler", () => { expect(result).toBeUndefined(); }); + it("should return entry with revalidate=-1 when tags are stale in nextMode", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(true); + + const result = await ComposableCache.get("test-key"); + + expect(tagCache.isStale).toHaveBeenCalledWith(["tag1", "tag2"], expect.any(Number)); + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); + }); + + it("should not override revalidate when tags are not stale in nextMode", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(false); + tagCache.isStale.mockResolvedValueOnce(false); + + const result = await ComposableCache.get("test-key"); + + expect(result).toBeDefined(); + expect(result?.revalidate).not.toBe(-1); + }); + + it("should not check stale when tags are already expired in nextMode", async () => { + tagCache.mode = "nextMode"; + tagCache.hasBeenRevalidated.mockResolvedValueOnce(true); + + const result = await ComposableCache.get("test-key"); + + expect(result).toBeUndefined(); + expect(tagCache.isStale).not.toHaveBeenCalled(); + }); + + it("should return entry with revalidate=-1 when path is stale in original mode", async () => { + tagCache.mode = "original"; + tagCache.getLastModified.mockResolvedValueOnce(Date.now()); // not expired + tagCache.isStale.mockResolvedValueOnce(true); + + const result = await ComposableCache.get("test-key"); + + expect(tagCache.isStale).toHaveBeenCalledWith("test-key", expect.any(Number)); + expect(result).toBeDefined(); + expect(result?.revalidate).toBe(-1); + }); + + it("should not check stale when path is already expired in original mode", async () => { + tagCache.mode = "original"; + tagCache.getLastModified.mockResolvedValueOnce(-1); // expired + + const result = await ComposableCache.get("test-key"); + + expect(result).toBeUndefined(); + expect(tagCache.isStale).not.toHaveBeenCalled(); + }); + it("should return pending write promise if available", async () => { const pendingEntry = Promise.resolve({ value: toReadableStream("pending-value"), @@ -434,6 +491,123 @@ describe("Composable cache handler", () => { }); }); + describe("updateTags", () => { + beforeEach(() => { + writtenTags.clear(); + }); + + it("should immediately expire tags when no durations provided - nextMode", async () => { + tagCache.mode = "nextMode"; + const now = Date.now(); + + await ComposableCache.updateTags(["tag1", "tag2"]); + + expect(tagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", expire: now }, + { tag: "tag2", expire: now }, + ]); + }); + + it("should set stale and expiry when durations.expire provided - nextMode", async () => { + tagCache.mode = "nextMode"; + const now = Date.now(); + + await ComposableCache.updateTags(["tag1", "tag2"], { expire: 60 }); + + expect(tagCache.writeTags).toHaveBeenCalledWith([ + { tag: "tag1", stale: now, expire: now + 60 * 1000 }, + { tag: "tag2", stale: now, expire: now + 60 * 1000 }, + ]); + }); + + it("should set stale without expiry when durations.expire is undefined - nextMode", async () => { + tagCache.mode = "nextMode"; + const now = Date.now(); + + await ComposableCache.updateTags(["tag1"], { expire: undefined }); + + expect(tagCache.writeTags).toHaveBeenCalledWith([{ tag: "tag1", stale: now, expire: undefined }]); + }); + + it("should immediately expire tags when no durations provided - original mode", async () => { + tagCache.mode = "original"; + tagCache.getByTag.mockImplementation(async (tag: string) => { + if (tag === "tag1") return ["/path1"]; + if (tag === "tag2") return ["/path2"]; + return []; + }); + const now = Date.now(); + + await ComposableCache.updateTags(["tag1", "tag2"]); + + expect(tagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", expire: now }, + { path: "/path2", tag: "tag2", expire: now }, + ]); + }); + + it("should set stale and expiry when durations.expire provided - original mode", async () => { + tagCache.mode = "original"; + tagCache.getByTag.mockImplementation(async (tag: string) => { + if (tag === "tag1") return ["/path1", "/path2"]; + return []; + }); + const now = Date.now(); + + await ComposableCache.updateTags(["tag1"], { expire: 30 }); + + expect(tagCache.writeTags).toHaveBeenCalledWith([ + { path: "/path1", tag: "tag1", stale: now, expire: now + 30 * 1000 }, + { path: "/path2", tag: "tag1", stale: now, expire: now + 30 * 1000 }, + ]); + }); + + it("should do nothing when tags list is empty", async () => { + tagCache.mode = "nextMode"; + + await ComposableCache.updateTags([]); + + expect(tagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should do nothing when disableTagCache is true", async () => { + tagCache.mode = "nextMode"; + globalThis.openNextConfig = { + dangerous: { + disableTagCache: true, + disableIncrementalCache: false, + }, + }; + + await ComposableCache.updateTags(["tag1"]); + + expect(tagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should do nothing when disableIncrementalCache is true", async () => { + tagCache.mode = "nextMode"; + globalThis.openNextConfig = { + dangerous: { + disableTagCache: false, + disableIncrementalCache: true, + }, + }; + + await ComposableCache.updateTags(["tag1"]); + + expect(tagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should not write tags when all paths are empty in original mode", async () => { + tagCache.mode = "original"; + tagCache.getByTag.mockResolvedValue([]); + + await ComposableCache.updateTags(["tag1"]); + + expect(tagCache.writeTags).not.toHaveBeenCalled(); + }); + }); + describe("receiveExpiredTags", () => { it("should do nothing", async () => { await ComposableCache.receiveExpiredTags("tag1", "tag2"); diff --git a/packages/tests-unit/tests/build/patch/patches/patchNextServer.test.ts b/packages/tests-unit/tests/build/patch/patches/patchNextServer.test.ts index 5db663bc..1e0af021 100644 --- a/packages/tests-unit/tests/build/patch/patches/patchNextServer.test.ts +++ b/packages/tests-unit/tests/build/patch/patches/patchNextServer.test.ts @@ -3,9 +3,10 @@ import { createEmptyBodyRule, disablePreloadingRule, emptyHandleNextImageRequestRule, + provideInternalWaitUntil, removeMiddlewareManifestRule, } from "@opennextjs/aws/build/patch/patches/patchNextServer.js"; -import { describe, it } from "vitest"; +import { describe, it, expect, test } from "vitest"; import { computePatchDiff } from "./util.js"; @@ -412,6 +413,26 @@ async imageOptimizer(req, res, paramsResult, previousCacheEntry) { `; describe("patchNextServer", () => { + it("should provide internal waitUntil", () => { + const code = ` +class NextNodeServer extends _baseserver.default { + getInternalWaitUntil() { + this.internalWaitUntil ??= this.createInternalWaitUntil(); + return this.internalWaitUntil; + } +} +`; + expect(patchCode(code, provideInternalWaitUntil)).toMatchInlineSnapshot(` + "class NextNodeServer extends _baseserver.default { + getInternalWaitUntil() { + this.internalWaitUntil ??= this.createInternalWaitUntil(); + return globalThis.__openNextAls.getStore()?.waitUntil; + } + } + " + `); + }); + it("should patch getMiddlewareManifest", async () => { expect(patchCode(nextServerGetMiddlewareManifestCode, removeMiddlewareManifestRule)) .toMatchInlineSnapshot(` diff --git a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts index 2b79831c..b2cb5582 100644 --- a/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts +++ b/packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts @@ -55,9 +55,11 @@ const incrementalCache = { const tagCache = { name: "mock", + mode: "original", getByTag: vi.fn(), getByPath: vi.fn(), getLastModified: vi.fn(), + isStale: vi.fn().mockResolvedValue(false), writeTags: vi.fn(), }; @@ -70,6 +72,7 @@ declare global { var queue: Queue; var incrementalCache: any; var tagCache: any; + var nextVersion: string; } globalThis.incrementalCache = incrementalCache; @@ -79,6 +82,7 @@ globalThis.queue = queue; beforeEach(() => { vi.useFakeTimers().setSystemTime("2024-01-02T00:00:00Z"); vi.clearAllMocks(); + globalThis.nextVersion = "16.0.0"; globalThis.openNextConfig = { dangerous: { disableTagCache: false, @@ -535,4 +539,118 @@ describe("cacheInterceptor", () => { const result = await cacheInterceptor(event); expect(result.statusCode).toBe(200); }); + + describe("isStale", () => { + it("should serve stale app content when isStale returns true", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + tagCache.isStale.mockResolvedValueOnce(true); + + const result = await cacheInterceptor(event); + + const body = await fromReadableStream(result.body); + expect(body).toEqual("Hello, world!"); + expect(result).toEqual( + expect.objectContaining({ + type: "core", + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + expect(queue.send).toHaveBeenCalled(); + }); + + it("should serve stale route content when isStale returns true", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "route", + body: "API response", + meta: { + status: 200, + headers: { "content-type": "application/json" }, + }, + revalidate: 300, + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + tagCache.isStale.mockResolvedValueOnce(true); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + "cache-control": "s-maxage=1, stale-while-revalidate=2592000", + "x-opennext-cache": "STALE", + }), + }) + ); + expect(queue.send).toHaveBeenCalled(); + }); + + it("should not check isStale when shouldBypassTagCache is true", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + shouldBypassTagCache: true, + }); + + await cacheInterceptor(event); + + expect(tagCache.isStale).not.toHaveBeenCalled(); + }); + + it("should not call isStale when nextVersion is below 16", async () => { + globalThis.nextVersion = "15.0.0"; + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + + await cacheInterceptor(event); + + expect(tagCache.isStale).not.toHaveBeenCalled(); + }); + + it("should serve fresh content when isStale returns false", async () => { + const event = createEvent({ url: "/albums" }); + incrementalCache.get.mockResolvedValueOnce({ + value: { + type: "app", + html: "Hello, world!", + }, + lastModified: new Date("2024-01-02T00:00:00Z").getTime(), + }); + tagCache.isStale.mockResolvedValueOnce(false); + + const result = await cacheInterceptor(event); + + expect(result).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + "cache-control": "s-maxage=31536000, stale-while-revalidate=2592000", + "x-opennext-cache": "HIT", + }), + }) + ); + expect(queue.send).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/tests-unit/tests/overrides/tagCache/dynamodb-lite.test.ts b/packages/tests-unit/tests/overrides/tagCache/dynamodb-lite.test.ts new file mode 100644 index 00000000..eb3c8d65 --- /dev/null +++ b/packages/tests-unit/tests/overrides/tagCache/dynamodb-lite.test.ts @@ -0,0 +1,379 @@ +import { RequestCache } from "@opennextjs/aws/utils/requestCache.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@opennextjs/aws/adapters/logger.js", () => ({ + debug: vi.fn(), + error: vi.fn(), +})); + +const mockFetch = vi.hoisted(() => vi.fn()); + +vi.mock("aws4fetch", () => ({ + AwsClient: vi.fn().mockReturnValue({}), +})); + +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ + customFetchClient: vi.fn().mockReturnValue(mockFetch), +})); + +// oxlint-disable-next-line eslint-plugin-import(first) +import tagCache from "@opennextjs/aws/overrides/tagCache/dynamodb-lite.js"; + +declare global { + //@ts-ignore + var openNextConfig: { dangerous?: { disableTagCache?: boolean } }; + //@ts-ignore + var __openNextAls: { getStore: () => any }; +} + +const BUILD_ID = "test-build-id"; +const TABLE_NAME = "test-table"; + +function makeStore() { + return { requestCache: new RequestCache() }; +} + +function makeJsonResponse(body: unknown, status = 200) { + return { + status, + json: vi.fn().mockResolvedValue(body), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_BUILD_ID = BUILD_ID; + process.env.CACHE_DYNAMO_TABLE = TABLE_NAME; + process.env.CACHE_BUCKET_REGION = "us-east-1"; + process.env.AWS_ACCESS_KEY_ID = "test-key"; + process.env.AWS_SECRET_ACCESS_KEY = "test-secret"; + globalThis.openNextConfig = { dangerous: { disableTagCache: false } }; + globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue(makeStore()), + }; +}); + +describe("dynamodb-lite tagCache", () => { + describe("getByPath", () => { + it("returns tags with buildId prefix stripped", async () => { + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Items: [{ tag: { S: BUILD_ID + "/tag1" } }, { tag: { S: BUILD_ID + "/tag2" } }], + }) + ); + + const result = await tagCache.getByPath("/some/path"); + + expect(result).toEqual(["tag1", "tag2"]); + }); + + it("sends a Query request with the correct key", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + await tagCache.getByPath("/some/path"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(body.ExpressionAttributeValues[":key"]).toEqual({ + S: BUILD_ID + "/some/path", + }); + }); + + it("returns [] when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getByPath("/some/path"); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns cached result on second call without re-fetching", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [{ tag: { S: BUILD_ID + "/tag1" } }] })); + + await tagCache.getByPath("/some/path"); + const result = await tagCache.getByPath("/some/path"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toEqual(["tag1"]); + }); + + it("throws a RecoverableError when the response status is not 200", async () => { + mockFetch.mockResolvedValueOnce({ status: 500, json: vi.fn() }); + + await expect(tagCache.getByPath("/some/path")).resolves.toEqual([]); + }); + + it("returns [] on fetch error", async () => { + mockFetch.mockRejectedValueOnce(new Error("network error")); + + const result = await tagCache.getByPath("/some/path"); + + expect(result).toEqual([]); + }); + }); + + describe("getByTag", () => { + it("returns paths with buildId prefix stripped", async () => { + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Items: [{ path: { S: BUILD_ID + "/path1" } }, { path: { S: BUILD_ID + "/path2" } }], + }) + ); + + const result = await tagCache.getByTag("my-tag"); + + expect(result).toEqual(["path1", "path2"]); + }); + + it("sends a Query request with the correct tag key", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + await tagCache.getByTag("my-tag"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(body.ExpressionAttributeValues[":tag"]).toEqual({ + S: BUILD_ID + "/my-tag", + }); + }); + + it("returns [] when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getByTag("my-tag"); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns cached result on second call", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [{ path: { S: BUILD_ID + "/path1" } }] })); + + await tagCache.getByTag("some-tag"); + const result = await tagCache.getByTag("some-tag"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toEqual(["path1"]); + }); + + it("returns [] on fetch error", async () => { + mockFetch.mockRejectedValueOnce(new Error("fetch error")); + + const result = await tagCache.getByTag("my-tag"); + + expect(result).toEqual([]); + }); + }); + + describe("getLastModified", () => { + it("returns lastModified when no revalidated tags exist", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(12345); + }); + + it("returns -1 when revalidated tags exist", async () => { + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Items: [{ revalidatedAt: { N: "99999" }, tag: { S: BUILD_ID + "/t" } }], + }) + ); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(-1); + }); + + it("returns -1 when an expired tag falls between lastModified and now", async () => { + const now = Date.now(); + const expiry = now - 1000; + const lastModified = now - 2000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Items: [ + { + revalidatedAt: { N: String(expiry) }, + expire: { N: String(expiry) }, + tag: { S: BUILD_ID + "/t" }, + }, + ], + }) + ); + + const result = await tagCache.getLastModified("/key", lastModified); + + expect(result).toBe(-1); + }); + + it("excludes a tag whose expiry !== revalidatedAt from the non-expired count", async () => { + const now = Date.now(); + const revalidatedAt = now - 5000; + const expiry = now + 60_000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Items: [ + { + revalidatedAt: { N: String(revalidatedAt) }, + expire: { N: String(expiry) }, + tag: { S: BUILD_ID + "/t" }, + }, + ], + }) + ); + + const result = await tagCache.getLastModified("/key", 0); + + expect(result).toBe(0); + }); + + it("returns lastModified when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getLastModified("/key", 12345); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(12345); + }); + + it("returns cached result on second call with same key+lastModified", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + await tagCache.getLastModified("/key", 100); + const result = await tagCache.getLastModified("/key", 100); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result).toBe(100); + }); + + it("returns lastModified on fetch error", async () => { + mockFetch.mockRejectedValueOnce(new Error("fetch error")); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(12345); + }); + }); + + describe("isStale", () => { + it("returns true when items exist beyond lastModified", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [{ revalidatedAt: { N: "99999" } }] })); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(true); + }); + + it("returns false when no items exist", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(false); + }); + + it("returns false when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.isStale("/key", 12345); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("reuses cached items with getLastModified for the same key+lastModified", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Items: [] })); + + await tagCache.isStale("/key", 100); + await tagCache.isStale("/key", 100); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("returns false on fetch error", async () => { + mockFetch.mockRejectedValueOnce(new Error("fetch error")); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(false); + }); + }); + + describe("writeTags", () => { + it("calls fetch for the batch of tags", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([ + { path: "/path1", tag: "tag1", revalidatedAt: 1000 }, + { path: "/path2", tag: "tag2", revalidatedAt: 2000 }, + ]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("includes stale and expiry in the put item when provided", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([ + { + path: "/path1", + tag: "tag1", + revalidatedAt: 1000, + stale: 500, + expire: 9999, + }, + ]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const item = body.RequestItems[TABLE_NAME][0].PutRequest.Item; + expect(item.stale).toEqual({ N: "500" }); + expect(item.expire).toEqual({ N: "9999" }); + }); + + it("does not include stale or expiry when not provided", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([{ path: "/path1", tag: "tag1", revalidatedAt: 1000 }]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const item = body.RequestItems[TABLE_NAME][0].PutRequest.Item; + expect(item.stale).toBeUndefined(); + expect(item.expire).toBeUndefined(); + }); + + it("builds the DynamoDB key with the buildId prefix", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([{ path: "/p", tag: "t", revalidatedAt: 1000 }]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const item = body.RequestItems[TABLE_NAME][0].PutRequest.Item; + expect(item.path.S).toBe(BUILD_ID + "/p"); + expect(item.tag.S).toBe(BUILD_ID + "/t"); + }); + + it("skips write when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + await tagCache.writeTags([{ path: "/path1", tag: "tag1", revalidatedAt: 1000 }]); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("splits writes into multiple batches when tags exceed MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + const tags = Array.from({ length: 26 }, (_, i) => ({ + path: "/path" + i, + tag: "tag" + i, + revalidatedAt: 1000, + })); + + await tagCache.writeTags(tags); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tagCache/dynamodb-nextMode.test.ts b/packages/tests-unit/tests/overrides/tagCache/dynamodb-nextMode.test.ts new file mode 100644 index 00000000..3ab9969e --- /dev/null +++ b/packages/tests-unit/tests/overrides/tagCache/dynamodb-nextMode.test.ts @@ -0,0 +1,366 @@ +import { RecoverableError } from "@opennextjs/aws/utils/error.js"; +import { RequestCache } from "@opennextjs/aws/utils/requestCache.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@opennextjs/aws/adapters/logger.js", () => ({ + debug: vi.fn(), + error: vi.fn(), +})); + +const mockFetch = vi.hoisted(() => vi.fn()); + +vi.mock("aws4fetch", () => ({ + AwsClient: vi.fn().mockReturnValue({}), +})); + +vi.mock("@opennextjs/aws/utils/fetch.js", () => ({ + customFetchClient: vi.fn().mockReturnValue(mockFetch), +})); + +// oxlint-disable-next-line eslint-plugin-import(first) +import tagCache from "@opennextjs/aws/overrides/tagCache/dynamodb-nextMode.js"; + +declare global { + //@ts-ignore + var openNextConfig: { dangerous?: { disableTagCache?: boolean } }; + //@ts-ignore + var __openNextAls: { getStore: () => any }; +} + +const BUILD_ID = "test-build-id"; +const TABLE_NAME = "test-table"; + +function makeStore() { + return { requestCache: new RequestCache() }; +} + +function makeJsonResponse(body: unknown, status = 200) { + return { + status, + json: vi.fn().mockResolvedValue(body), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_BUILD_ID = BUILD_ID; + process.env.CACHE_DYNAMO_TABLE = TABLE_NAME; + process.env.CACHE_BUCKET_REGION = "us-east-1"; + process.env.AWS_ACCESS_KEY_ID = "test-key"; + process.env.AWS_SECRET_ACCESS_KEY = "test-secret"; + globalThis.openNextConfig = { dangerous: { disableTagCache: false } }; + globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue(makeStore()), + }; +}); + +function buildKey(tag: string) { + return BUILD_ID + "/_tag/" + tag; +} + +describe("dynamodb-nextMode tagCache", () => { + describe("getLastRevalidated", () => { + it("always returns 0", async () => { + const result = await tagCache.getLastRevalidated(["tag1", "tag2"]); + + expect(result).toBe(0); + }); + }); + + describe("hasBeenRevalidated", () => { + it("returns false when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.hasBeenRevalidated(["tag1"], 12345); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("throws RecoverableError when tags.length > 100", async () => { + const tags = Array.from({ length: 101 }, (_, i) => "tag" + i); + + await expect(tagCache.hasBeenRevalidated(tags, 0)).rejects.toThrow(RecoverableError); + }); + + it("returns false when no tags were revalidated after lastModified", async () => { + const lastModified = 50000; + const revalidatedAt = 30000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: String(revalidatedAt) }, + }, + ], + }, + }) + ); + + const result = await tagCache.hasBeenRevalidated(["tag1"], lastModified); + + expect(result).toBe(false); + }); + + it("returns true when a tag was revalidated after lastModified", async () => { + const lastModified = 50000; + const revalidatedAt = 80000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: String(revalidatedAt) }, + }, + ], + }, + }) + ); + + const result = await tagCache.hasBeenRevalidated(["tag1"], lastModified); + + expect(result).toBe(true); + }); + + it("returns true when a tag has an expired TTL between lastModified and now", async () => { + const now = Date.now(); + const expiry = now - 1000; + const lastModified = now - 2000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: String(lastModified - 1) }, + expire: { N: String(expiry) }, + }, + ], + }, + }) + ); + + const result = await tagCache.hasBeenRevalidated(["tag1"], lastModified); + + expect(result).toBe(true); + }); + + it("returns false for a tag absent from DynamoDB", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Responses: { [TABLE_NAME]: [] } })); + + const result = await tagCache.hasBeenRevalidated(["unknown-tag"], 0); + + expect(result).toBe(false); + }); + + it("uses cached items on second call for the same tags", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Responses: { [TABLE_NAME]: [] } })); + + await tagCache.hasBeenRevalidated(["tag1"], 0); + await tagCache.hasBeenRevalidated(["tag1"], 0); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws RecoverableError when the response status is not 200", async () => { + mockFetch.mockResolvedValueOnce({ status: 500, json: vi.fn() }); + + await expect(tagCache.hasBeenRevalidated(["tag1"], 0)).rejects.toThrow(RecoverableError); + }); + }); + + describe("isStale", () => { + it("returns false when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.isStale(["tag1"], 12345); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns false when tags array is empty", async () => { + const result = await tagCache.isStale([], 12345); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("throws RecoverableError when tags.length > 100", async () => { + const tags = Array.from({ length: 101 }, (_, i) => "tag" + i); + + await expect(tagCache.isStale(tags, 0)).rejects.toThrow(RecoverableError); + }); + + it("returns false when no tag has a stale timestamp after lastModified", async () => { + const lastModified = 50000; + const stale = 30000; + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: "1000" }, + stale: { N: String(stale) }, + }, + ], + }, + }) + ); + + const result = await tagCache.isStale(["tag1"], lastModified); + + expect(result).toBe(false); + }); + + it("returns true when a tag has a stale timestamp after lastModified and revalidatedAt is also after lastModified", async () => { + const lastModified = 50000; + const revalidatedAt = 60000; // after lastModified + const stale = 80000; // >= lastModified + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: String(revalidatedAt) }, + stale: { N: String(stale) }, + }, + ], + }, + }) + ); + + const result = await tagCache.isStale(["tag1"], lastModified); + + expect(result).toBe(true); + }); + + it("returns false when stale >= lastModified but revalidatedAt <= lastModified", async () => { + const lastModified = 50000; + const revalidatedAt = 40000; // before lastModified + const stale = 80000; // >= lastModified, but revalidatedAt fails the check + + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: String(revalidatedAt) }, + stale: { N: String(stale) }, + }, + ], + }, + }) + ); + + const result = await tagCache.isStale(["tag1"], lastModified); + + expect(result).toBe(false); + }); + + it("returns false when the tag has no stale field", async () => { + mockFetch.mockResolvedValueOnce( + makeJsonResponse({ + Responses: { + [TABLE_NAME]: [ + { + tag: { S: buildKey("tag1") }, + revalidatedAt: { N: "1000" }, + }, + ], + }, + }) + ); + + const result = await tagCache.isStale(["tag1"], 0); + + expect(result).toBe(false); + }); + + it("shares the per-request cache with hasBeenRevalidated across calls", async () => { + mockFetch.mockResolvedValueOnce(makeJsonResponse({ Responses: { [TABLE_NAME]: [] } })); + + await tagCache.hasBeenRevalidated(["tag1"], 0); + await tagCache.isStale(["tag1"], 0); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws RecoverableError when the response status is not 200", async () => { + mockFetch.mockResolvedValueOnce({ status: 500, json: vi.fn() }); + + await expect(tagCache.isStale(["tag1"], 0)).rejects.toThrow(RecoverableError); + }); + }); + + describe("writeTags", () => { + it("writes string tags using the tag as both path and tag key", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags(["tag1", "tag2"]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const items = body.RequestItems[TABLE_NAME].map((r: any) => r.PutRequest.Item); + expect(items[0].path.S).toBe(buildKey("tag1")); + expect(items[0].tag.S).toBe(buildKey("tag1")); + }); + + it("writes object tags including stale and expiry", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([{ tag: "tag1", stale: 500, expire: 9999 }]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const item = body.RequestItems[TABLE_NAME][0].PutRequest.Item; + expect(item.stale).toEqual({ N: "500" }); + expect(item.expire).toEqual({ N: "9999" }); + }); + + it("does not include stale or expiry when not provided in objects", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + await tagCache.writeTags([{ tag: "tag1" }]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const item = body.RequestItems[TABLE_NAME][0].PutRequest.Item; + expect(item.stale).toBeUndefined(); + expect(item.expire).toBeUndefined(); + }); + + it("skips write when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + await tagCache.writeTags(["tag1"]); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("splits writes into multiple batches when tags exceed MAX_DYNAMO_BATCH_WRITE_ITEM_COUNT", async () => { + mockFetch.mockResolvedValue({ status: 200 }); + + const tags = Array.from({ length: 26 }, (_, i) => "tag" + i); + + await tagCache.writeTags(tags); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("throws a RecoverableError when the response status is not 200", async () => { + mockFetch.mockResolvedValueOnce({ status: 500 }); + + await expect(tagCache.writeTags(["tag1"])).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/tests-unit/tests/overrides/tagCache/dynamodb.test.ts b/packages/tests-unit/tests/overrides/tagCache/dynamodb.test.ts new file mode 100644 index 00000000..522a79af --- /dev/null +++ b/packages/tests-unit/tests/overrides/tagCache/dynamodb.test.ts @@ -0,0 +1,305 @@ +import { RequestCache } from "@opennextjs/aws/utils/requestCache.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@opennextjs/aws/adapters/logger.js", () => ({ + awsLogger: {}, + debug: vi.fn(), + error: vi.fn(), +})); + +const mockSend = vi.hoisted(() => { + process.env.NEXT_BUILD_ID = "test-build-id"; + process.env.CACHE_DYNAMO_TABLE = "test-table"; + process.env.CACHE_BUCKET_REGION = "us-east-1"; + return vi.fn(); +}); + +vi.mock("@aws-sdk/client-dynamodb", () => ({ + DynamoDBClient: vi.fn().mockReturnValue({ send: mockSend }), + QueryCommand: vi.fn().mockImplementation((params: any) => params), + BatchWriteItemCommand: vi.fn().mockImplementation((params: any) => params), +})); + +// oxlint-disable-next-line eslint-plugin-import(first) +import tagCache from "@opennextjs/aws/overrides/tagCache/dynamodb.js"; + +declare global { + //@ts-ignore + var openNextConfig: { dangerous?: { disableTagCache?: boolean } }; + //@ts-ignore + var __openNextAls: { getStore: () => any }; +} + +const BUILD_ID = "test-build-id"; +const TABLE_NAME = "test-table"; + +function makeStore() { + return { requestCache: new RequestCache() }; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_BUILD_ID = BUILD_ID; + process.env.CACHE_DYNAMO_TABLE = TABLE_NAME; + process.env.CACHE_BUCKET_REGION = "us-east-1"; + globalThis.openNextConfig = { dangerous: { disableTagCache: false } }; + globalThis.__openNextAls = { + getStore: vi.fn().mockReturnValue(makeStore()), + }; +}); + +describe("dynamodb tagCache", () => { + describe("getByPath", () => { + it("returns tags with buildId prefix stripped", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ tag: { S: BUILD_ID + "/tag1" } }, { tag: { S: BUILD_ID + "/tag2" } }], + }); + + const result = await tagCache.getByPath("/some/path"); + + expect(result).toEqual(["tag1", "tag2"]); + }); + + it("queries DynamoDB with the correct key", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + await tagCache.getByPath("/some/path"); + + const sentCommand = mockSend.mock.calls[0][0]; + expect(sentCommand.ExpressionAttributeValues[":key"]).toEqual({ + S: BUILD_ID + "/some/path", + }); + }); + + it("returns [] when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getByPath("/some/path"); + + expect(mockSend).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns cached result on second call without re-querying DynamoDB", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ tag: { S: BUILD_ID + "/tag1" } }], + }); + + await tagCache.getByPath("/some/path"); + const result = await tagCache.getByPath("/some/path"); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(result).toEqual(["tag1"]); + }); + + it("returns [] on DynamoDB error", async () => { + mockSend.mockRejectedValueOnce(new Error("DynamoDB error")); + + const result = await tagCache.getByPath("/some/path"); + + expect(result).toEqual([]); + }); + + it("returns empty array when Items is undefined", async () => { + mockSend.mockResolvedValueOnce({ Items: undefined }); + + const result = await tagCache.getByPath("/some/path"); + + expect(result).toEqual([]); + }); + }); + + describe("getByTag", () => { + it("returns paths with buildId prefix stripped", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ path: { S: BUILD_ID + "/path1" } }, { path: { S: BUILD_ID + "/path2" } }], + }); + + const result = await tagCache.getByTag("my-tag"); + + expect(result).toEqual(["path1", "path2"]); + }); + + it("queries DynamoDB with the correct tag key", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + await tagCache.getByTag("my-tag"); + + const sentCommand = mockSend.mock.calls[0][0]; + expect(sentCommand.ExpressionAttributeValues[":tag"]).toEqual({ + S: BUILD_ID + "/my-tag", + }); + }); + + it("returns [] when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getByTag("my-tag"); + + expect(mockSend).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("returns cached result on second call without re-querying DynamoDB", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ path: { S: BUILD_ID + "/path1" } }], + }); + + await tagCache.getByTag("some-tag"); + const result = await tagCache.getByTag("some-tag"); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(result).toEqual(["path1"]); + }); + + it("returns [] on DynamoDB error", async () => { + mockSend.mockRejectedValueOnce(new Error("DynamoDB error")); + + const result = await tagCache.getByTag("my-tag"); + + expect(result).toEqual([]); + }); + }); + + describe("getLastModified", () => { + it("returns lastModified when no revalidated tags exist", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(12345); + }); + + it("returns -1 when revalidated tags exist", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ revalidatedAt: { N: "99999" }, tag: { S: BUILD_ID + "/t" } }], + }); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(-1); + }); + + it("returns -1 when an expired tag falls between lastModified and now", async () => { + const now = Date.now(); + const expiry = now - 1000; + const lastModified = now - 2000; + + mockSend.mockResolvedValueOnce({ + Items: [ + { + revalidatedAt: { N: String(expiry) }, + expire: { N: String(expiry) }, + tag: { S: BUILD_ID + "/t" }, + }, + ], + }); + + const result = await tagCache.getLastModified("/key", lastModified); + + expect(result).toBe(-1); + }); + + it("ignores a still-active expiry tag in the revalidated-tag count", async () => { + const now = Date.now(); + const expiry = now + 60_000; + + mockSend.mockResolvedValueOnce({ + Items: [ + { + revalidatedAt: { N: String(now - 5000) }, + expire: { N: String(expiry) }, + tag: { S: BUILD_ID + "/t" }, + }, + ], + }); + + const result = await tagCache.getLastModified("/key", now - 10_000); + + expect(result).toBe(-1); + }); + + it("returns lastModified when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.getLastModified("/key", 12345); + + expect(mockSend).not.toHaveBeenCalled(); + expect(result).toBe(12345); + }); + + it("uses cached items when called twice with the same key and lastModified", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + await tagCache.getLastModified("/key", 100); + await tagCache.getLastModified("/key", 100); + + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it("returns lastModified on DynamoDB error", async () => { + mockSend.mockRejectedValueOnce(new Error("DynamoDB error")); + + const result = await tagCache.getLastModified("/key", 12345); + + expect(result).toBe(12345); + }); + + it("returns Date.now() when lastModified is undefined and no tags", async () => { + vi.useFakeTimers().setSystemTime(50000); + mockSend.mockResolvedValueOnce({ Items: [] }); + + const result = await tagCache.getLastModified("/key", undefined); + + expect(result).toBe(50000); + vi.useRealTimers(); + }); + }); + + describe("isStale", () => { + it("returns true when items exist beyond lastModified", async () => { + mockSend.mockResolvedValueOnce({ + Items: [{ revalidatedAt: { N: "99999" } }], + }); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(true); + }); + + it("returns false when no items exist", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(false); + }); + + it("returns false when disableTagCache is true", async () => { + globalThis.openNextConfig = { dangerous: { disableTagCache: true } }; + + const result = await tagCache.isStale("/key", 12345); + + expect(mockSend).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("shares cached items with getLastModified for the same key+lastModified", async () => { + mockSend.mockResolvedValueOnce({ Items: [] }); + + await tagCache.getLastModified("/key", 100); + const result = await tagCache.isStale("/key", 100); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it("returns false on DynamoDB error", async () => { + mockSend.mockRejectedValueOnce(new Error("DynamoDB error")); + + const result = await tagCache.isStale("/key", 12345); + + expect(result).toBe(false); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 191fab7a..2568342a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,7 +162,7 @@ importers: dependencies: '@opennextjs/cloudflare': specifier: ^1.17.1 - version: 1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) + version: 1.18.0(aws-crt@1.23.0)(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0)) next: specifier: 16.1.4 version: 16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -1437,6 +1437,9 @@ importers: specifier: workspace:* version: link:../open-next devDependencies: + '@aws-sdk/client-dynamodb': + specifier: 3.984.0 + version: 3.984.0(aws-crt@1.23.0) '@types/testing-library__jest-dom': specifier: ^5.14.9 version: 5.14.9 @@ -14857,7 +14860,7 @@ snapshots: dependencies: '@octokit/openapi-types': 22.2.0 - '@opennextjs/aws@3.9.16(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))': + '@opennextjs/aws@3.9.16(aws-crt@1.23.0)(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))': dependencies: '@ast-grep/napi': 0.40.5 '@aws-sdk/client-cloudfront': 3.984.0(aws-crt@1.23.0) @@ -14881,11 +14884,11 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.18.0(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': + '@opennextjs/cloudflare@1.18.0(aws-crt@1.23.0)(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(wrangler@4.60.0(@cloudflare/workers-types@4.20260123.0))': dependencies: '@ast-grep/napi': 0.40.5 '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.9.16(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) + '@opennextjs/aws': 3.9.16(aws-crt@1.23.0)(next@16.1.4(@opentelemetry/api@1.9.0)(@playwright/test@1.58.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) cloudflare: 4.5.0 comment-json: 4.6.2 enquirer: 2.4.1