Преглед изворни кода

[web] integrate clsi-cache into download pdf from project dashboard (#31138)

* [monorepo] fix downloads from non-sharded clsi-cache

* [web] check some compile from cache options server-side

* [web] integrate clsi-cache into download pdf from project dashboard

* [web] remove frontend tests for server-side validation

* [web] remove unused fetch mock

* [web] use helper that adds polyfill for AbortSignal.any()

* [web] upgrade fetch-mock to fix leaking AbortSignal

* [web] do not add an extra timeout to clsi-cache request

The web backend service has a low timeout already.

GitOrigin-RevId: a90984b92f5d4f24005db5a09f2c5d2424436886
Jakob Ackermann пре 6 месеци
родитељ
комит
5829a7fe43

+ 17 - 17
package-lock.json

@@ -29962,6 +29962,22 @@
         "node": ">=4.0.0"
       }
     },
+    "node_modules/fetch-mock": {
+      "version": "12.6.0",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.6.0.tgz",
+      "integrity": "sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/glob-to-regexp": "^0.4.4",
+        "dequal": "^2.0.3",
+        "glob-to-regexp": "^0.4.1",
+        "regexparam": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=18.11.0"
+      }
+    },
     "node_modules/fflate": {
       "version": "0.8.2",
       "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -58447,7 +58463,7 @@
         "eventsource-client": "^1.1.4",
         "fake-indexeddb": "^6.0.0",
         "fast-fuzzy": "^1.12.0",
-        "fetch-mock": "^12.5.2",
+        "fetch-mock": "^12.6.0",
         "formik": "^2.2.9",
         "fuse.js": "^7.0.0",
         "glob": "^12.0.0",
@@ -59775,22 +59791,6 @@
         }
       }
     },
-    "services/web/node_modules/fetch-mock": {
-      "version": "12.5.2",
-      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz",
-      "integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/glob-to-regexp": "^0.4.4",
-        "dequal": "^2.0.3",
-        "glob-to-regexp": "^0.4.1",
-        "regexparam": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=18.11.0"
-      }
-    },
     "services/web/node_modules/foreground-child": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",

+ 16 - 4
services/web/app/src/Features/Compile/ClsiCacheManager.mjs

@@ -1,3 +1,4 @@
+import Path from 'node:path'
 import _ from 'lodash'
 import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
 import ClsiCacheHandler from './ClsiCacheHandler.mjs'
