ClsiCacheManager.mjs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import _ from 'lodash'
  2. import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
  3. import ClsiCacheHandler from './ClsiCacheHandler.js'
  4. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  5. import ProjectGetter from '../Project/ProjectGetter.mjs'
  6. import UserGetter from '../User/UserGetter.js'
  7. import Settings from '@overleaf/settings'
  8. import { fetchJson, RequestFailedError } from '@overleaf/fetch-utils'
  9. import Metrics from '@overleaf/metrics'
  10. import Features from '../../infrastructure/Features.js'
  11. /**
  12. * Get the most recent build and metadata
  13. *
  14. * Internal: internal metadata; External: fine to send to user as-is.
  15. *
  16. * @param projectId
  17. * @param userId
  18. * @param filename
  19. * @param signal
  20. * @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
  21. */
  22. async function getLatestBuildFromCache(projectId, userId, filename, signal) {
  23. const [
  24. { location, lastModified: lastCompiled, zone, shard, size, allFiles },
  25. lastUpdatedInRedis,
  26. { lastUpdated: lastUpdatedInMongo },
  27. ] = await Promise.all([
  28. ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
  29. DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
  30. ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
  31. ])
  32. const lastUpdated =
  33. lastUpdatedInRedis > lastUpdatedInMongo
  34. ? lastUpdatedInRedis
  35. : lastUpdatedInMongo
  36. const isUpToDate = lastCompiled >= lastUpdated
  37. return {
  38. internal: {
  39. location,
  40. },
  41. external: {
  42. isUpToDate,
  43. lastUpdated,
  44. size,
  45. allFiles,
  46. shard,
  47. zone,
  48. },
  49. }
  50. }
  51. class MetaFileExpiredError extends NotFoundError {}
  52. async function getLatestCompileResult(projectId, userId) {
  53. const signal = AbortSignal.timeout(15_000)
  54. for (let attempt = 0; attempt < 3; attempt++) {
  55. try {
  56. return await tryGetLatestCompileResult(projectId, userId, signal)
  57. } catch (err) {
  58. if (err instanceof MetaFileExpiredError) {
  59. continue
  60. }
  61. throw err
  62. }
  63. }
  64. throw new NotFoundError()
  65. }
  66. async function tryGetLatestCompileResult(projectId, userId, signal) {
  67. const {
  68. internal: { location: metaLocation },
  69. external: {
  70. isUpToDate,
  71. allFiles,
  72. zone,
  73. shard: clsiCacheShard,
  74. size: jsonSize,
  75. },
  76. } = await getLatestBuildFromCache(
  77. projectId,
  78. userId,
  79. 'output.overleaf.json',
  80. signal
  81. )
  82. if (!isUpToDate) throw new ResourceGoneError()
  83. let meta
  84. try {
  85. meta = await fetchJson(metaLocation, {
  86. signal: AbortSignal.timeout(5 * 1000),
  87. })
  88. } catch (err) {
  89. if (err instanceof RequestFailedError && err.response.status === 404) {
  90. throw new MetaFileExpiredError(
  91. 'build expired between listing and reading'
  92. )
  93. }
  94. throw err
  95. }
  96. Metrics.count('clsi_cache_egress', jsonSize, 1, {
  97. path: ClsiCacheHandler.getEgressLabel('output.overleaf.json'),
  98. })
  99. const [, editorId, buildId] = metaLocation.match(
  100. /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\//
  101. )
  102. const {
  103. ranges,
  104. contentId,
  105. clsiServerId,
  106. compileGroup,
  107. size,
  108. options,
  109. stats,
  110. timings,
  111. } = meta
  112. let baseURL = `/project/${projectId}`
  113. if (userId) {
  114. baseURL += `/user/${userId}`
  115. }
  116. const outputFiles = allFiles
  117. .filter(path => path !== 'output.overleaf.json' && path !== 'output.tar.gz')
  118. .map(path => {
  119. const f = {
  120. url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`,
  121. downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`,
  122. build: buildId,
  123. path,
  124. type: path.split('.').pop(),
  125. }
  126. if (path === 'output.pdf') {
  127. Object.assign(f, {
  128. size,
  129. editorId,
  130. })
  131. if (clsiServerId !== clsiCacheShard) {
  132. // Enable PDF caching and attempt to download from VM first.
  133. // (clsi VMs do not have the editorId in the path on disk, omit it).
  134. Object.assign(f, {
  135. url: `${baseURL}/build/${buildId}/output/output.pdf`,
  136. ranges,
  137. contentId,
  138. })
  139. }
  140. }
  141. return f
  142. })
  143. return {
  144. allFiles,
  145. zone,
  146. outputFiles,
  147. compileGroup,
  148. clsiServerId,
  149. clsiCacheShard,
  150. options,
  151. stats,
  152. timings,
  153. }
  154. }
  155. /**
  156. * Collect metadata and prepare the clsi-cache for the given project.
  157. *
  158. * @param projectId
  159. * @param userId
  160. * @param sourceProjectId
  161. * @param templateId
  162. * @param templateVersionId
  163. * @return {Promise<void>}
  164. */
  165. async function prepareClsiCache(
  166. projectId,
  167. userId,
  168. { sourceProjectId, templateId, templateVersionId }
  169. ) {
  170. if (!Features.hasFeature('saas')) return
  171. const features = await UserGetter.promises.getUserFeatures(userId)
  172. if (features.compileGroup !== 'priority') return
  173. const signal = AbortSignal.timeout(ClsiCacheHandler.TIMEOUT)
  174. let lastUpdated
  175. let shard = _.shuffle(Settings.apis.clsiCache.instances)[0].shard
  176. if (sourceProjectId) {
  177. try {
  178. ;({
  179. external: { lastUpdated, shard },
  180. } = await getLatestBuildFromCache(
  181. sourceProjectId,
  182. userId,
  183. 'output.tar.gz',
  184. signal
  185. ))
  186. } catch (err) {
  187. if (err instanceof NotFoundError) return // nothing cached yet
  188. throw err
  189. }
  190. }
  191. try {
  192. await ClsiCacheHandler.prepareCacheSource(projectId, userId, {
  193. sourceProjectId,
  194. templateId,
  195. templateVersionId,
  196. shard,
  197. lastUpdated,
  198. signal,
  199. })
  200. } catch (err) {
  201. if (err instanceof NotFoundError) return // nothing cached yet/expired.
  202. throw err
  203. }
  204. }
  205. export default {
  206. getLatestBuildFromCache,
  207. getLatestCompileResult,
  208. prepareClsiCache,
  209. }