Skip to content

Commit c13ca95

Browse files
committed
split r2 cache and regional cache into separate things
1 parent ecc83aa commit c13ca95

File tree

4 files changed

+151
-97
lines changed

4 files changed

+151
-97
lines changed

examples/e2e/app-router/open-next.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache";
33
// import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache";
44
import r2IncrementalCache from "@opennextjs/cloudflare/r2-incremental-cache";
55
import memoryQueue from "@opennextjs/cloudflare/memory-queue";
6+
import { withRegionalCache } from "@opennextjs/cloudflare/regional-cache";
67

78
export default defineCloudflareConfig({
8-
incrementalCache: r2IncrementalCache,
9+
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }),
910
tagCache: d1TagCache,
1011
queue: memoryQueue,
1112
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { CacheValue } from "@opennextjs/aws/types/overrides.js";
2+
3+
export type IncrementalCacheEntry<IsFetch extends boolean> = {
4+
value: CacheValue<IsFetch>;
5+
lastModified: number;
6+
};

packages/cloudflare/src/api/r2-incremental-cache.ts

Lines changed: 10 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@ import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs
33
import { IgnorableError } from "@opennextjs/aws/utils/error.js";
44

55
import { getCloudflareContext } from "./cloudflare-context.js";
6-
7-
type Entry<IsFetch extends boolean> = {
8-
value: CacheValue<IsFetch>;
9-
lastModified: number;
10-
};
11-
12-
const ONE_YEAR_IN_SECONDS = 31536000;
6+
import { IncrementalCacheEntry } from "./internal/incremental-cache.js";
137

