ClsiManager.mjs 33 KB

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