Skip to content

feat: populate kv incremental cache #546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

feat: populate kv incremental cache
6 changes: 6 additions & 0 deletions examples/ssg-app/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"kv_namespaces": [
{
"binding": "NEXT_INC_CACHE_KV",
"id": "<BINDING_ID>"
}
],
"vars": {
"APP_VERSION": "1.2.345"
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides";
import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js";
import { error } from "@opennextjs/aws/adapters/logger.js";
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
import { IgnorableError } from "@opennextjs/aws/utils/error.js";

import { getCloudflareContext } from "../../cloudflare-context.js";

export const CACHE_ASSET_DIR = "cdn-cgi/_next_cache";

export const STATUS_DELETED = 1;
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";

export const NAME = "cf-kv-incremental-cache";

export const BINDING_NAME = "NEXT_INC_CACHE_KV";

/**
* Open Next cache based on cloudflare KV and Assets.
* Open Next cache based on Cloudflare KV.
*
* Note: The class is instantiated outside of the request context.
* The cloudflare context and process.env are not initialized yet
Expand All @@ -23,69 +23,32 @@ class KVIncrementalCache implements IncrementalCache {
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
const cfEnv = getCloudflareContext().env;
const kv = cfEnv.NEXT_INC_CACHE_KV;
const assets = cfEnv.ASSETS;

if (!(kv || assets)) {
throw new IgnorableError(`No KVNamespace nor Fetcher`);
}
const kv = getCloudflareContext().env[BINDING_NAME];
if (!kv) throw new IgnorableError("No KV Namespace");

this.debug(`Get ${key}`);
debugCache(`Get ${key}`);

try {
let entry: {
value?: CacheValue<IsFetch>;
lastModified?: number;
status?: number;
} | null = null;

if (kv) {
this.debug(`- From KV`);
const kvKey = this.getKVKey(key, isFetch);
entry = await kv.get(kvKey, "json");
if (entry?.status === STATUS_DELETED) {
return null;
}
}
const entry = await kv.get<IncrementalCacheEntry<IsFetch> | CacheValue<IsFetch>>(
this.getKVKey(key, isFetch),
"json"
);

if (!entry) return null;

if (!entry && assets) {
this.debug(`- From Assets`);
const url = this.getAssetUrl(key, isFetch);
const response = await assets.fetch(url);
if (response.ok) {
// TODO: consider populating KV with the asset value if faster.
// This could be optional as KV writes are $$.
// See https://github.com/opennextjs/opennextjs-cloudflare/pull/194#discussion_r1893166026
entry = {
value: await response.json(),
// __BUILD_TIMESTAMP_MS__ is injected by ESBuild.
lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__,
};
}
if (!kv) {
// The cache can not be updated when there is no KV
// As we don't want to keep serving stale data for ever,
// we pretend the entry is not in cache
if (
entry?.value &&
"kind" in entry.value &&
entry.value.kind === "FETCH" &&
entry.value.data?.headers?.expires
) {
const expiresTime = new Date(entry.value.data.headers.expires).getTime();
if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
this.debug(`found expired entry (expire time: ${entry.value.data.headers.expires})`);
return null;
}
}
}
if ("lastModified" in entry) {
return entry;
}

this.debug(entry ? `-> hit` : `-> miss`);
return { value: entry?.value, lastModified: entry?.lastModified };
} catch {
throw new RecoverableError(`Failed to get cache [${key}]`);
// if there is no lastModified property, the file was stored during build-time cache population.
return {
value: entry,
// __BUILD_TIMESTAMP_MS__ is injected by ESBuild.
lastModified: (globalThis as { __BUILD_TIMESTAMP_MS__?: number }).__BUILD_TIMESTAMP_MS__,
};
} catch (e) {
error("Failed to get from cache", e);
return null;
}
}

Expand All @@ -94,69 +57,44 @@ class KVIncrementalCache implements IncrementalCache {
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV;

if (!kv) {
throw new IgnorableError(`No KVNamespace`);
}
const kv = getCloudflareContext().env[BINDING_NAME];
if (!kv) throw new IgnorableError("No KV Namespace");

this.debug(`Set ${key}`);
debugCache(`Set ${key}`);

try {
const kvKey = this.getKVKey(key, isFetch);
// Note: We can not set a TTL as we might fallback to assets,
// still removing old data (old BUILD_ID) could help avoiding
// the cache growing too big.
await kv.put(
kvKey,
this.getKVKey(key, isFetch),
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
})
// TODO: Figure out how to best leverage KV's TTL.
// NOTE: Ideally, the cache should operate in an SWR-like manner.
);
} catch {
throw new RecoverableError(`Failed to set cache [${key}]`);
} catch (e) {
error("Failed to set to cache", e);
}
}

async delete(key: string): Promise<void> {
const kv = getCloudflareContext().env.NEXT_INC_CACHE_KV;
const kv = getCloudflareContext().env[BINDING_NAME];
if (!kv) throw new IgnorableError("No KV Namespace");

if (!kv) {
throw new IgnorableError(`No KVNamespace`);
}

this.debug(`Delete ${key}`);
debugCache(`Delete ${key}`);

try {
const kvKey = this.getKVKey(key, /* isFetch= */ false);
// Do not delete the key as we would then fallback to the assets.
await kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED }));
} catch {
throw new RecoverableError(`Failed to delete cache [${key}]`);
await kv.delete(this.getKVKey(key, /* isFetch= */ false));
} catch (e) {
error("Failed to delete from cache", e);
}
}

