ClsiManager.mjs 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288
  1. import { callbackify } from 'node:util'
  2. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  3. import {
  4. fetchStream,
  5. fetchString,
  6. fetchStringWithResponse,
  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. import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
  27. // use the redis db with eviction policy enabled
  28. const rclient = RedisWrapper.client('clsi_cookie')
  29. const ClsiCookieManager = ClsiCookieManagerFactory(
  30. Settings.apis.clsi?.backendGroupName
  31. )
  32. const NewBackendCloudClsiCookieManager = ClsiCookieManagerFactory(
  33. Settings.apis.clsi_new?.backendGroupName
  34. )
  35. const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
  36. const OUTPUT_FILE_TIMEOUT_MS = 60000
  37. const CLSI_COOKIES_ENABLED = (Settings.clsiCookie?.key ?? '') !== ''
  38. // The timeout in services/clsi/app.js is 10 minutes, so we'll be on the safe side with 12 minutes
  39. const COMPILE_REQUEST_TIMEOUT_MS = 12 * 60 * 1000
  40. // Enable clsi-cache for all compiles for 20min when detecting low capacity.
  41. const ENABLE_COMPILE_FROM_CACHE_ON_503_MS = 20 * 60 * 1000
  42. let enableCompileFromCacheUntil = 0
  43. function _baseHistoryVersionKey(projectId, userId) {
  44. return `baseHistoryVersion:${projectId}:${userId}`
  45. }
  46. async function getBaseHistoryVersion(projectId, userId) {
  47. let v
  48. try {
  49. v = await rclient.get(_baseHistoryVersionKey(projectId, userId))
  50. } catch (err) {
  51. logger.warn({ err, projectId, userId }, 'failed to get baseHistoryVersion')
  52. return -1
  53. }
  54. if (!v) return -1
  55. const n = parseInt(v, 10)
  56. if (Number.isNaN(n)) return -1
  57. return n
  58. }
  59. async function setBaseHistoryVersion(projectId, userId, baseHistoryVersion) {
  60. const clsiCacheExpiryInSeconds = 8 * 24 * 60 * 60 // 8 days
  61. try {
  62. await rclient.setex(
  63. _baseHistoryVersionKey(projectId, userId),
  64. clsiCacheExpiryInSeconds,
  65. baseHistoryVersion
  66. )
  67. } catch (err) {
  68. logger.warn({ err, projectId, userId }, 'failed to set baseHistoryVersion')
  69. }
  70. }
  71. async function clearBaseHistoryVersion(projectId, userId) {
  72. await rclient.del(_baseHistoryVersionKey(projectId, userId))
  73. }
  74. function getNewCompileBackendClass(projectId, compileBackendClass) {
  75. // Sample x% of projects to move up one bracket.
  76. if (
  77. SplitTestHandler.getPercentile(projectId, 'double-compile', 'release') >=
  78. Settings.apis.clsi_new.sample
  79. ) {
  80. return null
  81. }
  82. switch (compileBackendClass) {
  83. case 'c3d':
  84. return 'n4'
  85. case 'c4d':
  86. return 'n4'
  87. default:
  88. throw new Error('unknown ?compileBackendClass')
  89. }
  90. }
  91. /**
  92. * @param {string} projectId
  93. * @param {string | null} userId
  94. * @param {string} compileBackendClass
  95. * @return {Promise<void>}
  96. */
  97. async function clearClsiServerId(projectId, userId, compileBackendClass) {
  98. const jobs = [
  99. ClsiCookieManager.promises.clearServerId(
  100. projectId,
  101. userId,
  102. compileBackendClass
  103. ),
  104. ]
  105. if (Settings.apis.clsi_new?.url) {
  106. // Mirror resetting the clsiserverid in both backends.
  107. const newCompileBackendClass = getNewCompileBackendClass(
  108. projectId,
  109. compileBackendClass
  110. )
  111. if (newCompileBackendClass) {
  112. jobs.push(
  113. NewBackendCloudClsiCookieManager.promises.clearServerId(
  114. projectId,
  115. userId,
  116. newCompileBackendClass
  117. )
  118. )
  119. }
  120. }
  121. await Promise.all(jobs)
  122. }
  123. function collectMetricsOnBlgFiles(outputFiles) {
  124. let topLevel = 0
  125. let nested = 0
  126. for (const outputFile of outputFiles) {
  127. if (outputFile.type === 'blg') {
  128. if (outputFile.path.includes('/')) {
  129. nested++
  130. } else {
  131. topLevel++
  132. }
  133. }
  134. }
  135. Metrics.count('blg_output_file', topLevel, 1, { path: 'top-level' })
  136. Metrics.count('blg_output_file', nested, 1, { path: 'nested' })
  137. }
  138. async function sendRequest(projectId, userId, options) {
  139. if (options == null) {
  140. options = {}
  141. }
  142. let result = await sendRequestOnce(projectId, userId, options)
  143. if (result.status === 'missing-updates') {
  144. // try again with updated baseline
  145. result = await sendRequestOnce(projectId, userId, {
  146. ...options,
  147. baseHistoryVersion: result.baseHistoryVersion,
  148. })
  149. } else if (result.status === 'conflict') {
  150. // Try again, with a full compile
  151. result = await sendRequestOnce(projectId, userId, {
  152. ...options,
  153. syncType: 'full',
  154. })
  155. } else if (result.status === 'unavailable') {
  156. result = await sendRequestOnce(projectId, userId, {
  157. ...options,
  158. syncType: 'full',
  159. forceNewClsiServer: true,
  160. })
  161. }
  162. return result
  163. }
  164. async function sendRequestOnce(projectId, userId, options) {
  165. let req
  166. try {
  167. req = await _buildRequest(projectId, userId, options)
  168. } catch (err) {
  169. if (err.message === 'no main file specified') {
  170. return {
  171. status: 'validation-problems',
  172. validationProblems: { mainFile: err.message },
  173. }
  174. } else {
  175. throw OError.tag(err, 'Could not build request to CLSI', {
  176. projectId,
  177. options,
  178. })
  179. }
  180. }
  181. return await _sendBuiltRequest(projectId, userId, req, options)
  182. }
  183. // for public API requests where there is no project id
  184. async function sendExternalRequest(submissionId, clsiRequest, options) {
  185. if (options == null) {
  186. options = {}
  187. }
  188. return await _sendBuiltRequest(submissionId, null, clsiRequest, options)
  189. }
  190. async function stopCompile(projectId, userId, options) {
  191. if (options == null) {
  192. options = {}
  193. }
  194. const { compileBackendClass, compileGroup } = options
  195. const url = _getCompilerUrl(
  196. compileBackendClass,
  197. compileGroup,
  198. projectId,
  199. userId,
  200. 'compile/stop'
  201. )
  202. const opts = { method: 'POST' }
  203. await _makeRequest(
  204. projectId,
  205. userId,
  206. compileGroup,
  207. compileBackendClass,
  208. url,
  209. opts
  210. )
  211. }
  212. /**
  213. * @param {PromiseSettledResult} result
  214. * @private
  215. */
  216. function _throwIfRejected(result) {
  217. if (result.status === 'rejected') {
  218. throw result.reason
  219. }
  220. }
  221. async function deleteAuxFiles(projectId, userId, options, clsiserverid) {
  222. if (options == null) {
  223. options = {}
  224. }
  225. const { compileBackendClass, compileGroup } = options
  226. const url = _getCompilerUrl(
  227. compileBackendClass,
  228. compileGroup,
  229. projectId,
  230. userId
  231. )
  232. const [
  233. clsiResult,
  234. clsiCacheResult,
  235. documentUpdaterResult,
  236. clsiServerIdResult,
  237. baseHistoryVersionResult,
  238. ] = await Promise.allSettled([
  239. _makeRequestWithClsiServerId(
  240. projectId,
  241. userId,
  242. compileGroup,
  243. compileBackendClass,
  244. url,
  245. { method: 'DELETE' },
  246. clsiserverid
  247. ),
  248. ClsiCacheHandler.clearCache(projectId, userId),
  249. DocumentUpdaterHandler.promises.clearProjectState(projectId),
  250. clearClsiServerId(projectId, userId, compileBackendClass),
  251. clearBaseHistoryVersion(projectId, userId),
  252. ])
  253. if (clsiCacheResult.status === 'rejected') {
  254. logger.warn(
  255. { err: clsiCacheResult.reason, projectId, userId },
  256. 'purge clsi-cache failed'
  257. )
  258. }
  259. if (baseHistoryVersionResult.status === 'rejected') {
  260. logger.warn(
  261. { err: baseHistoryVersionResult.reason, projectId, userId },
  262. 'failed to clear baseHistoryVersion'
  263. )
  264. }
  265. _throwIfRejected(clsiResult)
  266. _throwIfRejected(documentUpdaterResult)
  267. _throwIfRejected(clsiServerIdResult)
  268. }
  269. async function _sendBuiltRequest(projectId, userId, req, options) {
  270. if (options.forceNewClsiServer) {
  271. await clearClsiServerId(projectId, userId, options.compileBackendClass)
  272. }
  273. const validationProblems = ClsiFormatChecker.checkRecoursesForProblems(
  274. req.compile?.resources || []
  275. )
  276. if (validationProblems != null) {
  277. logger.debug(
  278. { projectId, validationProblems },
  279. 'problems with users latex before compile was attempted'
  280. )
  281. return {
  282. status: 'validation-problems',
  283. validationProblems,
  284. }
  285. }
  286. const { response, clsiServerId } = await _postToClsi(
  287. projectId,
  288. userId,
  289. req,
  290. options.compileBackendClass,
  291. options.compileGroup
  292. )
  293. const outputFiles = _parseOutputFiles(
  294. projectId,
  295. response && response.compile && response.compile.outputFiles
  296. )
  297. collectMetricsOnBlgFiles(outputFiles)
  298. const compile = response?.compile || {}
  299. if (compile.baseHistoryVersion) {
  300. await setBaseHistoryVersion(projectId, userId, compile.baseHistoryVersion)
  301. }
  302. return {
  303. status: compile.status,
  304. outputFiles,
  305. clsiServerId,
  306. buildId: compile.buildId,
  307. stats: compile.stats,
  308. timings: compile.timings,
  309. outputUrlPrefix: compile.outputUrlPrefix,
  310. clsiCacheShard: compile.clsiCacheShard,
  311. baseHistoryVersion: compile.baseHistoryVersion,
  312. }
  313. }
  314. async function _makeRequestWithClsiServerId(
  315. projectId,
  316. userId,
  317. compileGroup,
  318. compileBackendClass,
  319. url,
  320. opts,
  321. clsiserverid
  322. ) {
  323. if (clsiserverid) {
  324. // ignore cookies and newBackend, go straight to the clsi node
  325. const urlWithId = new URL(url)
  326. urlWithId.searchParams.set('clsiserverid', clsiserverid)
  327. let body
  328. try {
  329. body = await fetchString(urlWithId, opts)
  330. } catch (err) {
  331. throw OError.tag(err, 'error making request to CLSI', {
  332. userId,
  333. projectId,
  334. })
  335. }
  336. let json
  337. try {
  338. json = JSON.parse(body)
  339. } catch (err) {
  340. // some responses are empty. Ignore JSON parsing errors.
  341. }
  342. _makeNewBackendRequest(
  343. projectId,
  344. userId,
  345. compileGroup,
  346. compileBackendClass,
  347. url,
  348. opts
  349. ).catch(err => {
  350. logger.warn({ err }, 'Error making request to new CLSI backend')
  351. })
  352. return { body: json }
  353. } else {
  354. return await _makeRequest(
  355. projectId,
  356. userId,
  357. compileGroup,
  358. compileBackendClass,
  359. url,
  360. opts
  361. )
  362. }
  363. }
  364. async function _makeRequest(
  365. projectId,
  366. userId,
  367. compileGroup,
  368. compileBackendClass,
  369. url,
  370. opts
  371. ) {
  372. const currentBackendStartTime = new Date()
  373. const clsiServerId = await ClsiCookieManager.promises.getServerId(
  374. projectId,
  375. userId,
  376. compileGroup,
  377. compileBackendClass
  378. )
  379. opts.headers = {
  380. Accept: 'application/json',
  381. 'Content-Type': 'application/json',
  382. }
  383. if (CLSI_COOKIES_ENABLED) {
  384. const cookie = new Cookie({
  385. key: Settings.clsiCookie.key,
  386. value: clsiServerId,
  387. })
  388. opts.headers.Cookie = cookie.cookieString()
  389. }
  390. const timer = new Metrics.Timer('compile.currentBackend')
  391. let response, body
  392. try {
  393. ;({ body, response } = await fetchStringWithResponse(url, opts))
  394. } catch (err) {
  395. throw OError.tag(err, 'error making request to CLSI', {
  396. projectId,
  397. userId,
  398. })
  399. }
  400. Metrics.inc(`compile.currentBackend.response.${response.status}`)
  401. let json
  402. try {
  403. json = JSON.parse(body)
  404. } catch (err) {
  405. // some responses are empty. Ignore JSON parsing errors
  406. }
  407. timer.done()
  408. let newClsiServerId
  409. if (CLSI_COOKIES_ENABLED) {
  410. newClsiServerId = _getClsiServerIdFromResponse(response)
  411. await ClsiCookieManager.promises.setServerId(
  412. projectId,
  413. userId,
  414. compileGroup,
  415. compileBackendClass,
  416. newClsiServerId,
  417. clsiServerId
  418. )
  419. }
  420. const currentCompileTime = new Date() - currentBackendStartTime
  421. // Start new backend request in the background
  422. const newBackendStartTime = new Date()
  423. _makeNewBackendRequest(
  424. projectId,
  425. userId,
  426. compileGroup,
  427. compileBackendClass,
  428. url,
  429. opts
  430. )
  431. .then(result => {
  432. if (result == null || !url.pathname.endsWith('/compile')) {
  433. return
  434. }
  435. const current = json.compile
  436. const {
  437. body: { compile: next },
  438. newCompileBackendClass,
  439. } = result
  440. const newBackendCompileTime = new Date() - newBackendStartTime
  441. const statusCodeSame = next.status === current.status
  442. const timeDifference = newBackendCompileTime - currentCompileTime
  443. logger.debug(
  444. {
  445. statusCodeSame,
  446. timeDifference,
  447. currentCompileTime,
  448. newBackendCompileTime,
  449. projectId,
  450. },
  451. 'both clsi requests returned'
  452. )
  453. if (
  454. current.status === 'success' &&
  455. current.status === next.status &&
  456. current.stats.isInitialCompile === next.stats.isInitialCompile &&
  457. current.stats.restoredClsiCache === next.stats.restoredClsiCache
  458. ) {
  459. const fraction = next.timings.compileE2E / current.timings.compileE2E
  460. Metrics.histogram(
  461. 'compile_backend_difference_v1',
  462. fraction * 100,
  463. [
  464. // Increment the version in the metrics name when changing the buckets.
  465. 0,
  466. 10, 20, 30, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100,
  467. 105, 110, 115, 120,
  468. ],
  469. { path: compileBackendClass, method: newCompileBackendClass }
  470. )
  471. AnalyticsManager.recordEventForUserInBackground(
  472. userId,
  473. 'double-compile-result',
  474. {
  475. projectId,
  476. compileBackendClass,
  477. newCompileBackendClass,
  478. status: current.status,
  479. compileTime: current.timings.compileE2E,
  480. newCompileTime: next.timings.compileE2E,
  481. clsiServerId: newClsiServerId || clsiServerId,
  482. newClsiServerId: result.newClsiServerId,
  483. // Successful compiles are guaranteed to have an output.pdf file.
  484. pdfSize: current.outputFiles.find(f => f.path === 'output.pdf')
  485. .size,
  486. newPdfSize: next.outputFiles.find(f => f.path === 'output.pdf')
  487. .size,
  488. }
  489. )
  490. }
  491. })
  492. .catch(err => {
  493. logger.warn({ err }, 'Error making request to new CLSI backend')
  494. })
  495. return {
  496. body: json,
  497. clsiServerId: newClsiServerId || clsiServerId,
  498. }
  499. }
  500. async function _makeNewBackendRequest(
  501. projectId,
  502. userId,
  503. compileGroup,
  504. currentCompileBackendClass,
  505. url,
  506. opts
  507. ) {
  508. if (Settings.apis.clsi_new?.url == null) {
  509. return null
  510. }
  511. const newCompileBackendClass = getNewCompileBackendClass(
  512. projectId,
  513. currentCompileBackendClass
  514. )
  515. if (!newCompileBackendClass) return null
  516. url = new URL(
  517. url.toString().replace(Settings.apis.clsi.url, Settings.apis.clsi_new.url)
  518. )
  519. url.searchParams.set('compileBackendClass', newCompileBackendClass)
  520. const clsiServerId =
  521. await NewBackendCloudClsiCookieManager.promises.getServerId(
  522. projectId,
  523. userId,
  524. compileGroup,
  525. newCompileBackendClass
  526. )
  527. opts = {
  528. ...opts,
  529. headers: {
  530. Accept: 'application/json',
  531. 'Content-Type': 'application/json',
  532. },
  533. }
  534. if (CLSI_COOKIES_ENABLED) {
  535. const cookie = new Cookie({
  536. key: Settings.clsiCookie.key,
  537. value: clsiServerId,
  538. })
  539. opts.headers.Cookie = cookie.cookieString()
  540. }
  541. const timer = new Metrics.Timer('compile.newBackend')
  542. let response, body
  543. try {
  544. ;({ body, response } = await fetchStringWithResponse(url, opts))
  545. } catch (err) {
  546. throw OError.tag(err, 'error making request to new CLSI', {
  547. userId,
  548. projectId,
  549. })
  550. }
  551. let json
  552. try {
  553. json = JSON.parse(body)
  554. } catch (err) {
  555. // Some responses are empty. Ignore JSON parsing errors
  556. }
  557. timer.done()
  558. let newClsiServerId
  559. if (CLSI_COOKIES_ENABLED) {
  560. newClsiServerId = _getClsiServerIdFromResponse(response)
  561. await NewBackendCloudClsiCookieManager.promises.setServerId(
  562. projectId,
  563. userId,
  564. compileGroup,
  565. newCompileBackendClass,
  566. newClsiServerId,
  567. clsiServerId
  568. )
  569. }
  570. return {
  571. response,
  572. body: json,
  573. newCompileBackendClass,
  574. newClsiServerId: clsiServerId || newClsiServerId,
  575. }
  576. }
  577. function _getCompilerUrl(
  578. compileBackendClass,
  579. compileGroup,
  580. projectId,
  581. userId,
  582. action
  583. ) {
  584. const u = new URL(Settings.apis.clsi.url)
  585. u.pathname = `/project/${projectId}`
  586. if (userId != null) {
  587. u.pathname += `/user/${userId}`
  588. }
  589. if (action != null) {
  590. u.pathname += `/${action}`
  591. }
  592. u.searchParams.set('compileBackendClass', compileBackendClass)
  593. u.searchParams.set('compileGroup', compileGroup)
  594. return u
  595. }
  596. async function _postToClsi(
  597. projectId,
  598. userId,
  599. req,
  600. compileBackendClass,
  601. compileGroup
  602. ) {
  603. const url = _getCompilerUrl(
  604. compileBackendClass,
  605. compileGroup,
  606. projectId,
  607. userId,
  608. 'compile'
  609. )
  610. const opts = {
  611. json: req,
  612. method: 'POST',
  613. signal: AbortSignal.timeout(COMPILE_REQUEST_TIMEOUT_MS),
  614. }
  615. try {
  616. const { body, clsiServerId } = await _makeRequest(
  617. projectId,
  618. userId,
  619. compileGroup,
  620. compileBackendClass,
  621. url,
  622. opts
  623. )
  624. return { response: body, clsiServerId }
  625. } catch (err) {
  626. if (err instanceof RequestFailedError) {
  627. if (err.response.status === 413) {
  628. return { response: { compile: { status: 'project-too-large' } } }
  629. } else if (err.response.status === 409) {
  630. try {
  631. const body = JSON.parse(err.body || '{}')
  632. if (body.compile?.status === 'missing-updates') {
  633. return { response: body }
  634. }
  635. } catch {}
  636. return { response: { compile: { status: 'conflict' } } }
  637. } else if (err.response.status === 423) {
  638. return { response: { compile: { status: 'compile-in-progress' } } }
  639. } else if (err.response.status === 502 || err.response.status === 503) {
  640. enableCompileFromCacheUntil =
  641. Date.now() + ENABLE_COMPILE_FROM_CACHE_ON_503_MS
  642. return { response: { compile: { status: 'unavailable' } } }
  643. } else if (err.response.status === 504) {
  644. return { response: { compile: { status: 'timedout' } } }
  645. } else {
  646. throw new OError('CLSI returned non-success code', {
  647. projectId,
  648. userId,
  649. compileOptions: req.compile.options,
  650. rootResourcePath: req.compile.rootResourcePath,
  651. clsiResponse: err.body,
  652. statusCode: err.response.status,
  653. })
  654. }
  655. } else {
  656. throw new OError(
  657. 'failed to make request to CLSI',
  658. {
  659. projectId,
  660. userId,
  661. compileOptions: req.compile.options,
  662. rootResourcePath: req.compile.rootResourcePath,
  663. },
  664. err
  665. )
  666. }
  667. }
  668. }
  669. function _parseOutputFiles(projectId, rawOutputFiles = []) {
  670. const outputFiles = []
  671. for (const file of rawOutputFiles) {
  672. const f = {
  673. path: file.path, // the clsi is now sending this to web
  674. url: new URL(file.url).pathname, // the location of the file on the clsi, excluding the host part
  675. type: file.type,
  676. build: file.build,
  677. }
  678. if (file.path === 'output.pdf') {
  679. f.contentId = file.contentId
  680. f.ranges = file.ranges || []
  681. f.size = file.size
  682. f.startXRefTable = file.startXRefTable
  683. f.createdAt = new Date()
  684. }
  685. outputFiles.push(f)
  686. }
  687. return outputFiles
  688. }
  689. async function _buildRequest(projectId, userId, options) {
  690. const project = await ProjectGetter.promises.getProject(projectId, {
  691. compiler: 1,
  692. imageName: 1,
  693. 'overleaf.history.id': 1,
  694. ...(options.compileFromHistory ? {} : { rootDoc_id: 1, rootFolder: 1 }),
  695. })
  696. if (project == null) {
  697. throw new Errors.NotFoundError(`project does not exist: ${projectId}`)
  698. }
  699. if (!VALID_COMPILERS.includes(project.compiler)) {
  700. project.compiler = 'pdflatex'
  701. }
  702. const historyId = project.overleaf.history.id
  703. let { baseHistoryVersion } = options
  704. if (options.compileFromHistory && !baseHistoryVersion) {
  705. baseHistoryVersion = await getBaseHistoryVersion(projectId, userId)
  706. }
  707. if (options.compileFromHistory && baseHistoryVersion === -1) {
  708. // full sync
  709. try {
  710. return await _buildRequestFromHistoryFull(
  711. projectId,
  712. historyId,
  713. options,
  714. project
  715. )
  716. } catch (err) {
  717. logger.warn(
  718. { err, projectId, historyId },
  719. 'failed to compose history-full request'
  720. )
  721. // fall back to old compile mode
  722. }
  723. } else if (options.compileFromHistory) {
  724. // incremental sync
  725. try {
  726. return await _buildRequestFromHistoryIncremental(
  727. projectId,
  728. historyId,
  729. options,
  730. project,
  731. baseHistoryVersion
  732. )
  733. } catch (err) {
  734. logger.warn(
  735. { err, projectId, historyId, baseHistoryVersion },
  736. 'failed to compose history-incremental request'
  737. )
  738. // fall back to old compile mode
  739. }
  740. }
  741. if (options.incrementalCompilesEnabled || options.syncType != null) {
  742. // new way, either incremental or full
  743. const timer = new Metrics.Timer('editor.compile-getdocs-redis')
  744. let projectStateHash, docUpdaterDocs
  745. try {
  746. ;({ projectStateHash, docs: docUpdaterDocs } =
  747. await getContentFromDocUpdaterIfMatch(projectId, project, options))
  748. } catch (err) {
  749. logger.error({ err, projectId }, 'error checking project state')
  750. // note: we don't bail out when there's an error getting
  751. // incremental files from the docupdater, we just fall back
  752. // to a normal compile below
  753. }
  754. timer.done()
  755. // see if we can send an incremental update to the CLSI
  756. if (docUpdaterDocs != null && options.syncType !== 'full') {
  757. Metrics.inc('compile-from-redis')
  758. return _buildRequestFromDocupdater(
  759. projectId,
  760. options,
  761. project,
  762. projectStateHash,
  763. docUpdaterDocs
  764. )
  765. } else {
  766. Metrics.inc('compile-from-mongo')
  767. return await _buildRequestFromMongo(
  768. projectId,
  769. options,
  770. project,
  771. projectStateHash
  772. )
  773. }
  774. } else {
  775. // old way, always from mongo
  776. const timer = new Metrics.Timer('editor.compile-getdocs-mongo')
  777. const { docs, files } = await _getContentFromMongo(projectId)
  778. timer.done()
  779. return _finaliseRequest(projectId, options, project, docs, files)
  780. }
  781. }
  782. async function getContentFromDocUpdaterIfMatch(projectId, project, options) {
  783. const projectStateHash = ClsiStateManager.computeHash(project, options)
  784. const docs = await DocumentUpdaterHandler.promises.getProjectDocsIfMatch(
  785. projectId,
  786. projectStateHash
  787. )
  788. return { projectStateHash, docs }
  789. }
  790. async function getOutputFileStream(
  791. projectId,
  792. userId,
  793. options,
  794. clsiServerId,
  795. buildId,
  796. outputFilePath
  797. ) {
  798. const { compileBackendClass, compileGroup } = options
  799. const url = new URL(Settings.apis.clsi.downloadHost)
  800. url.pathname = `/project/${projectId}/user/${userId}/build/${buildId}/output/${outputFilePath}`
  801. url.searchParams.set('compileBackendClass', compileBackendClass)
  802. url.searchParams.set('compileGroup', compileGroup)
  803. url.searchParams.set('clsiserverid', clsiServerId)
  804. try {
  805. const stream = await fetchStream(url, {
  806. signal: AbortSignal.timeout(OUTPUT_FILE_TIMEOUT_MS),
  807. })
  808. return stream
  809. } catch (err) {
  810. throw new Errors.OutputFileFetchFailedError(
  811. 'failed to fetch output file from CLSI',
  812. {
  813. projectId,
  814. userId,
  815. url,
  816. status: err.response?.status,
  817. }
  818. )
  819. }
  820. }
  821. /**
  822. * @param {import('overleaf-editor-core/lib/types.js').RawChange[]} changes
  823. * @return {import('overleaf-editor-core/lib/types.js').RawOperation[][]}
  824. * @private
  825. */
  826. function _rawChangeOperationsFromChanges(changes) {
  827. // omit timestamp (required, back-filled in clsi)
  828. // omit authors (optional)
  829. // omit v2Authors (optional)
  830. // omit origin (optional)
  831. // omit projectVersion (optional)
  832. // omit v2DocVersions (optional)
  833. return changes.map(change => change.operations)
  834. }
  835. /**
  836. * @param {import('overleaf-editor-core/lib/types.js').RawOperation[][]} rawChangeOperations
  837. * @return {Set<string>}
  838. * @private
  839. */
  840. function _collectGlobalBlobs(rawChangeOperations) {
  841. const globalBlobs = new Set()
  842. for (const operations of rawChangeOperations) {
  843. for (const operation of operations) {
  844. const hash = operation?.file?.hash
  845. if (hash && HistoryManager.isGlobalBlob(hash)) {
  846. globalBlobs.add(hash)
  847. }
  848. }
  849. }
  850. return globalBlobs
  851. }
  852. async function _buildRequestFromHistoryFull(
  853. projectId,
  854. historyId,
  855. options,
  856. project
  857. ) {
  858. await HistoryManager.promises.flushProject(projectId)
  859. const [
  860. {
  861. chunk: {
  862. history: { snapshot: rawSnapshot, changes: rawChanges },
  863. startVersion,
  864. },
  865. },
  866. /* ensureNoResyncPending throws */
  867. ] = await Promise.all([
  868. HistoryManager.promises.getLatestHistoryWithHistoryId(historyId),
  869. HistoryManager.promises.ensureNoResyncPending(projectId),
  870. ])
  871. const rawChangeOperations = _rawChangeOperationsFromChanges(rawChanges)
  872. const globalBlobs = _collectGlobalBlobs(rawChangeOperations)
  873. for (const { hash, rangesHash } of Object.values(rawSnapshot.files)) {
  874. if (hash && HistoryManager.isGlobalBlob(hash)) {
  875. globalBlobs.add(hash)
  876. }
  877. if (rangesHash && HistoryManager.isGlobalBlob(rangesHash)) {
  878. globalBlobs.add(rangesHash)
  879. }
  880. }
  881. options = {
  882. ...options,
  883. syncType: 'history-full',
  884. historyId,
  885. baseHistoryVersion: startVersion,
  886. rawSnapshot,
  887. rawChangeOperations,
  888. globalBlobs: Array.from(globalBlobs),
  889. }
  890. return _finaliseRequest(projectId, options, project, [], [])
  891. }
  892. async function _buildRequestFromHistoryIncremental(
  893. projectId,
  894. historyId,
  895. options,
  896. project,
  897. baseHistoryVersion
  898. ) {
  899. await HistoryManager.promises.flushProject(projectId)
  900. const rawChangeOperations = []
  901. let hasMore = true
  902. let since = baseHistoryVersion
  903. let size = 0
  904. while (hasMore) {
  905. let changes
  906. ;[{ changes, hasMore } /* resyncPending throws */] = await Promise.all([
  907. HistoryManager.promises.getChangesWithHistoryId(historyId, { since }),
  908. HistoryManager.promises.ensureNoResyncPending(projectId),
  909. ])
  910. since += changes.length
  911. const newRawChangeOperations = _rawChangeOperationsFromChanges(changes)
  912. size += Buffer.from(JSON.stringify(newRawChangeOperations)).byteLength
  913. if (size > 6.5 * 1024 * 1024) {
  914. // clsi has a payload limit of 7MiB. Do not send too many operations.
  915. // Fall back to sending the latest snapshot instead.
  916. try {
  917. return await _buildRequestFromHistoryFull(
  918. projectId,
  919. historyId,
  920. options,
  921. project
  922. )
  923. } catch (err) {
  924. throw OError.tag(err, 'upgrade to history-full failed', { size })
  925. }
  926. }
  927. rawChangeOperations.push(...newRawChangeOperations)
  928. }
  929. const globalBlobs = _collectGlobalBlobs(rawChangeOperations)
  930. options = {
  931. ...options,
  932. syncType: 'history-incremental',
  933. historyId,
  934. baseHistoryVersion,
  935. rawChangeOperations,
  936. globalBlobs: Array.from(globalBlobs),
  937. }
  938. return _finaliseRequest(projectId, options, project, [], [])
  939. }
  940. function _buildRequestFromDocupdater(
  941. projectId,
  942. options,
  943. project,
  944. projectStateHash,
  945. docUpdaterDocs
  946. ) {
  947. const docPath = ProjectEntityHandler.getAllDocPathsFromProject(project)
  948. const docs = {}
  949. for (const doc of docUpdaterDocs || []) {
  950. const path = docPath[doc._id]
  951. docs[path] = doc
  952. }
  953. // send new docs but not files as those are already on the clsi
  954. options = _.clone(options)
  955. options.syncType = 'incremental'
  956. options.syncState = projectStateHash
  957. // create stub doc entries for any possible root docs, if not
  958. // present in the docupdater. This allows finaliseRequest to
  959. // identify the root doc.
  960. const possibleRootDocIds = [options.rootDoc_id, project.rootDoc_id]
  961. for (const rootDocId of possibleRootDocIds) {
  962. if (rootDocId != null && rootDocId in docPath) {
  963. const path = docPath[rootDocId]
  964. if (docs[path] == null) {
  965. docs[path] = { _id: rootDocId, path }
  966. }
  967. }
  968. }
  969. return _finaliseRequest(projectId, options, project, docs, [])
  970. }
  971. async function _buildRequestFromMongo(
  972. projectId,
  973. options,
  974. project,
  975. projectStateHash
  976. ) {
  977. const { docs, files } = await _getContentFromMongo(projectId)
  978. options = {
  979. ...options,
  980. syncType: 'full',
  981. syncState: projectStateHash,
  982. }
  983. return _finaliseRequest(projectId, options, project, docs, files)
  984. }
  985. async function _getContentFromMongo(projectId) {
  986. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  987. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  988. const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
  989. return { docs, files }
  990. }
  991. function _finaliseRequest(projectId, options, project, docs, files) {
  992. const resources = []
  993. let flags
  994. let rootResourcePath = options.rootResourcePath
  995. let rootResourcePathOverride = null
  996. let hasMainFile = false
  997. let numberOfDocsInProject = 0
  998. for (let path in docs) {
  999. const doc = docs[path]
  1000. path = path.replace(/^\//, '') // Remove leading /
  1001. numberOfDocsInProject++
  1002. if (doc.lines != null) {
  1003. // add doc to resources unless it is just a stub entry
  1004. resources.push({
  1005. path,
  1006. content: doc.lines.join('\n'),
  1007. })
  1008. }
  1009. if (
  1010. project.rootDoc_id != null &&
  1011. doc._id.toString() === project.rootDoc_id.toString()
  1012. ) {
  1013. rootResourcePath = path
  1014. }
  1015. if (
  1016. options.rootDoc_id != null &&
  1017. doc._id.toString() === options.rootDoc_id.toString()
  1018. ) {
  1019. rootResourcePathOverride = path
  1020. }
  1021. if (path === 'main.tex') {
  1022. hasMainFile = true
  1023. }
  1024. }
  1025. if (rootResourcePathOverride != null) {
  1026. rootResourcePath = rootResourcePathOverride
  1027. }
  1028. if (rootResourcePath == null) {
  1029. if (hasMainFile) {
  1030. rootResourcePath = 'main.tex'
  1031. } else if (numberOfDocsInProject === 1) {
  1032. // only one file, must be the main document
  1033. for (const path in docs) {
  1034. // Remove leading /
  1035. rootResourcePath = path.replace(/^\//, '')
  1036. }
  1037. } else {
  1038. throw new OError('no main file specified', { projectId })
  1039. }
  1040. }
  1041. const historyId = project.overleaf.history.id
  1042. if (!historyId) {
  1043. throw new OError('project does not have a history id', { projectId })
  1044. }
  1045. for (let path in files) {
  1046. const file = files[path]
  1047. path = path.replace(/^\//, '') // Remove leading /
  1048. resources.push({
  1049. path,
  1050. url: HistoryManager.getFilestoreBlobURL(historyId, file.hash),
  1051. modified: file.created?.getTime(),
  1052. })
  1053. }
  1054. if (options.fileLineErrors) {
  1055. flags = ['-file-line-error']
  1056. }
  1057. return {
  1058. compile: {
  1059. options: {
  1060. historyId: options.historyId?.toString(), // send as string, if set
  1061. buildId: options.buildId,
  1062. editorId: options.editorId,
  1063. compiler: project.compiler,
  1064. timeout: options.timeout,
  1065. imageName: project.imageName,
  1066. draft: Boolean(options.draft),
  1067. stopOnFirstError: Boolean(options.stopOnFirstError),
  1068. check: options.check,
  1069. syncType: options.syncType,
  1070. syncState: options.syncState,
  1071. compileGroup: options.compileGroup,
  1072. // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
  1073. compileFromClsiCache:
  1074. // enable for premium compiles
  1075. (['alpha', 'priority'].includes(options.compileGroup) ||
  1076. // enable for free for short period when we saw low capacity
  1077. enableCompileFromCacheUntil > Date.now()) &&
  1078. options.compileFromClsiCache,
  1079. populateClsiCache: options.populateClsiCache,
  1080. enablePdfCaching:
  1081. (Settings.enablePdfCaching && options.enablePdfCaching) || false,
  1082. pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
  1083. flags,
  1084. metricsMethod: options.compileGroup,
  1085. metricsPath: options.metricsPath,
  1086. },
  1087. baseHistoryVersion: options.baseHistoryVersion,
  1088. rawSnapshot: options.rawSnapshot,
  1089. rawChangeOperations: options.rawChangeOperations,
  1090. globalBlobs: options.globalBlobs,
  1091. rootResourcePath,
  1092. resources,
  1093. },
  1094. }
  1095. }
  1096. async function wordCount(projectId, userId, file, limits, clsiserverid) {
  1097. const { compileBackendClass, compileGroup } = limits
  1098. const req = await _buildRequest(projectId, userId, limits)
  1099. const filename = file || req.compile.rootResourcePath
  1100. const url = _getCompilerUrl(
  1101. compileBackendClass,
  1102. compileGroup,
  1103. projectId,
  1104. userId,
  1105. 'wordcount'
  1106. )
  1107. url.searchParams.set('file', filename)
  1108. url.searchParams.set('image', req.compile.options.imageName)
  1109. const opts = {
  1110. method: 'GET',
  1111. }
  1112. const { body } = await _makeRequestWithClsiServerId(
  1113. projectId,
  1114. userId,
  1115. compileGroup,
  1116. compileBackendClass,
  1117. url,
  1118. opts,
  1119. clsiserverid
  1120. )
  1121. return body
  1122. }
  1123. async function syncTeX(
  1124. projectId,
  1125. userId,
  1126. {
  1127. direction,
  1128. compileFromClsiCache,
  1129. limits,
  1130. imageName,
  1131. validatedOptions,
  1132. clsiServerId,
  1133. }
  1134. ) {
  1135. const { compileBackendClass, compileGroup } = limits
  1136. const url = _getCompilerUrl(
  1137. compileBackendClass,
  1138. compileGroup,
  1139. projectId,
  1140. userId,
  1141. `sync/${direction}`
  1142. )
  1143. url.searchParams.set(
  1144. 'compileFromClsiCache',
  1145. compileFromClsiCache && ['alpha', 'priority'].includes(compileGroup)
  1146. )
  1147. url.searchParams.set('imageName', imageName)
  1148. for (const [key, value] of Object.entries(validatedOptions)) {
  1149. url.searchParams.set(key, value)
  1150. }
  1151. const opts = {
  1152. method: 'GET',
  1153. }
  1154. try {
  1155. const { body } = await _makeRequestWithClsiServerId(
  1156. projectId,
  1157. userId,
  1158. compileGroup,
  1159. compileBackendClass,
  1160. url,
  1161. opts,
  1162. clsiServerId
  1163. )
  1164. return body
  1165. } catch (err) {
  1166. if (err instanceof RequestFailedError && err.response.status === 404) {
  1167. throw new Errors.NotFoundError()
  1168. }
  1169. throw err
  1170. }
  1171. }
  1172. function _getClsiServerIdFromResponse(response) {
  1173. const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
  1174. for (const header of setCookieHeaders) {
  1175. const cookie = Cookie.parse(header)
  1176. if (cookie.key === Settings.clsiCookie.key) {
  1177. return cookie.value
  1178. }
  1179. }
  1180. return null
  1181. }
  1182. export default {
  1183. _finaliseRequest,
  1184. sendRequest: callbackifyMultiResult(sendRequest, [
  1185. 'status',
  1186. 'outputFiles',
  1187. 'clsiServerId',
  1188. 'validationProblems',
  1189. 'stats',
  1190. 'timings',
  1191. 'outputUrlPrefix',
  1192. 'buildId',
  1193. 'clsiCacheShard',
  1194. ]),
  1195. sendExternalRequest: callbackifyMultiResult(sendExternalRequest, [
  1196. 'status',
  1197. 'outputFiles',
  1198. 'clsiServerId',
  1199. 'validationProblems',
  1200. 'stats',
  1201. 'timings',
  1202. 'outputUrlPrefix',
  1203. ]),
  1204. stopCompile: callbackify(stopCompile),
  1205. deleteAuxFiles: callbackify(deleteAuxFiles),
  1206. getOutputFileStream: callbackify(getOutputFileStream),
  1207. wordCount: callbackify(wordCount),
  1208. syncTeX: callbackify(syncTeX),
  1209. promises: {
  1210. sendRequest,
  1211. sendExternalRequest,
  1212. stopCompile,
  1213. deleteAuxFiles,
  1214. getOutputFileStream,
  1215. wordCount,
  1216. syncTeX,
  1217. },
  1218. }