Compare commits

...

2 Commits

Author SHA1 Message Date
Nicolas Dorseuil f5cec41875 fix rebase 2025-06-12 12:47:21 +02:00
Nicolas Dorseuil 26d7906361 prevent concurrent execution of api call in the same process in vercel 2025-06-12 12:46:13 +02:00
2 changed files with 313 additions and 195 deletions
+240 -190
View File
@@ -9,7 +9,11 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { DataFetcherError, wrapDataFetcherError } from './errors';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
import {
withCacheKey,
withoutConcurrentProcessExecution,
withoutConcurrentRequestExecution,
} from './memoize';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
@@ -205,26 +209,30 @@ export function createDataFetcher(
}
const getUserById = withCacheKey(
withoutConcurrentExecution(async (_, input: DataFetcherInput, params: { userId: string }) => {
'use cache';
return trace(`getUserById(${params.userId})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.users.getUserById(params.userId, {
...noCacheFetchOptions,
withoutConcurrentRequestExecution(
async (cacheKey, input: DataFetcherInput, params: { userId: string }) => {
'use cache';
return trace(`getUserById(${params.userId})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.users.getUserById(params.userId, {
...noCacheFetchOptions,
})
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
});
})
}
)
);
const getSpace = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; shareKey: string | undefined }
) => {
@@ -239,14 +247,16 @@ const getSpace = withCacheKey(
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getSpaceById(
params.spaceId,
{
shareKey: params.shareKey,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getSpaceById(
params.spaceId,
{
shareKey: params.shareKey,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
@@ -258,9 +268,9 @@ const getSpace = withCacheKey(
);
const getChangeRequest = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; changeRequestId: string }
) => {
@@ -278,12 +288,14 @@ const getChangeRequest = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId,
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId,
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('minutes');
@@ -296,9 +308,9 @@ const getChangeRequest = withCacheKey(
);
const getRevision = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean }
) => {
@@ -306,15 +318,17 @@ const getRevision = withCacheKey(
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -326,9 +340,9 @@ const getRevision = withCacheKey(
);
const getRevisionPages = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean }
) => {
@@ -336,15 +350,17 @@ const getRevisionPages = withCacheKey(
return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -356,9 +372,9 @@ const getRevisionPages = withCacheKey(
);
const getRevisionFile = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; fileId: string }
) => {
@@ -368,14 +384,16 @@ const getRevisionFile = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -388,9 +406,9 @@ const getRevisionFile = withCacheKey(
);
const getRevisionPageMarkdown = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
@@ -400,16 +418,18 @@ const getRevisionPageMarkdown = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
@@ -427,9 +447,9 @@ const getRevisionPageMarkdown = withCacheKey(
);
const getRevisionPageDocument = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
@@ -439,16 +459,18 @@ const getRevisionPageDocument = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getPageDocumentInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
evaluated: true,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getPageDocumentInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
evaluated: true,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
@@ -463,9 +485,9 @@ const getRevisionPageDocument = withCacheKey(
);
const getRevisionPageByPath = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; path: string }
) => {
@@ -476,14 +498,16 @@ const getRevisionPageByPath = withCacheKey(
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -496,19 +520,25 @@ const getRevisionPageByPath = withCacheKey(
);
const getDocument = withCacheKey(
withoutConcurrentExecution(
async (_, input: DataFetcherInput, params: { spaceId: string; documentId: string }) => {
withoutConcurrentRequestExecution(
async (
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; documentId: string }
) => {
'use cache';
return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getDocumentById(
params.spaceId,
params.documentId,
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getDocumentById(
params.spaceId,
params.documentId,
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -520,9 +550,9 @@ const getDocument = withCacheKey(
);
const getComputedDocument = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: {
spaceId: string;
@@ -547,16 +577,18 @@ const getComputedDocument = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getComputedDocument(
params.spaceId,
{
source: params.source,
seed: params.seed,
},
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getComputedDocument(
params.spaceId,
{
source: params.source,
seed: params.seed,
},
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -569,9 +601,9 @@ const getComputedDocument = withCacheKey(
);
const getReusableContent = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; reusableContentId: string }
) => {
@@ -581,14 +613,16 @@ const getReusableContent = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId,
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId,
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -601,8 +635,12 @@ const getReusableContent = withCacheKey(
);
const getLatestOpenAPISpecVersionContent = withCacheKey(
withoutConcurrentExecution(
async (_, input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
withoutConcurrentRequestExecution(
async (
cacheKey,
input: DataFetcherInput,
params: { organizationId: string; slug: string }
) => {
'use cache';
cacheTag(
getCacheTag({
@@ -617,12 +655,14 @@ const getLatestOpenAPISpecVersionContent = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug,
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug,
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
@@ -635,9 +675,9 @@ const getLatestOpenAPISpecVersionContent = withCacheKey(
);
const getPublishedContentSite = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { organizationId: string; siteId: string; siteShareKey: string | undefined }
) => {
@@ -654,15 +694,17 @@ const getPublishedContentSite = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
@@ -675,9 +717,9 @@ const getPublishedContentSite = withCacheKey(
);
const getSiteRedirectBySource = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: {
organizationId: string;
@@ -699,16 +741,18 @@ const getSiteRedirectBySource = withCacheKey(
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
@@ -721,8 +765,8 @@ const getSiteRedirectBySource = withCacheKey(
);
const getEmbedByUrl = withCacheKey(
withoutConcurrentExecution(
async (_, input: DataFetcherInput, params: { spaceId: string; url: string }) => {
withoutConcurrentRequestExecution(
async (cacheKey, input: DataFetcherInput, params: { spaceId: string; url: string }) => {
'use cache';
cacheTag(
getCacheTag({
@@ -734,14 +778,16 @@ const getEmbedByUrl = withCacheKey(
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(
params.spaceId,
{
url: params.url,
},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.spaces.getEmbedByUrlInSpace(
params.spaceId,
{
url: params.url,
},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('weeks');
@@ -753,9 +799,9 @@ const getEmbedByUrl = withCacheKey(
);
const searchSiteContent = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => {
@@ -773,17 +819,19 @@ const searchSiteContent = withCacheKey(
return wrapDataFetcherError(async () => {
const { organizationId, siteId, query, scope } = params;
const api = apiClient(input);
const res = await api.orgs.searchSiteContent(
organizationId,
siteId,
{
query,
...scope,
},
{},
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.orgs.searchSiteContent(
organizationId,
siteId,
{
query,
...scope,
},
{},
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('hours');
@@ -796,9 +844,9 @@ const searchSiteContent = withCacheKey(
);
const renderIntegrationUi = withCacheKey(
withoutConcurrentExecution(
withoutConcurrentRequestExecution(
async (
_,
cacheKey,
input: DataFetcherInput,
params: { integrationName: string; request: RenderIntegrationUI }
) => {
@@ -813,12 +861,14 @@ const renderIntegrationUi = withCacheKey(
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request,
{
...noCacheFetchOptions,
}
const res = await withoutConcurrentProcessExecution(cacheKey, async () =>
api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request,
{
...noCacheFetchOptions,
}
)
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
+73 -5
View File
@@ -1,14 +1,18 @@
import { cache } from 'react';
import { getCloudflareContext } from './cloudflare';
// This is used to create a context specific to the current request.
// This version works both in cloudflare and in vercel.
const getRequestContext = cache(() => ({}));
const globalMapSymbol = Symbol('globalPromiseCache');
/**
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
* With a logic to work per-request.
* This is the one to use if you're using `use cache` in your function or any other caching mechanism (i.e. cacheTag) from Next.js.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
export function withoutConcurrentRequestExecution<ArgsType extends any[], ReturnType>(
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
@@ -44,6 +48,45 @@ export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
};
}
/**
*
* This function should not be used for a function that uses `use cache`.
* It should only be used for functions inside a `use cache` and should under no circumstances contain any side effects or `cacheTag`
*
* @param cacheKey The cache key to use for the concurrent execution.
* @param wrapped The function to wrap. It should return a Promise and have no arguments.
* @returns A Promise that resolves to the result of the wrapped function.
*/
export function withoutConcurrentProcessExecution<ReturnType>(
cacheKey: string,
wrapped: () => Promise<ReturnType>
): Promise<ReturnType> {
// If we are in cloudflare workers, we just want to execute the function to avoid concurrent I/O errors.
const cfContext = getCloudflareContext();
if (cfContext) {
return wrapped();
}
const globalMapPromiseCache = getGlobalPromiseCache<ReturnType>();
const concurrent = globalMapPromiseCache.get(cacheKey);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped();
return result;
} finally {
globalMapPromiseCache.delete(cacheKey);
}
})();
globalMapPromiseCache.set(cacheKey, promise);
return promise;
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
@@ -51,16 +94,23 @@ export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
const cacheKey = getCacheKey(args, wrapped.name);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
* We need to use the function name and the arguments to ensure that
* the cache key is unique for each function and its arguments.
* This is useful to avoid cache collisions when using the same arguments
* for different functions.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
function getCacheKey(args: any[], name: string) {
return JSON.stringify({
args: deepSortValue(args),
name,
});
}
function deepSortValue(value: unknown): unknown {
@@ -90,3 +140,21 @@ function deepSortValue(value: unknown): unknown {
return value;
}
/**
*
* @returns A global cache that is shared across all requests.
* This cache is used to store promises that are being executed concurrently.
*/
export function getGlobalPromiseCache<ReturnType>(): Map<string, Promise<ReturnType>> {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const globalMapPromiseCache = (globalThis as any)[globalMapSymbol] as
| Map<string, Promise<ReturnType>>
| undefined;
if (!globalMapPromiseCache) {
const newCache = new Map<string, Promise<ReturnType>>();
(globalThis as any)[globalMapSymbol] = newCache;
return newCache;
}
return globalMapPromiseCache;
}