CLSICacheHandler.js 8.4 KB

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