CLSICacheHandler.js 6.9 KB

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