CLSICacheHandler.js 9.8 KB

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