CompileManager.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import Crypto from 'node:crypto'
  2. import Settings from '@overleaf/settings'
  3. import RedisWrapper from '../../infrastructure/RedisWrapper.js'
  4. import ProjectGetter from '../Project/ProjectGetter.mjs'
  5. import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
  6. import UserGetter from '../User/UserGetter.js'
  7. import ClsiManager from './ClsiManager.mjs'
  8. import Metrics from '@overleaf/metrics'
  9. import { RateLimiter } from '../../infrastructure/RateLimiter.js'
  10. import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js'
  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. } = await ClsiManager.promises.sendRequest(projectId, compileAsUser, options)
  79. return {
  80. status,
  81. outputFiles,
  82. clsiServerId,
  83. limits,
  84. validationProblems,
  85. stats,
  86. timings,
  87. outputUrlPrefix,
  88. buildId,
  89. clsiCacheShard,
  90. }
  91. }
  92. const instrumentedCompile = instrumentWithTimer(compile, 'editor.compile')
  93. async function getProjectCompileLimits(projectId) {
  94. const project = await ProjectGetter.promises.getProject(projectId, {
  95. owner_ref: 1,
  96. })
  97. return _getProjectCompileLimits(project)
  98. }
  99. async function _getProjectCompileLimits(project) {
  100. if (!project) {
  101. throw new Error('project not found')
  102. }
  103. const owner = await UserGetter.promises.getUser(project.owner_ref, {
  104. _id: 1,
  105. alphaProgram: 1,
  106. analyticsId: 1,
  107. betaProgram: 1,
  108. features: 1,
  109. })
  110. const ownerFeatures = (owner && owner.features) || {}
  111. // put alpha users into their own compile group
  112. if (owner && owner.alphaProgram) {
  113. ownerFeatures.compileGroup = 'alpha'
  114. }
  115. const analyticsId = await UserAnalyticsIdCache.get(owner._id)
  116. const compileGroup =
  117. ownerFeatures.compileGroup || Settings.defaultFeatures.compileGroup
  118. const limits = {
  119. timeout:
  120. ownerFeatures.compileTimeout || Settings.defaultFeatures.compileTimeout,
  121. compileGroup,
  122. compileBackendClass: compileGroup === 'standard' ? 'n2d' : 'c2d',
  123. ownerAnalyticsId: analyticsId,
  124. }
  125. return limits
  126. }
  127. async function wordCount(projectId, userId, file, clsiserverid) {
  128. const limits =
  129. await CompileManager.promises.getProjectCompileLimits(projectId)
  130. return await ClsiManager.promises.wordCount(
  131. projectId,
  132. userId,
  133. file,
  134. limits,
  135. clsiserverid
  136. )
  137. }
  138. async function syncTeX(
  139. projectId,
  140. userId,
  141. { direction, compileFromClsiCache, validatedOptions, clsiServerId }
  142. ) {
  143. const project = await ProjectGetter.promises.getProject(projectId, {
  144. owner_ref: 1,
  145. imageName: 1,
  146. })
  147. const limits = await _getProjectCompileLimits(project)
  148. const { imageName } = project
  149. return await ClsiManager.promises.syncTeX(projectId, userId, {
  150. direction,
  151. limits,
  152. imageName,
  153. compileFromClsiCache,
  154. validatedOptions,
  155. clsiServerId,
  156. })
  157. }
  158. async function stopCompile(projectId, userId) {
  159. const limits =
  160. await CompileManager.promises.getProjectCompileLimits(projectId)
  161. return await ClsiManager.promises.stopCompile(projectId, userId, limits)
  162. }
  163. async function deleteAuxFiles(projectId, userId, clsiserverid) {
  164. const limits =
  165. await CompileManager.promises.getProjectCompileLimits(projectId)
  166. return await ClsiManager.promises.deleteAuxFiles(
  167. projectId,
  168. userId,
  169. limits,
  170. clsiserverid
  171. )
  172. }
  173. export default CompileManager = {
  174. promises: {
  175. compile: instrumentedCompile,
  176. deleteAuxFiles,
  177. getProjectCompileLimits,
  178. stopCompile,
  179. wordCount,
  180. syncTeX,
  181. },
  182. compile: callbackifyMultiResult(instrumentedCompile, [
  183. 'status',
  184. 'outputFiles',
  185. 'clsiServerId',
  186. 'limits',
  187. 'validationProblems',
  188. 'stats',
  189. 'timings',
  190. 'outputUrlPrefix',
  191. 'buildId',
  192. 'clsiCacheShard',
  193. ]),
  194. stopCompile: callbackify(stopCompile),
  195. deleteAuxFiles: callbackify(deleteAuxFiles),
  196. getProjectCompileLimits: callbackify(getProjectCompileLimits),
  197. COMPILE_DELAY: 1, // seconds
  198. async _checkIfRecentlyCompiled(projectId, userId) {
  199. const key = `compile:${projectId}:${userId}`
  200. const ok = await rclient.set(key, true, 'EX', this.COMPILE_DELAY, 'NX')
  201. return ok !== 'OK'
  202. },
  203. async _checkCompileGroupAutoCompileLimit(isAutoCompile, compileGroup) {
  204. if (!isAutoCompile) {
  205. return true
  206. }
  207. if (compileGroup === 'standard') {
  208. // apply extra limits to the standard compile group
  209. return await CompileManager._checkIfAutoCompileLimitHasBeenHit(
  210. isAutoCompile,
  211. compileGroup
  212. )
  213. } else {
  214. Metrics.inc(`auto-compile-${compileGroup}`)
  215. return true
  216. }
  217. }, // always allow priority group users to compile
  218. async _checkIfAutoCompileLimitHasBeenHit(isAutoCompile, compileGroup) {
  219. if (!isAutoCompile) {
  220. return true
  221. }
  222. Metrics.inc(`auto-compile-${compileGroup}`)
  223. const rateLimiter = getAutoCompileRateLimiter(compileGroup)
  224. try {
  225. await rateLimiter.consume('global', 1, { method: 'global' })
  226. return true
  227. } catch (e) {
  228. // Don't differentiate between errors and rate limits. Silently trigger
  229. // the rate limit if there's an error consuming the points.
  230. Metrics.inc(`auto-compile-${compileGroup}-limited`)
  231. return false
  232. }
  233. },
  234. wordCount: callbackify(wordCount),
  235. syncTeX: callbackify(syncTeX),
  236. }
  237. const autoCompileRateLimiters = new Map()
  238. function getAutoCompileRateLimiter(compileGroup) {
  239. let rateLimiter = autoCompileRateLimiters.get(compileGroup)
  240. if (rateLimiter == null) {
  241. rateLimiter = new RateLimiter(`auto-compile:${compileGroup}`, {
  242. points: Settings.rateLimit.autoCompile[compileGroup] || 25,
  243. duration: 20,
  244. })
  245. autoCompileRateLimiters.set(compileGroup, rateLimiter)
  246. }
  247. return rateLimiter
  248. }