ClsiCacheController.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
  2. import {
  3. fetchStreamWithResponse,
  4. RequestFailedError,
  5. } from '@overleaf/fetch-utils'
  6. import Path from 'node:path'
  7. import { pipeline } from 'node:stream/promises'
  8. import logger from '@overleaf/logger'
  9. import ClsiCacheManager from './ClsiCacheManager.mjs'
  10. import CompileController from './CompileController.mjs'
  11. import { expressify } from '@overleaf/promise-utils'
  12. import ClsiCacheHandler from './ClsiCacheHandler.mjs'
  13. import ProjectGetter from '../Project/ProjectGetter.mjs'
  14. import { MeteredStream } from '@overleaf/stream-utils'
  15. import Metrics from '@overleaf/metrics'
  16. /**
  17. * Download a file from a specific build on the clsi-cache.
  18. *
  19. * @param req
  20. * @param res
  21. * @return {Promise<*>}
  22. */
  23. async function downloadFromCache(req, res) {
  24. const { Project_id: projectId, buildId, filename } = req.params
  25. return await _downloadFromCacheWithParams(
  26. req,
  27. res,
  28. projectId,
  29. buildId,
  30. filename
  31. )
  32. }
  33. /**
  34. * Download a file from a specific build on the clsi-cache.
  35. *
  36. * @param req
  37. * @param res
  38. * @param projectId
  39. * @param buildId
  40. * @param filename
  41. * @param projectId
  42. * @param buildId
  43. * @param filename
  44. * @return {Promise<*>}
  45. */
  46. async function _downloadFromCacheWithParams(
  47. req,
  48. res,
  49. projectId,
  50. buildId,
  51. filename
  52. ) {
  53. const userId = CompileController._getUserIdForCompile(req)
  54. const ac = new AbortController()
  55. let timer = setTimeout(() => ac.abort(), 10_000)
  56. let location, projectName
  57. try {
  58. ;[{ location }, { name: projectName }] = await Promise.all([
  59. ClsiCacheHandler.getOutputFile(
  60. projectId,
  61. userId,
  62. buildId,
  63. filename,
  64. ac.signal
  65. ),
  66. ProjectGetter.promises.getProject(projectId, { name: 1 }),
  67. ])
  68. } catch (err) {
  69. clearTimeout(timer)
  70. if (err instanceof NotFoundError) {
  71. // res.sendStatus() sends a description of the status as body.
  72. // Using res.status().end() avoids sending that fake body.
  73. return res.status(404).end()
  74. } else {
  75. throw err
  76. }
  77. }
  78. let stream, response
  79. try {
  80. ;({ stream, response } = await fetchStreamWithResponse(location, {
  81. signal: ac.signal,
  82. }))
  83. } finally {
  84. clearTimeout(timer)
  85. }
  86. if (req.destroyed) {
  87. // The client has disconnected already, avoid trying to write into the broken connection.
  88. stream.destroy(new Error('user aborted the request'))
  89. return
  90. }
  91. for (const key of ['Content-Length', 'Content-Type']) {
  92. if (response.headers.has(key)) res.setHeader(key, response.headers.get(key))
  93. }
  94. const ext = Path.extname(filename)
  95. res.attachment(
  96. ext === '.pdf'
  97. ? `${CompileController._getSafeProjectName({ name: projectName })}.pdf`
  98. : filename
  99. )
  100. // Downloads can take a while on a slow connection, increase timeouts to 10min
  101. const TEN_MINUTES_IN_MS = 10 * 60 * 1000
  102. res.setTimeout(TEN_MINUTES_IN_MS)
  103. timer = setTimeout(() => ac.abort(), TEN_MINUTES_IN_MS)
  104. // Disable buffering in nginx
  105. res.setHeader('X-Accel-Buffering', 'no')
  106. try {
  107. res.writeHead(response.status)
  108. await pipeline(
  109. stream,
  110. new MeteredStream(Metrics, 'clsi_cache_egress', {
  111. path: ClsiCacheHandler.getEgressLabel(filename),
  112. }),
  113. res
  114. )
  115. } catch (err) {
  116. const reqAborted = Boolean(req.destroyed)
  117. const streamingStarted = Boolean(res.headersSent)
  118. if (!streamingStarted) {
  119. if (err instanceof RequestFailedError) {
  120. res.sendStatus(err.response.status)
  121. } else {
  122. res.sendStatus(500)
  123. }
  124. }
  125. if (
  126. streamingStarted &&
  127. reqAborted &&
  128. (err.code === 'ERR_STREAM_PREMATURE_CLOSE' ||
  129. err.code === 'ERR_STREAM_UNABLE_TO_PIPE')
  130. ) {
  131. // Ignore noisy spurious error
  132. return
  133. }
  134. logger.warn(
  135. {
  136. err,
  137. projectId,
  138. location,
  139. filename,
  140. reqAborted,
  141. streamingStarted,
  142. },
  143. 'CLSI-cache proxy error'
  144. )
  145. } finally {
  146. clearTimeout(timer)
  147. }
  148. }
  149. /**
  150. * Prepare a compile response from the clsi-cache.
  151. *
  152. * @param req
  153. * @param res
  154. * @return {Promise<void>}
  155. */
  156. async function getLatestBuildFromCache(req, res) {
  157. const { Project_id: projectId } = req.params
  158. const userId = CompileController._getUserIdForCompile(req)
  159. try {
  160. const {
  161. zone,
  162. outputFiles,
  163. compileGroup,
  164. clsiServerId,
  165. clsiCacheShard,
  166. options,
  167. stats,
  168. timings,
  169. } = await ClsiCacheManager.getLatestCompileResult(projectId, userId)
  170. let { pdfCachingMinChunkSize, pdfDownloadDomain } =
  171. await CompileController._getSplitTestOptions(req, res)
  172. pdfDownloadDomain += `/zone/${zone}`
  173. res.json({
  174. fromCache: true,
  175. status: 'success',
  176. outputFiles,
  177. compileGroup,
  178. clsiServerId,
  179. clsiCacheShard,
  180. pdfDownloadDomain,
  181. pdfCachingMinChunkSize,
  182. options,
  183. stats,
  184. timings,
  185. })
  186. } catch (err) {
  187. if (err instanceof NotFoundError) {
  188. res.sendStatus(404)
  189. } else if (err instanceof ResourceGoneError) {
  190. res.sendStatus(410)
  191. } else {
  192. throw err
  193. }
  194. }
  195. }
  196. export default {
  197. _downloadFromCacheWithParams,
  198. downloadFromCache: expressify(downloadFromCache),
  199. getLatestBuildFromCache: expressify(getLatestBuildFromCache),
  200. }