Experimental new cache backend powered by DO (#2479)

Co-authored-by: Johan Preynat <johan.preynat@gmail.com>
This commit is contained in:
Samy Pessé
2024-09-24 12:55:49 +02:00
committed by GitHub
parent b144368e3f
commit 636b868bf1
32 changed files with 990 additions and 524 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/cache-do': minor
---
First version of a new cache backend powered by Cloudflare Durable Objects
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
+23
View File
@@ -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 }}
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
.wrangler
worker-configuration.d.ts
dist/
+22
View File
@@ -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.
+42
View File
@@ -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"
]
}
+274
View File
@@ -0,0 +1,274 @@
import { encode, decode } from '@msgpack/msgpack';
import { DurableObject } from 'cloudflare:workers';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
}
/**
* Value stored in a chunked binary msgpack format.
* Stored under the key `prop.${key}.${index}`.
*/
interface CacheObjectProp<Value = unknown> {
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<string, { match: CacheObjectProp | undefined }>(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 <Value = unknown>(key: string) => {
return this.get<Value>(key);
},
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
await this.set(key, value, expiresAt);
},
};
}
/**
* Get the value of a property.
*/
public async get<Value = unknown>(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<Value>(key);
});
}
/**
* Get the value of a property from the DO storage.
*/
public async getFromStorage<Value = unknown>(key: string) {
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(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<Value = unknown>(key: string, value: Value, expiresAt: number) {
return timeFn(`set: ${key}`, async () => {
const prop: CacheObjectProp<Value> = {
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<string>();
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
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<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
const toDeleteSet = new Set<string>();
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<T>(key: string, value: T): Record<string, Uint8Array> {
const buf = encode(value);
const entries: Record<string, Uint8Array> = {};
const chunks = chunkUint8Array(buf, 128 * 1024);
for (let index = 0; index < chunks.length; index++) {
entries[getStoragePropChunkKey(key, index)] = chunks[index];
}
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): 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<T>(message: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
console.log(`${message} (${performance.now() - start}ms)`);
}
}
+91
View File
@@ -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<CacheObject>,
/** 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<Value = unknown>(key: string) {
const desc = await this.open();
return await desc.get<Value>(key);
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(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<Value>(key, value, expiresAt);
}
/**
* Purge all keys in the cache tag.
*/
async purge() {
const keys = new Set<string>();
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}`;
}
+1
View File
@@ -0,0 +1 @@
export * from './CacheObjectStub';
+9
View File
@@ -0,0 +1,9 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
export * from './CacheObject';
export default class Worker extends WorkerEntrypoint {
fetch() {
return new Response('Hello, world!');
}
}
+21
View File
@@ -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"]
}
+17
View File
@@ -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"}
]
+7
View File
@@ -0,0 +1,7 @@
import type { CacheObject } from '@gitbook/cache-do';
declare global {
interface CloudflareEnv {
CACHE?: DurableObjectNamespace<CacheObject>;
}
}
+1
View File
@@ -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",
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
success: true,
keys: result.keys,
stats: result.stats,
});
}
+205 -178
View File
@@ -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<GitBookAPI>();
@@ -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<Customizat
/**
* Get the infos about a collection by its ID.
*/
export const getCollection = cache(
'api.getCollection',
async (collectionId: string, options: CacheFunctionOptions) => {
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);
}
+2 -4
View File
@@ -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,
];
+10 -6
View File
@@ -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(),
};
},
});
});
+43 -37
View File
@@ -43,11 +43,26 @@ export interface CacheResult<Result> {
* 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<Args extends any[], Result> {
/** 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<CacheResult<Result>>;
}
/**
@@ -55,21 +70,11 @@ export interface CacheResult<Result> {
* We don't use the next.js cache because it has a 2MB limit.
*/
export function cache<Args extends any[], Result>(
cacheName: string,
fn: (...args: [...Args, CacheFunctionOptions]) => Promise<CacheResult<Result>>,
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<Args, Result>,
): CacheFunction<Args, Result> {
// 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<Args extends any[], Result>(
},
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<Args extends any[], Result>(
// 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<Args extends any[], Result>(
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<Args extends any[], Result>(
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<Args extends any[], Result>(
(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<Args extends any[], Result>(
const cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
const [args, { signal }] = extractCacheFunctionOptions<Args>(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<Args extends any[], Result>(
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
const [args, { signal }] = extractCacheFunctionOptions<Args>(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<Args extends any[], Result>(
};
// @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)));
},
);
}
+16 -14
View File
@@ -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<string> {
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,
+101
View File
@@ -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<CacheEntry>(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<CacheEntry>(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<object, Map<string, CacheObjectStub>>();
async function getStub(tag: string): Promise<CacheObjectStub | null> {
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;
}
+16 -26
View File
@@ -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<Promise<unknown>> = [];
@@ -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 };
},
};
-1
View File
@@ -52,7 +52,6 @@ export function cacheResponse<Result, DefaultData = Result>(
return {
ttl: defaultEntry.ttl ?? parsed.ttl,
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
revalidateBefore: defaultEntry.revalidateBefore,
// @ts-ignore
data: defaultEntry.data ?? response.data,
+12 -13
View File
@@ -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,
};
},
};
-174
View File
@@ -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<CacheEntry>(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<CacheEntryMeta | null> = [];
if (keys.size > 0) {
// Read the meta
metas = (
await redis.mget<Array<CacheEntryMeta | null>>(
// 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}`;
}
+25 -37
View File
@@ -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<string, { set: boolean }>;
};
}
/**
* 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<string>();
const keysByBackend = new Map<number, string[]>();
const keys = new Set<string>();
const metas: CacheEntryMeta[] = [];
const entries = new Map<string, CacheEntryLookup>();
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,
};
}
+8 -11
View File
@@ -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<CacheEntry | null>;
get(entry: CacheEntryLookup, options?: { signal?: AbortSignal }): Promise<CacheEntry | null>;
/**
* Set a value in the cache.
*/
set(key: string, entry: CacheEntry): Promise<void>;
set(entry: CacheEntry): Promise<void>;
/**
* Delete a value from the cache.
*/
del(keys: string[]): Promise<void>;
del(keys: CacheEntryLookup[]): Promise<void>;
/**
* 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<CacheEntryLookup | CacheEntryMeta> }>;
}
+1 -1
View File
@@ -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;
}
+22 -19
View File
@@ -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,
};
+1 -1
View File
@@ -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"]
}
+7 -1
View File
@@ -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": {