CLSICacheHandler.js 7.8 KB

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