CLSICacheHandler.js 11 KB

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