148
/**
159
* An instance of the Incremental Cache that uses an R2 bucket (`NEXT_CACHE_R2_BUCKET`) as it's
@@ -24,8 +18,6 @@ const ONE_YEAR_IN_SECONDS = 31536000;
2418
class R2IncrementalCache implements IncrementalCache {
2519
readonly name = "r2-incremental-cache";
2620

27-
protected localCache: Cache | undefined;
28-
2921
async get<IsFetch extends boolean = false>(
3022
key: string,
3123
isFetch?: IsFetch
@@ -36,39 +28,12 @@ class R2IncrementalCache implements IncrementalCache {
3628
debug(`Get ${key}`);
3729

3830
try {
39-
const r2Response = r2.get(this.getR2Key(key));
40-
41-
const localCacheKey = this.getLocalCacheKey(key, isFetch);
42-
43-
// Check for a cached entry as this will be faster than R2.
44-
const cachedResponse = await this.getFromLocalCache(localCacheKey);
45-
if (cachedResponse) {
46-
debug(` -> Cached response`);
47-
// Update the local cache after the R2 fetch has completed.
48-
getCloudflareContext().ctx.waitUntil(
49-
Promise.resolve(r2Response).then(async (res) => {
50-
if (res) {
51-
const entry: Entry<IsFetch> = await res.json();
52-
await this.putToLocalCache(localCacheKey, JSON.stringify(entry), entry.value.revalidate);
53-
}
54-
})
55-
);
56-
57-
return cachedResponse.json();
58-
}
59-
60-
const r2Object = await r2Response;
31+
const r2Object = await r2.get(this.getR2Key(key, isFetch));
6132
if (!r2Object) return null;
62-
const entry: Entry<IsFetch> = await r2Object.json();
6333

64-
// Update the locale cache after retrieving from R2.
65-
getCloudflareContext().ctx.waitUntil(
66-
this.putToLocalCache(localCacheKey, JSON.stringify(entry), entry.value.revalidate)
67-
);
68-
69-
return entry;
34+
return r2Object.json();
7035
} catch (e) {
71-
error(`Failed to get from cache`, e);
36+
error("Failed to get from cache", e);
7237
return null;
7338
}
7439
}
@@ -84,24 +49,16 @@ class R2IncrementalCache implements IncrementalCache {
8449
debug(`Set ${key}`);
8550

8651
try {
87-
const entry: Entry<IsFetch> = {
52+
const entry: IncrementalCacheEntry<IsFetch> = {
8853
value,
8954
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
9055
// See https://developers.cloudflare.com/workers/reference/security-model/
9156
lastModified: Date.now(),
9257
};
9358

94-
await Promise.all([
95-
r2.put(this.getR2Key(key, isFetch), JSON.stringify(entry)),
96-
// Update the locale cache for faster retrieval.
97-
this.putToLocalCache(
98-
this.getLocalCacheKey(key, isFetch),
99-
JSON.stringify(entry),
100-
entry.value.revalidate
101-
),
102-
]);
59+
await r2.put(this.getR2Key(key, isFetch), JSON.stringify(entry));
10360
} catch (e) {
104-
error(`Failed to set to cache`, e);
61+
error("Failed to set to cache", e);
10562
}
10663
}
10764

@@ -112,59 +69,16 @@ class R2IncrementalCache implements IncrementalCache {
11269
debug(`Delete ${key}`);
11370

11471
try {
115-
await Promise.all([
116-
r2.delete(this.getR2Key(key)),
117-
this.deleteFromLocalCache(this.getLocalCacheKey(key)),
118-
]);
72+
await r2.delete(this.getR2Key(key));
11973
} catch (e) {
120-
error(`Failed to delete from cache`, e);
74+
error("Failed to delete from cache", e);
12175
}
12276
}
12377

124-
protected getBaseCacheKey(key: string, isFetch?: boolean): string {
125-
return `${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`;
126-
}
127-
12878
protected getR2Key(key: string, isFetch?: boolean): string {
12979
const directory = getCloudflareContext().env.NEXT_CACHE_R2_PREFIX ?? "incremental-cache";
130-
return `${directory}/${this.getBaseCacheKey(key, isFetch)}`;
131-
}
132-
133-
protected getLocalCacheKey(key: string, isFetch?: boolean) {
134-
return new Request(new URL(this.getBaseCacheKey(key, isFetch), "http://cache.local"));
135-
}
136-
137-
protected async getLocalCacheInstance(): Promise<Cache> {
138-
if (this.localCache) return this.localCache;
139-
140-
this.localCache = await caches.open("incremental-cache");
141-
return this.localCache;
142-
}
143-
144-
protected async getFromLocalCache(key: Request) {
145-
const cache = await this.getLocalCacheInstance();
146-
return cache.match(key);
147-
}
148-
149-
protected async putToLocalCache(
150-
key: Request,
151-
entry: string,
152-
revalidate: number | false | undefined
153-
): Promise<void> {
154-
const cache = await this.getLocalCacheInstance();
155-
await cache.put(
156-
key,
157-
new Response(entry, {
158-
headers: new Headers({
159-
"cache-control": `max-age=${revalidate || ONE_YEAR_IN_SECONDS}`,
160-
}),
161-
})
162-
);
163-
}
16480

165-
protected async deleteFromLocalCache(key: Request) {
166-
const cache = await this.getLocalCacheInstance();
167-
await cache.delete(key);
81+
return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`;
16882
}
16983
}
17084

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
2+
import { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
3+
4+
import { getCloudflareContext } from "./cloudflare-context.js";
5+
import { IncrementalCacheEntry } from "./internal/incremental-cache.js";
6+
7+
const ONE_YEAR_IN_SECONDS = 31536000;
8+
const ONE_MINUTE_IN_SECONDS = 60;
9+
10+
type Options = {
11+
mode: "short-lived" | "long-lived";
12+
};
13+
14+
class RegionalCache implements IncrementalCache {
15+
public name: string;
16+
17+
protected localCache: Cache | undefined;
18+
19+
constructor(
20+
private store: IncrementalCache,
21+
private opts: Options
22+
) {
23+
this.name = this.store.name;
24+
}
25+
26+
async get<IsFetch extends boolean = false>(
27+
key: string,
28+
isFetch?: IsFetch
29+
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
30+
try {
31+
const storeResponse = this.store.get(key, isFetch);
32+
33+
const localCacheKey = this.getCacheKey(key, isFetch);
34+
35+
// Check for a cached entry as this will be faster than the store response.
36+
const cache = await this.getCacheInstance();
37+
const cachedResponse = await cache.match(localCacheKey);
38+
if (cachedResponse) {
39+
debug("Get - cached response");
40+
41+
// Update the local cache after the R2 fetch has completed.
42+
getCloudflareContext().ctx.waitUntil(
43+
Promise.resolve(storeResponse).then(async (rawEntry) => {
44+
const { value, lastModified } = rawEntry ?? {};
45+
46+
if (value && typeof lastModified === "number") {
47+
await this.putToCache(localCacheKey, { value, lastModified });
48+
}
49+
})
50+
);
51+
52+
return cachedResponse.json();
53+
}
54+
55+
const rawEntry = await storeResponse;
56+
const { value, lastModified } = rawEntry ?? {};
57+
if (!value || typeof lastModified !== "number") return null;
58+
59+
// Update the locale cache after retrieving from the store.
60+
getCloudflareContext().ctx.waitUntil(this.putToCache(localCacheKey, { value, lastModified }));
61+
62+
return { value, lastModified };
63+
} catch (e) {
64+
error("Failed to get from regional cache", e);
65+
return null;
66+
}
67+
}
68+
69+
async set<IsFetch extends boolean = false>(
70+
key: string,
71+
value: CacheValue<IsFetch>,
72+
isFetch?: IsFetch
73+
): Promise<void> {
74+
try {
75+
await Promise.all([
76+
this.store.set(key, value, isFetch),
77+
this.putToCache(this.getCacheKey(key, isFetch), {
78+
value,
79+
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
80+
// See https://developers.cloudflare.com/workers/reference/security-model/
81+
lastModified: Date.now(),
82+
}),
83+
]);
84+
} catch (e) {
85+
error(`Failed to get from regional cache`, e);
86+
}
87+
}
88+
89+
async delete(key: string): Promise<void> {
90+
try {
91+
const cache = await this.getCacheInstance();
92+
await Promise.all([this.store.delete(key), cache.delete(this.getCacheKey(key))]);
93+
} catch (e) {
94+
error("Failed to delete from regional cache", e);
95+
}
96+
}
97+
98+
protected async getCacheInstance(): Promise<Cache> {
99+
if (this.localCache) return this.localCache;
100+
101+
this.localCache = await caches.open("incremental-cache");
102+
return this.localCache;
103+
}
104+
105+
protected getCacheKey(key: string, isFetch?: boolean) {
106+
return new Request(
107+
new URL(
108+
`${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`,
109+
"http://cache.local"
110+
)
111+
);
112+
}
113+
114+
protected async putToCache(key: Request, entry: IncrementalCacheEntry<boolean>): Promise<void> {
115+
const cache = await this.getCacheInstance();
116+
117+
const age =
118+
this.opts.mode === "short-lived"
119+
? ONE_MINUTE_IN_SECONDS
120+
: entry.value.revalidate || ONE_YEAR_IN_SECONDS;
121+
122+
await cache.put(
123+
key,
124+
new Response(JSON.stringify(entry), {
125+
headers: new Headers({ "cache-control": `max-age=${age}` }),
126+
})
127+
);
128+
}
129+
}
130+
131+
export function withRegionalCache(cache: IncrementalCache, opts: Options) {
132+
return new RegionalCache(cache, opts);
133+
}

0 commit comments

Comments
 (0)