ClsiManager.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  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. return await _buildRequest(projectId, userId, {
  723. ...options,
  724. compileFromHistory: false,
  725. })
  726. }
  727. } else if (options.compileFromHistory) {
  728. // incremental sync
  729. try {
  730. return await _buildRequestFromHistoryIncremental(
  731. projectId,
  732. historyId,
  733. options,
  734. project,
  735. baseHistoryVersion
  736. )
  737. } catch (err) {
  738. logger.warn(
  739. { err, projectId, historyId, baseHistoryVersion },
  740. 'failed to compose history-incremental request'
  741. )
  742. // fall back to old compile mode
  743. return await _buildRequest(projectId, userId, {
  744. ...options,
  745. compileFromHistory: false,
  746. })
  747. }
  748. }
  749. if (options.incrementalCompilesEnabled || options.syncType != null) {
  750. // new way, either incremental or full
  751. const timer = new Metrics.Timer('editor.compile-getdocs-redis')
  752. let projectStateHash, docUpdaterDocs
  753. try {
  754. ;({ projectStateHash, docs: docUpdaterDocs } =
  755. await getContentFromDocUpdaterIfMatch(projectId, project, options))
  756. } catch (err) {
  757. logger.error({ err, projectId }, 'error checking project state')
  758. // note: we don't bail out when there's an error getting
  759. // incremental files from the docupdater, we just fall back
  760. // to a normal compile below
  761. }
  762. timer.done()
  763. // see if we can send an incremental update to the CLSI
  764. if (docUpdaterDocs != null && options.syncType !== 'full') {
  765. Metrics.inc('compile-from-redis')
  766. return _buildRequestFromDocupdater(
  767. projectId,
  768. options,
  769. project,
  770. projectStateHash,
  771. docUpdaterDocs
  772. )
  773. } else {
  774. Metrics.inc('compile-from-mongo')
  775. return await _buildRequestFromMongo(
  776. projectId,
  777. options,
  778. project,
  779. projectStateHash
  780. )
  781. }
  782. } else {
  783. // old way, always from mongo
  784. const timer = new Metrics.Timer('editor.compile-getdocs-mongo')
  785. const { docs, files } = await _getContentFromMongo(projectId)
  786. timer.done()
  787. return _finaliseRequest(projectId, options, project, docs, files)
  788. }
  789. }
  790. async function getContentFromDocUpdaterIfMatch(projectId, project, options) {
  791. const projectStateHash = ClsiStateManager.computeHash(project, options)
  792. const docs = await DocumentUpdaterHandler.promises.getProjectDocsIfMatch(
  793. projectId,
  794. projectStateHash
  795. )
  796. return { projectStateHash, docs }
  797. }
  798. async function getOutputFileStream(
  799. projectId,
  800. userId,
  801. options,
  802. clsiServerId,
  803. buildId,
  804. outputFilePath
  805. ) {
  806. const { compileBackendClass, compileGroup } = options
  807. const url = new URL(Settings.apis.clsi.downloadHost)
  808. url.pathname = `/project/${projectId}/user/${userId}/build/${buildId}/output/${outputFilePath}`
  809. url.searchParams.set('compileBackendClass', compileBackendClass)
  810. url.searchParams.set('compileGroup', compileGroup)
  811. url.searchParams.set('clsiserverid', clsiServerId)
  812. try {
  813. const stream = await fetchStream(url, {
  814. signal: AbortSignal.timeout(OUTPUT_FILE_TIMEOUT_MS),
  815. })
  816. return stream
  817. } catch (err) {
  818. throw new Errors.OutputFileFetchFailedError(
  819. 'failed to fetch output file from CLSI',
  820. {
  821. projectId,
  822. userId,
  823. url,
  824. status: err.response?.status,
  825. }
  826. )
  827. }
  828. }
  829. /**
  830. * @param {import('overleaf-editor-core/lib/types.js').RawChange[]} changes
  831. * @return {import('overleaf-editor-core/lib/types.js').RawOperation[][]}
  832. * @private
  833. */
  834. function _rawChangeOperationsFromChanges(changes) {
  835. // omit timestamp (required, back-filled in clsi)
  836. // omit authors (optional)
  837. // omit v2Authors (optional)
  838. // omit origin (optional)
  839. // omit projectVersion (optional)
  840. // omit v2DocVersions (optional)
  841. return changes.map(change => change.operations)
  842. }
  843. /**
  844. * @param {import('overleaf-editor-core/lib/types.js').RawOperation[][]} rawChangeOperations
  845. * @return {Set<string>}
  846. * @private
  847. */
  848. function _collectGlobalBlobs(rawChangeOperations) {
  849. const globalBlobs = new Set()
  850. for (const operations of rawChangeOperations) {
  851. for (const operation of operations) {
  852. const hash = operation?.file?.hash
  853. if (hash && HistoryManager.isGlobalBlob(hash)) {
  854. globalBlobs.add(hash)
  855. }
  856. }
  857. }
  858. return globalBlobs
  859. }
  860. async function _buildRequestFromHistoryFull(
  861. projectId,
  862. historyId,
  863. options,
  864. project
  865. ) {
  866. await HistoryManager.promises.flushProject(projectId)
  867. const [
  868. {
  869. chunk: {
  870. history: { snapshot: rawSnapshot, changes: rawChanges },
  871. startVersion,
  872. },
  873. },
  874. /* ensureNoResyncPending throws */
  875. ] = await Promise.all([
  876. HistoryManager.promises.getLatestHistoryWithHistoryId(historyId),
  877. HistoryManager.promises.ensureNoResyncPending(projectId),
  878. ])
  879. const rawChangeOperations = _rawChangeOperationsFromChanges(rawChanges)
  880. const globalBlobs = _collectGlobalBlobs(rawChangeOperations)
  881. for (const { hash, rangesHash } of Object.values(rawSnapshot.files)) {
  882. if (hash && HistoryManager.isGlobalBlob(hash)) {
  883. globalBlobs.add(hash)
  884. }
  885. if (rangesHash && HistoryManager.isGlobalBlob(rangesHash)) {
  886. globalBlobs.add(rangesHash)
  887. }
  888. }
  889. options = {
  890. ...options,
  891. syncType: 'history-full',
  892. historyId,
  893. baseHistoryVersion: startVersion,
  894. rawSnapshot,
  895. rawChangeOperations,
  896. globalBlobs: Array.from(globalBlobs),
  897. }
  898. return _finaliseRequest(projectId, options, project, [], [])
  899. }
  900. async function _buildRequestFromHistoryIncremental(
  901. projectId,
  902. historyId,
  903. options,
  904. project,
  905. baseHistoryVersion
  906. ) {
  907. await HistoryManager.promises.flushProject(projectId)
  908. const rawChangeOperations = []
  909. let hasMore = true
  910. let since = baseHistoryVersion
  911. let size = 0
  912. while (hasMore) {
  913. let changes
  914. ;[{ changes, hasMore } /* resyncPending throws */] = await Promise.all([
  915. HistoryManager.promises.getChangesWithHistoryId(historyId, { since }),
  916. HistoryManager.promises.ensureNoResyncPending(projectId),
  917. ])
  918. since += changes.length
  919. const newRawChangeOperations = _rawChangeOperationsFromChanges(changes)
  920. size += Buffer.from(JSON.stringify(newRawChangeOperations)).byteLength
  921. if (size > 6.5 * 1024 * 1024) {
  922. // clsi has a payload limit of 7MiB. Do not send too many operations.
  923. // Fall back to sending the latest snapshot instead.
  924. try {
  925. return await _buildRequestFromHistoryFull(
  926. projectId,
  927. historyId,
  928. options,
  929. project
  930. )
  931. } catch (err) {
  932. throw OError.tag(err, 'upgrade to history-full failed', { size })
  933. }
  934. }
  935. rawChangeOperations.push(...newRawChangeOperations)
  936. }
  937. const globalBlobs = _collectGlobalBlobs(rawChangeOperations)
  938. options = {
  939. ...options,
  940. syncType: 'history-incremental',
  941. historyId,
  942. baseHistoryVersion,
  943. rawChangeOperations,
  944. globalBlobs: Array.from(globalBlobs),
  945. }
  946. return _finaliseRequest(projectId, options, project, [], [])
  947. }
  948. function _buildRequestFromDocupdater(
  949. projectId,
  950. options,
  951. project,
  952. projectStateHash,
  953. docUpdaterDocs
  954. ) {
  955. const docPath = ProjectEntityHandler.getAllDocPathsFromProject(project)
  956. const docs = {}
  957. for (const doc of docUpdaterDocs || []) {
  958. const path = docPath[doc._id]
  959. docs[path] = doc
  960. }
  961. // send new docs but not files as those are already on the clsi
  962. options = _.clone(options)
  963. options.syncType = 'incremental'
  964. options.syncState = projectStateHash
  965. // create stub doc entries for any possible root docs, if not
  966. // present in the docupdater. This allows finaliseRequest to
  967. // identify the root doc.
  968. const possibleRootDocIds = [options.rootDoc_id, project.rootDoc_id]
  969. for (const rootDocId of possibleRootDocIds) {
  970. if (rootDocId != null && rootDocId in docPath) {
  971. const path = docPath[rootDocId]
  972. if (docs[path] == null) {
  973. docs[path] = { _id: rootDocId, path }
  974. }
  975. }
  976. }
  977. return _finaliseRequest(projectId, options, project, docs, [])
  978. }
  979. async function _buildRequestFromMongo(
  980. projectId,
  981. options,
  982. project,
  983. projectStateHash
  984. ) {
  985. const { docs, files } = await _getContentFromMongo(projectId)
  986. options = {
  987. ...options,
  988. syncType: 'full',
  989. syncState: projectStateHash,
  990. }
  991. return _finaliseRequest(projectId, options, project, docs, files)
  992. }
  993. async function _getContentFromMongo(projectId) {
  994. await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
  995. const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
  996. const files = await ProjectEntityHandler.promises.getAllFiles(projectId)
  997. return { docs, files }
  998. }
  999. function _finaliseRequest(projectId, options, project, docs, files) {
  1000. const resources = []
  1001. let flags
  1002. let rootResourcePath = options.rootResourcePath
  1003. let rootResourcePathOverride = null
  1004. let hasMainFile = false
  1005. let numberOfDocsInProject = 0
  1006. for (let path in docs) {
  1007. const doc = docs[path]
  1008. path = path.replace(/^\//, '') // Remove leading /
  1009. numberOfDocsInProject++
  1010. if (doc.lines != null) {
  1011. // add doc to resources unless it is just a stub entry
  1012. resources.push({
  1013. path,
  1014. content: doc.lines.join('\n'),
  1015. })
  1016. }
  1017. if (
  1018. project.rootDoc_id != null &&
  1019. doc._id.toString() === project.rootDoc_id.toString()
  1020. ) {
  1021. rootResourcePath = path
  1022. }
  1023. if (
  1024. options.rootDoc_id != null &&
  1025. doc._id.toString() === options.rootDoc_id.toString()
  1026. ) {
  1027. rootResourcePathOverride = path
  1028. }
  1029. if (path === 'main.tex') {
  1030. hasMainFile = true
  1031. }
  1032. }
  1033. if (rootResourcePathOverride != null) {
  1034. rootResourcePath = rootResourcePathOverride
  1035. }
  1036. if (rootResourcePath == null) {
  1037. if (hasMainFile) {
  1038. rootResourcePath = 'main.tex'
  1039. } else if (numberOfDocsInProject === 1) {
  1040. // only one file, must be the main document
  1041. for (const path in docs) {
  1042. // Remove leading /
  1043. rootResourcePath = path.replace(/^\//, '')
  1044. }
  1045. } else {
  1046. throw new OError('no main file specified', { projectId })
  1047. }
  1048. }
  1049. const historyId = project.overleaf.history.id
  1050. if (!historyId) {
  1051. throw new OError('project does not have a history id', { projectId })
  1052. }
  1053. for (let path in files) {
  1054. const file = files[path]
  1055. path = path.replace(/^\//, '') // Remove leading /
  1056. resources.push({
  1057. path,
  1058. url: HistoryManager.getFilestoreBlobURL(historyId, file.hash),
  1059. modified: file.created?.getTime(),
  1060. })
  1061. }
  1062. if (options.fileLineErrors) {
  1063. flags = ['-file-line-error']
  1064. }
  1065. return {
  1066. compile: {
  1067. options: {
  1068. historyId: options.historyId?.toString(), // send as string, if set
  1069. buildId: options.buildId,
  1070. editorId: options.editorId,
  1071. compiler: project.compiler,
  1072. timeout: options.timeout,
  1073. imageName: project.imageName,
  1074. draft: Boolean(options.draft),
  1075. stopOnFirstError: Boolean(options.stopOnFirstError),
  1076. check: options.check,
  1077. syncType: options.syncType,
  1078. syncState: options.syncState,
  1079. compileGroup: options.compileGroup,
  1080. // Overleaf alpha/staff users get compileGroup=alpha (via getProjectCompileLimits in CompileManager), enroll them into the premium rollout of clsi-cache.
  1081. compileFromClsiCache:
  1082. // enable for premium compiles
  1083. (['alpha', 'priority'].includes(options.compileGroup) ||
  1084. // enable for free for short period when we saw low capacity
  1085. enableCompileFromCacheUntil > Date.now()) &&
  1086. options.compileFromClsiCache,
  1087. populateClsiCache: options.populateClsiCache,
  1088. enablePdfCaching:
  1089. (Settings.enablePdfCaching && options.enablePdfCaching) || false,
  1090. pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
  1091. flags,
  1092. metricsMethod: options.compileGroup,
  1093. metricsPath: options.metricsPath,
  1094. },
  1095. baseHistoryVersion: options.baseHistoryVersion,
  1096. rawSnapshot: options.rawSnapshot,
  1097. rawChangeOperations: options.rawChangeOperations,
  1098. globalBlobs: options.globalBlobs,
  1099. rootResourcePath,
  1100. resources,
  1101. },
  1102. }
  1103. }
  1104. async function wordCount(projectId, userId, file, limits, clsiserverid) {
  1105. const { compileBackendClass, compileGroup } = limits
  1106. const req = await _buildRequest(projectId, userId, limits)
  1107. const filename = file || req.compile.rootResourcePath
  1108. const url = _getCompilerUrl(
  1109. compileBackendClass,
  1110. compileGroup,
  1111. projectId,
  1112. userId,
  1113. 'wordcount'
  1114. )
  1115. url.searchParams.set('file', filename)
  1116. url.searchParams.set('image', req.compile.options.imageName)
  1117. const opts = {
  1118. method: 'GET',
  1119. }
  1120. const { body } = await _makeRequestWithClsiServerId(
  1121. projectId,
  1122. userId,
  1123. compileGroup,
  1124. compileBackendClass,
  1125. url,
  1126. opts,
  1127. clsiserverid
  1128. )
  1129. return body
  1130. }
  1131. async function syncTeX(
  1132. projectId,
  1133. userId,
  1134. {
  1135. direction,
  1136. compileFromClsiCache,
  1137. limits,
  1138. imageName,
  1139. validatedOptions,
  1140. clsiServerId,
  1141. }
  1142. ) {
  1143. const { compileBackendClass, compileGroup } = limits
  1144. const url = _getCompilerUrl(
  1145. compileBackendClass,
  1146. compileGroup,
  1147. projectId,
  1148. userId,
  1149. `sync/${direction}`
  1150. )
  1151. url.searchParams.set(
  1152. 'compileFromClsiCache',
  1153. compileFromClsiCache && ['alpha', 'priority'].includes(compileGroup)
  1154. )
  1155. url.searchParams.set('imageName', imageName)
  1156. for (const [key, value] of Object.entries(validatedOptions)) {
  1157. url.searchParams.set(key, value)
  1158. }
  1159. const opts = {
  1160. method: 'GET',
  1161. }
  1162. try {
  1163. const { body } = await _makeRequestWithClsiServerId(
  1164. projectId,
  1165. userId,
  1166. compileGroup,
  1167. compileBackendClass,
  1168. url,
  1169. opts,
  1170. clsiServerId
  1171. )
  1172. return body
  1173. } catch (err) {
  1174. if (err instanceof RequestFailedError && err.response.status === 404) {
  1175. throw new Errors.NotFoundError()
  1176. }
  1177. throw err
  1178. }
  1179. }
  1180. function _getClsiServerIdFromResponse(response) {
  1181. const setCookieHeaders = response.headers.raw()['set-cookie'] ?? []
  1182. for (const header of setCookieHeaders) {
  1183. const cookie = Cookie.parse(header)
  1184. if (cookie.key === Settings.clsiCookie.key) {
  1185. return cookie.value
  1186. }
  1187. }
  1188. return null
  1189. }
  1190. export default {
  1191. _finaliseRequest,
  1192. sendRequest: callbackifyMultiResult(sendRequest, [
  1193. 'status',
  1194. 'outputFiles',
  1195. 'clsiServerId',
  1196. 'validationProblems',
  1197. 'stats',
  1198. 'timings',
  1199. 'outputUrlPrefix',
  1200. 'buildId',
  1201. 'clsiCacheShard',
  1202. ]),
  1203. sendExternalRequest: callbackifyMultiResult(sendExternalRequest, [
  1204. 'status',
  1205. 'outputFiles',
  1206. 'clsiServerId',
  1207. 'validationProblems',
  1208. 'stats',
  1209. 'timings',
  1210. 'outputUrlPrefix',
  1211. ]),
  1212. stopCompile: callbackify(stopCompile),
  1213. deleteAuxFiles: callbackify(deleteAuxFiles),
  1214. getOutputFileStream: callbackify(getOutputFileStream),
  1215. wordCount: callbackify(wordCount),
  1216. syncTeX: callbackify(syncTeX),
  1217. promises: {
  1218. sendRequest,
  1219. sendExternalRequest,
  1220. stopCompile,
  1221. deleteAuxFiles,
  1222. getOutputFileStream,
  1223. wordCount,
  1224. syncTeX,
  1225. },
  1226. }