CLSICacheHandler.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // @ts-check
  2. import crypto from 'node:crypto'
  3. import fs from 'node:fs'
  4. import Path from 'node:path'
  5. import { pipeline } from 'node:stream/promises'
  6. import { crc32, createGzip, createGunzip } from 'node:zlib'
  7. import tarFs from 'tar-fs'
  8. import _ from 'lodash'
  9. import {
  10. fetchNothing,
  11. fetchStream,
  12. RequestFailedError,
  13. } from '@overleaf/fetch-utils'
  14. import logger from '@overleaf/logger'
  15. import Metrics from '@overleaf/metrics'
  16. import Settings from '@overleaf/settings'
  17. import { MeteredStream } from '@overleaf/stream-utils'
  18. import OutputCacheManager from './OutputCacheManager.js'
  19. import ResourceWriter from './ResourceWriter.js'
  20. const { CACHE_SUBDIR } = OutputCacheManager
  21. const { isExtraneousFile } = ResourceWriter
  22. const TIMEOUT = 5_000
  23. /**
  24. * @type {Map<string, number>}
  25. */
  26. const lastFailures = new Map()
  27. const TIMING_BUCKETS = [
  28. 0, 10, 100, 1000, 2000, 5000, 10000, 15000, 20000, 30000,
  29. ]
  30. const MAX_ENTRIES_IN_OUTPUT_TAR = 100
  31. const MAX_BLG_FILES = 50
  32. const OBJECT_ID_REGEX = /^[0-9a-f]{24}$/
  33. const MIGRATE_FROM = new Date('2026-01-14').getTime()
  34. const MIGRATE_UNTIL = new Date('2026-01-21').getTime()
  35. /**
  36. * @param {string} projectId
  37. * @return {{shard: string, url: string}}
  38. */
  39. function getShard(projectId) {
  40. // Layout of mongodb object id bytes:
  41. // [timestamp 4bytes][random per machine 5bytes][counter 3bytes]
  42. // [32bit 4bytes]
  43. const last4Bytes = Buffer.from(projectId, 'hex').subarray(8, 12)
  44. const counter = last4Bytes.readUInt32BE()
  45. const nShards = Settings.apis.clsiCache.shards.length
  46. // Use the "counter" part of the id for a stable assignment with an even
  47. // distribution. This is where the current data resides.
  48. const preferredShard = counter % nShards
  49. // Gradually migrate over to the crc base shard assignment.
  50. const remaining = MIGRATE_UNTIL - Date.now()
  51. if ((counter % 100) / 100 < remaining / (MIGRATE_UNTIL - MIGRATE_FROM)) {
  52. const shard = Settings.apis.clsiCache.shards[preferredShard]
  53. if (!isCircuitBreakerTripped(shard.url)) return shard
  54. }
  55. // Then use a crc for generating a stable sequence of shards with even
  56. // distribution to try next.
  57. const shards = Settings.apis.clsiCache.shards.slice(0)
  58. let i = 0
  59. while (shards.length > 0) {
  60. const idx = crc32(`${projectId}-${i++}`) % shards.length
  61. const candidate = shards[idx]
  62. if (!isCircuitBreakerTripped(candidate.url)) return candidate
  63. shards.splice(idx, 1)
  64. }
  65. // All shards are down. Return the original preference.
  66. return Settings.apis.clsiCache.shards[preferredShard]
  67. }
  68. /**
  69. * @param {string} url
  70. * @return {boolean}
  71. */
  72. function isCircuitBreakerTripped(url) {
  73. const lastFailure = lastFailures.get(url) ?? 0
  74. if (lastFailure) {
  75. // Circuit breaker that avoids retries for 5-20s.
  76. const retryDelay = TIMEOUT * (1 + 3 * Math.random())
  77. if (performance.now() - lastFailure < retryDelay) {
  78. return true
  79. }
  80. }
  81. return false
  82. }
  83. /**
  84. * @param {string} url
  85. */
  86. function tripCircuitBreaker(url) {
  87. lastFailures.set(url, performance.now()) // The shard is unhealthy. Refresh timestamp of last failure.
  88. }
  89. /**
  90. * @param {string} url
  91. */
  92. function closeCircuitBreaker(url) {
  93. lastFailures.delete(url) // The shard is back up.
  94. }
  95. /**
  96. * @param {Object} opts
  97. * @param {string} opts.projectId
  98. * @param {string} opts.userId
  99. * @param {string} opts.buildId
  100. * @param {string} opts.editorId
  101. * @param {[{path: string}]} opts.outputFiles
  102. * @param {string} opts.compileGroup
  103. * @param {Record<string, number>} opts.stats
  104. * @param {Record<string, number>} opts.timings
  105. * @param {Record<string, any>} opts.options
  106. * @return {string | undefined}
  107. */
  108. function notifyCLSICacheAboutBuild({
  109. projectId,
  110. userId,
  111. buildId,
  112. editorId,
  113. outputFiles,
  114. compileGroup,
  115. stats,
  116. timings,
  117. options,
  118. }) {
  119. if (!Settings.apis.clsiCache.enabled) return undefined
  120. if (!OBJECT_ID_REGEX.test(projectId)) return undefined
  121. const { url, shard } = getShard(projectId)
  122. if (isCircuitBreakerTripped(url)) return undefined
  123. /**
  124. * @param {{path: string}[]} files
  125. */
  126. const enqueue = files => {
  127. const body = Buffer.from(
  128. JSON.stringify({
  129. projectId,
  130. userId,
  131. buildId,
  132. editorId,
  133. files,
  134. downloadHost: Settings.apis.clsi.downloadHost,
  135. clsiServerId: Settings.apis.clsi.clsiServerId,
  136. compileGroup,
  137. stats,
  138. timings,
  139. options,
  140. })
  141. )
  142. const bodySize = body.byteLength
  143. if (bodySize > 10_000_000) {
  144. const outputPDF = files.find(f => f.path === 'output.pdf')
  145. logger.warn(
  146. {
  147. projectId,
  148. userId,
  149. bodySize,
  150. nFiles: files.length,
  151. outputPDFSize:
  152. outputPDF && Buffer.from(JSON.stringify(outputPDF)).byteLength,
  153. nPDFCachingRanges:
  154. outputPDF &&
  155. 'ranges' in outputPDF &&
  156. Array.isArray(outputPDF.ranges) &&
  157. outputPDF.ranges.length,
  158. },
  159. 'large clsi-cache request'
  160. )
  161. }
  162. Metrics.count('clsi_cache_enqueue_files', files.length)
  163. fetchNothing(`${url}/enqueue`, {
  164. method: 'POST',
  165. body,
  166. headers: { 'Content-Type': 'application/json' },
  167. signal: AbortSignal.timeout(TIMEOUT),
  168. })
  169. .then(() => {
  170. closeCircuitBreaker(url)
  171. })
  172. .catch(err => {
  173. tripCircuitBreaker(url)
  174. logger.warn(
  175. { err, projectId, userId, buildId },
  176. 'enqueue for clsi cache failed'
  177. )
  178. })
  179. }
  180. // PDF preview
  181. enqueue(
  182. outputFiles
  183. .filter(
  184. f =>
  185. f.path === 'output.pdf' ||
  186. f.path === 'output.log' ||
  187. f.path === 'output.synctex.gz'
  188. )
  189. .concat(
  190. outputFiles.filter(f => f.path.endsWith('.blg')).slice(0, MAX_BLG_FILES)
  191. )
  192. .map(f => {
  193. const lean = { path: f.path }
  194. if (f.path === 'output.pdf') {
  195. Object.assign(lean, _.pick(f, 'path', 'size', 'contentId', 'ranges'))
  196. }
  197. return lean
  198. })
  199. )
  200. // Compile Cache
  201. buildTarball({ projectId, userId, buildId, outputFiles })
  202. .then(() => {
  203. enqueue([{ path: 'output.tar.gz' }])
  204. })
  205. .catch(err => {
  206. logger.warn(
  207. { err, projectId, userId, buildId },
  208. 'build output.tar.gz for clsi cache failed'
  209. )
  210. })
  211. return shard
  212. }
  213. /**
  214. * @param {Object} opts
  215. * @param {string} opts.projectId
  216. * @param {string} opts.userId
  217. * @param {string} opts.buildId
  218. * @param {[{path: string}]} opts.outputFiles
  219. * @return {Promise<void>}
  220. */
  221. async function buildTarball({ projectId, userId, buildId, outputFiles }) {
  222. const timer = new Metrics.Timer('clsi_cache_build', 1, {}, TIMING_BUCKETS)
  223. const outputDir = Path.join(
  224. Settings.path.outputDir,
  225. userId ? `${projectId}-${userId}` : projectId,
  226. CACHE_SUBDIR,
  227. buildId
  228. )
  229. const files = outputFiles.filter(f => !isExtraneousFile(f.path))
  230. if (files.length > MAX_ENTRIES_IN_OUTPUT_TAR) {
  231. Metrics.inc('clsi_cache_build_too_many_entries')
  232. throw new Error('too many output files for output.tar.gz')
  233. }
  234. Metrics.count('clsi_cache_build_files', files.length)
  235. const path = Path.join(outputDir, 'output.tar.gz')
  236. try {
  237. await pipeline(
  238. tarFs.pack(outputDir, { entries: files.map(f => f.path) }),
  239. createGzip(),
  240. fs.createWriteStream(path)
  241. )
  242. } catch (err) {
  243. try {
  244. await fs.promises.unlink(path)
  245. } catch (e) {}
  246. throw err
  247. } finally {
  248. timer.done()
  249. }
  250. }
  251. /**
  252. * @param {string} projectId
  253. * @param {string} userId
  254. * @param {string} editorId
  255. * @param {string} buildId
  256. * @param {string} outputDir
  257. * @return {Promise<boolean>}
  258. */
  259. async function downloadOutputDotSynctexFromCompileCache(
  260. projectId,
  261. userId,
  262. editorId,
  263. buildId,
  264. outputDir
  265. ) {
  266. if (!Settings.apis.clsiCache.enabled) return false
  267. if (!OBJECT_ID_REGEX.test(projectId)) return false
  268. const { url } = getShard(projectId)
  269. if (isCircuitBreakerTripped(url)) return false
  270. const timer = new Metrics.Timer(
  271. 'clsi_cache_download',
  272. 1,
  273. { method: 'synctex' },
  274. TIMING_BUCKETS
  275. )
  276. let stream
  277. try {
  278. stream = await fetchStream(
  279. `${url}/project/${projectId}/${
  280. userId ? `user/${userId}/` : ''
  281. }build/${editorId}-${buildId}/search/output/output.synctex.gz`,
  282. {
  283. method: 'GET',
  284. signal: AbortSignal.timeout(TIMEOUT),
  285. }
  286. )
  287. } catch (err) {
  288. if (err instanceof RequestFailedError && err.response.status === 404) {
  289. closeCircuitBreaker(url)
  290. timer.done({ status: 'not-found' })
  291. return false
  292. }
  293. tripCircuitBreaker(url)
  294. timer.done({ status: 'error' })
  295. throw err
  296. }
  297. await fs.promises.mkdir(outputDir, { recursive: true })
  298. const dst = Path.join(outputDir, 'output.synctex.gz')
  299. const tmp = dst + crypto.randomUUID()
  300. try {
  301. await pipeline(
  302. stream,
  303. new MeteredStream(Metrics, 'clsi_cache_egress', {
  304. path: 'output.synctex.gz',
  305. }),
  306. fs.createWriteStream(tmp)
  307. )
  308. await fs.promises.rename(tmp, dst)
  309. } catch (err) {
  310. tripCircuitBreaker(url)
  311. try {
  312. await fs.promises.unlink(tmp)
  313. } catch {}
  314. throw err
  315. }
  316. closeCircuitBreaker(url)
  317. timer.done({ status: 'success' })
  318. return true
  319. }
  320. /**
  321. * @param {string} projectId
  322. * @param {string} userId
  323. * @param {string} compileDir
  324. * @return {Promise<boolean>}
  325. */
  326. async function downloadLatestCompileCache(projectId, userId, compileDir) {
  327. if (!Settings.apis.clsiCache.enabled) return false
  328. if (!OBJECT_ID_REGEX.test(projectId)) return false
  329. const { url } = getShard(projectId)
  330. if (isCircuitBreakerTripped(url)) return false
  331. const timer = new Metrics.Timer(
  332. 'clsi_cache_download',
  333. 1,
  334. { method: 'tar' },
  335. TIMING_BUCKETS
  336. )
  337. let stream
  338. try {
  339. stream = await fetchStream(
  340. `${url}/project/${projectId}/${
  341. userId ? `user/${userId}/` : ''
  342. }latest/output/output.tar.gz`,
  343. {
  344. method: 'GET',
  345. signal: AbortSignal.timeout(TIMEOUT),
  346. }
  347. )
  348. } catch (err) {
  349. if (err instanceof RequestFailedError && err.response.status === 404) {
  350. closeCircuitBreaker(url)
  351. timer.done({ status: 'not-found' })
  352. return false
  353. }
  354. tripCircuitBreaker(url)
  355. timer.done({ status: 'error' })
  356. throw err
  357. }
  358. let n = 0
  359. let abort = false
  360. try {
  361. await pipeline(
  362. stream,
  363. new MeteredStream(Metrics, 'clsi_cache_egress', {
  364. path: 'output.tar.gz',
  365. }),
  366. createGunzip(),
  367. tarFs.extract(compileDir, {
  368. // use ignore hook for counting entries (files+folders) and validation.
  369. // Include folders as they incur mkdir calls.
  370. ignore(_, header) {
  371. if (abort) return true // log once
  372. n++
  373. if (n > MAX_ENTRIES_IN_OUTPUT_TAR) {
  374. abort = true
  375. logger.warn(
  376. {
  377. projectId,
  378. userId,
  379. compileDir,
  380. },
  381. 'too many entries in tar-ball from clsi-cache'
  382. )
  383. } else if (header.type !== 'file' && header.type !== 'directory') {
  384. abort = true
  385. logger.warn(
  386. {
  387. projectId,
  388. userId,
  389. compileDir,
  390. entryType: header.type,
  391. },
  392. 'unexpected entry in tar-ball from clsi-cache'
  393. )
  394. }
  395. return abort
  396. },
  397. })
  398. )
  399. } catch (err) {
  400. tripCircuitBreaker(url)
  401. throw err
  402. }
  403. closeCircuitBreaker(url)
  404. Metrics.count('clsi_cache_download_entries', n)
  405. timer.done({ status: 'success' })
  406. return !abort
  407. }
  408. export default {
  409. notifyCLSICacheAboutBuild,
  410. downloadLatestCompileCache,
  411. downloadOutputDotSynctexFromCompileCache,
  412. }