diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 88c95c4016..d7eba29317 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -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[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'); diff --git a/packages/gitbook-v2/src/lib/data/memoize.ts b/packages/gitbook-v2/src/lib/data/memoize.ts index 5110d015fa..5b37d34989 100644 --- a/packages/gitbook-v2/src/lib/data/memoize.ts +++ b/packages/gitbook-v2/src/lib/data/memoize.ts @@ -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( +export function withoutConcurrentRequestExecution( wrapped: (key: string, ...args: ArgsType) => Promise ): (cacheKey: string, ...args: ArgsType) => Promise { const globalPromiseCache = new WeakMap>>(); @@ -44,6 +48,45 @@ export function withoutConcurrentExecution( }; } +/** + * + * 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( + cacheKey: string, + wrapped: () => Promise +): Promise { + // 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(); + + 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( wrapped: (cacheKey: string, ...args: ArgsType) => Promise ): (...args: ArgsType) => Promise { 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(): Map> { + // biome-ignore lint/suspicious/noExplicitAny: + const globalMapPromiseCache = (globalThis as any)[globalMapSymbol] as + | Map> + | undefined; + if (!globalMapPromiseCache) { + const newCache = new Map>(); + (globalThis as any)[globalMapSymbol] = newCache; + return newCache; + } + return globalMapPromiseCache; +}