CompileManager.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import Crypto from 'node:crypto'
  2. import Settings from '@overleaf/settings'
  3. import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
  4. import ProjectGetter from '../Project/ProjectGetter.mjs'
  5. import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
  6. import UserGetter from '../User/UserGetter.mjs'
  7. import ClsiManager from './ClsiManager.mjs'
  8. import Metrics from '@overleaf/metrics'
  9. import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
  10. import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.mjs'
  11. import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
  12. let CompileManager
  13. const rclient = RedisWrapper.client('clsi_recently_compiled')
  14. function instrumentWithTimer(fn, key) {
  15. return async (...args) => {
  16. const timer = new Metrics.Timer(key)
  17. try {
  18. return await fn(...args)
  19. } finally {
  20. timer.done()
  21. }
  22. }
  23. }
  24. function generateBuildId() {
  25. return `${Date.now().toString(16)}-${Crypto.randomBytes(8).toString('hex')}`
  26. }
  27. async function compile(projectId, userId, options = {}) {
  28. const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
  29. projectId,
  30. userId
  31. )
  32. if (recentlyCompiled) {
  33. return { status: 'too-recently-compiled', outputFiles: [] }
  34. }
  35. try {
  36. const canCompile = await CompileManager._checkIfAutoCompileLimitHasBeenHit(
  37. options.isAutoCompile,
  38. 'everyone'
  39. )
  40. if (!canCompile) {
  41. return { status: 'autocompile-backoff', outputFiles: [] }
  42. }
  43. } catch (error) {
  44. return { status: 'autocompile-backoff', outputFiles: [] }
  45. }
  46. await ProjectRootDocManager.promises.ensureRootDocumentIsSet(projectId)
  47. const limits =
  48. await CompileManager.promises.getProjectCompileLimits(projectId)
  49. for (const key in limits) {
  50. const value = limits[key]
  51. options[key] = value
  52. }
  53. try {
  54. const canCompile = await CompileManager._checkCompileGroupAutoCompileLimit(
  55. options.isAutoCompile,
  56. limits.compileGroup
  57. )
  58. if (!canCompile) {
  59. return { status: 'autocompile-backoff', outputFiles: [] }
  60. }
  61. } catch (error) {
  62. return { message: 'autocompile-backoff', outputFiles: [] }
  63. }
  64. // Generate the buildId ahead of fetching the project content from redis/mongo so that the buildId's timestamp is before any lastUpdated date.
  65. options.buildId = generateBuildId()
  66. // only pass userId down to clsi if this is a per-user compile
  67. const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
  68. const {
  69. status,
  70. outputFiles,
  71. clsiServerId,
  72. validationProblems,
  73. stats,
  74. timings,
  75. outputUrlPrefix,
  76. buildId,
  77. clsiCacheShard,
  78. baseHistoryVersion,
  79. } = await ClsiManager.promises.sendRequest(projectId, compileAsUser, options)
  80. return {
  81. status,
  82. outputFiles,
  83. clsiServerId,
  84. limits,
  85. validationProblems,
  86. stats,
  87. timings,
  88. outputUrlPrefix,
  89. buildId,
  90. clsiCacheShard,
  91. baseHistoryVersion,
  92. }
  93. }
  94. const instrumentedCompile = instrumentWithTimer(compile, 'editor.compile')
  95. async function getProjectCompileLimits(projectId) {
  96. const project = await ProjectGetter.promises.getProject(projectId, {
  97. owner_ref: 1,
  98. fromV1TemplateId: 1,
  99. })
  100. return _getProjectCompileLimits(project)
  101. }
  102. async function _getProjectCompileLimits(project) {
  103. if (!project) {
  104. throw new Error('project not found')
  105. }
  106. const owner = await UserGetter.promises.getUser(project.owner_ref, {
  107. _id: 1,
  108. alphaProgram: 1,
  109. analyticsId: 1,
  110. betaProgram: 1,
  111. features: 1,
  112. })
  113. const ownerFeatures = (owner && owner.features) || {}
  114. // put alpha users into their own compile group
  115. if (owner && owner.alphaProgram) {
  116. ownerFeatures.compileGroup = 'alpha'
  117. }
  118. const analyticsId = await UserAnalyticsIdCache.get(owner._id)
  119. const compileGroup =
  120. ownerFeatures.compileGroup || Settings.defaultFeatures.compileGroup
  121. const limits = {
  122. timeout:
  123. ownerFeatures.compileTimeout || Settings.defaultFeatures.compileTimeout,
  124. compileGroup,
  125. compileBackendClass: compileGroup === 'standard' ? 'c3d' : 'c4d',
  126. ownerAnalyticsId: analyticsId,
  127. }
  128. if (project.fromV1TemplateId === Settings.overrideCompileTimeForTemplate) {
  129. limits.timeout = Math.max(limits.timeout, 20)
  130. }
  131. return limits
  132. }
  133. async function wordCount(projectId, userId, file, clsiserverid) {
  134. const limits =
  135. await CompileManager.promises.getProjectCompileLimits(projectId)
  136. return await ClsiManager.promises.wordCount(
  137. projectId,
  138. userId,
  139. file,
  140. limits,
  141. clsiserverid
  142. )
  143. }
  144. async function syncTeX(
  145. projectId,
  146. userId,
  147. { direction, compileFromClsiCache, validatedOptions, clsiServerId }
  148. ) {
  149. const project = await ProjectGetter.promises.getProject(projectId, {
  150. owner_ref: 1,
  151. imageName: 1,
  152. fromV1TemplateId: 1,
  153. })
  154. const limits = await _getProjectCompileLimits(project)
  155. const { imageName } = project
  156. return await ClsiManager.promises.syncTeX(projectId, userId, {
  157. direction,
  158. limits,
  159. imageName,
  160. compileFromClsiCache,
  161. validatedOptions,
  162. clsiServerId,
  163. })
  164. }
  165. async function stopCompile(projectId, userId) {
  166. const limits =
  167. await CompileManager.promises.getProjectCompileLimits(projectId)
  168. return await ClsiManager.promises.stopCompile(projectId, userId, limits)
  169. }
  170. async function deleteAuxFiles(projectId, userId, clsiserverid) {
  171. const limits =
  172. await CompileManager.promises.getProjectCompileLimits(projectId)
  173. return await ClsiManager.promises.deleteAuxFiles(
  174. projectId,
  175. userId,
  176. limits,
  177. clsiserverid
  178. )
  179. }
  180. export default CompileManager = {
  181. promises: {
  182. compile: instrumentedCompile,
  183. deleteAuxFiles,
  184. getProjectCompileLimits,
  185. stopCompile,
  186. wordCount,
  187. syncTeX,
  188. },
  189. compile: callbackifyMultiResult(instrumentedCompile, [
  190. 'status',
  191. 'outputFiles',
  192. 'clsiServerId',
  193. 'limits',
  194. 'validationProblems',
  195. 'stats',
  196. 'timings',
  197. 'outputUrlPrefix',
  198. 'buildId',
  199. 'clsiCacheShard',
  200. ]),
  201. stopCompile: callbackify(stopCompile),
  202. deleteAuxFiles: callbackify(deleteAuxFiles),
  203. getProjectCompileLimits: callbackify(getProjectCompileLimits),
  204. COMPILE_DELAY: 1, // seconds
  205. async _checkIfRecentlyCompiled(projectId, userId) {
  206. const key = `compile:${projectId}:${userId}`
  207. const ok = await rclient.set(key, true, 'EX', this.COMPILE_DELAY, 'NX')
  208. return ok !== 'OK'
  209. },
  210. async _checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup) {
  211. if (!isAutoCompile) {
  212. return true
  213. }
  214. if (compileGroup === 'standard') {
  215. // apply extra limits to the standard compile group
  216. return await CompileManager._checkIfAutoCompileLimitHasBeenHit(
  217. isAutoCompile,
  218. compileGroup
  219. )
  220. } else {
  221. Metrics.inc(`auto-compile-${compileGroup}`)
  222. return true
  223. }
  224. }, // always allow priority group users to compile
  225. async _checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup) {
  226. if (!isAutoCompile) {
  227. return true
  228. }
  229. Metrics.inc(`auto-compile-${compileGroup}`)
  230. const rateLimiter = getAutoCompileRateLimiter(compileGroup)
  231. try {
  232. await rateLimiter.consume('global', 1, { method: 'global' })
  233. return true
  234. } catch (e) {
  235. // Don't differentiate between errors and rate limits. Silently trigger
  236. // the rate limit if there's an error consuming the points.
  237. Metrics.inc(`auto-compile-${compileGroup}-limited`)
  238. return false
  239. }
  240. },
  241. wordCount: callbackify(wordCount),
  242. syncTeX: callbackify(syncTeX),
  243. }
  244. const autoCompileRateLimiters = new Map()
  245. function getAutoCompileRateLimiter(compileGroup) {
  246. let rateLimiter = autoCompileRateLimiters.get(compileGroup)
  247. if (rateLimiter == null) {
  248. rateLimiter = new RateLimiter(`auto-compile:${compileGroup}`, {
  249. points: Settings.rateLimit.autoCompile[compileGroup] || 25,
  250. duration: 20,
  251. })
  252. autoCompileRateLimiters.set(compileGroup, rateLimiter)
  253. }
  254. return rateLimiter
  255. }