ClsiCacheManager.mjs 8.4 KB

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