CLSICacheHandler.js 6.8 KB

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