CLSICacheHandler.js 7.6 KB

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