CLSICacheHandler.js 14 KB

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