ClsiManager.mjs 35 KB

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