ClsiManager.mjs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  1. import { callbackify } from 'node:util'
  2. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  3. import {
  4. fetchString,
  5. fetchStringWithResponse,
  6. fetchStream,
  7. RequestFailedError,
  8. } from '@overleaf/fetch-utils'
  9. import Settings from '@overleaf/settings'
  10. import ProjectGetter from '../Project/ProjectGetter.mjs'
  11. import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
  12. import logger from '@overleaf/logger'
  13. import OError from '@overleaf/o-error'
  14. import { Cookie } from 'tough-cookie'
  15. import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
  16. import ClsiStateManager from './ClsiStateManager.mjs'
  17. import _ from 'lodash'
  18. import ClsiFormatChecker from './ClsiFormatChecker.mjs'
  19. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  20. import Metrics from '@overleaf/metrics'
  21. import Errors from '../Errors/Errors.js'
  22. import ClsiCacheHandler from './ClsiCacheHandler.mjs'
  23. import HistoryManager from '../History/HistoryManager.mjs'
  24. import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
  25. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  26. const ClsiCookieManager = ClsiCookieManagerFactory(
  27. Settings.apis.clsi?.backendGroupName
  28. )
  29. const NewBackendCloudClsiCookieManager = ClsiCookieManagerFactory(
  30. Settings.apis.clsi_new?.backendGroupName
  31. )
  32. const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
  33. const OUTPUT_FILE_TIMEOUT_MS = 60000
  34. const CLSI_COOKIES_ENABLED = (Settings.clsiCookie?.key ?? '') !== ''
  35. // The timeout in services/clsi/app.js is 10 minutes, so we'll be on the safe side with 12 minutes
  36. const COMPILE_REQUEST_TIMEOUT_MS = 12 * 60 * 1000
  37. function getNewCompileBackendClass(projectId, compileBackendClass) {
  38. // Sample x% of projects to move up one bracket.
  39. if (
  40. SplitTestHandler.getPercentile(projectId, 'double-compile', 'release') >=
  41. Settings.apis.clsi_new.sample
  42. ) {
  43. return null
  44. }
  45. switch (compileBackendClass) {
  46. case 'n2d':
  47. return 'n4'
  48. case 'c2d':
  49. return 'n4'
  50. case 'c3d':
  51. return 'n4'
  52. case 'c4d':
  53. return 'n4'
  54. default:
  55. throw new Error('unknown ?compileBackendClass')
  56. }
  57. }
  58. /**
  59. * @param {string} projectId
  60. * @param {string | null} userId
  61. * @param {string} compileBackendClass
  62. * @return {Promise<void>}
  63. */
  64. async function clearClsiServerId(projectId, userId, compileBackendClass) {
  65. const jobs = [
  66. ClsiCookieManager.promises.clearServerId(
  67. projectId,
  68. userId,
  69. compileBackendClass
  70. ),
  71. ]
  72. if (Settings.apis.clsi_new?.url) {
  73. // Mirror resetting the clsiserverid in both backends.
  74. const newCompileBackendClass = getNewCompileBackendClass(
  75. projectId,
  76. compileBackendClass
  77. )
  78. if (newCompileBackendClass) {
  79. jobs.push(
  80. NewBackendCloudClsiCookieManager.promises.clearServerId(
  81. projectId,
  82. userId,
  83. newCompileBackendClass
  84. )
  85. )
  86. }
  87. }
  88. await Promise.all(jobs)
  89. }
  90. function collectMetricsOnBlgFiles(outputFiles) {
  91. let topLevel = 0
  92. let nested = 0
  93. for (const outputFile of outputFiles) {
  94. if (outputFile.type === 'blg') {
  95. if (outputFile.path.includes('/')) {
  96. nested++
  97. } else {
  98. topLevel++
  99. }
  100. }
  101. }
  102. Metrics.count('blg_output_file', topLevel, 1, { path: 'top-level' })
  103. Metrics.count('blg_output_file', nested, 1, { path: 'nested' })
  104. }
  105. async function sendRequest(projectId, userId, options) {
  106. if (options == null) {
  107. options = {}
  108. }
  109. let result = await sendRequestOnce(projectId, userId, options)
  110. if (result.status === 'conflict') {
  111. // Try again, with a full compile
  112. result = await sendRequestOnce(projectId, userId, {
  113. ...options,
  114. syncType: 'full',
  115. })
  116. } else if (result.status === 'unavailable') {
  117. result = await sendRequestOnce(projectId, userId, {
  118. ...options,
  119. syncType: 'full',
  120. forceNewClsiServer: true,
  121. })
  122. }
  123. return result
  124. }
  125. async function sendRequestOnce(projectId, userId, options) {
  126. let req
  127. try {
  128. req = await _buildRequest(projectId, options)
  129. } catch (err) {
  130. if (err.message === 'no main file specified') {
  131. return {
  132. status: 'validation-problems',
  133. validationProblems: { mainFile: err.message },
  134. }
  135. } else {
  136. throw OError.tag(err, 'Could not build request to CLSI', {
  137. projectId,
  138. options,
  139. })
  140. }
  141. }
  142. return await _sendBuiltRequest(projectId, userId, req, options)
  143. }
  144. // for public API requests where there is no project id
  145. async function sendExternalRequest(submissionId, clsiRequest, options) {
  146. if (options == null) {
  147. options = {}
  148. }
  149. return await _sendBuiltRequest(submissionId, null, clsiRequest, options)
  150. }
  151. async function stopCompile(projectId, userId, options) {
  152. if (options == null) {
  153. options = {}
  154. }
  155. const { compileBackendClass, compileGroup } = options
  156. const url = _getCompilerUrl(
  157. compileBackendClass,
  158. compileGroup,
  159. projectId,
  160. userId,
  161. 'compile/stop'
  162. )
  163. const opts = { method: 'POST' }
  164. await _makeRequest(
  165. projectId,
  166. userId,
  167. compileGroup,
  168. compileBackendClass,
  169. url,
  170. opts
  171. )
  172. }
  173. async function deleteAuxFiles(projectId, userId, options, clsiserverid) {
  174. if (options == null) {
  175. options = {}
  176. }
  177. const { compileBackendClass, compileGroup } = options
  178. const url = _getCompilerUrl(
  179. compileBackendClass,
  180. compileGroup,
  181. projectId,
  182. userId
  183. )
  184. const opts = {
  185. method: 'DELETE',
  186. }
  187. try {
  188. await _makeRequestWithClsiServerId(
  189. projectId,
  190. userId,
  191. compileGroup,
  192. compileBackendClass,
  193. url,
  194. opts,
  195. clsiserverid
  196. )
  197. } finally {
  198. // always clear the clsi-cache
  199. try {
  200. await ClsiCacheHandler.clearCache(projectId, userId)
  201. } catch (err) {
  202. logger.warn({ err, projectId, userId }, 'purge clsi-cache failed')
  203. }
  204. // always clear the project state from the docupdater, even if there
  205. // was a problem with the request to the clsi
  206. try {
  207. await DocumentUpdaterHandler.promises.clearProjectState(projectId)
  208. } finally {
  209. // always clear the clsi server id, even if prior actions failed
  210. await clearClsiServerId(projectId, userId, compileBackendClass)
  211. }
  212. }
  213. }
  214. async function _sendBuiltRequest(projectId, userId, req, options) {
  215. if (options.forceNewClsiServer) {
  216. await clearClsiServerId(projectId, userId, options.compileBackendClass)
  217. }
  218. const validationProblems = ClsiFormatChecker.checkRecoursesForProblems(
  219. req.compile?.resources
  220. )
  221. if (validationProblems != null) {
  222. logger.debug(
  223. { projectId, validationProblems },
  224. 'problems with users latex before compile was attempted'
  225. )
  226. return {
  227. status: 'validation-problems',
  228. validationProblems,
  229. }
  230. }
  231. const { response, clsiServerId } = await _postToClsi(
  232. projectId,
  233. userId,
  234. req,
  235. options.compileBackendClass,
  236. options.compileGroup
  237. )
  238. const outputFiles = _parseOutputFiles(
  239. projectId,
  240. response && response.compile && response.compile.outputFiles
  241. )
  242. collectMetricsOnBlgFiles(outputFiles)
  243. const compile = response?.compile || {}
  244. return {
  245. status: compile.status,
  246. outputFiles,
  247. clsiServerId,
  248. buildId: compile.buildId,
  249. stats: compile.stats,
  250. timings: compile.timings,
  251. outputUrlPrefix: compile.outputUrlPrefix,
  252. clsiCacheShard: compile.clsiCacheShard,
  253. }
  254. }
  255. async function _makeRequestWithClsiServerId(
  256. projectId,
  257. userId,
  258. compileGroup,
  259. compileBackendClass,
  260. url,
  261. opts,
  262. clsiserverid
  263. ) {
  264. if (clsiserverid) {
  265. // ignore cookies and newBackend, go straight to the clsi node
  266. const urlWithId = new URL(url)
  267. urlWithId.searchParams.set('clsiserverid', clsiserverid)
  268. let body
  269. try {
  270. body = await fetchString(urlWithId, opts)
  271. } catch (err) {
  272. throw OError.tag(err, 'error making request to CLSI', {
  273. userId,
  274. projectId,
  275. })
  276. }
  277. let json
  278. try {
  279. json = JSON.parse(body)
  280. } catch (err) {
  281. // some responses are empty. Ignore JSON parsing errors.
  282. }
  283. _makeNewBackendRequest(
  284. projectId,
  285. userId,
  286. compileGroup,
  287. compileBackendClass,
  288. url,
  289. opts
  290. ).catch(err => {
  291. logger.warn({ err }, 'Error making request to new CLSI backend')
  292. })
  293. return { body: json }
  294. } else {
  295. return await _makeRequest(
  296. projectId,
  297. userId,
  298. compileGroup,
  299. compileBackendClass,
  300. url,
  301. opts
  302. )
  303. }
  304. }
  305. async function _makeRequest(
  306. projectId,
  307. userId,
  308. compileGroup,
  309. compileBackendClass,
  310. url,
  311. opts
  312. ) {
  313. const currentBackendStartTime = new Date()
  314. const clsiServerId = await ClsiCookieManager.promises.getServerId(
  315. projectId,
  316. userId,
  317. compileGroup,
  318. compileBackendClass
  319. )
  320. opts.headers = {
  321. Accept: 'application/json',
  322. 'Content-Type': 'application/json',
  323. }
  324. if (CLSI_COOKIES_ENABLED) {
  325. const cookie = new Cookie({
  326. key: Settings.clsiCookie.key,
  327. value: clsiServerId,
  328. })
  329. opts.headers.Cookie = cookie.cookieString()
  330. }
  331. const timer = new Metrics.Timer('compile.currentBackend')
  332. let response, body
  333. try {
  334. ;({ body, response } = await fetchStringWithResponse(url, opts))
  335. } catch (err) {
  336. throw OError.tag(err, 'error making request to CLSI', {
  337. projectId,
  338. userId,
  339. })
  340. }
  341. Metrics.inc(`compile.currentBackend.response.${response.status}`)
  342. let json
  343. try {
  344. json = JSON.parse(body)
  345. } catch (err) {
  346. // some responses are empty. Ignore JSON parsing errors
  347. }
  348. timer.done()
  349. let newClsiServerId
  350. if (CLSI_COOKIES_ENABLED) {
  351. newClsiServerId = _getClsiServerIdFromResponse(response)
  352. await ClsiCookieManager.promises.setServerId(
  353. projectId,
  354. userId,
  355. compileGroup,
  356. compileBackendClass,
  357. newClsiServerId,
  358. clsiServerId
  359. )
  360. }
  361. const currentCompileTime = new Date() - currentBackendStartTime
  362. // Start new backend request in the background
  363. const newBackendStartTime = new Date()
  364. _makeNewBackendRequest(
  365. projectId,
  366. userId,
  367. compileGroup,
  368. compileBackendClass,
  369. url,
  370. opts
  371. )
  372. .then(result => {
  373. if (result == null || !url.pathname.endsWith('/compile')) {
  374. return
  375. }
  376. const current = json.compile
  377. const {
  378. body: { compile: next },
  379. newCompileBackendClass,
  380. } = result
  381. const newBackendCompileTime = new Date() - newBackendStartTime
  382. const statusCodeSame = next.status === current.status
  383. const timeDifference = newBackendCompileTime - currentCompileTime
  384. logger.debug(
  385. {
  386. statusCodeSame,
  387. timeDifference,
  388. currentCompileTime,
  389. newBackendCompileTime,
  390. projectId,
  391. },
  392. 'both clsi requests returned'
  393. )
  394. if (
  395. current.status === 'success' &&
  396. current.status === next.status &&
  397. current.stats.isInitialCompile === next.stats.isInitialCompile &&
  398. current.stats.restoredClsiCache === next.stats.restoredClsiCache
  399. ) {
  400. const fraction = next.timings.compileE2E / current.timings.compileE2E
  401. Metrics.histogram(
  402. 'compile_backend_difference_v1',
  403. fraction * 100,
  404. [
  405. // Increment the version in the metrics name when changing the buckets.
  406. 0,
  407. 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100,
  408. 105, 110, 115, 120,
  409. ],
  410. { path: compileBackendClass, method: newCompileBackendClass }
  411. )
  412. AnalyticsManager.recordEventForUserInBackground(
  413. userId,
  414. 'double-compile-result',
  415. {
  416. projectId,
  417. compileBackendClass,
  418. newCompileBackendClass,
  419. status: current.status,
  420. compileTime: current.timings.compileE2E,
  421. newCompileTime: next.timings.compileE2E,
  422. clsiServerId: newClsiServerId || clsiServerId,
  423. newClsiServerId: result.newClsiServerId,
  424. // Successful compiles are guaranteed to have an output.pdf file.
  425. pdfSize: current.outputFiles.find(f => f.path === 'output.pdf')
  426. .size,
  427. newPdfSize: next.outputFiles.find(f => f.path === 'output.pdf')
  428. .size,
  429. }
  430. )
  431. }
  432. })
  433. .catch(err => {
  434. logger.warn({ err }, 'Error making request to new CLSI backend')
  435. })
  436. return {
  437. body: json,
  438. clsiServerId: newClsiServerId || clsiServerId,
  439. }
  440. }
  441. async function _makeNewBackendRequest(
  442. projectId,
  443. userId,
  444. compileGroup,
  445. currentCompileBackendClass,
  446. url,
  447. opts
  448. ) {
  449. if (Settings.apis.clsi_new?.url == null) {
  450. return null
  451. }
  452. const newCompileBackendClass = getNewCompileBackendClass(
  453. projectId,
  454. currentCompileBackendClass
  455. )
  456. if (!newCompileBackendClass) return null
  457. url = new URL(
  458. url.toString().replace(Settings.apis.clsi.url, Settings.apis.clsi_new.url)
  459. )
  460. url.searchParams.set('compileBackendClass', newCompileBackendClass)
  461. const clsiServerId =
  462. await NewBackendCloudClsiCookieManager.promises.getServerId(
  463. projectId,
  464. userId,
  465. compileGroup,
  466. newCompileBackendClass
  467. )
  468. opts = {
  469. ...opts,
  470. headers: {
  471. Accept: 'application/json',
  472. 'Content-Type': 'application/json',
  473. },
  474. }
  475. if (CLSI_COOKIES_ENABLED) {
  476. const cookie = new Cookie({
  477. key: Settings.clsiCookie.key,
  478. value: clsiServerId,
  479. })
  480. opts.headers.Cookie = cookie.cookieString()
  481. }
  482. const timer = new Metrics.Timer('compile.newBackend')
  483. let response, body
  484. try {
  485. ;({ body, response } = await fetchStringWithResponse(url, opts))
  486. } catch (err) {
  487. throw OError.tag(err, 'error making request to new CLSI', {
  488. userId,
  489. projectId,
  490. })
  491. }
  492. let json
  493. try {
  494. json = JSON.parse(body)
  495. } catch (err) {
  496. // Some responses are empty. Ignore JSON parsing errors
  497. }
  498. timer.done()
  499. let newClsiServerId
  500. if (CLSI_COOKIES_ENABLED) {
  501. newClsiServerId = _getClsiServerIdFromResponse(response)
  502. await NewBackendCloudClsiCookieManager.promises.setServerId(
  503. projectId,
  504. userId,
  505. compileGroup,
  506. newCompileBackendClass,
  507. newClsiServerId,
  508. clsiServerId
  509. )
  510. }
  511. return {
  512. response,
  513. body: json,
  514. newCompileBackendClass,
  515. newClsiServerId: clsiServerId || newClsiServerId,
  516. }
  517. }
  518. function _getCompilerUrl(
  519. compileBackendClass,
  520. compileGroup,
  521. projectId,
  522. userId,
  523. action
  524. ) {
  525. const u = new URL(`/project/${projectId}`, Settings.apis.clsi.url)
  526. if (userId != null) {
  527. u.pathname += `/user/${userId}`
  528. }
  529. if (action != null) {
  530. u.pathname += `/${action}`
  531. }
  532. u.searchParams.set('compileBackendClass', compileBackendClass)
  533. u.searchParams.set('compileGroup', compileGroup)
  534. return u
  535. }
  536. async function _postToClsi(
  537. projectId,
  538. userId,
  539. req,
  540. compileBackendClass,
  541. compileGroup
  542. ) {
  543. const url = _getCompilerUrl(
  544. compileBackendClass,
  545. compileGroup,
  546. projectId,
  547. userId,
  548. 'compile'
  549. )
  550. const opts = {
  551. json: req,
  552. method: 'POST',
  553. signal: AbortSignal.timeout(COMPILE_REQUEST_TIMEOUT_MS),
  554. }
  555. try {
  556. const { body, clsiServerId } = await _makeRequest(
  557. projectId,
  558. userId,
  559. compileGroup,
  560. compileBackendClass,
  561. url,
  562. opts
  563. )
  564. return { response: body, clsiServerId }
  565. } catch (err) {
  566. if (err instanceof RequestFailedError) {
  567. if (err.response.status === 413) {
  568. return { response: { compile: { status: 'project-too-large' } } }
  569. } else if (err.response.status === 409) {
  570. return { response: { compile: { status: 'conflict' } } }
  571. } else if (err.response.status === 423) {
  572. return { response: { compile: { status: 'compile-in-progress' } } }
  573. } else if (err.response.status === 503) {
  574. return { response: { compile: { status: 'unavailable' } } }
  575. } else {
  576. throw new OError(
  577. `CLSI returned non-success code: ${err.response.status}`,
  578. {
  579. projectId,
  580. userId,
  581. compileOptions: req.compile.options,
  582. rootResourcePath: req.compile.rootResourcePath,
  583. clsiResponse: err.body,
  584. statusCode: err.response.status,
  585. }
  586. )
  587. }
  588. } else {
  589. throw new OError(
  590. 'failed to make request to CLSI',
  591. {
  592. projectId,
  593. userId,
  594. compileOptions: req.compile.options,
  595. rootResourcePath: req.compile.rootResourcePath,
  596. },
  597. err
  598. )
  599. }
  600. }
  601. }
  602. function _parseOutputFiles(projectId, rawOutputFiles = []) {
  603. const outputFiles = []
  604. for (const file of rawOutputFiles) {
  605. const f = {
  606. path: file.path, // the clsi is now sending this to web
  607. url: new URL(file.url).pathname, // the location of the file on the clsi, excluding the host part
  608. type: file.type,
  609. build: file.build,
  610. }
  611. if (file.path === 'output.pdf') {
  612. f.contentId = file.contentId
  613. f.ranges = file.ranges || []
  614. f.size = file.size
  615. f.startXRefTable = file.startXRefTable
  616. f.createdAt = new Date()
  617. }
  618. outputFiles.push(f)
  619. }
  620. return outputFiles
  621. }
  622. async function _buildRequest(projectId, options) {
  623. const project = await ProjectGetter.promises.getProject(projectId, {
  624. compiler: 1,
  625. rootDoc_id: 1,
  626. imageName: 1,
  627. rootFolder: 1,
  628. 'overleaf.history.id': 1,
  629. })
  630. if (project == null) {
  631. throw new Errors.NotFoundError(`project does not exist: ${projectId}`)
  632. }
  633. if (!VALID_COMPILERS.includes(project.compiler)) {
  634. project.compiler = 'pdflatex'
  635. }
  636. if (options.incrementalCompilesEnabled || options.syncType != null) {
  637. // new way, either incremental or full
  638. const timer = new Metrics.Timer('editor.compile-getdocs-redis')
  639. let projectStateHash, docUpdaterDocs
  640. try {
  641. ;({ projectStateHash, docs: docUpdaterDocs } =
  642. await getContentFromDocUpdaterIfMatch(projectId, project, options))
  643. } catch (err) {
  644. logger.error({ err, projectId }, 'error checking project state')
  645. // note: we don't bail out when there's an error getting
  646. // incremental files from the docupdater, we just fall back
  647. // to a normal compile below
  648. }
  649. timer.done()
  650. // see if we can send an incremental update to the CLSI
  651. if (docUpdaterDocs != null && options.syncType !== 'full') {
  652. Metrics.inc('compile-from-redis')
  653. return _buildRequestFromDocupdater(
  654. projectId,
  655. options,
  656. project,
  657. projectStateHash,
  658. docUpdaterDocs
  659. )
  660. } else {
  661. Metrics.inc('compile-from-mongo')
  662. return await _buildRequestFromMongo(
  663. projectId,
  664. options,
  665. project,
  666. projectStateHash
  667. )
  668. }
  669. } else {
  670. // old way, always from mongo
  671. const timer = new Metrics.Timer('editor.compile-getdocs-mongo')
  672. const { docs, files } = await _getContentFromMongo(projectId)
  673. timer.done()
  674. return _finaliseRequest(projectId, options, project, docs, files)
  675. }
  676. }
  677. async function getContentFromDocUpdaterIfMatch(projectId, project, options) {
  678. const projectStateHash = ClsiStateManager.computeHash(project, options)
  679. const docs = await DocumentUpdaterHandler.promises.getProjectDocsIfMatch(
  680. projectId,
  681. projectStateHash
  682. )
  683. return { projectStateHash, docs }
  684. }
  685. async function getOutputFileStream(
  686. projectId,
  687. userId,
  688. options,
  689. clsiServerId,
  690. buildId,
  691. outputFilePath
  692. ) {
  693. const { compileBackendClass, compileGroup } = options
  694. const url = new URL(
  695. `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/output/${outputFilePath}`
  696. )
  697. url.searchParams.set('compileBackendClass', compileBackendClass)
  698. url.searchParams.set('compileGroup', compileGroup)
  699. url.searchParams.set('clsiserverid', clsiServerId)
  700. try {
  701. const stream = await fetchStream(url, {
  702. signal: AbortSignal.timeout(OUTPUT_FILE_TIMEOUT_MS),
  703. })
  704. return stream
  705. } catch (err) {
  706. throw new Errors.OutputFileFetchFailedError(
  707. 'failed to fetch output file from CLSI',
  708. {
  709. projectId,
  710. userId,
  711. url,
  712. status: err.response?.status,
  713. }
  714. )
  715. }
  716. }
  717. function _buildRequestFromDocupdater(
  718. projectId,
  719. options,
  720. project,
  721. projectStateHash,
  722. docUpdaterDocs
  723. ) {
  724. const docPath = ProjectEntityHandler.getAllDocPathsFromProject(project)
  725. const docs = {}
  726. for (const doc of docUpdaterDocs || []) {
  727. const path = docPath[doc._id]
  728. docs[path] = doc
  729. }
  730. // send new docs but not files as those are already on the clsi
  731. options = _.clone(options)
  732. options.syncType = 'incremental'
  733. options.syncState = projectStateHash
  734. // create stub doc entries for any possible root docs, if not
  735. // present in the docupdater. This allows finaliseRequest to
  736. // identify the root doc.
  737. const possibleRootDocIds = [options.rootDoc_id, project.rootDoc_id]
  738. for (const rootDocId of possibleRootDocIds) {
  739. if (rootDocId != null && rootDocId in docPath) {
  740. const path = docPath[rootDocId]
  741. if (docs[path] == null) {
  742. docs[path] = { _id: rootDocId, path }
  743. }
  744. }
  745. }
  746. return _finaliseRequest(projectId, options, project, docs, [])
  747. }
  748. async function _buildRequestFromMongo(
  749. projectId,
  750. options,
  751. project,
  752. projectStateHash
  753. ) {
  754. const { docs, files } = await _getContentFromMongo(projectId)
  755. options = {
  756. ...options,
  757. syncType: 'full',
  758. syncState: projectStateHash,
  759. }
  760. return _finaliseRequest(projectId, options, project, docs, files)
  761. }
  762. async function _getContentFromMongo(projectId) {
  763. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  764. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  765. const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
  766. return { docs, files }
  767. }
  768. function _finaliseRequest(projectId, options, project, docs, files) {
  769. const resources = []
  770. let flags
  771. let rootResourcePath = null
  772. let rootResourcePathOverride = null
  773. let hasMainFile = false
  774. let numberOfDocsInProject = 0
  775. for (let path in docs) {
  776. const doc = docs[path]
  777. path = path.replace(/^\//, '') // Remove leading /
  778. numberOfDocsInProject++
  779. if (doc.lines != null) {
  780. // add doc to resources unless it is just a stub entry
  781. resources.push({
  782. path,
  783. content: doc.lines.join('\n'),
  784. })
  785. }
  786. if (
  787. project.rootDoc_id != null &&
  788. doc._id.toString() === project.rootDoc_id.toString()
  789. ) {
  790. rootResourcePath = path
  791. }
  792. if (
  793. options.rootDoc_id != null &&
  794. doc._id.toString() === options.rootDoc_id.toString()
  795. ) {
  796. rootResourcePathOverride = path
  797. }
  798. if (path === 'main.tex') {
  799. hasMainFile = true
  800. }
  801. }
  802. if (rootResourcePathOverride != null) {
  803. rootResourcePath = rootResourcePathOverride
  804. }
  805. if (rootResourcePath == null) {
  806. if (hasMainFile) {
  807. rootResourcePath = 'main.tex'
  808. } else if (numberOfDocsInProject === 1) {
  809. // only one file, must be the main document
  810. for (const path in docs) {
  811. // Remove leading /
  812. rootResourcePath = path.replace(/^\//, '')
  813. }
  814. } else {
  815. throw new OError('no main file specified', { projectId })
  816. }
  817. }
  818. const historyId = project.overleaf.history.id
  819. if (!historyId) {
  820. throw new OError('project does not have a history id', { projectId })
  821. }
  822. for (let path in files) {
  823. const file = files[path]
  824. path = path.replace(/^\//, '') // Remove leading /
  825. resources.push({
  826. path,
  827. url: HistoryManager.getFilestoreBlobURL(historyId, file.hash),
  828. modified: file.created?.getTime(),
  829. })
  830. }
  831. if (options.fileLineErrors) {
  832. flags = ['-file-line-error']
  833. }
  834. return {
  835. compile: {
  836. options: {
  837. buildId: options.buildId,
  838. editorId: options.editorId,
  839. compiler: project.compiler,
  840. timeout: options.timeout,
  841. imageName: project.imageName,
  842. draft: Boolean(options.draft),
  843. stopOnFirstError: Boolean(options.stopOnFirstError),
  844. check: options.check,
  845. syncType: options.syncType,
  846. syncState: options.syncState,
  847. compileGroup: options.compileGroup,
  848. // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
  849. compileFromClsiCache:
  850. ['alpha', 'priority'].includes(options.compileGroup) &&
  851. options.compileFromClsiCache,
  852. populateClsiCache:
  853. ['alpha', 'priority'].includes(options.compileGroup) &&
  854. options.populateClsiCache,
  855. enablePdfCaching:
  856. (Settings.enablePdfCaching && options.enablePdfCaching) || false,
  857. pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
  858. flags,
  859. metricsMethod: options.compileGroup,
  860. metricsPath: options.metricsPath,
  861. },
  862. rootResourcePath,
  863. resources,
  864. },
  865. }
  866. }
  867. async function wordCount(projectId, userId, file, limits, clsiserverid) {
  868. const { compileBackendClass, compileGroup } = limits
  869. const req = await _buildRequest(projectId, limits)
  870. const filename = file || req.compile.rootResourcePath
  871. const url = _getCompilerUrl(
  872. compileBackendClass,
  873. compileGroup,
  874. projectId,
  875. userId,
  876. 'wordcount'
  877. )
  878. url.searchParams.set('file', filename)
  879. url.searchParams.set('image', req.compile.options.imageName)
  880. const opts = {
  881. method: 'GET',
  882. }
  883. const { body } = await _makeRequestWithClsiServerId(
  884. projectId,
  885. userId,
  886. compileGroup,
  887. compileBackendClass,
  888. url,
  889. opts,
  890. clsiserverid
  891. )
  892. return body
  893. }
  894. async function syncTeX(
  895. projectId,
  896. userId,
  897. {
  898. direction,
  899. compileFromClsiCache,
  900. limits,
  901. imageName,
  902. validatedOptions,
  903. clsiServerId,
  904. }
  905. ) {
  906. const { compileBackendClass, compileGroup } = limits
  907. const url = _getCompilerUrl(
  908. compileBackendClass,
  909. compileGroup,
  910. projectId,
  911. userId,
  912. `sync/${direction}`
  913. )
  914. url.searchParams.set(
  915. 'compileFromClsiCache',
  916. compileFromClsiCache && ['alpha', 'priority'].includes(compileGroup)
  917. )
  918. url.searchParams.set('imageName', imageName)
  919. for (const [key, value] of Object.entries(validatedOptions)) {
  920. url.searchParams.set(key, value)
  921. }
  922. const opts = {
  923. method: 'GET',
  924. }
  925. try {
  926. const { body } = await _makeRequestWithClsiServerId(
  927. projectId,
  928. userId,
  929. compileGroup,
  930. compileBackendClass,
  931. url,
  932. opts,
  933. clsiServerId
  934. )
  935. return body
  936. } catch (err) {
  937. if (err instanceof RequestFailedError && err.response.status === 404) {
  938. throw new Errors.NotFoundError()
  939. }
  940. throw err
  941. }
  942. }
  943. function _getClsiServerIdFromResponse(response) {
  944. const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
  945. for (const header of setCookieHeaders) {
  946. const cookie = Cookie.parse(header)
  947. if (cookie.key === Settings.clsiCookie.key) {
  948. return cookie.value
  949. }
  950. }
  951. return null
  952. }
  953. export default {
  954. sendRequest: callbackifyMultiResult(sendRequest, [
  955. 'status',
  956. 'outputFiles',
  957. 'clsiServerId',
  958. 'validationProblems',
  959. 'stats',
  960. 'timings',
  961. 'outputUrlPrefix',
  962. 'buildId',
  963. 'clsiCacheShard',
  964. ]),
  965. sendExternalRequest: callbackifyMultiResult(sendExternalRequest, [
  966. 'status',
  967. 'outputFiles',
  968. 'clsiServerId',
  969. 'validationProblems',
  970. 'stats',
  971. 'timings',
  972. 'outputUrlPrefix',
  973. ]),
  974. stopCompile: callbackify(stopCompile),
  975. deleteAuxFiles: callbackify(deleteAuxFiles),
  976. getOutputFileStream: callbackify(getOutputFileStream),
  977. wordCount: callbackify(wordCount),
  978. syncTeX: callbackify(syncTeX),
  979. promises: {
  980. sendRequest,
  981. sendExternalRequest,
  982. stopCompile,
  983. deleteAuxFiles,
  984. getOutputFileStream,
  985. wordCount,
  986. syncTeX,
  987. },
  988. }