ClsiCacheManager.mjs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import _ from 'lodash'
  2. import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
  3. import ClsiCacheHandler from './ClsiCacheHandler.mjs'
  4. import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
  5. import ProjectGetter from '../Project/ProjectGetter.mjs'
  6. import UserGetter from '../User/UserGetter.mjs'
  7. import Settings from '@overleaf/settings'
  8. import logger from '@overleaf/logger'
  9. import { fetchJson, RequestFailedError } from '@overleaf/fetch-utils'
  10. import Metrics from '@overleaf/metrics'
  11. import Features from '../../infrastructure/Features.mjs'
  12. import ClsiManager from './ClsiManager.mjs'
  13. import Crypto from 'node:crypto'
  14. import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
  15. import { ObjectId } from '../../infrastructure/mongodb.mjs'
  16. const ClsiCookieManager = ClsiCookieManagerFactory(
  17. Settings.apis.clsi?.backendGroupName
  18. )
  19. /**
  20. * Get the most recent build and metadata
  21. *
  22. * Internal: internal metadata; External: fine to send to user as-is.
  23. *
  24. * @param projectId
  25. * @param userId
  26. * @param filename
  27. * @param signal
  28. * @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
  29. */
  30. async function getLatestBuildFromCache(projectId, userId, filename, signal) {
  31. const [
  32. { location, lastModified: lastCompiled, zone, shard, size, allFiles },
  33. lastUpdatedInRedis,
  34. { lastUpdated: lastUpdatedInMongo },
  35. ] = await Promise.all([
  36. ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
  37. DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
  38. ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
  39. ])
  40. const lastUpdated =
  41. lastUpdatedInRedis > lastUpdatedInMongo
  42. ? lastUpdatedInRedis
  43. : lastUpdatedInMongo
  44. const isUpToDate = lastCompiled >= lastUpdated
  45. return {
  46. internal: {
  47. location,
  48. },
  49. external: {
  50. isUpToDate,
  51. lastUpdated,
  52. size,
  53. allFiles,
  54. shard,
  55. zone,
  56. },
  57. }
  58. }
  59. class MetaFileExpiredError extends NotFoundError {}
  60. async function getLatestCompileResult(projectId, userId) {
  61. const signal = AbortSignal.timeout(15_000)
  62. for (let attempt = 0; attempt < 3; attempt++) {
  63. try {
  64. return await tryGetLatestCompileResult(projectId, userId, signal)
  65. } catch (err) {
  66. if (err instanceof MetaFileExpiredError) {
  67. continue
  68. }
  69. throw err
  70. }
  71. }
  72. throw new NotFoundError()
  73. }
  74. async function tryGetLatestCompileResult(projectId, userId, signal) {
  75. const {
  76. internal: { location: metaLocation },
  77. external: {
  78. isUpToDate,
  79. allFiles,
  80. zone,
  81. shard: clsiCacheShard,
  82. size: jsonSize,
  83. },
  84. } = await getLatestBuildFromCache(
  85. projectId,
  86. userId,
  87. 'output.overleaf.json',
  88. signal
  89. )
  90. if (!isUpToDate) throw new ResourceGoneError()
  91. let meta
  92. try {
  93. meta = await fetchJson(metaLocation, {
  94. signal: AbortSignal.timeout(5 * 1000),
  95. })
  96. } catch (err) {
  97. if (err instanceof RequestFailedError && err.response.status === 404) {
  98. throw new MetaFileExpiredError(
  99. 'build expired between listing and reading'
  100. )
  101. }
  102. throw err
  103. }
  104. Metrics.count('clsi_cache_egress', jsonSize, 1, {
  105. path: ClsiCacheHandler.getEgressLabel('output.overleaf.json'),
  106. })
  107. const [, editorId, buildId] = metaLocation.match(
  108. /\/build\/([a-f0-9-]+?)-([a-f0-9]+-[a-f0-9]+)\//
  109. )
  110. const {
  111. ranges,
  112. contentId,
  113. clsiServerId,
  114. compileGroup,
  115. size,
  116. options,
  117. stats,
  118. timings,
  119. } = meta
  120. let baseURL = `/project/${projectId}`
  121. if (userId) {
  122. baseURL += `/user/${userId}`
  123. }
  124. const outputFiles = allFiles
  125. .filter(path => path !== 'output.overleaf.json' && path !== 'output.tar.gz')
  126. .map(path => {
  127. const f = {
  128. url: `${baseURL}/build/${editorId}-${buildId}/output/${path}`,
  129. downloadURL: `/download/project/${projectId}/build/${editorId}-${buildId}/output/cached/${path}`,
  130. build: buildId,
  131. path,
  132. type: path.split('.').pop(),
  133. }
  134. if (path === 'output.pdf') {
  135. Object.assign(f, {
  136. size,
  137. editorId,
  138. })
  139. if (clsiServerId !== clsiCacheShard) {
  140. // Enable PDF caching and attempt to download from VM first.
  141. // (clsi VMs do not have the editorId in the path on disk, omit it).
  142. Object.assign(f, {
  143. url: `${baseURL}/build/${buildId}/output/output.pdf`,
  144. ranges,
  145. contentId,
  146. })
  147. }
  148. }
  149. return f
  150. })
  151. return {
  152. allFiles,
  153. zone,
  154. outputFiles,
  155. compileGroup,
  156. clsiServerId,
  157. clsiCacheShard,
  158. options,
  159. stats,
  160. timings,
  161. }
  162. }
  163. /**
  164. * Collect metadata and prepare the clsi-cache for the given project.
  165. *
  166. * Returns true when downloaded; false when download failed; undefined when
  167. * disabled for env/user;
  168. *
  169. * @param projectId
  170. * @param userId
  171. * @param sourceProjectId
  172. * @param templateVersionId
  173. * @param imageName
  174. * @return {Promise<boolean|undefined>}
  175. */
  176. async function prepareClsiCache(
  177. projectId,
  178. userId,
  179. { sourceProjectId, templateVersionId, imageName }
  180. ) {
  181. if (!Features.hasFeature('saas')) return undefined
  182. const features = await UserGetter.promises.getUserFeatures(userId)
  183. if (features.compileGroup !== 'priority') return undefined
  184. const signal = AbortSignal.timeout(ClsiCacheHandler.TIMEOUT)
  185. let lastUpdated
  186. let shard = _.shuffle(Settings.apis.clsiCache.instances)[0].shard
  187. if (sourceProjectId) {
  188. try {
  189. ;({
  190. external: { lastUpdated, shard },
  191. } = await getLatestBuildFromCache(
  192. sourceProjectId,
  193. userId,
  194. 'output.tar.gz',
  195. signal
  196. ))
  197. } catch (err) {
  198. if (err instanceof NotFoundError) return false // nothing cached yet
  199. throw err
  200. }
  201. }
  202. try {
  203. await ClsiCacheHandler.prepareCacheSource(projectId, userId, {
  204. sourceProjectId,
  205. templateVersionId,
  206. imageName,
  207. shard,
  208. lastUpdated,
  209. signal,
  210. })
  211. } catch (err) {
  212. if (err instanceof NotFoundError) return false // nothing cached yet/expired.
  213. throw err
  214. }
  215. return true
  216. }
  217. async function createTemplateClsiCache({
  218. templateVersionId,
  219. project,
  220. fileEntries,
  221. docEntries,
  222. }) {
  223. const compileGroup = Settings.defaultFeatures.compileGroup
  224. const compileBackendClass = Settings.apis.clsi.submissionBackendClass
  225. const submissionId = new ObjectId().toString()
  226. const editorId = Crypto.randomUUID()
  227. const options = {
  228. editorId,
  229. compileGroup,
  230. compileBackendClass,
  231. timeout: 60,
  232. syncType: 'full',
  233. compileFromClsiCache: false,
  234. populateClsiCache: true,
  235. enablePdfCaching: false,
  236. pdfCachingMinChunkSize: 0,
  237. metricsPath: 'clsi-cache-template',
  238. }
  239. const req = ClsiManager._finaliseRequest(
  240. submissionId,
  241. options,
  242. project,
  243. Object.fromEntries(
  244. docEntries.map(doc => [
  245. doc.path,
  246. { _id: doc.doc._id, lines: doc.docLines.split('\n') },
  247. ])
  248. ),
  249. Object.fromEntries(fileEntries.map(file => [file.path, file.file]))
  250. )
  251. let clsiServerId = await ClsiCookieManager.promises.getServerId(
  252. submissionId,
  253. undefined,
  254. compileGroup,
  255. compileBackendClass
  256. )
  257. const { imageName } = project
  258. try {
  259. let status, buildId, clsiCacheShard
  260. ;({ status, buildId, clsiCacheShard, clsiServerId } =
  261. await ClsiManager.promises.sendExternalRequest(
  262. submissionId,
  263. req,
  264. options
  265. ))
  266. if (status !== 'success') {
  267. logger.warn(
  268. { status, templateVersionId, imageName },
  269. 'compiling template failed'
  270. )
  271. return
  272. }
  273. if (!clsiCacheShard) {
  274. // The circuit breaker tripped for all clsi -> clsi-cache shards. Try again later.
  275. return
  276. }
  277. await ClsiCacheHandler.exportSubmissionAsTemplate(
  278. clsiCacheShard,
  279. submissionId,
  280. editorId + '-' + buildId,
  281. templateVersionId,
  282. imageName
  283. )
  284. } finally {
  285. await ClsiManager.promises.deleteAuxFiles(
  286. submissionId,
  287. null,
  288. options,
  289. clsiServerId
  290. )
  291. }
  292. }
  293. export default {
  294. getLatestBuildFromCache,
  295. getLatestCompileResult,
  296. prepareClsiCache,
  297. createTemplateClsiCache,
  298. }