-
Notifications
You must be signed in to change notification settings - Fork 75
feat: r2 adapter for the incremental cache #443
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
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
81aae0e
feat: auto-populating d1 cache data
james-elicx c19f43f
checks for output directory / enabled
james-elicx 697e2f8
Update packages/cloudflare/src/cli/build/utils/populate-cache.ts
james-elicx 87b39b0
add suggestions
james-elicx 76e2c5d
rename onlyPopulate to onlyPopulateWithoutBuilding
james-elicx 46d8047
feat: r2 adapter for the incremental cache
james-elicx ecc83aa
directory -> prefix
james-elicx c13ca95
split r2 cache and regional cache into separate things
james-elicx 28c27a9
add timeout in revalidate path test as it runs too fast locally
james-elicx e018c71
incorporate suggestions
james-elicx a2b2667
lazily update the regional cache in the background via option
james-elicx 03ffc01
add comments and make the lazy boolean non-optional
james-elicx 3c3b7f3
add warning comment
james-elicx d676d1d
add comments to env vars
james-elicx 0c1887e
change lazy update defaults
james-elicx 5ff320b
change fallback ttl to 30 mins
james-elicx 216affe
resolve conflicts manually as gitbutler seems to have issues with con…
james-elicx 039dc1b
add separate app for e2e
james-elicx 8566368
comments
james-elicx 33c238b
undo directory change as it breaks build
james-elicx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@opennextjs/cloudflare": patch | ||
| --- | ||
|
|
||
| feat: auto-populating d1 cache data |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@opennextjs/cloudflare": patch | ||
| --- | ||
|
|
||
| feat: r2 adapter for the incremental cache |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,15 @@ | ||
| import { defineCloudflareConfig } from "@opennextjs/cloudflare"; | ||
| import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache"; | ||
| import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache"; | ||
| // import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache"; | ||
| import r2IncrementalCache from "@opennextjs/cloudflare/r2-incremental-cache"; | ||
| import memoryQueue from "@opennextjs/cloudflare/memory-queue"; | ||
| import { withRegionalCache } from "@opennextjs/cloudflare/regional-cache"; | ||
|
|
||
| export default defineCloudflareConfig({ | ||
| incrementalCache: kvIncrementalCache, | ||
| incrementalCache: withRegionalCache(r2IncrementalCache, { | ||
| mode: "long-lived", | ||
| shouldLazilyUpdateOnCacheHit: true, | ||
| }), | ||
| tagCache: d1TagCache, | ||
| queue: memoryQueue, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { CacheValue } from "@opennextjs/aws/types/overrides.js"; | ||
|
|
||
| export type IncrementalCacheEntry<IsFetch extends boolean> = { | ||
| value: CacheValue<IsFetch>; | ||
| lastModified: number; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { debug, 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"; | ||
|
|
||
| /** | ||
| * An instance of the Incremental Cache that uses an R2 bucket (`NEXT_CACHE_R2_BUCKET`) as it's | ||
| * underlying data store. | ||
| * | ||
| * The directory that the cache entries are stored in can be confused with the `NEXT_CACHE_R2_PREFIX` | ||
james-elicx marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * environment variable, and defaults to `incremental-cache`. | ||
| * | ||
| * The cache uses an instance of the Cache API (`incremental-cache`) to store a local version of the | ||
| * R2 cache entry to enable fast retrieval, with the cache being updated from R2 in the background. | ||
| */ | ||
| class R2IncrementalCache implements IncrementalCache { | ||
| readonly name = "r2-incremental-cache"; | ||
|
|
||
| async get<IsFetch extends boolean = false>( | ||
| key: string, | ||
| isFetch?: IsFetch | ||
| ): Promise<WithLastModified<CacheValue<IsFetch>> | null> { | ||
| const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET; | ||
| if (!r2) throw new IgnorableError("No R2 bucket"); | ||
|
|
||
| debug(`Get ${key}`); | ||
|
|
||
| try { | ||
| const r2Object = await r2.get(this.getR2Key(key, isFetch)); | ||
| if (!r2Object) return null; | ||
|
|
||
| return { | ||
| value: await r2Object.json(), | ||
| lastModified: r2Object.uploaded.getTime(), | ||
| }; | ||
| } catch (e) { | ||
| error("Failed to get from cache", e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| async set<IsFetch extends boolean = false>( | ||
| key: string, | ||
| value: CacheValue<IsFetch>, | ||
| isFetch?: IsFetch | ||
| ): Promise<void> { | ||
| const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET; | ||
| if (!r2) throw new IgnorableError("No R2 bucket"); | ||
|
|
||
| debug(`Set ${key}`); | ||
|
|
||
| try { | ||
| await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value)); | ||
| } catch (e) { | ||
| error("Failed to set to cache", e); | ||
| } | ||
| } | ||
|
|
||
| async delete(key: string): Promise<void> { | ||
| const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET; | ||
| if (!r2) throw new IgnorableError("No R2 bucket"); | ||
|
|
||
| debug(`Delete ${key}`); | ||
|
|
||
| try { | ||
| await r2.delete(this.getR2Key(key)); | ||
| } catch (e) { | ||
| error("Failed to delete from cache", e); | ||
| } | ||
| } | ||
|
|
||
| protected getR2Key(key: string, isFetch?: boolean): string { | ||
| const directory = getCloudflareContext().env.NEXT_CACHE_R2_PREFIX ?? "incremental-cache"; | ||
james-elicx marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`; | ||
| } | ||
| } | ||
|
|
||
| export default new R2IncrementalCache(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { debug, 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/incremental-cache.js"; | ||
|
|
||
| const ONE_YEAR_IN_SECONDS = 31536000; | ||
| const ONE_MINUTE_IN_SECONDS = 60; | ||
|
|
||
| type Options = { | ||
| /** | ||
| * The mode to use for the regional cache. | ||
| * | ||
| * - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved. | ||
| * - `long-lived`: Re-use a cache entry until it is revalidated. | ||
james-elicx marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| */ | ||
| mode: "short-lived" | "long-lived"; | ||
| /** | ||
| * Whether the regional cache entry should be updated in the background or not when it experiences | ||
| * a cache hit. | ||
| * | ||
| * Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode. | ||
| */ | ||
| shouldLazilyUpdateOnCacheHit?: boolean; | ||
| }; | ||
|
|
||
| class RegionalCache implements IncrementalCache { | ||
| public name: string; | ||
|
|
||
| protected localCache: Cache | undefined; | ||
|
|
||
| constructor( | ||
| private store: IncrementalCache, | ||
| private opts: Options | ||
| ) { | ||
| this.name = this.store.name; | ||
|
|
||
| this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived"; | ||
| } | ||
|
|
||
| async get<IsFetch extends boolean = false>( | ||
| key: string, | ||
| isFetch?: IsFetch | ||
| ): Promise<WithLastModified<CacheValue<IsFetch>> | null> { | ||
| try { | ||
| const cache = await this.getCacheInstance(); | ||
| const localCacheKey = this.getCacheKey(key, isFetch); | ||
|
|
||
| // 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"); | ||
|
|
||
| // Re-fetch from the store and update the regional cache in the background | ||
| if (this.opts.shouldLazilyUpdateOnCacheHit) { | ||
james-elicx marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| getCloudflareContext().ctx.waitUntil( | ||
| this.store.get(key, isFetch).then(async (rawEntry) => { | ||
| const { value, lastModified } = rawEntry ?? {}; | ||
|
|
||
| if (value && typeof lastModified === "number") { | ||
| await this.putToCache(localCacheKey, { value, lastModified }); | ||
| } | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| return cachedResponse.json(); | ||
| } | ||
|
|
||
| const rawEntry = await this.store.get(key, isFetch); | ||
| const { value, lastModified } = rawEntry ?? {}; | ||
| if (!value || typeof lastModified !== "number") return null; | ||
|
|
||
| // Update the locale cache after retrieving from the store. | ||
| getCloudflareContext().ctx.waitUntil(this.putToCache(localCacheKey, { value, lastModified })); | ||
|
|
||
| return { value, lastModified }; | ||
| } catch (e) { | ||
| error("Failed to get from regional cache", e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| async set<IsFetch extends boolean = false>( | ||
| key: string, | ||
| value: CacheValue<IsFetch>, | ||
| isFetch?: IsFetch | ||
| ): Promise<void> { | ||
| try { | ||
| await this.store.set(key, value, isFetch); | ||
|
|
||
| await this.putToCache(this.getCacheKey(key, isFetch), { | ||
| 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(), | ||
| }); | ||
| } catch (e) { | ||
| error(`Failed to get from regional cache`, e); | ||
| } | ||
| } | ||
|
|
||
| async delete(key: string): Promise<void> { | ||
| try { | ||
| await this.store.delete(key); | ||
|
|
||
| const cache = await this.getCacheInstance(); | ||
| await cache.delete(this.getCacheKey(key)); | ||
| } catch (e) { | ||
| error("Failed to delete from regional cache", e); | ||
| } | ||
| } | ||
|
|
||
| protected async getCacheInstance(): Promise<Cache> { | ||
| if (this.localCache) return this.localCache; | ||
|
|
||
| this.localCache = await caches.open("incremental-cache"); | ||
| return this.localCache; | ||
| } | ||
|
|
||
| protected getCacheKey(key: string, isFetch?: boolean) { | ||
| return new Request( | ||
| new URL( | ||
| `${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`, | ||
| "http://cache.local" | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| protected async putToCache(key: Request, entry: IncrementalCacheEntry<boolean>): Promise<void> { | ||
| const cache = await this.getCacheInstance(); | ||
|
|
||
| const age = | ||
| this.opts.mode === "short-lived" | ||
| ? ONE_MINUTE_IN_SECONDS | ||
| : entry.value.revalidate || ONE_YEAR_IN_SECONDS; | ||
james-elicx marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| await cache.put( | ||
| key, | ||
| new Response(JSON.stringify(entry), { | ||
| headers: new Headers({ "cache-control": `max-age=${age}` }), | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * A regional cache will wrap an incremental cache and provide faster cache lookups for an entry | ||
| * when making requests within the region. | ||
| * | ||
| * The regional cache uses the Cache API. | ||
| * | ||
| * **WARNING:** If an entry is revalidated in one region, it will trigger an additional revalidation if | ||
| * a request is made to another region that has an entry stored in its regional cache. | ||
| * | ||
| * @param cache - Incremental cache instance. | ||
| * @param opts.mode - The mode to use for the regional cache. | ||
| * - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved. | ||
| * - `long-lived`: Re-use a cache entry until it is revalidated. | ||
| * @param opts.shouldLazilyUpdateOnCacheHit - Whether the regional cache entry should be updated in | ||
| * the background or not when it experiences a cache hit. | ||
| * | ||
| * Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode. | ||
| */ | ||
| export function withRegionalCache(cache: IncrementalCache, opts: Options) { | ||
| return new RegionalCache(cache, opts); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.