diff --git a/services/web/app/src/Features/Compile/ClsiCacheController.js b/services/web/app/src/Features/Compile/ClsiCacheController.js index 42f037985d..d76f0a02bd 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheController.js +++ b/services/web/app/src/Features/Compile/ClsiCacheController.js @@ -1,8 +1,7 @@ -const { NotFoundError } = require('../Errors/Errors') +const { NotFoundError, ResourceGoneError } = require('../Errors/Errors') const { fetchStreamWithResponse, RequestFailedError, - fetchJson, } = require('@overleaf/fetch-utils') const Path = require('path') const { pipeline } = require('stream/promises') @@ -110,61 +109,14 @@ async function getLatestBuildFromCache(req, res) { const userId = CompileController._getUserIdForCompile(req) try { const { - internal: { location: metaLocation }, - external: { isUpToDate, allFiles, zone, shard }, - } = await ClsiCacheManager.getLatestBuildFromCache( - projectId, - userId, - 'output.overleaf.json' - ) + zone, + outputFiles, + compileGroup, + clsiServerId, + clsiCacheShard, + options, + } = await ClsiCacheManager.getLatestCompileResult(projectId, userId) - if (!isUpToDate) return res.sendStatus(410) - - const meta = await fetchJson(metaLocation, { - signal: AbortSignal.timeout(5 * 1000), - }) - - const [, editorId, buildId] = metaLocation.match( - /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\// - ) - - let baseURL = `/project/${projectId}` - if (userId) { - baseURL += `/user/${userId}` - } - - const { ranges, contentId, clsiServerId, compileGroup, size, options } = - meta - - const outputFiles = allFiles - .filter( - path => path !== 'output.overleaf.json' && path !== 'output.tar.gz' - ) - .map(path => { - const f = { - url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`, - downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`, - build: buildId, - path, - type: path.split('.').pop(), - } - if (path === 'output.pdf') { - Object.assign(f, { - size, - editorId, - }) - if (clsiServerId !== shard) { - // Enable PDF caching and attempt to download from VM first. - // (clsi VMs do not have the editorId in the path on disk, omit it). - Object.assign(f, { - url: `${baseURL}/build/${buildId}/output/output.pdf`, - ranges, - contentId, - }) - } - } - return f - }) let { pdfCachingMinChunkSize, pdfDownloadDomain } = await CompileController._getSplitTestOptions(req, res) pdfDownloadDomain += `/zone/${zone}` @@ -174,7 +126,7 @@ async function getLatestBuildFromCache(req, res) { outputFiles, compileGroup, clsiServerId, - clsiCacheShard: shard, + clsiCacheShard, pdfDownloadDomain, pdfCachingMinChunkSize, options, @@ -182,6 +134,8 @@ async function getLatestBuildFromCache(req, res) { } catch (err) { if (err instanceof NotFoundError) { res.sendStatus(404) + } else if (err instanceof ResourceGoneError) { + res.sendStatus(410) } else { throw err } diff --git a/services/web/app/src/Features/Compile/ClsiCacheManager.js b/services/web/app/src/Features/Compile/ClsiCacheManager.js index cf0665af56..45970f4619 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheManager.js +++ b/services/web/app/src/Features/Compile/ClsiCacheManager.js @@ -1,11 +1,12 @@ const _ = require('lodash') -const { NotFoundError } = require('../Errors/Errors') +const { NotFoundError, ResourceGoneError } = require('../Errors/Errors') const ClsiCacheHandler = require('./ClsiCacheHandler') const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') const ProjectGetter = require('../Project/ProjectGetter') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const UserGetter = require('../User/UserGetter') const Settings = require('@overleaf/settings') +const { fetchJson, RequestFailedError } = require('@overleaf/fetch-utils') /** * Get the most recent build and metadata @@ -50,6 +51,98 @@ async function getLatestBuildFromCache(projectId, userId, filename, signal) { } } +class MetaFileExpiredError extends NotFoundError {} + +async function getLatestCompileResult(projectId, userId) { + const signal = AbortSignal.timeout(15_000) + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await tryGetLatestCompileResult(projectId, userId, signal) + } catch (err) { + if (err instanceof MetaFileExpiredError) { + continue + } + throw err + } + } + throw new NotFoundError() +} + +async function tryGetLatestCompileResult(projectId, userId, signal) { + const { + internal: { location: metaLocation }, + external: { isUpToDate, allFiles, zone, shard: clsiCacheShard }, + } = await getLatestBuildFromCache( + projectId, + userId, + 'output.overleaf.json', + signal + ) + if (!isUpToDate) throw new ResourceGoneError() + + let meta + try { + meta = await fetchJson(metaLocation, { + signal: AbortSignal.timeout(5 * 1000), + }) + } catch (err) { + if (err instanceof RequestFailedError && err.response.status === 404) { + throw new MetaFileExpiredError( + 'build expired between listing and reading' + ) + } + throw err + } + + const [, editorId, buildId] = metaLocation.match( + /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\// + ) + const { ranges, contentId, clsiServerId, compileGroup, size, options } = meta + + let baseURL = `/project/${projectId}` + if (userId) { + baseURL += `/user/${userId}` + } + + const outputFiles = allFiles + .filter(path => path !== 'output.overleaf.json' && path !== 'output.tar.gz') + .map(path => { + const f = { + url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`, + downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`, + build: buildId, + path, + type: path.split('.').pop(), + } + if (path === 'output.pdf') { + Object.assign(f, { + size, + editorId, + }) + if (clsiServerId !== clsiCacheShard) { + // Enable PDF caching and attempt to download from VM first. + // (clsi VMs do not have the editorId in the path on disk, omit it). + Object.assign(f, { + url: `${baseURL}/build/${buildId}/output/output.pdf`, + ranges, + contentId, + }) + } + } + return f + }) + + return { + allFiles, + zone, + outputFiles, + compileGroup, + clsiServerId, + clsiCacheShard, + options, + } +} + /** * Collect metadata and prepare the clsi-cache for the given project. * @@ -109,5 +202,6 @@ async function prepareClsiCache( module.exports = { getLatestBuildFromCache, + getLatestCompileResult, prepareClsiCache, } diff --git a/services/web/app/src/Features/Errors/Errors.js b/services/web/app/src/Features/Errors/Errors.js index 618c1c234c..4b1b7dd064 100644 --- a/services/web/app/src/Features/Errors/Errors.js +++ b/services/web/app/src/Features/Errors/Errors.js @@ -41,6 +41,8 @@ class ServiceNotConfiguredError extends BackwardCompatibleError {} class TooManyRequestsError extends BackwardCompatibleError {} +class ResourceGoneError extends BackwardCompatibleError {} + class DuplicateNameError extends OError {} class InvalidNameError extends BackwardCompatibleError {} @@ -319,6 +321,7 @@ module.exports = { ForbiddenError, ServiceNotConfiguredError, TooManyRequestsError, + ResourceGoneError, DuplicateNameError, InvalidNameError, UnsupportedFileTypeError,