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 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_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-openapi": "workspace:*",
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/cache-do": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1", "@sentry/nextjs": "^7.94.1",
@@ -29,7 +29,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
keys: result.keys,
stats: result.stats, stats: result.stats,
}); });
} }
+205 -178
View File
@@ -72,12 +72,10 @@ export interface ContentTarget {
const immutableCacheTtl_7days = { const immutableCacheTtl_7days = {
revalidateBefore: 24 * 60 * 60, revalidateBefore: 24 * 60 * 60,
ttl: 7 * 24 * 60 * 60, ttl: 7 * 24 * 60 * 60,
tags: [],
}; };
const immutableCacheTtl_1day = { const immutableCacheTtl_1day = {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
ttl: 24 * 60 * 60, ttl: 24 * 60 * 60,
tags: [],
}; };
const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>(); const apiSyncStorage = new AsyncLocalStorage<GitBookAPI>();
@@ -143,9 +141,14 @@ export type PublishedContentWithCache =
/** /**
* Get a user by its ID. * Get a user by its ID.
*/ */
export const getUserById = cache( export const getUserById = cache({
'api.getUserById', name: 'api.getUserById',
async (userId: string, options: CacheFunctionOptions) => { tag: (userId) =>
getAPICacheTag({
tag: 'user',
user: userId,
}),
get: async (userId: string, options: CacheFunctionOptions) => {
try { try {
const response = await api().users.getUserById(userId, { const response = await api().users.getUserById(userId, {
signal: options.signal, signal: options.signal,
@@ -153,28 +156,31 @@ export const getUserById = cache(
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [],
}); });
} catch (error) { } catch (error) {
if ((error as GitBookAPIError).code === 404) { if ((error as GitBookAPIError).code === 404) {
return { return {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
data: null, data: null,
tags: [],
}; };
} }
throw error; throw error;
} }
}, },
); });
/** /**
* Get a synced block by its ref. * Get a synced block by its ref.
*/ */
export const getSyncedBlockContent = cache( export const getSyncedBlockContent = cache({
'api.getSyncedBlockContent', name: 'api.getSyncedBlockContent',
async ( tag: (apiToken, organizationId, syncedBlockId) =>
getAPICacheTag({
tag: 'synced-block',
syncedBlock: syncedBlockId,
}),
get: async (
apiToken: string, apiToken: string,
organizationId: string, organizationId: string,
syncedBlockId: string, syncedBlockId: string,
@@ -191,38 +197,36 @@ export const getSyncedBlockContent = cache(
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'synced-block',
syncedBlock: syncedBlockId,
}),
],
}); });
} catch (error) { } catch (error) {
if ((error as GitBookAPIError).code === 404) { if ((error as GitBookAPIError).code === 404) {
return { return {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
data: null, data: null,
tags: [],
}; };
} }
throw error; throw error;
} }
}, },
{ // We don't cache apiToken as it's not a stable key
// We don't cache apiToken as it's not a stable key getKeyArgs: (args) => [args[1], args[2]],
extractArgs: (args) => [args[1], args[2]], });
},
);
/** /**
* Resolve a URL to the content to render. * Resolve a URL to the content to render.
*/ */
export const getPublishedContentByUrl = cache( export const getPublishedContentByUrl = cache({
'api.getPublishedContentByUrl.v3', name: 'api.getPublishedContentByUrl.v3',
async (url: string, visitorAuthToken: string | undefined, options: CacheFunctionOptions) => { tag: (url) =>
const parsedURL = new URL(url); getAPICacheTag({
tag: 'url',
hostname: new URL(url).hostname,
}),
get: async (
url: string,
visitorAuthToken: string | undefined,
options: CacheFunctionOptions,
) => {
try { try {
const response = await api().urls.getPublishedContentByUrl( const response = await api().urls.getPublishedContentByUrl(
{ {
@@ -243,12 +247,6 @@ export const getPublishedContentByUrl = cache(
cacheTags: parsed.tags, cacheTags: parsed.tags,
}; };
return { return {
tags: [
getAPICacheTag({
tag: 'url',
hostname: parsedURL.hostname,
}),
],
ttl: parsed.ttl, ttl: parsed.ttl,
data, 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 // 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 // and to avoid caching too many entries when being spammed by botss
ttl: 60 * 10, ttl: 60 * 10,
tags: [
getAPICacheTag({
tag: 'url',
hostname: parsedURL.hostname,
}),
],
}; };
} }
throw error; throw error;
} }
}, },
); });
/** /**
* Get a space by its ID. * Get a space by its ID.
*/ */
export const getSpace = cache( export const getSpace = cache({
'api.getSpace', name: 'api.getSpace',
async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => {
const response = await api().spaces.getSpaceById( const response = await api().spaces.getSpaceById(
spaceId, spaceId,
{ {
@@ -296,47 +289,47 @@ export const getSpace = cache(
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
}); });
}, },
); });
/** /**
* Get a change request by its ID. * Get a change request by its ID.
*/ */
export const getChangeRequest = cache( export const getChangeRequest = cache({
'api.getChangeRequest', name: 'api.getChangeRequest',
async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { 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, { const response = await api().spaces.getChangeRequestById(spaceId, changeRequestId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response, {
// We don't cache for long as we currently don't invalidate change-request cache // 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, ttl: 60 * 60,
revalidateBefore: 10 * 60, revalidateBefore: 10 * 60,
tags: [],
}); });
}, },
); });
/** /**
* List the scripts to load for the space. * List the scripts to load for the space.
*/ */
export const getSpaceIntegrationScripts = cache( export const getSpaceIntegrationScripts = cache({
'api.getSpaceIntegrationScripts', name: 'api.getSpaceIntegrationScripts',
async (spaceId: string, options: CacheFunctionOptions) => { tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, options: CacheFunctionOptions) => {
const response = await api().spaces.listSpaceIntegrationScripts(spaceId, { const response = await api().spaces.listSpaceIntegrationScripts(spaceId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
}); });
}, },
); });
interface GetRevisionOptions { interface GetRevisionOptions {
/** /**
@@ -351,9 +344,11 @@ interface GetRevisionOptions {
/** /**
* Get a revision by its ID. * Get a revision by its ID.
*/ */
export const getRevision = cache( export const getRevision = cache({
'api.getRevision.v2', name: 'api.getRevision.v2',
async ( tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (
spaceId: string, spaceId: string,
revisionId: string, revisionId: string,
fetchOptions: GetRevisionOptions, fetchOptions: GetRevisionOptions,
@@ -376,17 +371,17 @@ export const getRevision = cache(
fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day, fetchOptions.metadata ? immutableCacheTtl_7days : immutableCacheTtl_1day,
); );
}, },
{ getKeyArgs: (args) => [args[0], args[1]],
extractArgs: (args) => [args[0], args[1]], });
},
);
/** /**
* Get all the pages in a revision of a space. * Get all the pages in a revision of a space.
*/ */
export const getRevisionPages = cache( export const getRevisionPages = cache({
'api.getRevisionPages.v4', name: 'api.getRevisionPages.v4',
async ( tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (
spaceId: string, spaceId: string,
revisionId: string, revisionId: string,
fetchOptions: GetRevisionOptions, fetchOptions: GetRevisionOptions,
@@ -409,17 +404,17 @@ export const getRevisionPages = cache(
data: response.data.pages, data: response.data.pages,
}); });
}, },
{ getKeyArgs: (args) => [args[0], args[1]],
extractArgs: (args) => [args[0], args[1]], });
},
);
/** /**
* Get a revision page by its path * Get a revision page by its path
*/ */
export const getRevisionPageByPath = cache( export const getRevisionPageByPath = cache({
'api.getRevisionPageByPath.v3', name: 'api.getRevisionPageByPath.v3',
async ( tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (
spaceId: string, spaceId: string,
revisionId: string, revisionId: string,
pagePath: string, pagePath: string,
@@ -453,15 +448,22 @@ export const getRevisionPageByPath = cache(
throw error; throw error;
} }
}, },
); });
/** /**
* Resolve a file by its ID. * Resolve a file by its ID.
* It should not be used directly, use `getRevisionFile` instead. * It should not be used directly, use `getRevisionFile` instead.
*/ */
const getRevisionFileById = cache( const getRevisionFileById = cache({
'api.getRevisionFile.v3', name: 'api.getRevisionFile.v3',
async (spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions) => { tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (
spaceId: string,
revisionId: string,
fileId: string,
options: CacheFunctionOptions,
) => {
try { try {
const response = await (async () => { const response = await (async () => {
return api().spaces.getFileInRevisionById( return api().spaces.getFileInRevisionById(
@@ -487,15 +489,17 @@ const getRevisionFileById = cache(
throw error; throw error;
} }
}, },
); });
/** /**
* Get all the files in a revision of a space. * Get all the files in a revision of a space.
* It should not be used directly, use `getRevisionFile` instead. * It should not be used directly, use `getRevisionFile` instead.
*/ */
const getRevisionAllFiles = cache( const getRevisionAllFiles = cache({
'api.getRevisionAllFiles.v2', name: 'api.getRevisionAllFiles.v2',
async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { tag: (spaceId, revisionId) =>
getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }),
get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => {
const response = await getAll( const response = await getAll(
(params) => (params) =>
api().spaces.listFilesInRevisionById( api().spaces.listFilesInRevisionById(
@@ -522,10 +526,8 @@ const getRevisionAllFiles = cache(
return cacheResponse(response, { ...immutableCacheTtl_7days, data: files }); return cacheResponse(response, { ...immutableCacheTtl_7days, data: files });
}, },
{ timeout: 60 * 1000,
timeout: 60 * 1000, });
},
);
/** /**
* Resolve a file by its ID. * 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. * Get a document by its ID.
*/ */
export const getDocument = cache( export const getDocument = cache({
'api.getDocument.v2', name: 'api.getDocument.v2',
async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { tag: (spaceId, documentId) =>
getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }),
get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => {
const response = await api().spaces.getDocumentById( const response = await api().spaces.getDocumentById(
spaceId, spaceId,
documentId, documentId,
@@ -599,20 +603,19 @@ export const getDocument = cache(
); );
return cacheResponse(response, immutableCacheTtl_7days); return cacheResponse(response, immutableCacheTtl_7days);
}, },
{ // Temporarily allow for a longer timeout than the default 10s
// Temporarily allow for a longer timeout than the default 10s // because GitBook's API currently re-normalizes all documents
// because GitBook's API currently re-normalizes all documents // and it can take more than 10s...
// and it can take more than 10s... timeout: 20 * 1000,
timeout: 20 * 1000, });
},
);
/** /**
* Get the customization settings for a site-space from the API. * Get the customization settings for a site-space from the API.
*/ */
const getSiteSpaceCustomizationFromAPI = cache( const getSiteSpaceCustomizationFromAPI = cache({
'api.getSiteSpaceCustomizationById', name: 'api.getSiteSpaceCustomizationById',
async ( tag: (organizationId, siteId, siteSpaceId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
organizationId: string, organizationId: string,
siteId: string, siteId: string,
siteSpaceId: string, siteSpaceId: string,
@@ -630,22 +633,17 @@ const getSiteSpaceCustomizationFromAPI = cache(
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
}); });
}, },
); });
/** /**
* Get the customization settings for a site from the API. * Get the customization settings for a site from the API.
*/ */
const getSiteCustomizationFromAPI = cache( const getSiteCustomizationFromAPI = cache({
'api.getSiteCustomizationById', name: 'api.getSiteCustomizationById',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteCustomizationById( const response = await api().orgs.getSiteCustomizationById(
organizationId, organizationId,
siteId, siteId,
@@ -657,15 +655,9 @@ const getSiteCustomizationFromAPI = cache(
); );
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [
getAPICacheTag({
tag: 'site',
site: siteId,
}),
],
}); });
}, },
); });
/** /**
* Get the customization settings for a site space from the API. * 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. * Get the infos about a site by its ID.
*/ */
export const getSite = cache( export const getSite = cache({
'api.getSite', name: 'api.getSite',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.getSiteById(organizationId, siteId, { const response = await api().orgs.getSiteById(organizationId, siteId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
}); });
}, },
); });
/** /**
* List all the site-spaces variants published in a site. * List all the site-spaces variants published in a site.
*/ */
export const getSiteSpaces = cache( export const getSiteSpaces = cache({
'api.getSiteSpaces', name: 'api.getSiteSpaces',
async ( tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
args: { args: {
organizationId: string; organizationId: string;
siteId: string; siteId: string;
@@ -744,27 +737,26 @@ export const getSiteSpaces = cache(
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
data: response.data.items.map((siteSpace) => siteSpace), data: response.data.items.map((siteSpace) => siteSpace),
tags: [getAPICacheTag({ tag: 'site', site: args.siteId })],
}); });
}, },
); });
/** /**
* List the scripts to load for the site. * List the scripts to load for the site.
*/ */
export const getSiteIntegrationScripts = cache( export const getSiteIntegrationScripts = cache({
'api.getSiteIntegrationScripts', name: 'api.getSiteIntegrationScripts',
async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => {
const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, { const response = await api().orgs.listSiteIntegrationScripts(organizationId, siteId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'site', site: siteId })],
}); });
}, },
); });
/** /**
* Fetch all the data to render the current site at once. * 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. * Get the customization settings for a space from the API.
*/ */
export const getSpaceCustomizationFromAPI = cache( export const getSpaceCustomizationFromAPI = cache({
'api.getSpaceCustomization', name: 'api.getSpaceCustomization',
async (spaceId: string, options: CacheFunctionOptions) => { tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, options: CacheFunctionOptions) => {
const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, { const response = await api().spaces.getSpacePublishingCustomizationById(spaceId, {
signal: options.signal, signal: options.signal,
...noCacheFetchOptions, ...noCacheFetchOptions,
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'space', space: spaceId })],
}); });
}, },
); });
/** /**
* Get the customization settings for a space from the API. * 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. * Get the infos about a collection by its ID.
*/ */
export const getCollection = cache( export const getCollection = cache({
'api.getCollection', name: 'api.getCollection',
async (collectionId: string, options: CacheFunctionOptions) => { tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
get: async (collectionId: string, options: CacheFunctionOptions) => {
const response = await api().collections.getCollectionById(collectionId, { const response = await api().collections.getCollectionById(collectionId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response, {
revalidateBefore: 60 * 60, revalidateBefore: 60 * 60,
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
}); });
}, },
); });
/** /**
* List all the spaces variants published in a collection. * List all the spaces variants published in a collection.
*/ */
export const getCollectionSpaces = cache( export const getCollectionSpaces = cache({
'api.getCollectionSpaces', name: 'api.getCollectionSpaces',
async (collectionId: string, options: CacheFunctionOptions) => { tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }),
get: async (collectionId: string, options: CacheFunctionOptions) => {
const response = await getAll((params) => const response = await getAll((params) =>
api().collections.listSpacesInCollectionById(collectionId, params, { api().collections.listSpacesInCollectionById(collectionId, params, {
...noCacheFetchOptions, ...noCacheFetchOptions,
@@ -908,10 +901,9 @@ export const getCollectionSpaces = cache(
data: response.data.items.filter( data: response.data.items.filter(
(space) => space.visibility === ContentVisibility.InCollection, (space) => space.visibility === ContentVisibility.InCollection,
), ),
tags: [getAPICacheTag({ tag: 'collection', collection: collectionId })],
}); });
}, },
); });
/** /**
* Fetch all the data to render a space at once. * 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. * Search content in a space.
*/ */
export const searchSpaceContent = cache( export const searchSpaceContent = cache({
'api.searchSpaceContent', name: 'api.searchSpaceContent',
async ( tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (
spaceId: string, spaceId: string,
/** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */ /** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */
revisionId: string, revisionId: string,
@@ -996,18 +989,17 @@ export const searchSpaceContent = cache(
signal: options.signal, signal: options.signal,
}, },
); );
return cacheResponse(response, { return cacheResponse(response);
tags: [],
});
}, },
); });
/** /**
* Search content accross all spaces in a parent (site or collection). * Search content accross all spaces in a parent (site or collection).
*/ */
export const searchParentContent = cache( export const searchParentContent = cache({
'api.searchParentContent', name: 'api.searchParentContent',
async (parentId: string, query: string, options: CacheFunctionOptions) => { tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (parentId: string, query: string, options: CacheFunctionOptions) => {
const response = await api().search.searchContent( const response = await api().search.searchContent(
{ query }, { query },
{ {
@@ -1017,17 +1009,17 @@ export const searchParentContent = cache(
); );
return cacheResponse(response, { return cacheResponse(response, {
ttl: 60 * 60, ttl: 60 * 60,
tags: [],
}); });
}, },
); });
/** /**
* Search content in a Site or specific SiteSpaces. * Search content in a Site or specific SiteSpaces.
*/ */
export const searchSiteContent = cache( export const searchSiteContent = cache({
'api.searchSiteContent', name: 'api.searchSiteContent',
async ( tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
organizationId: string, organizationId: string,
siteId: string, siteId: string,
query: string, query: string,
@@ -1052,33 +1044,32 @@ export const searchSiteContent = cache(
return cacheResponse(response, { return cacheResponse(response, {
ttl: 60 * 60, ttl: 60 * 60,
tags: [],
}); });
}, },
); });
/** /**
* Get a list of recommended questions in a space. * Get a list of recommended questions in a space.
*/ */
export const getRecommendedQuestionsInSpace = cache( export const getRecommendedQuestionsInSpace = cache({
'api.getRecommendedQuestionsInSpace', name: 'api.getRecommendedQuestionsInSpace',
async (spaceId: string, options: CacheFunctionOptions) => { tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }),
get: async (spaceId: string, options: CacheFunctionOptions) => {
const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, { const response = await api().spaces.getRecommendedQuestionsInSpace(spaceId, {
...noCacheFetchOptions, ...noCacheFetchOptions,
signal: options.signal, signal: options.signal,
}); });
return cacheResponse(response, { return cacheResponse(response);
tags: [],
});
}, },
); });
/** /**
* Render an integration contentkit UI * Render an integration contentkit UI
*/ */
export const renderIntegrationUi = cache( export const renderIntegrationUi = cache({
'api.renderIntegrationUi', name: 'api.renderIntegrationUi',
async ( tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }),
get: async (
integrationName: string, integrationName: string,
request: RequestRenderIntegrationUI, request: RequestRenderIntegrationUI,
options: CacheFunctionOptions, options: CacheFunctionOptions,
@@ -1091,21 +1082,47 @@ export const renderIntegrationUi = cache(
signal: options.signal, signal: options.signal,
}, },
); );
return cacheResponse(response, { return cacheResponse(response);
tags: [],
});
}, },
); });
/** /**
* Create a cache tag for the API. * Create a cache tag for the API.
*/ */
export function getAPICacheTag( 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'; tag: 'space';
space: string; 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 // All data related to the URL of a content
| { | {
tag: 'url'; tag: 'url';
@@ -1128,16 +1145,26 @@ export function getAPICacheTag(
}, },
): string { ): string {
switch (spec.tag) { switch (spec.tag) {
case 'user':
return `user:${spec.user}`;
case 'url': case 'url':
return `url:${spec.hostname}`; return `url:${spec.hostname}`;
case 'space': case 'space':
return `space:${spec.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': case 'collection':
return `collection:${spec.collection}`; return `collection:${spec.collection}`;
case 'synced-block': case 'synced-block':
return `synced-block:${spec.syncedBlock}`; return `synced-block:${spec.syncedBlock}`;
case 'site': case 'site':
return `site:${spec.site}`; return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
default: default:
assertNever(spec); assertNever(spec);
} }
+2 -4
View File
@@ -1,18 +1,16 @@
import { cloudflareCache } from './cloudflare-cache'; import { cloudflareCache } from './cloudflare-cache';
import { cloudflareDOCache } from './cloudflare-do';
import { cloudflareKVCache } from './cloudflare-kv'; import { cloudflareKVCache } from './cloudflare-kv';
import { memoryCache } from './memory'; import { memoryCache } from './memory';
import { redisCache } from './redis';
export const cacheBackends = [ export const cacheBackends = [
// Cache local to the process // Cache local to the process
// (can't be globally purged or shared between processes) // (can't be globally purged or shared between processes)
memoryCache, 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 // Cache local to the datacenter
// It can't be purged globally but it's faster // It can't be purged globally but it's faster
cloudflareCache, cloudflareCache,
// Cache global, but with slow replication // Cache global, but with slow replication
cloudflareKVCache, cloudflareKVCache,
cloudflareDOCache,
]; ];
+10 -6
View File
@@ -15,12 +15,16 @@ describe('cache', () => {
testId += 1; testId += 1;
getTtl = () => 1000; getTtl = () => 1000;
fn = cache(`cache-${testId}`, async (arg: string, options: CacheFunctionOptions) => { fn = cache({
await new Promise((resolve) => setTimeout(resolve, 20)); name: `cache-${testId}`,
return { tag: (arg) => 'test',
data: impl(arg), get: async (arg: string, options: CacheFunctionOptions) => {
ttl: getTtl(), 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). * Time before ttl where the cache should be revalidated (in seconds).
*/ */
revalidateBefore?: number; revalidateBefore?: number;
}
/** export interface CacheDefinition<Args extends any[], Result> {
* Tags to associate with the cache entry. /** Unique name for the cache */
*/ name: string;
tags?: 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. * We don't use the next.js cache because it has a 2MB limit.
*/ */
export function cache<Args extends any[], Result>( export function cache<Args extends any[], Result>(
cacheName: string, cacheDef: CacheDefinition<Args, Result>,
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;
} = {},
): CacheFunction<Args, Result> { ): CacheFunction<Args, Result> {
// We stop everything after 10s to avoid pending requests // 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( const revalidate = singletonMap(
async (key: string, signal: AbortSignal | undefined, ...args: Args) => { async (key: string, signal: AbortSignal | undefined, ...args: Args) => {
@@ -80,19 +85,18 @@ export function cache<Args extends any[], Result>(
}, },
async (span) => { async (span) => {
// Fetch upstream // Fetch upstream
const result = await fn(...args, { signal }); const result = await cacheDef.get(...args, { signal });
signal?.throwIfAborted(); signal?.throwIfAborted();
const setAt = Date.now(); const setAt = Date.now();
const expiresAt = const expiresAt = setAt + (result.ttl ?? defaultTtl) * 1000;
setAt + (result.ttl ?? options.defaultTtl ?? 60 * 60 * 24) * 1000;
const cacheEntry: CacheEntry = { const cacheEntry: CacheEntry = {
data: result.data, data: result.data,
meta: { meta: {
key, key,
cache: cacheName, cache: cacheDef.name,
tags: result.tags ?? [], tag: cacheDef.tag?.(...args),
setAt, setAt,
expiresAt, expiresAt,
revalidatesAt: result.revalidateBefore revalidatesAt: result.revalidateBefore
@@ -106,7 +110,7 @@ export function cache<Args extends any[], Result>(
// Write it to the cache // Write it to the cache
if (result.ttl && result.ttl > 0) { if (result.ttl && result.ttl > 0) {
await waitUntil(setCacheEntry(key, cacheEntry)); await waitUntil(setCacheEntry(cacheEntry));
} }
return cacheEntry; return cacheEntry;
@@ -122,9 +126,10 @@ export function cache<Args extends any[], Result>(
let fetchDuration = 0; let fetchDuration = 0;
let result: readonly [CacheEntry, string] | null = null; 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 // 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) { if (memoryEntry) {
span.setAttribute('memory', true); span.setAttribute('memory', true);
result = [memoryEntry, 'memory'] as const; result = [memoryEntry, 'memory'] as const;
@@ -132,7 +137,7 @@ export function cache<Args extends any[], Result>(
result = await race( result = await race(
cacheBackends, cacheBackends,
async (backend, { signal }) => { 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; return entry ? ([entry, backend.name] as const) : null;
}, },
{ {
@@ -184,7 +189,7 @@ export function cache<Args extends any[], Result>(
(backend) => (backend) =>
backend.name !== backendName && backend.replication === 'local', 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 cacheFn = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs); const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
const key = getCacheKey(cacheName, cacheArgs); const key = getCacheKey(cacheDef.name, cacheArgs);
return await trace( return await trace(
{ {
@@ -231,17 +236,18 @@ export function cache<Args extends any[], Result>(
cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => {
const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs); const [args, { signal }] = extractCacheFunctionOptions<Args>(rawArgs);
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
const key = getCacheKey(cacheName, cacheArgs); const key = getCacheKey(cacheDef.name, cacheArgs);
await revalidate(key, signal, ...args); await revalidate(key, signal, ...args);
}; };
cacheFn.hasInMemory = async (...args: Args) => { cacheFn.hasInMemory = async (...args: Args) => {
const cacheArgs = options.extractArgs ? options.extractArgs(args) : args; const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args;
const key = getCacheKey(cacheName, cacheArgs); 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) { if (memoryEntry) {
return true; return true;
} }
@@ -250,7 +256,7 @@ export function cache<Args extends any[], Result>(
}; };
// @ts-ignore // @ts-ignore
registeredCaches.set(cacheName, cacheFn); registeredCaches.set(cacheDef.name, cacheFn);
return cacheFn; return cacheFn;
} }
@@ -289,14 +295,14 @@ function hashValue(arg: any): string {
return JSON.stringify(arg); return JSON.stringify(arg);
} }
async function setCacheEntry(key: string, entry: CacheEntry) { async function setCacheEntry(entry: CacheEntry) {
return await trace( return await trace(
{ {
operation: `cache.setCacheEntry`, operation: `cache.setCacheEntry`,
name: key, name: entry.meta.key,
}, },
async () => { 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 = { export const cloudflareCache: CacheBackend = {
name: 'cloudflare', name: 'cloudflare',
replication: 'local', replication: 'local',
async get(key, options) { async get(entry, options) {
const cache = getCache(); const cache = getCache();
if (!cache) { if (!cache) {
return null; return null;
@@ -27,10 +27,10 @@ export const cloudflareCache: CacheBackend = {
return trace( return trace(
{ {
operation: `cloudflareCache.get`, operation: `cloudflareCache.get`,
name: key, name: entry.key,
}, },
async (span) => { async (span) => {
const cacheKey = await serializeKey(key); const cacheKey = await serializeKey(entry.key);
const response = await cache.match(cacheKey); const response = await cache.match(cacheKey);
span.setAttribute('hit', !!response); span.setAttribute('hit', !!response);
@@ -39,32 +39,32 @@ export const cloudflareCache: CacheBackend = {
return null; return null;
} }
const entry = await deserializeEntry(response); const cacheEntry = await deserializeEntry(response);
return entry; return cacheEntry;
}, },
); );
}, },
async set(key, entry) { async set(entry) {
const cache = getCache(); const cache = getCache();
if (cache) { if (cache) {
await trace( await trace(
{ {
operation: `cloudflareCache.set`, operation: `cloudflareCache.set`,
name: key, name: entry.meta.key,
}, },
async () => { async () => {
const cacheKey = await serializeKey(key); const cacheKey = await serializeKey(entry.meta.key);
await cache.put(cacheKey, serializeEntry(entry)); await cache.put(cacheKey, serializeEntry(entry));
}, },
); );
} }
}, },
async del(keys) { async del(entries) {
const cache = getCache(); const cache = getCache();
if (cache) { if (cache) {
await Promise.all( await Promise.all(
keys.map(async (key) => { entries.map(async (entry) => {
const cacheKey = await serializeKey(key); const cacheKey = await serializeKey(entry.key);
await cache.delete(cacheKey); await cache.delete(cacheKey);
}), }),
); );
@@ -72,8 +72,7 @@ export const cloudflareCache: CacheBackend = {
}, },
async revalidateTags(tags) { async revalidateTags(tags) {
return { return {
keys: [], entries: [],
metas: [],
}; };
}, },
}; };
@@ -103,7 +102,10 @@ async function serializeKey(key: string): Promise<string> {
function serializeEntry(entry: CacheEntry): WorkerResponse { function serializeEntry(entry: CacheEntry): WorkerResponse {
const headers = new Headers(); const headers = new Headers();
headers.set('Content-Type', 'application/json'); 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( const maxAge = getCacheMaxAge(
entry.meta, 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 = { export const cloudflareKVCache: CacheBackend = {
name: 'cloudflare-kv', name: 'cloudflare-kv',
replication: 'global', replication: 'global',
async get(key, options) { async get({ key }, options) {
const kv = await getKVNamespace(); const kv = await getKVNamespace();
if (!kv) { if (!kv) {
return null; return null;
@@ -42,7 +42,7 @@ export const cloudflareKVCache: CacheBackend = {
}, },
); );
}, },
async set(key, entry) { async set(entry) {
const kv = await getKVNamespace(); const kv = await getKVNamespace();
if (!kv) { if (!kv) {
return; return;
@@ -51,7 +51,7 @@ export const cloudflareKVCache: CacheBackend = {
return trace( return trace(
{ {
operation: `cloudflareKV.set`, operation: `cloudflareKV.set`,
name: key, name: entry.meta.key,
}, },
async () => { async () => {
const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24); const secondsFromNow = getCacheMaxAge(entry.meta, 0, 60 * 60 * 24);
@@ -61,54 +61,45 @@ export const cloudflareKVCache: CacheBackend = {
return; return;
} }
const kvKey = getValueKey(key); const kvKey = getValueKey(entry.meta.key);
await kv.put(kvKey, JSON.stringify(entry), { await kv.put(kvKey, JSON.stringify(entry), {
expirationTtl: secondsFromNow, expirationTtl: secondsFromNow,
}); });
if (entry.meta.tags.length > 0) { if (entry.meta.tag) {
const metadata: KVTagMetadata = { const metadata: KVTagMetadata = {
meta: entry.meta, meta: entry.meta,
}; };
const jsonMetadata = JSON.stringify(metadata); const jsonMetadata = JSON.stringify(metadata);
const tagKey = getTagKey(entry.meta.tag, entry.meta.key);
// Write a key for each tag await kv.put(tagKey, jsonMetadata, {
await Promise.all( metadata,
entry.meta.tags.map(async (tag) => { expirationTtl: secondsFromNow,
const tagKey = getTagKey(tag, key); });
await kv.put(tagKey, jsonMetadata, {
metadata,
expirationTtl: secondsFromNow,
});
}),
);
} }
}, },
); );
}, },
async del(keys) { async del(entries) {
const kv = await getKVNamespace(); const kv = await getKVNamespace();
if (!kv) { if (!kv) {
return; return;
} }
await Promise.all( await Promise.all(
keys.map(async (key) => { entries.map(async ({ key }) => {
const kvKey = getValueKey(key); const kvKey = getValueKey(key);
await kv.delete(kvKey); await kv.delete(kvKey);
}), }),
); );
}, },
async revalidateTags(tags) { async revalidateTags(tags) {
const result: { keys: string[]; metas: CacheEntryMeta[] } = { const result: CacheEntryMeta[] = [];
keys: [],
metas: [],
};
const kv = await getKVNamespace(); const kv = await getKVNamespace();
if (!kv) { if (!kv) {
return result; return { entries: result };
} }
const pendingDeletions: Array<Promise<unknown>> = []; const pendingDeletions: Array<Promise<unknown>> = [];
@@ -125,8 +116,7 @@ export const cloudflareKVCache: CacheBackend = {
const metadata = entry.metadata; const metadata = entry.metadata;
const key = metadata.meta.key; const key = metadata.meta.key;
result.metas.push(metadata.meta); result.push(metadata.meta);
result.keys.push(key);
// Delete the tag key and the value key // Delete the tag key and the value key
pendingDeletions.push(kv.delete(getValueKey(key))); pendingDeletions.push(kv.delete(getValueKey(key)));
@@ -147,7 +137,7 @@ export const cloudflareKVCache: CacheBackend = {
await Promise.all(pendingDeletions); await Promise.all(pendingDeletions);
return result; return { entries: result };
}, },
}; };
-1
View File
@@ -52,7 +52,6 @@ export function cacheResponse<Result, DefaultData = Result>(
return { return {
ttl: defaultEntry.ttl ?? parsed.ttl, ttl: defaultEntry.ttl ?? parsed.ttl,
tags: [...(defaultEntry.tags ?? []), ...parsed.tags],
revalidateBefore: defaultEntry.revalidateBefore, revalidateBefore: defaultEntry.revalidateBefore,
// @ts-ignore // @ts-ignore
data: defaultEntry.data ?? response.data, 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 { NON_IMMUTABLE_LOCAL_CACHE_MAX_AGE_SECONDS, isCacheEntryImmutable } from './utils';
import { getGlobalContext } from '../waitUntil'; import { getGlobalContext } from '../waitUntil';
export const memoryCache: CacheBackend = { export const memoryCache: CacheBackend = {
name: 'memory', name: 'memory',
replication: 'local', replication: 'local',
async get(key) { async get(entry) {
const memoryCache = await getMemoryCache(); const memoryCache = await getMemoryCache();
const memoryEntry = memoryCache.get(key); const memoryEntry = memoryCache.get(entry.key);
if (!memoryEntry) { if (!memoryEntry) {
return null; return null;
@@ -16,12 +16,12 @@ export const memoryCache: CacheBackend = {
if (memoryEntry.meta.expiresAt > Date.now()) { if (memoryEntry.meta.expiresAt > Date.now()) {
return memoryEntry; return memoryEntry;
} else { } else {
memoryCache.delete(key); memoryCache.delete(entry.key);
} }
return null; return null;
}, },
async set(key, entry) { async set(entry) {
const memoryCache = await getMemoryCache(); const memoryCache = await getMemoryCache();
// When the entry is immutable, we can cache it for the entire duration. // When the entry is immutable, we can cache it for the entire duration.
// Else we cache it for a very short time. // Else we cache it for a very short time.
@@ -39,26 +39,25 @@ export const memoryCache: CacheBackend = {
meta.expiresAt = expiresAt; 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(); const memoryCache = await getMemoryCache();
keys.forEach((key) => memoryCache.delete(key)); entries.forEach((entry) => memoryCache.delete(entry.key));
}, },
async revalidateTags(tags) { async revalidateTags(tags) {
const memoryCache = await getMemoryCache(); const memoryCache = await getMemoryCache();
const keys: string[] = []; const entries: CacheEntryLookup[] = [];
memoryCache.forEach((entry, key) => { memoryCache.forEach((entry, key) => {
if (tags.some((tag) => entry.meta.tags.includes(tag))) { if (entry.meta.tag && tags.includes(entry.meta.tag)) {
keys.push(key); entries.push({ key, tag: entry.meta.tag });
memoryCache.delete(key); memoryCache.delete(key);
} }
}); });
return { return {
keys, entries,
metas: [],
}; };
}, },
}; };
-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 { cacheBackends } from './backends';
import { getCacheKey } from './cache'; import { CacheEntryLookup } from './types';
import { CacheEntryMeta } from './types';
interface RevalidateTagsStats { interface RevalidateTagsStats {
[key: string]: { [key: string]: {
/**
* Tag associated with the key.
*/
tag?: string;
/** /**
* Backends that have the key. * Backends that have the key.
*/ */
[backend: string]: { set: boolean; setAt?: number; expiresAt?: number }; backends: Record<string, { set: boolean }>;
}; };
} }
/** /**
* Revalidate all values associated with tags. * Purge all cache entries associated with the given tags.
* It clears the values from the caches, but also start a background task to revalidate them. * TODO: Implement background revalidation.
*/ */
export async function revalidateTags(tags: string[]): Promise<{ export async function revalidateTags(tags: string[]): Promise<{
keys: string[];
stats: RevalidateTagsStats; stats: RevalidateTagsStats;
}> { }> {
if (tags.length === 0) { if (tags.length === 0) {
return { keys: [], stats: {} }; return { stats: {} };
} }
const stats: RevalidateTagsStats = {}; const stats: RevalidateTagsStats = {};
const processed = new Set<string>();
const keysByBackend = new Map<number, string[]>(); const keysByBackend = new Map<number, string[]>();
const keys = new Set<string>(); const entries = new Map<string, CacheEntryLookup>();
const metas: CacheEntryMeta[] = [];
await Promise.all( await Promise.all(
cacheBackends.map(async (backend, backendIndex) => { 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) => { entries.set(key, { tag, key });
stats[key] = stats[key] ?? {};
stats[key][backend.name] = { set: true };
keys.add(key);
keysByBackend.set(backendIndex, [...(keysByBackend.get(backendIndex) ?? []), 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 // Clear the keys on the backends that didn't return them
await Promise.all( await Promise.all(
cacheBackends.map(async (backend, backendIndex) => { cacheBackends.map(async (backend, backendIndex) => {
const unclearedKeys = Array.from(keys).filter( const unclearedEntries = Array.from(entries.values()).filter(
(key) => !keysByBackend.get(backendIndex)?.includes(key), (entry) => !keysByBackend.get(backendIndex)?.includes(entry.key),
); );
if (unclearedKeys.length > 0) { if (unclearedEntries.length > 0) {
unclearedKeys.forEach((key) => { unclearedEntries.forEach((entry) => {
stats[key][backend.name] = { set: false }; stats[entry.key].backends[backend.name] = { set: false };
}); });
await backend.del(unclearedKeys); await backend.del(unclearedEntries);
} }
}), }),
); );
return { return {
keys: Array.from(keys.keys()),
stats, stats,
}; };
} }
+8 -11
View File
@@ -1,6 +1,9 @@
export interface CacheEntryMeta { export interface CacheEntryLookup {
key: string; key: string;
tag?: string;
}
export interface CacheEntryMeta extends CacheEntryLookup {
/** /**
* Timestamp when the entry was created. * Timestamp when the entry was created.
*/ */
@@ -21,12 +24,6 @@ export interface CacheEntryMeta {
*/ */
revalidatesAt?: number; 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. * Arguments that were passed to the function.
*/ */
@@ -51,21 +48,21 @@ export interface CacheBackend {
/** /**
* Get a value from the cache. * 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 a value in the cache.
*/ */
set(key: string, entry: CacheEntry): Promise<void>; set(entry: CacheEntry): Promise<void>;
/** /**
* Delete a value from the cache. * Delete a value from the cache.
*/ */
del(keys: string[]): Promise<void>; del(keys: CacheEntryLookup[]): Promise<void>;
/** /**
* Revalidate all keys associated with tags. * Revalidate all keys associated with tags.
* It should return the meta of all entries that were revalidated. * 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. * Return true if a cache entry can be considered immutable.
*/ */
export function isCacheEntryImmutable(meta: CacheEntryMeta): boolean { 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 = { const fetcher: OpenAPIFetcher = {
fetch: cache('openapi.fetch', async (url: string, options: CacheFunctionOptions) => { fetch: cache({
// Wrap the raw string to prevent invalid URLs from being passed to fetch. name: 'openapi.fetch',
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: get: async (url: string, options: CacheFunctionOptions) => {
// https://github.com/cloudflare/workerd/issues/1957 // Wrap the raw string to prevent invalid URLs from being passed to fetch.
const response = await fetch(new URL(url), { // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
...noCacheFetchOptions, // https://github.com/cloudflare/workerd/issues/1957
signal: options.signal, const response = await fetch(new URL(url), {
}); ...noCacheFetchOptions,
signal: options.signal,
});
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
); );
} }
const text = await response.text(); const text = await response.text();
const data = await parseOpenAPIV3(url, text); const data = await parseOpenAPIV3(url, text);
return { return {
...parseCacheResponse(response), ...parseCacheResponse(response),
data, data,
}; };
},
}), }),
parseMarkdown, parseMarkdown,
}; };
+1 -1
View File
@@ -25,6 +25,6 @@
"bun-types" // add Bun global "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"] "exclude": ["node_modules", "packages/react-openapi", "packages/react-math"]
} }
+7 -1
View File
@@ -46,7 +46,13 @@
}, },
// Script run when the package is deployed // Script run when the package is deployed
"release": { "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 up the package
"clean": { "clean": {