CLSICacheHandler.js 10 KB

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