protected getKVKey(key: string, isFetch?: boolean): string {
return `${this.getBuildId()}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
}

protected getAssetUrl(key: string, isFetch?: boolean): string {
return isFetch
? `http://assets.local/${CACHE_ASSET_DIR}/__fetch/${this.getBuildId()}/${key}`
: `http://assets.local/${CACHE_ASSET_DIR}/${this.getBuildId()}/${key}.cache`;
}

protected debug(...args: unknown[]) {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.log(`[Cache ${this.name}] `, ...args);
}
}

protected getBuildId() {
return process.env.NEXT_BUILD_ID ?? "no-build-id";
const buildId = process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
return `${buildId}/${key}.${isFetch ? "fetch" : "cache"}`.replace(/\/+/g, "/");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import { error } from "@opennextjs/aws/adapters/logger.js";
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
import { IgnorableError } from "@opennextjs/aws/utils/error.js";

import { getCloudflareContext } from "../../cloudflare-context.js";
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";

export const NAME = "cf-r2-incremental-cache";

export const BINDING_NAME = "NEXT_INC_CACHE_R2_BUCKET";

export const PREFIX_ENV_NAME = "NEXT_INC_CACHE_R2_PREFIX";
export const DEFAULT_PREFIX = "incremental-cache";

/**
* An instance of the Incremental Cache that uses an R2 bucket (`NEXT_INC_CACHE_R2_BUCKET`) as it's
* underlying data store.
Expand All @@ -20,10 +26,10 @@ class R2IncrementalCache implements IncrementalCache {
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Get ${key}`);
debugCache(`Get ${key}`);

try {
const r2Object = await r2.get(this.getR2Key(key, isFetch));
Expand All @@ -44,10 +50,10 @@ class R2IncrementalCache implements IncrementalCache {
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Set ${key}`);
debugCache(`Set ${key}`);

try {
await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value));
Expand All @@ -57,10 +63,10 @@ class R2IncrementalCache implements IncrementalCache {
}

async delete(key: string): Promise<void> {
const r2 = getCloudflareContext().env.NEXT_INC_CACHE_R2_BUCKET;
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Delete ${key}`);
debugCache(`Delete ${key}`);

try {
await r2.delete(this.getR2Key(key));
Expand All @@ -70,9 +76,9 @@ class R2IncrementalCache implements IncrementalCache {
}

protected getR2Key(key: string, isFetch?: boolean): string {
const directory = getCloudflareContext().env.NEXT_INC_CACHE_R2_PREFIX ?? "incremental-cache";
const directory = getCloudflareContext().env[PREFIX_ENV_NAME] ?? DEFAULT_PREFIX;

return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`.replace(
return `${directory}/${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`.replace(
/\/+/g,
"/"
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import { error } from "@opennextjs/aws/adapters/logger.js";
import { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";

import { getCloudflareContext } from "../../cloudflare-context.js";
import { IncrementalCacheEntry } from "./internal.js";
import { debugCache, FALLBACK_BUILD_ID, IncrementalCacheEntry } from "../internal.js";
import { NAME as KV_CACHE_NAME } from "./kv-incremental-cache.js";

const ONE_MINUTE_IN_SECONDS = 60;
Expand Down Expand Up @@ -57,7 +57,7 @@ class RegionalCache implements IncrementalCache {
// Check for a cached entry as this will be faster than the store response.
const cachedResponse = await cache.match(localCacheKey);
if (cachedResponse) {
debug("Get - cached response");
debugCache("Get - cached response");

// Re-fetch from the store and update the regional cache in the background
if (this.opts.shouldLazilyUpdateOnCacheHit) {
Expand Down Expand Up @@ -129,7 +129,7 @@ class RegionalCache implements IncrementalCache {
protected getCacheKey(key: string, isFetch?: boolean) {
return new Request(
new URL(
`${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`,
`${process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID}/${key}.${isFetch ? "fetch" : "cache"}`,
"http://cache.local"
)
);
Expand Down
14 changes: 14 additions & 0 deletions packages/cloudflare/src/api/overrides/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CacheValue } from "@opennextjs/aws/types/overrides.js";

export type IncrementalCacheEntry<IsFetch extends boolean> = {
value: CacheValue<IsFetch>;
lastModified: number;
};

export const debugCache = (name: string, ...args: unknown[]) => {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.log(`[${name}] `, ...args);
}
};

export const FALLBACK_BUILD_ID = "no-build-id";
5 changes: 3 additions & 2 deletions packages/cloudflare/src/api/overrides/queue/memory-queue.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import { error } from "@opennextjs/aws/adapters/logger.js";
import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js";
import { IgnorableError } from "@opennextjs/aws/utils/error.js";

import { getCloudflareContext } from "../../cloudflare-context";
import { debugCache } from "../internal";

export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000;

Expand Down Expand Up @@ -48,7 +49,7 @@ export class MemoryQueue implements Queue {
if (response.status !== 200 || response.headers.get("x-nextjs-cache") !== "REVALIDATED") {
error(`Revalidation failed for ${url} with status ${response.status}`);
}
debug(`Revalidation successful for ${url}`);
debugCache(`Revalidation successful for ${url}`);
} catch (e) {
error(e);
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import { error } from "@opennextjs/aws/adapters/logger.js";
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js";
import { RecoverableError } from "@opennextjs/aws/utils/error.js";

import { getCloudflareContext } from "../../cloudflare-context.js";
import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";

export const NAME = "d1-next-mode-tag-cache";

export const BINDING_NAME = "NEXT_TAG_CACHE_D1";

export class D1NextModeTagCache implements NextModeTagCache {
readonly mode = "nextMode" as const;
readonly name = NAME;
Expand Down Expand Up @@ -45,10 +48,9 @@ export class D1NextModeTagCache implements NextModeTagCache {
}

private getConfig() {
const cfEnv = getCloudflareContext().env;
const db = cfEnv.NEXT_TAG_CACHE_D1;
const db = getCloudflareContext().env[BINDING_NAME];

if (!db) debug("No D1 database found");
if (!db) debugCache("No D1 database found");

const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig
.dangerous?.disableTagCache;
Expand All @@ -70,7 +72,7 @@ export class D1NextModeTagCache implements NextModeTagCache {
}

protected getBuildId() {
return process.env.NEXT_BUILD_ID ?? "no-build-id";
return process.env.NEXT_BUILD_ID ?? FALLBACK_BUILD_ID;
}
}

Expand Down
Loading