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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/port-pr-1122.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions .changeset/port-pr-1142.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .changeset/port-pr-1168.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .changeset/port-pr-1193.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .changeset/port-pr-1200.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@opennextjs/cloudflare": patch
---

Ported PR #1200 from source repository

https://github.com/opennextjs/opennextjs-cloudflare/pull/1200
Original file line number Diff line number Diff line change
Expand Up @@ -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<SqlStorage["exec"]>);
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);
});
});
});
109 changes: 74 additions & 35 deletions packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,108 @@ 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<CloudflareEnv> {
sql: SqlStorage;

constructor(state: DurableObjectState, env: CloudflareEnv) {
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<number> {
async getTagData(tags: string[]): Promise<Record<string, TagData>> {
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<boolean> {
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<number> {
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<boolean> {
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<void> {
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<Record<string, number>> {
const data = await this.getTagData(tags);
return Object.fromEntries(Object.entries(data).map(([tag, { revalidatedAt }]) => [tag, revalidatedAt]));
}

async getRevalidationTimes(tags: string[]): Promise<Record<string, number>> {
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<string | { tag: string; stale?: number; expire?: number | null }>,
lastModified?: number
): Promise<void> {
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
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { error } from "@opennextjs/aws/adapters/logger.js";
import { compareSemver } from "@opennextjs/aws/build/helper.js";
import {
CacheEntryType,
CacheValue,
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<CacheType extends CacheEntryType = "cache">(
Expand Down Expand Up @@ -123,7 +152,7 @@ class RegionalCache implements IncrementalCache {

return {
...responseJson,
shouldBypassTagCache: this.#bypassTagCacheOnCacheHit,
shouldBypassTagCache: this.opts.bypassTagCacheOnCacheHit,
};
}

Expand Down
Loading
Loading