diff --git a/.changeset/heavy-jeans-unite.md b/.changeset/heavy-jeans-unite.md new file mode 100644 index 000000000..c37c39b78 --- /dev/null +++ b/.changeset/heavy-jeans-unite.md @@ -0,0 +1,5 @@ +--- +'@gitbook/cache-do': minor +--- + +First version of a new cache backend powered by Cloudflare Durable Objects diff --git a/.changeset/odd-candles-cover.md b/.changeset/odd-candles-cover.md new file mode 100644 index 000000000..27cb8fb8b --- /dev/null +++ b/.changeset/odd-candles-cover.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc). diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 2445ac1a6..c232ebd72 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -35,3 +35,26 @@ jobs: # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + release-preview: + # For now it releases the cache-do to both preview and production + # Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do` + name: Release Preview + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Setup bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.1.18 + - name: Install dependencies + run: bun install --frozen-lockfile + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - name: Release preview packages + run: bun run release:preview + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/bun.lockb b/bun.lockb index 44eff2399..7da1719ec 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/cache-do/.gitignore b/packages/cache-do/.gitignore new file mode 100644 index 000000000..9b16495b9 --- /dev/null +++ b/packages/cache-do/.gitignore @@ -0,0 +1,3 @@ +.wrangler +worker-configuration.d.ts +dist/ diff --git a/packages/cache-do/README.md b/packages/cache-do/README.md new file mode 100644 index 000000000..7a479a82d --- /dev/null +++ b/packages/cache-do/README.md @@ -0,0 +1,22 @@ +# `@gitbook/cache-do` + +Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases. + +### Performances + +The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it. + +### Geo-distribution + +To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents. + +It makes it possible to purge all 7 locations in one go and achieve fast consistency. + +### Concepts + +**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation. +Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues. + +**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`. + +**Location**: cache is distributed over 7 unique locations, one for each continent. diff --git a/packages/cache-do/package.json b/packages/cache-do/package.json new file mode 100644 index 000000000..de96bee8e --- /dev/null +++ b/packages/cache-do/package.json @@ -0,0 +1,42 @@ +{ + "name": "@gitbook/cache-do", + "type": "module", + "private": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "development": "./src/api.ts", + "default": "./dist/api.js" + } + }, + "version": "0.0.0", + "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", + "lru_map": "^0.4.1" + }, + "devDependencies": { + "typescript": "^5.5.3", + "wrangler": "^3.78.7" + }, + "scripts": { + "generate": "wrangler types --experimental-include-runtime", + "build": "tsc", + "typecheck": "tsc --noEmit", + "dev": "tsc -w", + "release": "wrangler deploy", + "release:preview": "wrangler deploy && wrangler deploy --env preview" + }, + "files": [ + "dist", + "src", + "bin", + "data", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/packages/cache-do/src/CacheObject.ts b/packages/cache-do/src/CacheObject.ts new file mode 100644 index 000000000..9730839f5 --- /dev/null +++ b/packages/cache-do/src/CacheObject.ts @@ -0,0 +1,274 @@ +import { encode, decode } from '@msgpack/msgpack'; +import { DurableObject } from 'cloudflare:workers'; +import { LRUMap } from 'lru_map'; + +export interface CacheObjectDescriptor { + get: (key: string) => Promise; + set: (key: string, value: Value, expiresAt: number) => Promise; +} + +/** + * Value stored in a chunked binary msgpack format. + * Stored under the key `prop.${key}.${index}`. + */ +interface CacheObjectProp { + value: Value; + expiresAt: number; +} + +/** + * Expiration clock stored under the key `exp.${expiresAt}.${key}`. + */ +interface CacheObjectExp { + /** Key of the property */ + k: string; + /** Number of chunks */ + c: number; +} + +/** + * Durable Object class being deployed as a distributed cache. + */ +export class CacheObject extends DurableObject { + private lru = new LRUMap(500); + + /** + * Open a descriptor to access the cache object. + * The goal is to minimize the amount of RPC sessions between the client and the cache object. + * One session is opened per request on the client side and used to perform multiple operations. + * https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods + */ + public open(): CacheObjectDescriptor { + return { + get: async (key: string) => { + return this.get(key); + }, + set: async (key: string, value: Value, expiresAt: number) => { + await this.set(key, value, expiresAt); + }, + }; + } + + /** + * Get the value of a property. + */ + public async get(key: string) { + return timeFn(`get: ${key}`, async () => { + // Try the memory state first. + const memoryEntry = this.lru.get(key); + if (memoryEntry) { + console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`); + if (!memoryEntry.match) { + return; + } + + if (memoryEntry.match.expiresAt > Date.now()) { + return memoryEntry.match.value as Value; + } + } + + return await this.getFromStorage(key); + }); + } + + /** + * Get the value of a property from the DO storage. + */ + public async getFromStorage(key: string) { + const entries = await this.ctx.storage.list({ + prefix: getStoragePropKey(key), + noCache: true, + }); + if (entries.size) { + const entry = decodeChunks>(entries); + if (entry && entry.expiresAt > Date.now()) { + // Found + this.lru.set(key, { match: entry }); + return entry.value; + } + } + + // Not found + this.lru.set(key, { match: undefined }); + } + + /** + * Set a value in the cache object. + */ + public async set(key: string, value: Value, expiresAt: number) { + return timeFn(`set: ${key}`, async () => { + const prop: CacheObjectProp = { + value, + expiresAt, + }; + + this.lru.set(key, { match: prop }); + await this.ctx.storage.transaction(async (tx) => { + const entries = encodeChunks(key, prop); + + const clockValue: CacheObjectExp = { + k: key, + c: Object.keys(entries).length, + }; + + await tx.put(getGCClockKey(key, expiresAt), clockValue); + await tx.put(entries); + + const currentAlarm = await tx.getAlarm(); + if (!currentAlarm) { + // Set an alarm to garbage collect all entries that have expired in 12h. + await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000); + } + }); + }); + } + + /** + * Purge all keys in the cache object. + */ + public async purge() { + let result = new Set(); + + try { + // List all the keys in the cache object. + const entries = await this.ctx.storage.list({ + prefix: 'exp.', + noCache: true, + }); + console.log(`purge: ${entries.size} entries`); + entries.forEach((exp) => { + result.add(exp.k); + }); + } catch (error) { + // If an error occurs, reset the cache object. + // This is a safety mechanism to prevent the cache object from being stuck in a bad state. + console.error('Error during purge, resetting the cache object', error); + } + + await this.reset(); + + console.log(`purge returns`, Array.from(result)); + return Array.from(result); + } + + /** + * Alarm to garbage collect all entries that have expired. + */ + async alarm() { + try { + const entries = await this.ctx.storage.list({ + prefix: 'exp.', + noCache: true, + }); + const toDeleteSet = new Set(); + + for (const [key, exp] of entries) { + const timestamp = parseInt(key.split('.')[1]); + if (timestamp < Date.now()) { + toDeleteSet.add(key); + for (let i = 0; i < exp.c; i++) { + toDeleteSet.add(getStoragePropChunkKey(exp.k, i)); + } + } + } + + // Delete the keys by batch of 128. + const toDelete = Array.from(toDeleteSet); + for (let i = 0; i < toDelete.length; i += 128) { + await this.ctx.storage.delete(toDelete.slice(i, i + 128)); + } + + // If there are still keys to delete, set an alarm to continue the deletion in 12h. + if (toDelete.length) { + await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000); + } + } catch (error) { + // If an error occurs, reset the cache object. + // This is a safety mechanism to prevent the cache object from being stuck in a bad state. + console.error('Error during alarm, reset the cache object', error); + await this.reset(); + } + } + + /** + * Reset the cache object. + */ + async reset() { + console.log('reset: clear all entries'); + this.lru.clear(); + await this.ctx.storage.deleteAll(); + } +} + +function getStoragePropKey(key: string): string { + return `prop.${key}.`; +} + +function getStoragePropChunkKey(key: string, index: number): string { + return `${getStoragePropKey(key)}${index}`; +} + +function getGCClockRootKey(timestamp: number): string { + return `exp.${timestamp}.`; +} + +function getGCClockKey(key: string, expiresAt: number): string { + return `${getGCClockRootKey(expiresAt)}${key}`; +} + +function encodeChunks(key: string, value: T): Record { + const buf = encode(value); + const entries: Record = {}; + const chunks = chunkUint8Array(buf, 128 * 1024); + + for (let index = 0; index < chunks.length; index++) { + entries[getStoragePropChunkKey(key, index)] = chunks[index]; + } + + return entries; +} + +function decodeChunks(entries: Map): T | undefined { + const chunks = Array.from(entries.entries()) + .map(([key, value]) => { + const index = parseInt(key.split('.').pop()!); + return [index, value] as const; + }) + .sort(([a], [b]) => a - b) + .map(([, value]) => value); + + if (chunks.length === 0) { + return; + } + + const buf = mergeUint8Array(chunks); + return decode(buf) as T; +} + +function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] { + const chunks: Uint8Array[] = []; + for (let i = 0; i < input.length; i += chunkSize) { + chunks.push(input.slice(i, i + chunkSize)); + } + return chunks; +} + +function mergeUint8Array(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +async function timeFn(message: string, fn: () => Promise): Promise { + const start = performance.now(); + try { + return await fn(); + } finally { + console.log(`${message} (${performance.now() - start}ms)`); + } +} diff --git a/packages/cache-do/src/CacheObjectStub.ts b/packages/cache-do/src/CacheObjectStub.ts new file mode 100644 index 000000000..54203c642 --- /dev/null +++ b/packages/cache-do/src/CacheObjectStub.ts @@ -0,0 +1,91 @@ +import type { CacheObject, CacheObjectDescriptor } from './CacheObject'; + +export type CacheLocationId = ContinentCode; +const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC']; + +/** + * Location hint for the CacheObject durable object. + */ +const doLocationHints: { + [key in CacheLocationId]: DurableObjectLocationHint; +} = { + AF: 'afr', + AS: 'apac', + NA: 'wnam', + SA: 'sam', + AN: 'oc', + EU: 'weur', + OC: 'oc', +}; + +/** + * Client to access a cache tag. + */ +export class CacheObjectStub { + private opened: CacheObjectDescriptor | null = null; + + constructor( + /** Binding to the CacheObject durable object */ + private doNamespace: DurableObjectNamespace, + /** ID of the location to target */ + private locationId: CacheLocationId, + /** Name of the tag */ + private tag: string, + ) {} + + /** + * Open the cache object. + */ + async open() { + if (!this.opened) { + const groupId = getCacheObjectIdName(this.locationId, this.tag); + const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), { + // Initialize the object with a locaiton hint, + // as we might want to purge all locations before the object is created. + // https://developers.cloudflare.com/durable-objects/reference/data-location/ + locationHint: doLocationHints[this.locationId], + }); + this.opened = await cacheGroup.open(); + } + + return this.opened; + } + + /** + * Get a value from the cache. + */ + async get(key: string) { + const desc = await this.open(); + return await desc.get(key); + } + + /** + * Set a value in the cache. + */ + async set(key: string, value: Value, expiresAt: number) { + // TODO: Should we write on all locations instead of just the current one? + const desc = await this.open(); + return await desc.set(key, value, expiresAt); + } + + /** + * Purge all keys in the cache tag. + */ + async purge() { + const keys = new Set(); + await Promise.all( + allLocations.map(async (locationId) => { + const groupId = getCacheObjectIdName(locationId, this.tag); + const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId)); + const locationkeys = await cacheGroup.purge(); + locationkeys.forEach((key) => keys.add(key)); + }), + ); + + return keys; + } +} + +function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string { + return `${locationId}:${tag}`; +} diff --git a/packages/cache-do/src/api.ts b/packages/cache-do/src/api.ts new file mode 100644 index 000000000..6000e87c6 --- /dev/null +++ b/packages/cache-do/src/api.ts @@ -0,0 +1 @@ +export * from './CacheObjectStub'; diff --git a/packages/cache-do/src/index.ts b/packages/cache-do/src/index.ts new file mode 100644 index 000000000..13099a0f5 --- /dev/null +++ b/packages/cache-do/src/index.ts @@ -0,0 +1,9 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; + +export * from './CacheObject'; + +export default class Worker extends WorkerEntrypoint { + fetch() { + return new Response('Hello, world!'); + } +} diff --git a/packages/cache-do/tsconfig.json b/packages/cache-do/tsconfig.json new file mode 100644 index 000000000..09c216749 --- /dev/null +++ b/packages/cache-do/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": false, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": ["./.wrangler/types/runtime.d.ts"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/cache-do/wrangler.toml b/packages/cache-do/wrangler.toml new file mode 100644 index 000000000..cf9eae37a --- /dev/null +++ b/packages/cache-do/wrangler.toml @@ -0,0 +1,17 @@ +main = "./src/index.ts" +name = "gitbook-open-cache" +compatibility_date = "2024-09-02" + +durable_objects.bindings = [ + {name = "CACHE", class_name = "CacheObject"} +] + +migrations = [ + {tag = "v1", new_classes = ["CacheObject"]} +] + +[env.preview] +name = "gitbook-open-cache-preview" +durable_objects.bindings = [ + {name = "CACHE", class_name = "CacheObject"} +] diff --git a/packages/gitbook/cf-env.d.ts b/packages/gitbook/cf-env.d.ts new file mode 100644 index 000000000..3721b9eb0 --- /dev/null +++ b/packages/gitbook/cf-env.d.ts @@ -0,0 +1,7 @@ +import type { CacheObject } from '@gitbook/cache-do'; + +declare global { + interface CloudflareEnv { + CACHE?: DurableObjectNamespace; + } +} diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 74df29241..0a9dbdd8e 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -22,6 +22,7 @@ "@gitbook/react-openapi": "workspace:*", "@gitbook/react-contentkit": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", + "@gitbook/cache-do": "workspace:*", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@sentry/nextjs": "^7.94.1", diff --git a/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts b/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts index eeb61d4fa..a719e68e3 100644 --- a/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts +++ b/packages/gitbook/src/app/(global)/~gitbook/revalidate/route.ts @@ -29,7 +29,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, - keys: result.keys, stats: result.stats, }); } diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 286f2e7d2..96b3f2fc3 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -72,12 +72,10 @@ export interface ContentTarget { const immutableCacheTtl_7days = { revalidateBefore: 24 * 60 * 60, ttl: 7 * 24 * 60 * 60, - tags: [], }; const immutableCacheTtl_1day = { revalidateBefore: 60 * 60, ttl: 24 * 60 * 60, - tags: [], }; const apiSyncStorage = new AsyncLocalStorage(); @@ -143,9 +141,14 @@ export type PublishedContentWithCache = /** * Get a user by its ID. */ -export const getUserById = cache( - 'api.getUserById', - async (userId: string, options: CacheFunctionOptions) => { +export const getUserById = cache({ + name: 'api.getUserById', + tag: (userId) => + getAPICacheTag({ + tag: 'user', + user: userId, + }), + get: async (userId: string, options: CacheFunctionOptions) => { try { const response = await api().users.getUserById(userId, { signal: options.signal, @@ -153,28 +156,31 @@ export const getUserById = cache( }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [], }); } catch (error) { if ((error as GitBookAPIError).code === 404) { return { revalidateBefore: 60 * 60, data: null, - tags: [], }; } throw error; } }, -); +}); /** * Get a synced block by its ref. */ -export const getSyncedBlockContent = cache( - 'api.getSyncedBlockContent', - async ( +export const getSyncedBlockContent = cache({ + name: 'api.getSyncedBlockContent', + tag: (apiToken, organizationId, syncedBlockId) => + getAPICacheTag({ + tag: 'synced-block', + syncedBlock: syncedBlockId, + }), + get: async ( apiToken: string, organizationId: string, syncedBlockId: string, @@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'synced-block', - syncedBlock: syncedBlockId, - }), - ], }); } catch (error) { if ((error as GitBookAPIError).code === 404) { return { revalidateBefore: 60 * 60, data: null, - tags: [], }; } throw error; } }, - { - // We don't cache apiToken as it's not a stable key - extractArgs: (args) => [args[1], args[2]], - }, -); + // We don't cache apiToken as it's not a stable key + getKeyArgs: (args) => [args[1], args[2]], +}); /** * Resolve a URL to the content to render. */ -export const getPublishedContentByUrl = cache( - 'api.getPublishedContentByUrl.v3', - async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => { - const parsedURL = new URL(url); - +export const getPublishedContentByUrl = cache({ + name: 'api.getPublishedContentByUrl.v3', + tag: (url) => + getAPICacheTag({ + tag: 'url', + hostname: new URL(url).hostname, + }), + get: async ( + url: string, + visitorAuthToken: string | undefined, + options: CacheFunctionOptions, + ) => { try { const response = await api().urls.getPublishedContentByUrl( { @@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache( cacheTags: parsed.tags, }; return { - tags: [ - getAPICacheTag({ - tag: 'url', - hostname: parsedURL.hostname, - }), - ], ttl: parsed.ttl, data, }; @@ -264,26 +262,21 @@ export const getPublishedContentByUrl = cache( // Cache errors for max 10 minutes in case the user is making changes to its content configuration // and to avoid caching too many entries when being spammed by botss ttl: 60 * 10, - tags: [ - getAPICacheTag({ - tag: 'url', - hostname: parsedURL.hostname, - }), - ], }; } throw error; } }, -); +}); /** * Get a space by its ID. */ -export const getSpace = cache( - 'api.getSpace', - async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { +export const getSpace = cache({ + name: 'api.getSpace', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { const response = await api().spaces.getSpaceById( spaceId, { @@ -296,47 +289,47 @@ export const getSpace = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); /** * Get a change request by its ID. */ -export const getChangeRequest = cache( - 'api.getChangeRequest', - async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { +export const getChangeRequest = cache({ + name: 'api.getChangeRequest', + tag: (spaceId, changeRequestId) => + getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }), + get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { // We don't cache for long as we currently don't invalidate change-request cache - // and it's only used for preview where perfs are not critical + // and it's only used for preview where perfs are not critical ttl: 60 * 60, revalidateBefore: 10 * 60, - tags: [], }); }, -); +}); /** * List the scripts to load for the space. */ -export const getSpaceIntegrationScripts = cache( - 'api.getSpaceIntegrationScripts', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getSpaceIntegrationScripts = cache({ + name: 'api.getSpaceIntegrationScripts', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.listSpaceIntegrationScripts(spaceId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); interface GetRevisionOptions { /** @@ -351,9 +344,11 @@ interface GetRevisionOptions { /** * Get a revision by its ID. */ -export const getRevision = cache( - 'api.getRevision.v2', - async ( +export const getRevision = cache({ + name: 'api.getRevision.v2', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, @@ -376,17 +371,17 @@ export const getRevision = cache( fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day, ); }, - { - extractArgs: (args) => [args[0], args[1]], - }, -); + getKeyArgs: (args) => [args[0], args[1]], +}); /** * Get all the pages in a revision of a space. */ -export const getRevisionPages = cache( - 'api.getRevisionPages.v4', - async ( +export const getRevisionPages = cache({ + name: 'api.getRevisionPages.v4', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, @@ -409,17 +404,17 @@ export const getRevisionPages = cache( data: response.data.pages, }); }, - { - extractArgs: (args) => [args[0], args[1]], - }, -); + getKeyArgs: (args) => [args[0], args[1]], +}); /** * Get a revision page by its path */ -export const getRevisionPageByPath = cache( - 'api.getRevisionPageByPath.v3', - async ( +export const getRevisionPageByPath = cache({ + name: 'api.getRevisionPageByPath.v3', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( spaceId: string, revisionId: string, pagePath: string, @@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache( throw error; } }, -); +}); /** * Resolve a file by its ID. * It should not be used directly, use `getRevisionFile` instead. */ -const getRevisionFileById = cache( - 'api.getRevisionFile.v3', - async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => { +const getRevisionFileById = cache({ + name: 'api.getRevisionFile.v3', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async ( + spaceId: string, + revisionId: string, + fileId: string, + options: CacheFunctionOptions, + ) => { try { const response = await (async () => { return api().spaces.getFileInRevisionById( @@ -487,15 +489,17 @@ const getRevisionFileById = cache( throw error; } }, -); +}); /** * Get all the files in a revision of a space. * It should not be used directly, use `getRevisionFile` instead. */ -const getRevisionAllFiles = cache( - 'api.getRevisionAllFiles.v2', - async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { +const getRevisionAllFiles = cache({ + name: 'api.getRevisionAllFiles.v2', + tag: (spaceId, revisionId) => + getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), + get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { const response = await getAll( (params) => api().spaces.listFilesInRevisionById( @@ -522,10 +526,8 @@ const getRevisionAllFiles = cache( return cacheResponse(response, { ...immutableCacheTtl_7days, data: files }); }, - { - timeout: 60 * 1000, - }, -); + timeout: 60 * 1000, +}); /** * Resolve a file by its ID. @@ -583,9 +585,11 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu /** * Get a document by its ID. */ -export const getDocument = cache( - 'api.getDocument.v2', - async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { +export const getDocument = cache({ + name: 'api.getDocument.v2', + tag: (spaceId, documentId) => + getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }), + get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getDocumentById( spaceId, documentId, @@ -599,20 +603,19 @@ export const getDocument = cache( ); return cacheResponse(response, immutableCacheTtl_7days); }, - { - // Temporarily allow for a longer timeout than the default 10s - // because GitBook's API currently re-normalizes all documents - // and it can take more than 10s... - timeout: 20 * 1000, - }, -); + // Temporarily allow for a longer timeout than the default 10s + // because GitBook's API currently re-normalizes all documents + // and it can take more than 10s... + timeout: 20 * 1000, +}); /** * Get the customization settings for a site-space from the API. */ -const getSiteSpaceCustomizationFromAPI = cache( - 'api.getSiteSpaceCustomizationById', - async ( +const getSiteSpaceCustomizationFromAPI = cache({ + name: 'api.getSiteSpaceCustomizationById', + tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( organizationId: string, siteId: string, siteSpaceId: string, @@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'site', - site: siteId, - }), - ], }); }, -); +}); /** * Get the customization settings for a site from the API. */ -const getSiteCustomizationFromAPI = cache( - 'api.getSiteCustomizationById', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +const getSiteCustomizationFromAPI = cache({ + name: 'api.getSiteCustomizationById', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.getSiteCustomizationById( organizationId, siteId, @@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache( ); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [ - getAPICacheTag({ - tag: 'site', - site: siteId, - }), - ], }); }, -); +}); /** * Get the customization settings for a site space from the API. @@ -698,26 +690,27 @@ async function getSiteCustomization(args: { /** * Get the infos about a site by its ID. */ -export const getSite = cache( - 'api.getSite', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +export const getSite = cache({ + name: 'api.getSite', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.getSiteById(organizationId, siteId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'site', site: siteId })], }); }, -); +}); /** * List all the site-spaces variants published in a site. */ -export const getSiteSpaces = cache( - 'api.getSiteSpaces', - async ( +export const getSiteSpaces = cache({ + name: 'api.getSiteSpaces', + tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( args: { organizationId: string; siteId: string; @@ -744,27 +737,26 @@ export const getSiteSpaces = cache( return cacheResponse(response, { revalidateBefore: 60 * 60, data: response.data.items.map((siteSpace) => siteSpace), - tags: [getAPICacheTag({ tag: 'site', site: args.siteId })], }); }, -); +}); /** * List the scripts to load for the site. */ -export const getSiteIntegrationScripts = cache( - 'api.getSiteIntegrationScripts', - async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { +export const getSiteIntegrationScripts = cache({ + name: 'api.getSiteIntegrationScripts', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'site', site: siteId })], }); }, -); +}); /** * Fetch all the data to render the current site at once. @@ -835,19 +827,19 @@ export async function getCurrentSiteCustomization(args: { /** * Get the customization settings for a space from the API. */ -export const getSpaceCustomizationFromAPI = cache( - 'api.getSpaceCustomization', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getSpaceCustomizationFromAPI = cache({ + name: 'api.getSpaceCustomization', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, { signal: options.signal, ...noCacheFetchOptions, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'space', space: spaceId })], }); }, -); +}); /** * Get the customization settings for a space from the API. @@ -876,26 +868,27 @@ export async function getSpaceCustomization(spaceId: string): Promise { +export const getCollection = cache({ + name: 'api.getCollection', + tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (collectionId: string, options: CacheFunctionOptions) => { const response = await api().collections.getCollectionById(collectionId, { ...noCacheFetchOptions, signal: options.signal, }); return cacheResponse(response, { revalidateBefore: 60 * 60, - tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })], }); }, -); +}); /** * List all the spaces variants published in a collection. */ -export const getCollectionSpaces = cache( - 'api.getCollectionSpaces', - async (collectionId: string, options: CacheFunctionOptions) => { +export const getCollectionSpaces = cache({ + name: 'api.getCollectionSpaces', + tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (collectionId: string, options: CacheFunctionOptions) => { const response = await getAll((params) => api().collections.listSpacesInCollectionById(collectionId, params, { ...noCacheFetchOptions, @@ -908,10 +901,9 @@ export const getCollectionSpaces = cache( data: response.data.items.filter( (space) => space.visibility === ContentVisibility.InCollection, ), - tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })], }); }, -); +}); /** * Fetch all the data to render a space at once. @@ -979,9 +971,10 @@ export async function getSpaceLayoutData(spaceId: string) { /** * Search content in a space. */ -export const searchSpaceContent = cache( - 'api.searchSpaceContent', - async ( +export const searchSpaceContent = cache({ + name: 'api.searchSpaceContent', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async ( spaceId: string, /** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */ revisionId: string, @@ -996,18 +989,17 @@ export const searchSpaceContent = cache( signal: options.signal, }, ); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Search content accross all spaces in a parent (site or collection). */ -export const searchParentContent = cache( - 'api.searchParentContent', - async (parentId: string, query: string, options: CacheFunctionOptions) => { +export const searchParentContent = cache({ + name: 'api.searchParentContent', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (parentId: string, query: string, options: CacheFunctionOptions) => { const response = await api().search.searchContent( { query }, { @@ -1017,17 +1009,17 @@ export const searchParentContent = cache( ); return cacheResponse(response, { ttl: 60 * 60, - tags: [], }); }, -); +}); /** * Search content in a Site or specific SiteSpaces. */ -export const searchSiteContent = cache( - 'api.searchSiteContent', - async ( +export const searchSiteContent = cache({ + name: 'api.searchSiteContent', + tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + get: async ( organizationId: string, siteId: string, query: string, @@ -1052,33 +1044,32 @@ export const searchSiteContent = cache( return cacheResponse(response, { ttl: 60 * 60, - tags: [], }); }, -); +}); /** * Get a list of recommended questions in a space. */ -export const getRecommendedQuestionsInSpace = cache( - 'api.getRecommendedQuestionsInSpace', - async (spaceId: string, options: CacheFunctionOptions) => { +export const getRecommendedQuestionsInSpace = cache({ + name: 'api.getRecommendedQuestionsInSpace', + tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (spaceId: string, options: CacheFunctionOptions) => { const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, { ...noCacheFetchOptions, signal: options.signal, }); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Render an integration contentkit UI */ -export const renderIntegrationUi = cache( - 'api.renderIntegrationUi', - async ( +export const renderIntegrationUi = cache({ + name: 'api.renderIntegrationUi', + tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }), + get: async ( integrationName: string, request: RequestRenderIntegrationUI, options: CacheFunctionOptions, @@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache( signal: options.signal, }, ); - return cacheResponse(response, { - tags: [], - }); + return cacheResponse(response); }, -); +}); /** * Create a cache tag for the API. */ export function getAPICacheTag( - spec: // All data related to a space + spec: // All data related to a user | { + tag: 'user'; + user: string; + } + // All data related to a space + | { tag: 'space'; space: string; } + // All data related to an integration + | { + tag: 'integration'; + integration: string; + } + // All data related to a change request + | { + tag: 'change-request'; + space: string; + changeRequest: string; + } + // Immutable data related to a revision + | { + tag: 'revision'; + space: string; + revision: string; + } + // Immutable data related to a document + | { + tag: 'document'; + space: string; + document: string; + } // All data related to the URL of a content | { tag: 'url'; @@ -1128,16 +1145,26 @@ export function getAPICacheTag( }, ): string { switch (spec.tag) { + case 'user': + return `user:${spec.user}`; case 'url': return `url:${spec.hostname}`; case 'space': return `space:${spec.space}`; + case 'change-request': + return `space:${spec.space}:change-request:${spec.changeRequest}`; + case 'revision': + return `space:${spec.space}:revision:${spec.revision}`; + case 'document': + return `space:${spec.space}:document:${spec.document}`; case 'collection': return `collection:${spec.collection}`; case 'synced-block': return `synced-block:${spec.syncedBlock}`; case 'site': return `site:${spec.site}`; + case 'integration': + return `integration:${spec.integration}`; default: assertNever(spec); } diff --git a/packages/gitbook/src/lib/cache/backends.ts b/packages/gitbook/src/lib/cache/backends.ts index e4dc4002c..ba33f23ff 100644 --- a/packages/gitbook/src/lib/cache/backends.ts +++ b/packages/gitbook/src/lib/cache/backends.ts @@ -1,18 +1,16 @@ import { cloudflareCache } from './cloudflare-cache'; +import { cloudflareDOCache } from './cloudflare-do'; import { cloudflareKVCache } from './cloudflare-kv'; import { memoryCache } from './memory'; -import { redisCache } from './redis'; export const cacheBackends = [ // Cache local to the process // (can't be globally purged or shared between processes) memoryCache, - // Global cache shared between all processes - // with proper replication and invalidation - // redisCache, // Disabled as we investigate high amount of fetch requests // Cache local to the datacenter // It can't be purged globally but it's faster cloudflareCache, // Cache global, but with slow replication cloudflareKVCache, + cloudflareDOCache, ]; diff --git a/packages/gitbook/src/lib/cache/cache.test.ts b/packages/gitbook/src/lib/cache/cache.test.ts index 9448c3cd4..e2d115d34 100644 --- a/packages/gitbook/src/lib/cache/cache.test.ts +++ b/packages/gitbook/src/lib/cache/cache.test.ts @@ -15,12 +15,16 @@ describe('cache', () => { testId += 1; getTtl = () => 1000; - fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => { - await new Promise((resolve) => setTimeout(resolve, 20)); - return { - data: impl(arg), - ttl: getTtl(), - }; + fn = cache({ + name: `cache-${testId}`, + tag: (arg) => 'test', + get: async (arg: string, options: CacheFunctionOptions) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return { + data: impl(arg), + ttl: getTtl(), + }; + }, }); }); diff --git a/packages/gitbook/src/lib/cache/cache.ts b/packages/gitbook/src/lib/cache/cache.ts index 8c154e672..dadabcf4d 100644 --- a/packages/gitbook/src/lib/cache/cache.ts +++ b/packages/gitbook/src/lib/cache/cache.ts @@ -43,11 +43,26 @@ export interface CacheResult { * Time before ttl where the cache should be revalidated (in seconds). */ revalidateBefore?: number; +} - /** - * Tags to associate with the cache entry. - */ - tags?: string[]; +export interface CacheDefinition { + /** Unique name for the cache */ + name: string; + + /** Tag to associate to the entry */ + tag?: (...args: Args) => string; + + /** Filter the arguments that should be taken into consideration for the cache key */ + getKeyArgs?: (args: Args) => any[]; + + /** Default ttl (in seconds) */ + defaultTtl?: number; + + /** When a request to the underlying resource will timeout. */ + timeout?: number; + + /** Function to get the value */ + get: (...args: [...Args, CacheFunctionOptions]) => Promise>; } /** @@ -55,21 +70,11 @@ export interface CacheResult { * We don't use the next.js cache because it has a 2MB limit. */ export function cache( - cacheName: string, - fn: (...args: [...Args, CacheFunctionOptions]) => Promise>, - options: { - /** Filter the arguments that should be taken into consideration for caching */ - extractArgs?: (args: Args) => any[]; - - /** Default ttl (in seconds) */ - defaultTtl?: number; - - /** When a request to the underlying resource will timeout. */ - timeout?: number; - } = {}, + cacheDef: CacheDefinition, ): CacheFunction { // We stop everything after 10s to avoid pending requests - const timeout = options.timeout ?? 1000 * 10; + const timeout = cacheDef.timeout ?? 1000 * 10; + const defaultTtl = cacheDef.defaultTtl ?? 60 * 60 * 24; const revalidate = singletonMap( async (key: string, signal: AbortSignal | undefined, ...args: Args) => { @@ -80,19 +85,18 @@ export function cache( }, async (span) => { // Fetch upstream - const result = await fn(...args, { signal }); + const result = await cacheDef.get(...args, { signal }); signal?.throwIfAborted(); const setAt = Date.now(); - const expiresAt = - setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000; + const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000; const cacheEntry: CacheEntry = { data: result.data, meta: { key, - cache: cacheName, - tags: result.tags ?? [], + cache: cacheDef.name, + tag: cacheDef.tag?.(...args), setAt, expiresAt, revalidatesAt: result.revalidateBefore @@ -106,7 +110,7 @@ export function cache( // Write it to the cache if (result.ttl && result.ttl > 0) { - await waitUntil(setCacheEntry(key, cacheEntry)); + await waitUntil(setCacheEntry(cacheEntry)); } return cacheEntry; @@ -122,9 +126,10 @@ export function cache( let fetchDuration = 0; let result: readonly [CacheEntry, string] | null = null; + const tag = cacheDef.tag?.(...args); // Try the memory backend, independently of the other backends as it doesn't have a network cost - const memoryEntry = await memoryCache.get(key); + const memoryEntry = await memoryCache.get({ key, tag }); if (memoryEntry) { span.setAttribute('memory', true); result = [memoryEntry, 'memory'] as const; @@ -132,7 +137,7 @@ export function cache( result = await race( cacheBackends, async (backend, { signal }) => { - const entry = await backend.get(key, { signal }); + const entry = await backend.get({ key, tag }, { signal }); return entry ? ([entry, backend.name] as const) : null; }, { @@ -184,7 +189,7 @@ export function cache( (backend) => backend.name !== backendName && backend.replication === 'local', ) - .map((backend) => backend.set(key, savedEntry)), + .map((backend) => backend.set(savedEntry)), ), ); } @@ -214,8 +219,8 @@ export function cache( const cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { const [args, { signal }] = extractCacheFunctionOptions(rawArgs); - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); return await trace( { @@ -231,17 +236,18 @@ export function cache( cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { const [args, { signal }] = extractCacheFunctionOptions(rawArgs); - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); await revalidate(key, signal, ...args); }; cacheFn.hasInMemory = async (...args: Args) => { - const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; - const key = getCacheKey(cacheName, cacheArgs); + const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; + const key = getCacheKey(cacheDef.name, cacheArgs); + const tag = cacheDef.tag?.(...args); - const memoryEntry = await memoryCache.get(key); + const memoryEntry = await memoryCache.get({ key, tag }); if (memoryEntry) { return true; } @@ -250,7 +256,7 @@ export function cache( }; // @ts-ignore - registeredCaches.set(cacheName, cacheFn); + registeredCaches.set(cacheDef.name, cacheFn); return cacheFn; } @@ -289,14 +295,14 @@ function hashValue(arg: any): string { return JSON.stringify(arg); } -async function setCacheEntry(key: string, entry: CacheEntry) { +async function setCacheEntry(entry: CacheEntry) { return await trace( { operation: `cache.setCacheEntry`, - name: key, + name: entry.meta.key, }, async () => { - await Promise.all(cacheBackends.map((backend) => backend.set(key, entry))); + await Promise.all(cacheBackends.map((backend) => backend.set(entry))); }, ); } diff --git a/packages/gitbook/src/lib/cache/cloudflare-cache.ts b/packages/gitbook/src/lib/cache/cloudflare-cache.ts index 98ae1d0d8..6744298d2 100644 --- a/packages/gitbook/src/lib/cache/cloudflare-cache.ts +++ b/packages/gitbook/src/lib/cache/cloudflare-cache.ts @@ -19,7 +19,7 @@ const cacheVersion = 2; export const cloudflareCache: CacheBackend = { name: 'cloudflare', replication: 'local', - async get(key, options) { + async get(entry, options) { const cache = getCache(); if (!cache) { return null; @@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = { return trace( { operation: `cloudflareCache.get`, - name: key, + name: entry.key, }, async (span) => { - const cacheKey = await serializeKey(key); + const cacheKey = await serializeKey(entry.key); const response = await cache.match(cacheKey); span.setAttribute('hit', !!response); @@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = { return null; } - const entry = await deserializeEntry(response); - return entry; + const cacheEntry = await deserializeEntry(response); + return cacheEntry; }, ); }, - async set(key, entry) { + async set(entry) { const cache = getCache(); if (cache) { await trace( { operation: `cloudflareCache.set`, - name: key, + name: entry.meta.key, }, async () => { - const cacheKey = await serializeKey(key); + const cacheKey = await serializeKey(entry.meta.key); await cache.put(cacheKey, serializeEntry(entry)); }, ); } }, - async del(keys) { + async del(entries) { const cache = getCache(); if (cache) { await Promise.all( - keys.map(async (key) => { - const cacheKey = await serializeKey(key); + entries.map(async (entry) => { + const cacheKey = await serializeKey(entry.key); await cache.delete(cacheKey); }), ); @@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = { }, async revalidateTags(tags) { return { - keys: [], - metas: [], + entries: [], }; }, }; @@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise { function serializeEntry(entry: CacheEntry): WorkerResponse { const headers = new Headers(); headers.set('Content-Type', 'application/json'); - const cacheTags = ['gitbook-open', ...entry.meta.tags]; + const cacheTags = ['gitbook-open']; + if (entry.meta.tag) { + cacheTags.push(entry.meta.tag); + } const maxAge = getCacheMaxAge( entry.meta, diff --git a/packages/gitbook/src/lib/cache/cloudflare-do.ts b/packages/gitbook/src/lib/cache/cloudflare-do.ts new file mode 100644 index 000000000..629702359 --- /dev/null +++ b/packages/gitbook/src/lib/cache/cloudflare-do.ts @@ -0,0 +1,101 @@ +import { CacheObjectStub, CacheLocationId } from '@gitbook/cache-do/api'; + +import { CacheBackend, CacheEntry, CacheEntryLookup } from './types'; +import { trace } from '../tracing'; + +/** + * Cache implementation using the custom Cloudflare Durable Object. + */ +export const cloudflareDOCache: CacheBackend = { + name: 'cloudflare-do', + replication: 'global', + async get(entry, options) { + const { key, tag } = entry; + if (!tag) { + return null; + } + + return trace( + { + operation: `cloudflareDO.get`, + name: entry.key, + }, + async (span) => { + const stub = await getStub(tag); + if (!stub) { + return null; + } + + return (await stub.get(key)) ?? null; + }, + ); + }, + async set(entry) { + const { key, tag } = entry.meta; + if (!tag) { + return; + } + + return trace( + { + operation: `cloudflareDO.set`, + name: key, + }, + async () => { + const stub = await getStub(tag); + if (!stub) { + return; + } + + await stub.set(key, entry, entry.meta.expiresAt); + }, + ); + }, + async del(entries) { + // We don't need to directly delete entries from the Cloudflare DO cache. + }, + async revalidateTags(tags) { + const entries: CacheEntryLookup[] = []; + + await Promise.all( + tags.map(async (tag) => { + const stub = await getStub(tag); + if (!stub) { + return; + } + + const keys = await stub.purge(); + keys.forEach((key) => { + entries.push({ key, tag }); + }); + }), + ); + + return { entries }; + }, +}; + +const globalStubs = new WeakMap>(); + +async function getStub(tag: string): Promise { + if (process.env.NODE_ENV === 'test') { + return null; + } + + // We lazy-load the next-on-pages package to avoid errors when running tests because of 'server-only'. + const { getOptionalRequestContext } = await import('@cloudflare/next-on-pages'); + const cloudflare = getOptionalRequestContext(); + if (!cloudflare || !cloudflare.env.CACHE) { + return null; + } + + const requestStubs = globalStubs.get(cloudflare.cf) ?? new Map(); + globalStubs.set(cloudflare.cf, requestStubs); + + const locationId: CacheLocationId = cloudflare.cf.continent ?? 'NA'; + const stub = + requestStubs.get(tag) ?? new CacheObjectStub(cloudflare.env.CACHE, locationId, tag); + requestStubs.set(tag, stub); + + return stub; +} diff --git a/packages/gitbook/src/lib/cache/cloudflare-kv.ts b/packages/gitbook/src/lib/cache/cloudflare-kv.ts index 8845b6352..a6f96009e 100644 --- a/packages/gitbook/src/lib/cache/cloudflare-kv.ts +++ b/packages/gitbook/src/lib/cache/cloudflare-kv.ts @@ -17,7 +17,7 @@ interface KVTagMetadata { export const cloudflareKVCache: CacheBackend = { name: 'cloudflare-kv', replication: 'global', - async get(key, options) { + async get({ key }, options) { const kv = await getKVNamespace(); if (!kv) { return null; @@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = { }, ); }, - async set(key, entry) { + async set(entry) { const kv = await getKVNamespace(); if (!kv) { return; @@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = { return trace( { operation: `cloudflareKV.set`, - name: key, + name: entry.meta.key, }, async () => { const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24); @@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = { return; } - const kvKey = getValueKey(key); + const kvKey = getValueKey(entry.meta.key); await kv.put(kvKey, JSON.stringify(entry), { expirationTtl: secondsFromNow, }); - if (entry.meta.tags.length > 0) { + if (entry.meta.tag) { const metadata: KVTagMetadata = { meta: entry.meta, }; const jsonMetadata = JSON.stringify(metadata); + const tagKey = getTagKey(entry.meta.tag, entry.meta.key); - // Write a key for each tag - await Promise.all( - entry.meta.tags.map(async (tag) => { - const tagKey = getTagKey(tag, key); - - await kv.put(tagKey, jsonMetadata, { - metadata, - expirationTtl: secondsFromNow, - }); - }), - ); + await kv.put(tagKey, jsonMetadata, { + metadata, + expirationTtl: secondsFromNow, + }); } }, ); }, - async del(keys) { + async del(entries) { const kv = await getKVNamespace(); if (!kv) { return; } await Promise.all( - keys.map(async (key) => { + entries.map(async ({ key }) => { const kvKey = getValueKey(key); await kv.delete(kvKey); }), ); }, async revalidateTags(tags) { - const result: { keys: string[]; metas: CacheEntryMeta[] } = { - keys: [], - metas: [], - }; + const result: CacheEntryMeta[] = []; const kv = await getKVNamespace(); if (!kv) { - return result; + return { entries: result }; } const pendingDeletions: Array> = []; @@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = { const metadata = entry.metadata; const key = metadata.meta.key; - result.metas.push(metadata.meta); - result.keys.push(key); + result.push(metadata.meta); // Delete the tag key and the value key pendingDeletions.push(kv.delete(getValueKey(key))); @@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = { await Promise.all(pendingDeletions); - return result; + return { entries: result }; }, }; diff --git a/packages/gitbook/src/lib/cache/http.ts b/packages/gitbook/src/lib/cache/http.ts index 145ae78de..6907ec326 100644 --- a/packages/gitbook/src/lib/cache/http.ts +++ b/packages/gitbook/src/lib/cache/http.ts @@ -52,7 +52,6 @@ export function cacheResponse( return { ttl: defaultEntry.ttl ?? parsed.ttl, - tags: [...(defaultEntry.tags ?? []), ...parsed.tags], revalidateBefore: defaultEntry.revalidateBefore, // @ts-ignore data: defaultEntry.data ?? response.data, diff --git a/packages/gitbook/src/lib/cache/memory.ts b/packages/gitbook/src/lib/cache/memory.ts index 70cc8e1f0..deff0e946 100644 --- a/packages/gitbook/src/lib/cache/memory.ts +++ b/packages/gitbook/src/lib/cache/memory.ts @@ -1,13 +1,13 @@ -import { CacheBackend, CacheEntry } from './types'; +import { CacheBackend, CacheEntry, CacheEntryLookup } from './types'; import { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils'; import { getGlobalContext } from '../waitUntil'; export const memoryCache: CacheBackend = { name: 'memory', replication: 'local', - async get(key) { + async get(entry) { const memoryCache = await getMemoryCache(); - const memoryEntry = memoryCache.get(key); + const memoryEntry = memoryCache.get(entry.key); if (!memoryEntry) { return null; @@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = { if (memoryEntry.meta.expiresAt > Date.now()) { return memoryEntry; } else { - memoryCache.delete(key); + memoryCache.delete(entry.key); } return null; }, - async set(key, entry) { + async set(entry) { const memoryCache = await getMemoryCache(); // When the entry is immutable, we can cache it for the entire duration. // Else we cache it for a very short time. @@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = { meta.expiresAt = expiresAt; } - memoryCache.set(key, { ...entry, meta }); + memoryCache.set(entry.meta.key, { ...entry, meta }); }, - async del(keys) { + async del(entries) { const memoryCache = await getMemoryCache(); - keys.forEach((key) => memoryCache.delete(key)); + entries.forEach((entry) => memoryCache.delete(entry.key)); }, async revalidateTags(tags) { const memoryCache = await getMemoryCache(); - const keys: string[] = []; + const entries: CacheEntryLookup[] = []; memoryCache.forEach((entry, key) => { - if (tags.some((tag) => entry.meta.tags.includes(tag))) { - keys.push(key); + if (entry.meta.tag && tags.includes(entry.meta.tag)) { + entries.push({ key, tag: entry.meta.tag }); memoryCache.delete(key); } }); return { - keys, - metas: [], + entries, }; }, }; diff --git a/packages/gitbook/src/lib/cache/redis.ts b/packages/gitbook/src/lib/cache/redis.ts deleted file mode 100644 index e42b25f8c..000000000 --- a/packages/gitbook/src/lib/cache/redis.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Redis } from '@upstash/redis/cloudflare'; - -import { CacheBackend, CacheEntry, CacheEntryMeta } from './types'; -import { getCacheMaxAge } from './utils'; -import { trace } from '../tracing'; -import { filterOutNullable } from '../typescript'; - -const cacheNamespace = process.env.UPSTASH_REDIS_NAMESPACE ?? 'gitbook'; -const cacheVersion = 2; - -export const redisCache: CacheBackend = { - name: 'redis', - replication: 'global', - async get(key, options) { - const redis = getRedis(options?.signal); - if (!redis) { - return null; - } - return trace( - { - operation: `redis.get`, - name: key, - }, - async (span) => { - const valueKey = getCacheEntryKey(key, 'value'); - const redisEntry = await redis.get(valueKey); - - span.setAttribute('hit', !!redisEntry); - - if (!redisEntry) { - return null; - } - - return redisEntry; - }, - ); - }, - - async set(key, entry) { - const redis = getRedis(); - if (!redis) { - return; - } - - return trace( - { - operation: `redis.set`, - name: key, - }, - async () => { - const expire = getCacheMaxAge(entry.meta); - - // Don't cache for less than 10min, as it's not worth it - if (expire <= 10 * 60) { - return; - } - - const multi = redis.multi(); - const valueKey = getCacheEntryKey(key, 'value'); - const metaKey = getCacheEntryKey(key, 'meta'); - - entry.meta.tags.forEach((tag) => { - const redisTagKey = getCacheTagKey(tag); - - multi.sadd(redisTagKey, key); - - // Set am expiration on the tag to be the maximum of the expiration of all keys - multi.expire(redisTagKey, expire, 'GT'); - multi.expire(redisTagKey, expire, 'NX'); - }); - - multi.set(valueKey, entry); - multi.set(metaKey, entry.meta); - multi.expire(valueKey, expire); - multi.expire(metaKey, expire); - - await multi.exec(); - }, - ); - }, - - async del(keys) { - const redis = getRedis(); - if (!redis) { - return; - } - - const multi = redis.multi(); - keys.forEach((key) => { - multi.del(getCacheEntryKey(key, 'value')); - multi.del(getCacheEntryKey(key, 'meta')); - }); - - await multi.exec(); - }, - - async revalidateTags(tags) { - const redis = getRedis(); - if (!redis) { - return { keys: [], metas: [] }; - } - - const keys = new Set( - (await Promise.all(tags.map((tag) => redis.smembers(getCacheTagKey(tag))))).flat(), - ); - - const pipeline = redis.pipeline(); - let metas: Array = []; - - if (keys.size > 0) { - // Read the meta - metas = ( - await redis.mget>( - // Hard limit to avoid fetching a massive list of data - // Starts with the smallest keys. - Array.from(keys) - .sort((a, b) => a.length - b.length) - .slice(0, 50) - .map((key) => getCacheEntryKey(key, 'meta')), - ) - ) - .flat() - .filter(filterOutNullable); - - // Delete all keys - keys.forEach((key) => { - pipeline.del(getCacheEntryKey(key, 'value')); - }); - } - - // And delete the tags - tags.forEach((tag) => { - pipeline.del(getCacheTagKey(tag)); - }); - - await pipeline.exec(); - return { keys: Array.from(keys), metas: metas.filter(filterOutNullable) }; - }, -}; - -/** - * Get the redis client. - */ -export function getRedis(signal?: AbortSignal) { - return process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN - ? new Redis({ - url: process.env.UPSTASH_REDIS_REST_URL, - token: process.env.UPSTASH_REDIS_REST_TOKEN, - signal, - automaticDeserialization: true, - }) - : null; -} - -/** - * Get the key for a tag. - */ -function getCacheTagKey(tag: string) { - return getRedisKey(`tags.${tag}`); -} - -/** - * Get the key for an entry. - */ -function getCacheEntryKey(key: string, type: 'meta' | 'value') { - return getRedisKey(`entry.${key}.${type}`); -} - -/** - * Create a redis key for a cache entry. - */ -function getRedisKey(key: string) { - return `${cacheNamespace}.${cacheVersion}.${key}`; -} diff --git a/packages/gitbook/src/lib/cache/revalidateTags.ts b/packages/gitbook/src/lib/cache/revalidateTags.ts index 13c7453b7..9cd029024 100644 --- a/packages/gitbook/src/lib/cache/revalidateTags.ts +++ b/packages/gitbook/src/lib/cache/revalidateTags.ts @@ -1,83 +1,71 @@ import { cacheBackends } from './backends'; -import { getCacheKey } from './cache'; -import { CacheEntryMeta } from './types'; +import { CacheEntryLookup } from './types'; interface RevalidateTagsStats { [key: string]: { + /** + * Tag associated with the key. + */ + tag?: string; + /** * Backends that have the key. */ - [backend: string]: { set: boolean; setAt?: number; expiresAt?: number }; + backends: Record; }; } /** - * Revalidate all values associated with tags. - * It clears the values from the caches, but also start a background task to revalidate them. + * Purge all cache entries associated with the given tags. + * TODO: Implement background revalidation. */ export async function revalidateTags(tags: string[]): Promise<{ - keys: string[]; stats: RevalidateTagsStats; }> { if (tags.length === 0) { - return { keys: [], stats: {} }; + return { stats: {} }; } const stats: RevalidateTagsStats = {}; - const processed = new Set(); - const keysByBackend = new Map(); - const keys = new Set(); - const metas: CacheEntryMeta[] = []; + const entries = new Map(); await Promise.all( cacheBackends.map(async (backend, backendIndex) => { - const { keys: addedKeys, metas: addedMetas } = await backend.revalidateTags(tags); + const { entries: addedEntries } = await backend.revalidateTags(tags); - console.log('revalidateTags', backend.name, addedKeys); + addedEntries.forEach(({ key, tag }) => { + stats[key] = stats[key] ?? { + tag, + backends: {}, + }; + stats[key].backends[backend.name] = { set: true }; - addedKeys.forEach((key) => { - stats[key] = stats[key] ?? {}; - stats[key][backend.name] = { set: true }; - - keys.add(key); + entries.set(key, { tag, key }); keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), key]); }); - addedMetas.forEach((meta) => { - const key = meta.key ?? getCacheKey(meta.cache, meta.args); - stats[key] = stats[key] ?? {}; - stats[key][backend.name] = { set: true }; - stats[key][backend.name].setAt = meta.setAt; - stats[key][backend.name].expiresAt = meta.expiresAt; - - if (!processed.has(key)) { - metas.push(meta); - processed.add(key); - } - }); }), ); // Clear the keys on the backends that didn't return them await Promise.all( cacheBackends.map(async (backend, backendIndex) => { - const unclearedKeys = Array.from(keys).filter( - (key) => !keysByBackend.get(backendIndex)?.includes(key), + const unclearedEntries = Array.from(entries.values()).filter( + (entry) => !keysByBackend.get(backendIndex)?.includes(entry.key), ); - if (unclearedKeys.length > 0) { - unclearedKeys.forEach((key) => { - stats[key][backend.name] = { set: false }; + if (unclearedEntries.length > 0) { + unclearedEntries.forEach((entry) => { + stats[entry.key].backends[backend.name] = { set: false }; }); - await backend.del(unclearedKeys); + await backend.del(unclearedEntries); } }), ); return { - keys: Array.from(keys.keys()), stats, }; } diff --git a/packages/gitbook/src/lib/cache/types.ts b/packages/gitbook/src/lib/cache/types.ts index ec12b9b46..0f5a2550e 100644 --- a/packages/gitbook/src/lib/cache/types.ts +++ b/packages/gitbook/src/lib/cache/types.ts @@ -1,6 +1,9 @@ -export interface CacheEntryMeta { +export interface CacheEntryLookup { key: string; + tag?: string; +} +export interface CacheEntryMeta extends CacheEntryLookup { /** * Timestamp when the entry was created. */ @@ -21,12 +24,6 @@ export interface CacheEntryMeta { */ revalidatesAt?: number; - /** - * Tags associated with the entry, used for revalidation. - * If no tags is present, the entry is considered immutable. - */ - tags: string[]; - /** * Arguments that were passed to the function. */ @@ -51,21 +48,21 @@ export interface CacheBackend { /** * Get a value from the cache. */ - get(key: string, options?: { signal?: AbortSignal }): Promise; + get(entry: CacheEntryLookup, options?: { signal?: AbortSignal }): Promise; /** * Set a value in the cache. */ - set(key: string, entry: CacheEntry): Promise; + set(entry: CacheEntry): Promise; /** * Delete a value from the cache. */ - del(keys: string[]): Promise; + del(keys: CacheEntryLookup[]): Promise; /** * Revalidate all keys associated with tags. * It should return the meta of all entries that were revalidated. */ - revalidateTags(tags: string[]): Promise<{ keys: string[]; metas: CacheEntryMeta[] }>; + revalidateTags(tags: string[]): Promise<{ entries: Array }>; } diff --git a/packages/gitbook/src/lib/cache/utils.ts b/packages/gitbook/src/lib/cache/utils.ts index e4f1837fa..a14ee9ec8 100644 --- a/packages/gitbook/src/lib/cache/utils.ts +++ b/packages/gitbook/src/lib/cache/utils.ts @@ -23,5 +23,5 @@ export function getCacheMaxAge(meta: CacheEntryMeta, min?: number, max?: number) * Return true if a cache entry can be considered immutable. */ export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean { - return !meta.tags || meta.tags.length === 0; + return !meta.tag; } diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index 18aff1cfc..a079295e3 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -48,27 +48,30 @@ export async function fetchOpenAPIBlock( } const fetcher: OpenAPIFetcher = { - fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => { - // Wrap the raw string to prevent invalid URLs from being passed to fetch. - // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: - // https://github.com/cloudflare/workerd/issues/1957 - const response = await fetch(new URL(url), { - ...noCacheFetchOptions, - signal: options.signal, - }); + fetch: cache({ + name: 'openapi.fetch', + get: async (url: string, options: CacheFunctionOptions) => { + // Wrap the raw string to prevent invalid URLs from being passed to fetch. + // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: + // https://github.com/cloudflare/workerd/issues/1957 + const response = await fetch(new URL(url), { + ...noCacheFetchOptions, + signal: options.signal, + }); - if (!response.ok) { - throw new Error( - `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, - ); - } + if (!response.ok) { + throw new Error( + `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, + ); + } - const text = await response.text(); - const data = await parseOpenAPIV3(url, text); - return { - ...parseCacheResponse(response), - data, - }; + const text = await response.text(); + const data = await parseOpenAPIV3(url, text); + return { + ...parseCacheResponse(response), + data, + }; + }, }), parseMarkdown, }; diff --git a/packages/gitbook/tsconfig.json b/packages/gitbook/tsconfig.json index 5b91e68fe..4374d4750 100644 --- a/packages/gitbook/tsconfig.json +++ b/packages/gitbook/tsconfig.json @@ -25,6 +25,6 @@ "bun-types" // add Bun global ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "cf-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules", "packages/react-openapi", "packages/react-math"] } diff --git a/turbo.json b/turbo.json index 981e863d9..7940ca37f 100644 --- a/turbo.json +++ b/turbo.json @@ -46,7 +46,13 @@ }, // Script run when the package is deployed "release": { - "dependsOn": ["^release", "build"] + "dependsOn": ["^release", "build"], + "env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] + }, + // Script to run when deploying on main to preview/staging + "release:preview": { + "dependsOn": ["^release:preview", "build"], + "env": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] }, // Clean up the package "clean": {