CLSICacheHandler.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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. * @param {{path:string,method:string}} opts.metricsOpts
  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. metricsOpts,
  122. }) {
  123. if (!Settings.apis.clsiCache.enabled) return undefined
  124. if (!OBJECT_ID_REGEX.test(projectId)) return undefined
  125. const shardCfg = getAvailableShard(projectId)
  126. if (!shardCfg) return undefined
  127. const { url, shard } = shardCfg
  128. /**
  129. * @param {{path: string}[]} files
  130. */
  131. const enqueue = files => {
  132. const body = Buffer.from(
  133. JSON.stringify({
  134. projectId,
  135. userId,
  136. buildId,
  137. editorId,
  138. files,
  139. downloadHost: Settings.apis.clsi.downloadHost,
  140. clsiServerId: Settings.apis.clsi.clsiServerId,
  141. compileGroup,
  142. stats,
  143. timings,
  144. options,
  145. })
  146. )
  147. const bodySize = body.byteLength
  148. if (bodySize > 10_000_000) {
  149. const outputPDF = files.find(f => f.path === 'output.pdf')
  150. logger.warn(
  151. {
  152. projectId,
  153. userId,
  154. bodySize,
  155. nFiles: files.length,
  156. outputPDFSize:
  157. outputPDF && Buffer.from(JSON.stringify(outputPDF)).byteLength,
  158. nPDFCachingRanges:
  159. outputPDF &&
  160. 'ranges' in outputPDF &&
  161. Array.isArray(outputPDF.ranges) &&
  162. outputPDF.ranges.length,
  163. },
  164. 'large clsi-cache request'
  165. )
  166. }
  167. Metrics.count('clsi_cache_enqueue_files', files.length)
  168. fetchNothing(`${url}/enqueue`, {
  169. method: 'POST',
  170. body,
  171. headers: { 'Content-Type': 'application/json' },
  172. signal: AbortSignal.timeout(TIMEOUT),
  173. })
  174. .then(() => {
  175. closeCircuitBreaker(url)
  176. })
  177. .catch(err => {
  178. tripCircuitBreaker(url)
  179. logger.warn(
  180. { err, projectId, userId, buildId, shard },
  181. 'enqueue for clsi-cache failed'
  182. )
  183. })
  184. }
  185. const isUserCompile = !metricsOpts.path
  186. if (!(isUserCompile && compileGroup === 'standard')) {
  187. // PDF preview, skip for free compiles
  188. enqueue(
  189. outputFiles
  190. .filter(
  191. f =>
  192. f.path === 'output.pdf' ||
  193. f.path === 'output.log' ||
  194. f.path === 'output.synctex.gz'
  195. )
  196. .concat(
  197. outputFiles
  198. .filter(f => f.path.endsWith('.blg'))
  199. .slice(0, MAX_BLG_FILES)
  200. )
  201. .map(f => {
  202. const lean = { path: f.path }
  203. if (f.path === 'output.pdf') {
  204. Object.assign(
  205. lean,
  206. _.pick(f, 'path', 'size', 'contentId', 'ranges')
  207. )
  208. }
  209. return lean
  210. })
  211. )
  212. }
  213. // Compile Cache
  214. buildTarball({ projectId, userId, buildId, outputFiles })
  215. .then(() => {
  216. enqueue([{ path: 'output.tar.gz' }])
  217. })
  218. .catch(err => {
  219. if (isENOENT(err)) return
  220. logger.warn(
  221. { err, projectId, userId, buildId, shard },
  222. 'build output.tar.gz for clsi-cache failed'
  223. )
  224. })
  225. copyHistorySnapshot({ projectId, userId, buildId })
  226. .then(() => {
  227. enqueue([{ path: 'history-resync.json.gz' }])
  228. })
  229. .catch(err => {
  230. if (isENOENT(err)) return
  231. logger.warn(
  232. { err, projectId, userId, buildId, shard },
  233. 'copy history-resync.json.gz for clsi-cache failed'
  234. )
  235. })
  236. return shard
  237. }
  238. /**
  239. * @param {Object} opts
  240. * @param {string} opts.projectId
  241. * @param {string} opts.userId
  242. * @param {string} opts.buildId
  243. * @return {Promise<void>}
  244. */
  245. async function copyHistorySnapshot({ projectId, userId, buildId }) {
  246. const src = Path.join(
  247. Settings.path.clsiCacheDir,
  248. userId ? `${projectId}-${userId}` : projectId,
  249. 'history.json.gz'
  250. )
  251. const outputDir = getOutputDir({ projectId, userId, buildId })
  252. const dst = Path.join(outputDir, 'history-resync.json.gz')
  253. await fs.promises.cp(src, dst)
  254. }
  255. /**
  256. * @param {Object} opts
  257. * @param {string} opts.projectId
  258. * @param {string} opts.userId
  259. * @param {string} opts.buildId
  260. * @param {[{path: string}]} opts.outputFiles
  261. * @return {Promise<void>}
  262. */
  263. async function buildTarball({ projectId, userId, buildId, outputFiles }) {
  264. const timer = new Metrics.Timer('clsi_cache_build', 1, {}, TIMING_BUCKETS)
  265. const outputDir = getOutputDir({ projectId, userId, buildId })
  266. const files = outputFiles.filter(f => !isExtraneousFile(f.path))
  267. if (files.length > MAX_ENTRIES_IN_OUTPUT_TAR) {
  268. Metrics.inc('clsi_cache_build_too_many_entries')
  269. throw new OError('too many output files for output.tar.gz', {
  270. nFiles: files.length,
  271. })
  272. }
  273. Metrics.count('clsi_cache_build_files', files.length)
  274. const path = Path.join(outputDir, 'output.tar.gz')
  275. try {
  276. await pipeline(
  277. tarFs.pack(outputDir, { entries: files.map(f => f.path) }),
  278. createGzip(),
  279. fs.createWriteStream(path)
  280. )
  281. } catch (err) {
  282. try {
  283. await fs.promises.unlink(path)
  284. } catch (e) {}
  285. throw err
  286. } finally {
  287. timer.done()
  288. }
  289. }
  290. /**
  291. * @param {string} projectId
  292. * @param {string} userId
  293. * @param {string} editorId
  294. * @param {string} buildId
  295. * @param {string} outputDir
  296. * @return {Promise<boolean>}
  297. */
  298. async function downloadOutputDotSynctexFromCompileCache(
  299. projectId,
  300. userId,
  301. editorId,
  302. buildId,
  303. outputDir
  304. ) {
  305. const requestPath = `/project/${projectId}/${
  306. userId ? `user/${userId}/` : ''
  307. }build/${editorId}-${buildId}/search/output/output.synctex.gz`
  308. return await downloadSingleFile(projectId, requestPath, outputDir, 'synctex')
  309. }
  310. /**
  311. * @param {string} projectId
  312. * @param {string} userId
  313. * @param {string} cacheDir
  314. * @return {Promise<boolean>}
  315. */
  316. async function downloadHistorySnapshot(projectId, userId, cacheDir) {
  317. const requestPath = `/project/${projectId}/${
  318. userId ? `user/${userId}/` : ''
  319. }latest/output/history-resync.json.gz`
  320. return await downloadSingleFile(projectId, requestPath, cacheDir, 'snapshot')
  321. }
  322. /**
  323. * @param {string} projectId
  324. * @param {string} requestPath
  325. * @param {string} outputDir
  326. * @param {string} label
  327. * @return {Promise<boolean>}
  328. */
  329. async function downloadSingleFile(projectId, requestPath, outputDir, label) {
  330. if (!Settings.apis.clsiCache.enabled) return false
  331. if (!OBJECT_ID_REGEX.test(projectId)) return false
  332. const shardCfg = getAvailableShard(projectId)
  333. if (!shardCfg) return false
  334. const { url, shard } = shardCfg
  335. const timer = new Metrics.Timer(
  336. 'clsi_cache_download',
  337. 1,
  338. { method: label },
  339. TIMING_BUCKETS
  340. )
  341. const u = new URL(url)
  342. u.pathname = requestPath
  343. let stream
  344. try {
  345. stream = await fetchStream(u, {
  346. method: 'GET',
  347. signal: AbortSignal.timeout(TIMEOUT),
  348. })
  349. } catch (err) {
  350. if (err instanceof RequestFailedError && err.response.status === 404) {
  351. closeCircuitBreaker(url)
  352. timer.done({ status: 'not-found' })
  353. return false
  354. }
  355. tripCircuitBreaker(url)
  356. timer.done({ status: 'error' })
  357. throw OError.tag(err, 'download failed', { shard })
  358. }
  359. await fs.promises.mkdir(outputDir, { recursive: true })
  360. const name = Path.basename(requestPath)
  361. const dst = Path.join(outputDir, name)
  362. const tmp = dst + crypto.randomUUID()
  363. try {
  364. await pipeline(
  365. stream,
  366. new MeteredStream(Metrics, 'clsi_cache_egress', {
  367. path: name,
  368. }),
  369. fs.createWriteStream(tmp)
  370. )
  371. await fs.promises.rename(tmp, dst)
  372. } catch (err) {
  373. if (isENOENT(err)) return false
  374. tripCircuitBreaker(url)
  375. try {
  376. await fs.promises.unlink(tmp)
  377. } catch {}
  378. throw OError.tag(err, 'stream failed', { shard })
  379. }
  380. closeCircuitBreaker(url)
  381. timer.done({ status: 'success' })
  382. return true
  383. }
  384. /**
  385. * @param {string} projectId
  386. * @param {string} userId
  387. * @param {string} compileDir
  388. * @return {Promise<boolean>}
  389. */
  390. async function downloadLatestCompileCache(projectId, userId, compileDir) {
  391. if (!Settings.apis.clsiCache.enabled) return false
  392. if (!OBJECT_ID_REGEX.test(projectId)) return false
  393. const shardCfg = getAvailableShard(projectId)
  394. if (!shardCfg) return false
  395. const { url, shard } = shardCfg
  396. const timer = new Metrics.Timer(
  397. 'clsi_cache_download',
  398. 1,
  399. { method: 'tar' },
  400. TIMING_BUCKETS
  401. )
  402. let stream
  403. try {
  404. stream = await fetchStream(
  405. `${url}/project/${projectId}/${
  406. userId ? `user/${userId}/` : ''
  407. }latest/output/output.tar.gz`,
  408. {
  409. method: 'GET',
  410. signal: AbortSignal.timeout(TIMEOUT),
  411. }
  412. )
  413. } catch (err) {
  414. if (err instanceof RequestFailedError && err.response.status === 404) {
  415. closeCircuitBreaker(url)
  416. timer.done({ status: 'not-found' })
  417. return false
  418. }
  419. tripCircuitBreaker(url)
  420. timer.done({ status: 'error' })
  421. throw OError.tag(err, 'download failed', { shard })
  422. }
  423. let n = 0
  424. let abort = false
  425. try {
  426. await pipeline(
  427. stream,
  428. new MeteredStream(Metrics, 'clsi_cache_egress', {
  429. path: 'output.tar.gz',
  430. }),
  431. createGunzip(),
  432. tarFs.extract(compileDir, {
  433. // use ignore hook for counting entries (files+folders) and validation.
  434. // Include folders as they incur mkdir calls.
  435. ignore(_, header) {
  436. if (abort) return true // log once
  437. n++
  438. if (n > MAX_ENTRIES_IN_OUTPUT_TAR) {
  439. abort = true
  440. logger.warn(
  441. {
  442. projectId,
  443. userId,
  444. compileDir,
  445. },
  446. 'too many entries in tar-ball from clsi-cache'
  447. )
  448. } else if (header.type !== 'file' && header.type !== 'directory') {
  449. abort = true
  450. logger.warn(
  451. {
  452. projectId,
  453. userId,
  454. compileDir,
  455. entryType: header.type,
  456. },
  457. 'unexpected entry in tar-ball from clsi-cache'
  458. )
  459. }
  460. return abort
  461. },
  462. })
  463. )
  464. } catch (err) {
  465. if (isENOENT(err)) return false
  466. tripCircuitBreaker(url)
  467. throw OError.tag(err, 'stream failed', { shard })
  468. }
  469. closeCircuitBreaker(url)
  470. Metrics.count('clsi_cache_download_entries', n)
  471. timer.done({ status: 'success' })
  472. return !abort
  473. }
  474. /**
  475. * @param {Object} opts
  476. * @param {string} opts.projectId
  477. * @param {string} opts.userId
  478. * @param {string} opts.buildId
  479. * @return {string}
  480. */
  481. function getOutputDir({ projectId, userId, buildId }) {
  482. return Path.join(
  483. Settings.path.outputDir,
  484. userId ? `${projectId}-${userId}` : projectId,
  485. CACHE_SUBDIR,
  486. buildId
  487. )
  488. }
  489. /**
  490. * @param {unknown} err
  491. * @return {boolean}
  492. */
  493. function isENOENT(err) {
  494. return err instanceof Error && 'code' in err && err.code === 'ENOENT'
  495. }
  496. export default {
  497. notifyCLSICacheAboutBuild,
  498. downloadLatestCompileCache,
  499. downloadHistorySnapshot,
  500. downloadOutputDotSynctexFromCompileCache,
  501. }