@@ -27,17 +28,21 @@ const ClsiCookieManager = ClsiCookieManagerFactory(
  * @param userId
  * @param filename
  * @param signal
- * @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
+ * @return {Promise<{internal: {location: string, imageName: string, compiler: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
  */
 async function getLatestBuildFromCache(projectId, userId, filename, signal) {
   const [
     { location, lastModified: lastCompiled, zone, shard, size, allFiles },
     lastUpdatedInRedis,
-    { lastUpdated: lastUpdatedInMongo },
+    { lastUpdated: lastUpdatedInMongo, imageName: fullImageName, compiler },
   ] = await Promise.all([
     ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
     DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
-    ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
+    ProjectGetter.promises.getProject(projectId, {
+      lastUpdated: 1,
+      imageName: 1,
+      compiler: 1,
+    }),
   ])
 
   const lastUpdated =
@@ -45,10 +50,13 @@ async function getLatestBuildFromCache(projectId, userId, filename, signal) {
       ? lastUpdatedInRedis
       : lastUpdatedInMongo
   const isUpToDate = lastCompiled >= lastUpdated
+  const imageName = Path.basename(fullImageName)
 
   return {
     internal: {
       location,
+      imageName,
+      compiler,
     },
     external: {
       isUpToDate,
@@ -80,7 +88,7 @@ async function getLatestCompileResult(projectId, userId) {
 
 async function tryGetLatestCompileResult(projectId, userId, signal) {
   const {
-    internal: { location: metaLocation },
+    internal: { location: metaLocation, imageName, compiler },
     external: {
       isUpToDate,
       allFiles,
@@ -127,6 +135,10 @@ async function tryGetLatestCompileResult(projectId, userId, signal) {
     timings,
   } = meta
 
+  if (options.imageName !== imageName || options.compiler !== compiler) {
+    throw new ResourceGoneError()
+  }
+
   let baseURL = `/project/${projectId}`
   if (userId) {
     baseURL += `/user/${userId}`

+ 63 - 32
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx

@@ -4,7 +4,7 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useLocation } from '../../../../../../shared/hooks/use-location'
 import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
-import { postJSON } from '../../../../../../infrastructure/fetch-json'
+import { getJSON, postJSON } from '../../../../../../infrastructure/fetch-json'
 import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
 import OLTooltip from '@/shared/components/ol/ol-tooltip'
 import OLButton from '@/shared/components/ol/ol-button'
@@ -16,6 +16,10 @@ import {
   OLModalTitle,
 } from '@/shared/components/ol/ol-modal'
 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()
 
 type CompileAndDownloadProjectPDFButtonProps = {
   project: Project
@@ -49,43 +53,70 @@ function CompileAndDownloadProjectPDFButton({
           isSmallDevice,
         })
 
-        postJSON(`/project/${project.id}/compile`, {
-          body: {
-            check: 'silent',
-            draft: false,
-            incrementalCompilesEnabled: true,
-          },
-          signal,
-        })
-          .catch(() => ({ status: 'error' }))
-          .then(data => {
-            setPendingCompile(false)
-            if (data.status === 'success') {
+        const { isOverleaf } = getMeta('ol-ExposedSettings')
+        ;(async () => {
+          if (isOverleaf) {
+            try {
+              const data = await getJSON(
+                `/project/${project.id}/output/cached/output.overleaf.json`,
+                { signal }
+              )
+              if (data.options.draft) throw new Error('options changed')
+
+              setPendingCompile(false)
               const outputFile = data.outputFiles
                 .filter((file: { path: string }) => file.path === 'output.pdf')
                 .pop()
-
-              const params = new URLSearchParams({
-                compileGroup: data.compileGroup,
-                popupDownload: 'true',
-              })
-              if (data.clsiServerId) {
-                params.set('clsiserverid', data.clsiServerId)
-              }
-              // Note: Triggering concurrent downloads does not work.
-              // Note: This is affecting the download of .zip files as well.
-              // When creating a dynamic `a` element with `download` attribute,
-              //  another "actual" UI click is needed to trigger downloads.
-              // Forwarding the click `event` to the dynamic `a` element does
-              //  not work either.
-              location.assign(
-                `/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
-              )
+              location.assign(outputFile.downloadURL)
               onDone?.(e)
-            } else {
-              setShowErrorModal(true)
+              return
+            } catch {
+              // fall back to compile
             }
+          }
+
+          let data
+          try {
+            data = await postJSON(`/project/${project.id}/compile`, {
+              body: {
+                check: 'silent',
+                draft: false,
+                incrementalCompilesEnabled: true,
+                // Add a fake editorId for enabling clsi-cache.
+                editorId: FAKE_EDITOR_ID,
+              },
+              signal,
+            })
+          } catch {
+            data = { status: 'error' }
+          }
+          setPendingCompile(false)
+          if (data.status !== 'success') {
+            setShowErrorModal(true)
+            return
+          }
+          const outputFile = data.outputFiles
+            .filter((file: { path: string }) => file.path === 'output.pdf')
+            .pop()
+
+          const params = new URLSearchParams({
+            compileGroup: data.compileGroup,
+            popupDownload: 'true',
           })
+          if (data.clsiServerId) {
+            params.set('clsiserverid', data.clsiServerId)
+          }
+          // Note: Triggering concurrent downloads does not work.
+          // Note: This is affecting the download of .zip files as well.
+          // When creating a dynamic `a` element with `download` attribute,
+          //  another "actual" UI click is needed to trigger downloads.
+          // Forwarding the click `event` to the dynamic `a` element does
+          //  not work either.
+          location.assign(
+            `/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
+          )
+          onDone?.(e)
+        })()
         return true
       })
     },

+ 0 - 2
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -398,8 +398,6 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
       const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
       settingsUpToDate =
         rootDocOverride === dataFromCache.rootDocId &&
-        dataFromCache.options.imageName === imageName &&
-        dataFromCache.options.compiler === compilerName &&
         dataFromCache.options.draft === draft &&
         // Allow stopOnFirstError to be enabled in the compile from cache and disabled locally.
         // Compiles that passed with stopOnFirstError=true will also pass with stopOnFirstError=false. The inverse does not hold, and we need to recompile.

+ 1 - 1
services/web/package.json

@@ -330,7 +330,7 @@
     "eventsource-client": "^1.1.4",
     "fake-indexeddb": "^6.0.0",
     "fast-fuzzy": "^1.12.0",
-    "fetch-mock": "^12.5.2",
+    "fetch-mock": "^12.6.0",
     "formik": "^2.2.9",
     "fuse.js": "^7.0.0",
     "glob": "^12.0.0",

+ 0 - 15
services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx

@@ -9,7 +9,6 @@ import {
 } from '../../../../frontend/js/shared/context/layout-context'
 import { FC, PropsWithChildren, ReactElement, useEffect } from 'react'
 import { useLocalCompileContext } from '@/shared/context/local-compile-context'
-import { ProjectCompiler } from '../../../../types/project-settings'
 
 const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
   localStorage.setItem(key, value)
@@ -211,20 +210,6 @@ describe('<PdfPreview/>', function () {
         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' as ProjectCompiler,
-        },
-      },
       'ignores the compile from cache when draft mode changed': {
         cached: false,
         setup: () => {

+ 1 - 0
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -790,6 +790,7 @@ describe('<UserNotifications />', function () {
       })
       const resendButton = resendButtons[0]
       fireEvent.click(resendButton)
+      await fetchMock.callHistory.flush(true)
 
       await screen.findByRole('dialog')
 

+ 97 - 0
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx

@@ -6,6 +6,7 @@ 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 fetchMock from 'fetch-mock'
 import * as eventTracking from '@/infrastructure/event-tracking'
+import getMeta from '@/utils/meta'
 
 describe('<CompileAndDownloadProjectPDFButton />', function () {
   let sendMBSpy: sinon.SinonSpy
@@ -31,6 +32,102 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
     await screen.findByRole('tooltip', { name: 'Download PDF' })
   })
 
+  it('downloads the project PDF from cache when clicked', async function () {
+    Object.assign(getMeta('ol-ExposedSettings'), {
+      isOverleaf: true,
+    })
+    fetchMock.get(
+      `/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
+      {
+        status: 'success',
+        compileGroup: 'standard',
+        clsiServerId: 'server-1',
+        outputFiles: [
+          { path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
+        ],
+        options: { draft: false },
+      },
+      { delay: 10 }
+    )
+
+    const btn = screen.getByRole('button', { name: 'Download PDF' })
+    fireEvent.click(btn)
+
+    await waitFor(() => {
+      screen.getByRole('button', { name: 'Compiling…' })
+    })
+
+    const assignStub = this.locationWrapperStub.assign
+    await waitFor(() => {
+      expect(assignStub).to.have.been.called
+    })
+
+    expect(assignStub).to.have.been.calledOnce
+    expect(assignStub).to.have.been.calledWith('cached.pdf')
+
+    expect(sendMBSpy).to.have.been.calledOnce
+    expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
+      action: 'downloadPDF',
+      page: '/',
+      projectId: projectsData[0].id,
+      isSmallDevice: true,
+    })
+  })
+
+  it('ignores cached draft PDF and downloads the project PDF when clicked', async function () {
+    Object.assign(getMeta('ol-ExposedSettings'), {
+      isOverleaf: true,
+    })
+    fetchMock.get(
+      `/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
+      {
+        status: 'success',
+        compileGroup: 'standard',
+        clsiServerId: 'server-1',
+        outputFiles: [
+          { path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
+        ],
+        options: { draft: true },
+      },
+      { delay: 10 }
+    )
+    fetchMock.post(
+      `/project/${projectsData[0].id}/compile`,
+      {
+        status: 'success',
+        compileGroup: 'standard',
+        clsiServerId: 'server-1',
+        outputFiles: [{ path: 'output.pdf', build: '123-321' }],
+      },
+      { delay: 10 }
+    )
+
+    const btn = screen.getByRole('button', { name: 'Download PDF' })
+    fireEvent.click(btn)
+
+    await waitFor(() => {
+      screen.getByRole('button', { name: 'Compiling…' })
+    })
+
+    const assignStub = this.locationWrapperStub.assign
+    await waitFor(() => {
+      expect(assignStub).to.have.been.called
+    })
+
+    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`
+    )
+
+    expect(sendMBSpy).to.have.been.calledOnce
+    expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
+      action: 'downloadPDF',
+      page: '/',
+      projectId: projectsData[0].id,
+      isSmallDevice: true,
+    })
+  })
+
   it('downloads the project PDF when clicked', async function () {
     fetchMock.post(
       `/project/${projectsData[0].id}/compile`,