[clsi-cache] check compiler settings before using compile from cache (#24845)

* [web] provide an actual rootFolder from EditorProviders in tests

- Fixup SocketIOMock and ShareJS mocks to provide the complete interface
- Extend SocketIOMock interface to count event listeners
- Fixup test that did not expect to find a working rootDoc

* [web] expose imageName from ProjectContext

* [clsi-cache] check compiler settings before using compile from cache

* [web] avoid fetching initial compile from clsi-cache in PDF detach tab

GitOrigin-RevId: e3c754a7ceca55f03a317e1bc8ae45ed12cc2f02
This commit is contained in:
Jakob Ackermann
2025-04-15 13:13:43 +01:00
committed by Copybot
parent ef958f97a1
commit 39110d9da9
17 changed files with 416 additions and 72 deletions

View File

@@ -28,6 +28,7 @@ const MAX_ENTRIES_IN_OUTPUT_TAR = 100
* @param {string} editorId
* @param {[{path: string}]} outputFiles
* @param {string} compileGroup
* @param {Record<string, any>} options
*/
function notifyCLSICacheAboutBuild({
projectId,
@@ -36,6 +37,7 @@ function notifyCLSICacheAboutBuild({
editorId,
outputFiles,
compileGroup,
options,
}) {
if (!Settings.apis.clsiCache.enabled) return
@@ -55,6 +57,7 @@ function notifyCLSICacheAboutBuild({
downloadHost: Settings.apis.clsi.downloadHost,
clsiServerId: Settings.apis.clsi.clsiServerId,
compileGroup,
options,
},
signal: AbortSignal.timeout(15_000),
}).catch(err => {

View File

@@ -1,3 +1,4 @@
const Path = require('node:path')
const RequestParser = require('./RequestParser')
const CompileManager = require('./CompileManager')
const Settings = require('@overleaf/settings')
@@ -123,6 +124,15 @@ function compile(req, res, next) {
editorId: request.editorId,
outputFiles,
compileGroup: request.compileGroup,
options: {
compiler: request.compiler,
draft: request.draft,
imageName: request.imageName
? Path.basename(request.imageName)
: undefined,
rootResourcePath: request.rootResourcePath,
stopOnFirstError: request.stopOnFirstError,
},
})
}

View File

@@ -133,7 +133,8 @@ async function getLatestBuildFromCache(req, res) {
baseURL += `/user/${userId}`
}
const { ranges, contentId, clsiServerId, compileGroup, size } = meta
const { ranges, contentId, clsiServerId, compileGroup, size, options } =
meta
const outputFiles = allFiles
.filter(
@@ -175,6 +176,7 @@ async function getLatestBuildFromCache(req, res) {
clsiServerId,
pdfDownloadDomain,
pdfCachingMinChunkSize,
options,
})
} catch (err) {
if (err instanceof NotFoundError) {

View File

@@ -43,26 +43,77 @@ const outputFiles = () => {
]
}
const compileFromCacheResponse = () => {
return {
fromCache: true,
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: outputFiles(),
options: {
rootResourcePath: 'main.tex',
imageName: 'texlive-full:2024.1',
compiler: 'pdflatex',
stopOnFirstError: false,
draft: false,
},
}
}
export const interceptCompileFromCacheRequest = ({
times,
promise,
}: {
times: number
promise: Promise<void>
}) => {
return cy.intercept(
{ path: '/project/*/output/cached/output.overleaf.json', times },
async req => {
await promise
req.reply({ body: compileFromCacheResponse() })
}
)
}
export const interceptCompileRequest = ({ times = 1 } = {}) => {
return cy.intercept(
{ method: 'POST', pathname: '/project/*/compile', times },
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: outputFiles(),
},
}
)
}
export const interceptCompile = ({
prefix = 'compile',
times = 1,
cached = false,
regular = true,
outputPDFFixture = 'output.pdf',
} = {}) => {
if (cached) {
cy.intercept(
{ pathname: '/project/*/output/cached/output.overleaf.json', times },
{
body: {
fromCache: true,
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: outputFiles(),
},
}
{ path: '/project/*/output/cached/output.overleaf.json', times },
{ body: compileFromCacheResponse() }
).as(`${prefix}-cached`)
} else {
cy.intercept(
{ pathname: '/project/*/output/cached/output.overleaf.json', times },
{ statusCode: 404 }
).as(`${prefix}-cached`)
}
if (regular) {
interceptCompileRequest({ times }).as(`${prefix}`)
} else {
cy.intercept(
{ method: 'POST', pathname: '/project/*/compile', times },
{
@@ -75,23 +126,6 @@ export const interceptCompile = ({
},
}
).as(`${prefix}`)
} else {
cy.intercept(
{ pathname: '/project/*/output/cached/output.overleaf.json', times },
{ statusCode: 404 }
).as(`${prefix}-cached`)
cy.intercept(
{ method: 'POST', pathname: '/project/*/compile', times },
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: outputFiles(),
},
}
).as(`${prefix}`)
}
cy.intercept(
@@ -114,12 +148,22 @@ export const waitForCompile = ({
prefix = 'compile',
pdf = false,
cached = false,
regular = true,
} = {}) => {
if (cached) {
cy.wait(`@${prefix}-cached`)
} else {
}
if (regular) {
cy.wait(`@${prefix}`)
}
return waitForCompileOutput({ prefix, pdf, cached })
}
export const waitForCompileOutput = ({
prefix = 'compile',
pdf = false,
cached = false,
} = {}) => {
cy.wait(`@${prefix}-log`)
.its('request.query.clsiserverid')
.should('eq', cached ? 'cache' : 'foo') // straight from cache if cached

View File

@@ -1,8 +1,10 @@
import '@testing-library/cypress/add-commands'
import {
interceptCompile,
interceptCompileFromCacheRequest,
waitForCompile,
interceptDeferredCompile,
interceptCompileRequest,
} from './compile'
import { interceptEvents } from './events'
import { interceptAsync } from './intercept-async'
@@ -21,6 +23,8 @@ declare global {
interface Chainable {
interceptAsync: typeof interceptAsync
interceptCompile: typeof interceptCompile
interceptCompileRequest: typeof interceptCompileRequest
interceptCompileFromCacheRequest: typeof interceptCompileFromCacheRequest
interceptEvents: typeof interceptEvents
interceptMetadata: typeof interceptMetadata
waitForCompile: typeof waitForCompile
@@ -36,6 +40,11 @@ declare global {
Cypress.Commands.add('interceptAsync', interceptAsync)
Cypress.Commands.add('interceptCompile', interceptCompile)
Cypress.Commands.add('interceptCompileRequest', interceptCompileRequest)
Cypress.Commands.add(
'interceptCompileFromCacheRequest',
interceptCompileFromCacheRequest
)
Cypress.Commands.add('interceptEvents', interceptEvents)
Cypress.Commands.add('interceptMetadata', interceptMetadata)
Cypress.Commands.add('waitForCompile', waitForCompile)

View File

@@ -277,13 +277,34 @@ if (typeof io === 'undefined' || !io) {
current = SocketShimV2
}
export class SocketIOMock extends EventEmitter {
export class SocketIOMock extends SocketShimBase {
constructor() {
super(new EventEmitter())
this.socket = {
get connected() {
return false
},
get sessionid() {
return undefined
},
get transport() {
return {}
},
get transports() {
return []
},
connect() {},
disconnect(reason) {},
}
}
addListener(event, listener) {
this.on(event, listener)
this._socket.on(event, listener)
}
removeListener(event, listener) {
this.off(event, listener)
this._socket.off(event, listener)
}
disconnect() {
@@ -294,6 +315,10 @@ export class SocketIOMock extends EventEmitter {
// Round-trip through JSON.parse/stringify to simulate (de-)serializing on network layer.
this.emit(...JSON.parse(JSON.stringify(args)))
}
countEventListeners(event) {
return this._socket.events[event].length
}
}
export default {

View File

@@ -34,6 +34,7 @@ import { buildFileList } from '../../features/pdf-preview/util/file-list'
import { useLayoutContext } from './layout-context'
import { useUserContext } from './user-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useDetachContext } from '@/shared/context/detach-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
@@ -46,6 +47,8 @@ import {
} from '@/shared/hooks/use-pdf-scroll-position'
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { captureException } from '@/infrastructure/error-reporter'
import OError from '@overleaf/o-error'
type PdfFile = Record<string, any>
@@ -117,8 +120,15 @@ export const LocalCompileContext = createContext<CompileContext | undefined>(
export const LocalCompileProvider: FC = ({ children }) => {
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
const { role } = useDetachContext()
const { _id: projectId, rootDocId, joinedOnce } = useProjectContext()
const {
_id: projectId,
rootDocId,
joinedOnce,
imageName,
compiler: compilerName,
} = useProjectContext()
const { pdfPreviewOpen } = useLayoutContext()
@@ -190,10 +200,15 @@ export const LocalCompileProvider: FC = ({ children }) => {
const [compiledOnce, setCompiledOnce] = useState(false)
// fetch initial compile response from cache
const [initialCompileFromCache, setInitialCompileFromCache] = useState(
isSplitTestEnabled('initial-compile-from-clsi-cache')
isSplitTestEnabled('initial-compile-from-clsi-cache') &&
// Avoid fetching the initial compile from cache in PDF detach tab
role !== 'detached'
)
// Compile triggered while fetching the initial compile from cache
const upgradeInitialCompileFromCacheRef = useRef(false)
// fetch of initial compile from cache is pending
const [pendingInitialCompileFromCache, setPendingInitialCompileFromCache] =
useState(false)
// Raw data from clsi-cache, will need post-processing and check settings
const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
@@ -337,41 +352,88 @@ export const LocalCompileProvider: FC = ({ children }) => {
// try to fetch the last compile result after opening the project, potentially before joining the project.
useEffect(() => {
if (initialCompileFromCache) {
setInitialCompileFromCache(false)
setCompiling(true)
setCompiledOnce(true)
if (initialCompileFromCache && !pendingInitialCompileFromCache) {
setPendingInitialCompileFromCache(true)
getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
.then((data: any) => {
setCompiling(false)
setData({
...data,
options: compiler.defaultOptions,
})
if (upgradeInitialCompileFromCacheRef.current) {
compilingRef.current = false
compiler.compile() // trigger regular compile
}
// Hand data over to next effect, it will wait for project/doc loading.
setDataFromCache(data)
})
.catch(() => {
setCompiling(false)
if (upgradeInitialCompileFromCacheRef.current) {
compilingRef.current = false
compiler.compile() // trigger regular compile
} else {
setCompiledOnce(false) // trigger auto compile
}
// Let the isAutoCompileOnLoad effect take over
setInitialCompileFromCache(false)
setPendingInitialCompileFromCache(false)
})
}
}, [projectId, initialCompileFromCache, compiler])
}, [projectId, initialCompileFromCache, pendingInitialCompileFromCache])
// Maybe adopt the compile from cache
useEffect(() => {
if (!dataFromCache) return // no compile from cache available
if (!joinedOnce) return // wait for joinProject, it populates the file-tree.
if (!currentDocument) return // wait for current doc to load, it affects the rootDoc override
if (compiledOnce) return // regular compile triggered
// Gracefully access file-tree and getRootDocOverride
let settingsUpToDate = false
try {
dataFromCache.rootDocId = findEntityByPath(
dataFromCache.options?.rootResourcePath || ''
)?.entity?._id
const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
settingsUpToDate =
rootDocOverride === dataFromCache.rootDocId &&
dataFromCache.options.imageName === imageName &&
dataFromCache.options.compiler === compilerName &&
dataFromCache.options.stopOnFirstError === stopOnFirstError &&
dataFromCache.options.draft === draft
} catch (err) {
captureException(
OError.tag(err as unknown as Error, 'validate compile options', {
options: dataFromCache.options,
})
)
}
if (settingsUpToDate) {
setData(dataFromCache)
setCompiledOnce(true)
}
setDataFromCache(undefined)
setInitialCompileFromCache(false)
setPendingInitialCompileFromCache(false)
}, [
dataFromCache,
joinedOnce,
currentDocument,
compiledOnce,
rootDocId,
findEntityByPath,
compiler,
compilerName,
imageName,
stopOnFirstError,
draft,
])
// always compile the PDF once after opening the project, after the doc has loaded
useEffect(() => {
if (!compiledOnce && currentDocument && !initialCompileFromCache) {
if (
!compiledOnce &&
currentDocument &&
!initialCompileFromCache &&
!pendingInitialCompileFromCache
) {
setCompiledOnce(true)
compiler.compile({ isAutoCompileOnLoad: true })
}
}, [compiledOnce, currentDocument, initialCompileFromCache, compiler])
}, [
compiledOnce,
currentDocument,
initialCompileFromCache,
pendingInitialCompileFromCache,
compiler,
])
useEffect(() => {
setHasShortCompileTimeout(
@@ -619,10 +681,10 @@ export const LocalCompileProvider: FC = ({ children }) => {
// start a compile manually
const startCompile = useCallback(
options => {
upgradeInitialCompileFromCacheRef.current = true
setCompiledOnce(true)
compiler.compile(options)
},
[compiler, upgradeInitialCompileFromCacheRef]
[compiler, setCompiledOnce]
)
// stop a compile manually

View File

@@ -34,6 +34,7 @@ export const ProjectProvider: FC = ({ children }) => {
const {
_id,
compiler,
imageName,
name,
rootDoc_id: rootDocId,
members,
@@ -59,6 +60,7 @@ export const ProjectProvider: FC = ({ children }) => {
return {
_id,
compiler,
imageName,
name,
rootDocId,
members,
@@ -75,6 +77,7 @@ export const ProjectProvider: FC = ({ children }) => {
}, [
_id,
compiler,
imageName,
name,
rootDocId,
members,

View File

@@ -18,6 +18,7 @@ export type ProjectContextValue = {
rootDocId?: string
mainBibliographyDocId?: string
compiler: string
imageName: string
members: ProjectContextMember[]
invites: ProjectContextMember[]
features: {

View File

@@ -89,6 +89,7 @@ const initialize = () => {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
},
open_doc_name: 'testfile.tex',
},

View File

@@ -2,6 +2,7 @@ export function mockDocument(text: string) {
return {
doc_id: 'story-doc',
getSnapshot: () => text,
hasBufferedOps: () => false,
}
}

View File

@@ -211,6 +211,7 @@ const mockDoc = (content: string, changes: Array<Record<string, any>> = []) => {
return null
},
ranges: new RangesTracker(changes, []),
hasBufferedOps: () => false,
}
}

View File

@@ -28,7 +28,19 @@ const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
}
describe('<PdfPreview/>', function () {
let projectId: string
beforeEach(function () {
/**
* There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
*
* Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
*
* This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
*
* All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
*/
projectId = Math.random().toString().slice(2)
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set(
'ol-compilesUserContentDomain',
@@ -61,7 +73,12 @@ describe('<PdfPreview/>', function () {
})
it('uses the cache when available', function () {
cy.interceptCompile({ prefix: 'compile', times: 1, cached: true })
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
@@ -74,13 +91,18 @@ describe('<PdfPreview/>', function () {
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true })
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
})
it('uses the cache when available then compiles', function () {
cy.interceptCompile({ prefix: 'compile', times: 1, cached: true })
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
@@ -93,7 +115,7 @@ describe('<PdfPreview/>', function () {
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true })
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
// Then trigger a new compile
@@ -112,6 +134,154 @@ describe('<PdfPreview/>', function () {
cy.contains('Modern Authoring Tools for Science')
})
describe('racing compile from cache and regular compile trigger', function () {
for (const [timing] of ['before rendering', 'after rendering']) {
it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
const requestedOnce = new Set()
;['log', 'pdf', 'blg'].forEach(ext => {
cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
if (requestedOnce.has(ext)) {
throw new Error(
`compile from cache triggered extra ${ext} request: ${req.url}`
)
}
requestedOnce.add(ext)
req.reply({ fixture: `build/output.${ext},null` })
}).as(`compile-${ext}`)
})
const { promise, resolve } = Promise.withResolvers<void>()
cy.interceptCompileFromCacheRequest({
promise,
times: 1,
}).as('cached-compile')
cy.interceptCompileRequest().as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope} projectId={projectId}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
if (timing === 'before rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
// wait for rendering to finish
cy.waitForCompile({ pdf: true, cached: false })
if (timing === 'after rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
cy.contains('Your Paper')
cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
'equal',
'blg,log,pdf'
)
})
}
})
describe('clsi-cache project settings validation', function () {
const cases = {
// Flaky, skip for now
'uses compile from cache when nothing changed': {
cached: true,
setup: () => {},
props: {},
},
'ignores the compile from cache when imageName changed': {
cached: false,
setup: () => {},
props: {
imageName: 'texlive-full:2025.1',
},
},
'ignores the compile from cache when compiler changed': {
cached: false,
setup: () => {},
props: {
compiler: 'lualatex',
},
},
'ignores the compile from cache when draft mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`draft:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when stopOnFirstError mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when rootDoc changed': {
cached: false,
setup: () => {},
props: {
rootDocId: 'new-root-doc-id',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
{
_id: 'new-root-doc-id',
name: 'new-main.tex',
},
],
folders: [],
fileRefs: [],
},
],
},
},
}
Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
it(name, function () {
cy.interceptCompile({
cached: true,
regular: !cached,
})
const scope = mockScope()
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
setup()
cy.mount(
<EditorProviders scope={scope} projectId={projectId} {...props}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for compile to finish
cy.waitForCompile({ pdf: true, cached, regular: !cached })
cy.contains('Your Paper')
})
})
})
it('runs a compile when the Recompile button is pressed', function () {
cy.interceptCompile()

View File

@@ -10,6 +10,7 @@ export const mockScope = () => ({
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
},
view: new EditorView({
doc: '\\documentclass{article}',

View File

@@ -56,8 +56,7 @@ describe('ChatContext', function () {
it('subscribes when mounted', function () {
const socket = new SocketIOMock()
renderChatContextHook({ socket })
// Assert that there is 1 listener
expect(socket.events['new-chat-message']).to.have.length(1)
expect(socket.countEventListeners('new-chat-message')).to.equal(1)
})
it('unsubscribes when unmounted', function () {
@@ -66,8 +65,7 @@ describe('ChatContext', function () {
unmount()
// Assert that there is 0 listeners
expect(socket.events['new-chat-message'].length).to.equal(0)
expect(socket.countEventListeners('new-chat-message')).to.equal(0)
})
it('adds received messages to the list', async function () {

View File

@@ -5,7 +5,7 @@ import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('<FileTreeToolbar/>', function () {
it('without selected files', function () {
cy.mount(
<EditorProviders>
<EditorProviders rootDocId="">
<FileTreeProvider>
<FileTreeToolbar />
</FileTreeProvider>

View File

@@ -44,6 +44,8 @@ export function EditorProviders({
email: 'owner@example.com',
},
rootDocId = '_root_doc_id',
imageName = 'texlive-full:2024.1',
compiler = 'pdflatex',
socket = new SocketIOMock(),
isRestrictedTokenMember = false,
clsiServerId = '1234',
@@ -58,7 +60,12 @@ export function EditorProviders({
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [],
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
@@ -99,6 +106,10 @@ export function EditorProviders({
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
on: () => {},
off: () => {},
leaveAndCleanUpPromise: async () => {},
},
},
project: {
@@ -108,6 +119,8 @@ export function EditorProviders({
features: projectFeatures,
rootDoc_id: rootDocId,
rootFolder,
imageName,
compiler,
},
ui,
$watch: (path, callback) => {