CLSICacheHandler.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  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 = Settings.apis.clsiCache.reshardFrom
  35. const MIGRATE_UNTIL = Settings.apis.clsiCache.reshardUntil
  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. (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, shard },
  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. if (isENOENT(err)) return
  210. logger.warn(
  211. { err, projectId, userId, buildId, shard },
  212. 'build output.tar.gz for clsi-cache failed'
  213. )
  214. })
  215. copyHistorySnapshot({ projectId, userId, buildId })
  216. .then(() => {
  217. enqueue([{ path: 'history-resync.json.gz' }])
  218. })
  219. .catch(err => {
  220. if (isENOENT(err)) return
  221. logger.warn(
  222. { err, projectId, userId, buildId, shard },
  223. 'copy history-resync.json.gz for clsi-cache failed'
  224. )
  225. })
  226. return shard
  227. }
  228. /**
  229. * @param {Object} opts
  230. * @param {string} opts.projectId
  231. * @param {string} opts.userId
  232. * @param {string} opts.buildId
  233. * @return {Promise<void>}
  234. */
  235. async function copyHistorySnapshot({ projectId, userId, buildId }) {
  236. const src = Path.join(
  237. Settings.path.clsiCacheDir,
  238. userId ? `${projectId}-${userId}` : projectId,
  239. 'history.json.gz'
  240. )
  241. const outputDir = getOutputDir({ projectId, userId, buildId })
  242. const dst = Path.join(outputDir, 'history-resync.json.gz')
  243. await fs.promises.cp(src, dst)
  244. }
  245. /**
  246. * @param {Object} opts
  247. * @param {string} opts.projectId
  248. * @param {string} opts.userId
  249. * @param {string} opts.buildId
  250. * @param {[{path: string}]} opts.outputFiles
  251. * @return {Promise<void>}
  252. */
  253. async function buildTarball({ projectId, userId, buildId, outputFiles }) {
  254. const timer = new Metrics.Timer('clsi_cache_build', 1, {}, TIMING_BUCKETS)
  255. const outputDir = getOutputDir({ projectId, userId, buildId })
  256. const files = outputFiles.filter(f => !isExtraneousFile(f.path))
  257. if (files.length > MAX_ENTRIES_IN_OUTPUT_TAR) {
  258. Metrics.inc('clsi_cache_build_too_many_entries')
  259. throw new OError('too many output files for output.tar.gz', {
  260. nFiles: files.length,
  261. })
  262. }
  263. Metrics.count('clsi_cache_build_files', files.length)
  264. const path = Path.join(outputDir, 'output.tar.gz')
  265. try {
  266. await pipeline(
  267. tarFs.pack(outputDir, { entries: files.map(f => f.path) }),
  268. createGzip(),
  269. fs.createWriteStream(path)
  270. )
  271. } catch (err) {
  272. try {
  273. await fs.promises.unlink(path)
  274. } catch (e) {}
  275. throw err
  276. } finally {
  277. timer.done()
  278. }
  279. }
  280. /**
  281. * @param {string} projectId
  282. * @param {string} userId
  283. * @param {string} editorId
  284. * @param {string} buildId
  285. * @param {string} outputDir
  286. * @return {Promise<boolean>}
  287. */
  288. async function downloadOutputDotSynctexFromCompileCache(
  289. projectId,
  290. userId,
  291. editorId,
  292. buildId,
  293. outputDir
  294. ) {
  295. const requestPath = `/project/${projectId}/${
  296. userId ? `user/${userId}/` : ''
  297. }build/${editorId}-${buildId}/search/output/output.synctex.gz`
  298. return await downloadSingleFile(projectId, requestPath, outputDir, 'synctex')
  299. }
  300. /**
  301. * @param {string} projectId
  302. * @param {string} userId
  303. * @param {string} cacheDir
  304. * @return {Promise<boolean>}
  305. */
  306. async function downloadHistorySnapshot(projectId, userId, cacheDir) {
  307. const requestPath = `/project/${projectId}/${
  308. userId ? `user/${userId}/` : ''
  309. }latest/output/history-resync.json.gz`
  310. return await downloadSingleFile(projectId, requestPath, cacheDir, 'snapshot')
  311. }
  312. /**
  313. * @param {string} projectId
  314. * @param {string} requestPath
  315. * @param {string} outputDir
  316. * @param {string} label
  317. * @return {Promise<boolean>}
  318. */
  319. async function downloadSingleFile(projectId, requestPath, outputDir, label) {
  320. if (!Settings.apis.clsiCache.enabled) return false
  321. if (!OBJECT_ID_REGEX.test(projectId)) return false
  322. const shardCfg = getAvailableShard(projectId)
  323. if (!shardCfg) return false
  324. const { url, shard } = shardCfg
  325. const timer = new Metrics.Timer(
  326. 'clsi_cache_download',
  327. 1,
  328. { method: label },
  329. TIMING_BUCKETS
  330. )
  331. const u = new URL(url)
  332. u.pathname = requestPath
  333. let stream
  334. try {
  335. stream = await fetchStream(u, {
  336. method: 'GET',
  337. signal: AbortSignal.timeout(TIMEOUT),
  338. })
  339. } catch (err) {
  340. if (err instanceof RequestFailedError && err.response.status === 404) {
  341. closeCircuitBreaker(url)
  342. timer.done({ status: 'not-found' })
  343. return false
  344. }
  345. tripCircuitBreaker(url)
  346. timer.done({ status: 'error' })
  347. throw OError.tag(err, 'download failed', { shard })
  348. }
  349. await fs.promises.mkdir(outputDir, { recursive: true })
  350. const name = Path.basename(requestPath)
  351. const dst = Path.join(outputDir, name)
  352. const tmp = dst + crypto.randomUUID()
  353. try {
  354. await pipeline(
  355. stream,
  356. new MeteredStream(Metrics, 'clsi_cache_egress', {
  357. path: name,
  358. }),
  359. fs.createWriteStream(tmp)
  360. )
  361. await fs.promises.rename(tmp, dst)
  362. } catch (err) {
  363. if (isENOENT(err)) return false
  364. tripCircuitBreaker(url)
  365. try {
  366. await fs.promises.unlink(tmp)
  367. } catch {}
  368. throw OError.tag(err, 'stream failed', { shard })
  369. }
  370. closeCircuitBreaker(url)
  371. timer.done({ status: 'success' })
  372. return true
  373. }
  374. /**
  375. * @param {string} projectId
  376. * @param {string} userId
  377. * @param {string} compileDir
  378. * @return {Promise<boolean>}
  379. */
  380. async function downloadLatestCompileCache(projectId, userId, compileDir) {
  381. if (!Settings.apis.clsiCache.enabled) return false
  382. if (!OBJECT_ID_REGEX.test(projectId)) return false
  383. const shardCfg = getAvailableShard(projectId)
  384. if (!shardCfg) return false
  385. const { url, shard } = shardCfg
  386. const timer = new Metrics.Timer(
  387. 'clsi_cache_download',
  388. 1,
  389. { method: 'tar' },
  390. TIMING_BUCKETS
  391. )
  392. let stream
  393. try {
  394. stream = await fetchStream(
  395. `${url}/project/${projectId}/${
  396. userId ? `user/${userId}/` : ''
  397. }latest/output/output.tar.gz`,
  398. {
  399. method: 'GET',
  400. signal: AbortSignal.timeout(TIMEOUT),
  401. }
  402. )
  403. } catch (err) {
  404. if (err instanceof RequestFailedError && err.response.status === 404) {
  405. closeCircuitBreaker(url)
  406. timer.done({ status: 'not-found' })
  407. return false
  408. }
  409. tripCircuitBreaker(url)
  410. timer.done({ status: 'error' })
  411. throw OError.tag(err, 'download failed', { shard })
  412. }
  413. let n = 0
  414. let abort = false
  415. try {
  416. await pipeline(
  417. stream,
  418. new MeteredStream(Metrics, 'clsi_cache_egress', {
  419. path: 'output.tar.gz',
  420. }),
  421. createGunzip(),
  422. tarFs.extract(compileDir, {
  423. // use ignore hook for counting entries (files+folders) and validation.
  424. // Include folders as they incur mkdir calls.
  425. ignore(_, header) {
  426. if (abort) return true // log once
  427. n++
  428. if (n > MAX_ENTRIES_IN_OUTPUT_TAR) {
  429. abort = true
  430. logger.warn(
  431. {
  432. projectId,
  433. userId,
  434. compileDir,
  435. },
  436. 'too many entries in tar-ball from clsi-cache'
  437. )
  438. } else if (header.type !== 'file' && header.type !== 'directory') {
  439. abort = true
  440. logger.warn(
  441. {
  442. projectId,
  443. userId,
  444. compileDir,
  445. entryType: header.type,
  446. },
  447. 'unexpected entry in tar-ball from clsi-cache'
  448. )
  449. }
  450. return abort
  451. },
  452. })
  453. )
  454. } catch (err) {
  455. if (isENOENT(err)) return false
  456. tripCircuitBreaker(url)
  457. throw OError.tag(err, 'stream failed', { shard })
  458. }
  459. closeCircuitBreaker(url)
  460. Metrics.count('clsi_cache_download_entries', n)
  461. timer.done({ status: 'success' })
  462. return !abort
  463. }
  464. /**
  465. * @param {Object} opts
  466. * @param {string} opts.projectId
  467. * @param {string} opts.userId
  468. * @param {string} opts.buildId
  469. * @return {string}
  470. */
  471. function getOutputDir({ projectId, userId, buildId }) {
  472. return Path.join(
  473. Settings.path.outputDir,
  474. userId ? `${projectId}-${userId}` : projectId,
  475. CACHE_SUBDIR,
  476. buildId
  477. )
  478. }
  479. /**
  480. * @param {unknown} err
  481. * @return {boolean}
  482. */
  483. function isENOENT(err) {
  484. return err instanceof Error && 'code' in err && err.code === 'ENOENT'
  485. }
  486. export default {
  487. notifyCLSICacheAboutBuild,
  488. downloadLatestCompileCache,
  489. downloadHistorySnapshot,
  490. downloadOutputDotSynctexFromCompileCache,
  491. }