CLSICacheHandler.js 7.7 KB

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