CompileManager.js 7.2 KB

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