CLSICacheHandler.js 8.5 KB

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