Skip to content

Custom Incremental Cache #3239

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions packages/gitbook-v2/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

# cloudflare
.open-next
.wrangler

# Symbolic links
public
4 changes: 1 addition & 3 deletions packages/gitbook-v2/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
import {
Expand All @@ -9,7 +7,7 @@ import {
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';

export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: withFilter({
tagCache: doShardedTagCache({
baseShardSize: 12,
Expand Down
181 changes: 181 additions & 0 deletions packages/gitbook-v2/openNext/incrementalCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { createHash } from 'node:crypto';

import { trace } from '@/lib/tracing';
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';

export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
export const DEFAULT_PREFIX = 'incremental-cache';

export type KeyOptions = {
cacheType?: CacheEntryType;
};

class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';

protected localCache: Cache | undefined;

async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
return trace(
{
operation: 'openNextIncrementalCacheGet',
name: key,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
const cacheKey = this.getR2Key(key, cacheType);
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
span.setAttribute('cacheHit', 'local');
return localCacheEntry.json();
}

const r2Object = await r2.get(this.getR2Key(key, cacheType));
if (!r2Object) return null;

span.setAttribute('cacheHit', 'r2');
return {
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
};
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
);
}

async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
return trace(
{
operation: 'openNextIncrementalCacheSet',
name: key,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');

try {
const cacheKey = this.getR2Key(key, cacheType);
await r2.put(cacheKey, JSON.stringify(value));

//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];

// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
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(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) {
console.error('Failed to set to cache', e);
}
}
);
}

async delete(key: string): Promise<void> {
return trace(
{
operation: 'openNextIncrementalCacheDelete',
name: key,
},
async () => {
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');

try {
const cacheKey = this.getR2Key(key);

await r2.delete(cacheKey);

// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
);
}

async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;
this.localCache = await caches.open('incremental-cache');
return this.localCache;
}

// Utility function to generate keys for R2/Cache API
getR2Key(key: string, cacheType?: CacheEntryType): string {
const hash = createHash('sha256').update(key).digest('hex');
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
/\/+/g,
'/'
);
}

getCacheUrlKey(cacheKey: string): string {
return `http://cache.local/${cacheKey}`;
}

getTagsFromCacheEntry<CacheType extends CacheEntryType>(
entry: CacheValue<CacheType>
): string[] | undefined {
if ('tags' in entry && entry.tags) {
return entry.tags;
}

if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
const rawTags = entry.meta.headers['x-next-cache-tags'];
if (typeof rawTags === 'string') {
return rawTags.split(',');
}
}
if ('value' in entry) {
return entry.tags;
}
}
}

export default new GitbookIncrementalCache();
4 changes: 2 additions & 2 deletions packages/gitbook-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
"unit": "bun test",
"typecheck": "tsc --noEmit"
}
}
}
Loading