[web] fallback to clsi-cache from regular output file download (#31555)

* [web] fallback to clsi-cache from regular output file download

* [web] add comment on req.params assignment

GitOrigin-RevId: 9d0c2e0a8e12d322aa5754c732a38f19adeac716
This commit is contained in:
Jakob Ackermann
2026-02-16 14:49:14 +01:00
committed by Copybot
parent 7c70b749d4
commit ddeafa1d7c
8 changed files with 185 additions and 6 deletions
@@ -23,6 +23,35 @@ import Metrics from '@overleaf/metrics'
*/
async function downloadFromCache(req, res) {
const { Project_id: projectId, buildId, filename } = req.params
return await _downloadFromCacheWithParams(
req,
res,
projectId,
buildId,
filename
)
}
/**
* Download a file from a specific build on the clsi-cache.
*
* @param req
* @param res
* @param projectId
* @param buildId
* @param filename
* @param projectId
* @param buildId
* @param filename
* @return {Promise<*>}
*/
async function _downloadFromCacheWithParams(
req,
res,
projectId,
buildId,
filename
) {
const userId = CompileController._getUserIdForCompile(req)
const signal = AbortSignal.timeout(60 * 1000)
let location, projectName
@@ -156,6 +185,7 @@ async function getLatestBuildFromCache(req, res) {
}
export default {
_downloadFromCacheWithParams,
downloadFromCache: expressify(downloadFromCache),
getLatestBuildFromCache: expressify(getLatestBuildFromCache),
}
@@ -22,6 +22,7 @@ import {
RequestFailedError,
} from '@overleaf/fetch-utils'
import Features from '../../infrastructure/Features.mjs'
import ClsiCacheController from './ClsiCacheController.mjs'
const { z, zz, parseReq } = Validation
const ClsiCookieManager = ClsiCookieManagerFactory(
@@ -341,6 +342,8 @@ const _CompileController = {
req.params.build_id,
'output.pdf'
)
// Align params with the generic output file download (via getFileFromClsi / getFileFromClsiWithoutUser).
req.params.file = 'output.pdf'
await CompileController._proxyToClsi(
projectId,
'output-file',
@@ -580,6 +583,16 @@ const _CompileController = {
timer.labels.status = 'success'
timer.done()
} catch (err) {
if (canTryClsiCacheFallback(req, res, action, err)) {
await ClsiCacheController._downloadFromCacheWithParams(
req,
res,
projectId,
`${req.query.editorId}-${req.params.build_id}`,
req.params.file
)
return
}
const reqAborted = Boolean(req.destroyed)
const status = reqAborted ? 'req-aborted-late' : 'error'
timer.labels.status = status
@@ -676,6 +689,27 @@ async function _getPersistenceOptions(
}
}
function canTryClsiCacheFallback(req, res, action, err) {
const reqAborted = Boolean(req.destroyed)
const streamingStarted = Boolean(res.headersSent)
return (
action === 'output-file' &&
err instanceof RequestFailedError &&
err.response.status === 404 &&
!streamingStarted &&
!reqAborted &&
req.params.build_id &&
req.query.editorId &&
req.params.file &&
// clsi-cache only has a small subset of files available outside the tar-ball
// The ClsiCacheHandler will validate the filename again.
(['output.log', 'output.pdf', 'output.synctex.gz'].includes(
req.params.file
) ||
req.params.file.endsWith('.blg'))
)
}
const CompileController = {
COMPILE_TIMEOUT_MS,
compile: expressify(_CompileController.compile),
@@ -1,6 +1,7 @@
import {
CompileOutputFile,
CompileResponseData,
PDFFile,
} from '../../../../../types/compile'
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
@@ -26,8 +27,14 @@ export function buildFileList(
if (fromCache) {
params.set('clsiserverid', clsiCacheShard || 'cache')
} else if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
} else {
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
const pdf = outputFiles.get('output.pdf') as PDFFile
if (pdf) {
params.set('editorId', pdf.editorId)
}
}
if (compileGroup) {
params.set('compileGroup', compileGroup)
@@ -52,6 +52,7 @@ export function handleOutputFiles(
} else {
// build the URL for downloading the PDF
params.set('popupDownload', 'true') // save PDF download as file
params.set('editorId', outputFile.editorId)
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
}
@@ -19,7 +19,7 @@ import OLIconButton from '@/shared/components/ol/ol-icon-button'
import getMeta from '@/utils/meta'
import { v4 as uuid } from 'uuid'
const FAKE_EDITOR_ID = uuid()
export const FAKE_EDITOR_ID = uuid()
type CompileAndDownloadProjectPDFButtonProps = {
project: Project
@@ -102,6 +102,7 @@ function CompileAndDownloadProjectPDFButton({
const params = new URLSearchParams({
compileGroup: data.compileGroup,
popupDownload: 'true',
editorId: FAKE_EDITOR_ID,
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
@@ -3,7 +3,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import sinon from 'sinon'
import { projectsData } from '../../../../fixtures/projects-data'
import { location } from '@/shared/components/location'
import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
import {
CompileAndDownloadProjectPDFButtonTooltip,
FAKE_EDITOR_ID,
} from '@/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
import fetchMock from 'fetch-mock'
import * as eventTracking from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
@@ -116,7 +119,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
expect(assignStub).to.have.been.calledOnce
expect(assignStub).to.have.been.calledWith(
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1`
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&editorId=${FAKE_EDITOR_ID}&clsiserverid=server-1`
)
expect(sendMBSpy).to.have.been.calledOnce
@@ -154,7 +157,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
expect(assignStub).to.have.been.calledOnce
expect(assignStub).to.have.been.calledWith(
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1`
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&editorId=${FAKE_EDITOR_ID}&clsiserverid=server-1`
)
expect(sendMBSpy).to.have.been.calledOnce
@@ -56,6 +56,7 @@ import { type CompileContext } from '@/shared/context/local-compile-context'
import { EditorContext } from '@/shared/context/editor-context'
import { Cobranding } from '@ol-types/cobranding'
import { TutorialContext } from '@/shared/context/tutorial-context'
import { EDITOR_SESSION_ID } from '@/features/pdf-preview/util/metrics'
// these constants can be imported in tests instead of
// using magic strings
@@ -778,6 +779,7 @@ const makeDetachCompileProvider = (mockCompileOnLoad: boolean = false) => {
const params = [
data.compileGroup && `compileGroup=${data.compileGroup}`,
data.clsiServerId && `clsiserverid=${data.clsiServerId}`,
`editorId=${EDITOR_SESSION_ID}`,
'popupDownload=true',
]
.filter(Boolean)
@@ -19,6 +19,9 @@ describe('CompileController', function () {
compileTimeout: 100,
},
}
ctx.ClsiCacheController = {
_downloadFromCacheWithParams: sinon.stub().resolves(),
}
ctx.CompileManager = {
promises: {
compile: sinon.stub(),
@@ -113,6 +116,13 @@ describe('CompileController', function () {
}),
}))
vi.doMock(
'../../../../app/src/Features/Compile/ClsiCacheController',
() => ({
default: ctx.ClsiCacheController,
})
)
vi.doMock('../../../../app/src/Features/Compile/CompileManager', () => ({
default: ctx.CompileManager,
}))
@@ -801,6 +811,97 @@ describe('CompileController', function () {
})
})
describe('when the output.pdf does not exist', function () {
beforeEach(async function (ctx) {
ctx.req.params.file = 'output.pdf'
ctx.req.params.build_id = ctx.build_id
ctx.url = `/project/${ctx.projectId}/build/${ctx.build_id}/output/${ctx.req.params.file}`
ctx.editorId = '00000000-0000-0000-0000-000000000042'
ctx.req.query = { editorId: ctx.editorId }
ctx.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves({
compileGroup: 'priority',
compileBackendClass: 'c4d',
})
ctx.fetchUtils.fetchStreamWithResponse.rejects(
new RequestFailedError(
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c4d`,
{ method: 'GET' },
{ status: 404 }
)
)
await ctx.CompileController._proxyToClsi(
ctx.projectId,
'output-file',
ctx.url,
{},
ctx.req,
ctx.res,
ctx.next
)
})
it('should open a request to the CLSI', function (ctx) {
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c4d`
)
})
it('should fallback to clsi-cache', function (ctx) {
ctx.ClsiCacheController._downloadFromCacheWithParams.should.have.been.calledWith(
ctx.req,
ctx.res,
ctx.projectId,
`${ctx.editorId}-${ctx.build_id}`,
'output.pdf'
)
})
})
describe('when the output.stderr does not exist', function () {
beforeEach(async function (ctx) {
ctx.req.params.file = 'output.stderr'
ctx.req.params.build_id = ctx.build_id
ctx.url = `/project/${ctx.projectId}/build/${ctx.build_id}/output/${ctx.req.params.file}`
ctx.editorId = '00000000-0000-0000-0000-000000000042'
ctx.req.query = { editorId: ctx.editorId }
ctx.CompileManager.promises.getProjectCompileLimits = sinon
.stub()
.resolves({
compileGroup: 'priority',
compileBackendClass: 'c4d',
})
ctx.fetchUtils.fetchStreamWithResponse.rejects(
new RequestFailedError(
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c4d`,
{ method: 'GET' },
{ status: 404 }
)
)
await ctx.CompileController._proxyToClsi(
ctx.projectId,
'output-file',
ctx.url,
{},
ctx.req,
ctx.res,
ctx.next
)
})
it('should open a request to the CLSI', function (ctx) {
ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
`${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c4d`
)
})
it('should not fallback to clsi-cache', function (ctx) {
ctx.ClsiCacheController._downloadFromCacheWithParams.should.not.have
.been.called
ctx.res.statusCode.should.equal(404)
})
})
describe('user with standard priority via query string', function () {
beforeEach(async function (ctx) {
ctx.req.query = { compileGroup: 'standard' }