ClsiCookieManager.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. const { URL, URLSearchParams } = require('url')
  2. const OError = require('@overleaf/o-error')
  3. const Settings = require('@overleaf/settings')
  4. const {
  5. fetchNothing,
  6. fetchStringWithResponse,
  7. RequestFailedError,
  8. } = require('@overleaf/fetch-utils')
  9. const RedisWrapper = require('../../infrastructure/RedisWrapper')
  10. const Cookie = require('cookie')
  11. const logger = require('@overleaf/logger')
  12. const Metrics = require('@overleaf/metrics')
  13. const clsiCookiesEnabled = (Settings.clsiCookie?.key ?? '') !== ''
  14. const rclient = RedisWrapper.client('clsi_cookie')
  15. let rclientSecondary
  16. if (Settings.redis.clsi_cookie_secondary != null) {
  17. rclientSecondary = RedisWrapper.client('clsi_cookie_secondary')
  18. }
  19. const ClsiCookieManagerFactory = function (backendGroup) {
  20. /**
  21. * @param {string} projectId
  22. * @param {string | null} userId
  23. * @param {string} compileBackendClass
  24. * @return {string}
  25. */
  26. function buildKey(projectId, userId, compileBackendClass) {
  27. if (backendGroup != null) {
  28. return `clsiserver:${backendGroup}:${compileBackendClass}:${projectId}:${userId}`
  29. } else {
  30. return `clsiserver:${compileBackendClass}:${projectId}:${userId}`
  31. }
  32. }
  33. function buildOldKey(projectId, userId) {
  34. if (backendGroup != null) {
  35. return `clsiserver:${backendGroup}:${projectId}:${userId}`
  36. } else {
  37. return `clsiserver:${projectId}:${userId}`
  38. }
  39. }
  40. async function getServerId(
  41. projectId,
  42. userId,
  43. compileGroup,
  44. compileBackendClass
  45. ) {
  46. if (!clsiCookiesEnabled) {
  47. return
  48. }
  49. let serverId = await rclient.get(
  50. buildKey(projectId, userId, compileBackendClass)
  51. )
  52. if (!serverId) {
  53. // Fallback to the old key.
  54. // TODO(das7pad): remove this in 24h.
  55. serverId = await rclient.get(buildOldKey(projectId, userId))
  56. }
  57. if (!serverId) {
  58. return await cookieManager.promises._populateServerIdViaRequest(
  59. projectId,
  60. userId,
  61. compileGroup,
  62. compileBackendClass
  63. )
  64. } else {
  65. return serverId
  66. }
  67. }
  68. async function _populateServerIdViaRequest(
  69. projectId,
  70. userId,
  71. compileGroup,
  72. compileBackendClass
  73. ) {
  74. const u = new URL(`${Settings.apis.clsi.url}/project/${projectId}/status`)
  75. u.search = new URLSearchParams({
  76. compileGroup,
  77. compileBackendClass,
  78. }).toString()
  79. let res
  80. try {
  81. res = await fetchNothing(u.href, {
  82. method: 'POST',
  83. signal: AbortSignal.timeout(30_000),
  84. })
  85. } catch (err) {
  86. OError.tag(err, 'error getting initial server id for project', {
  87. project_id: projectId,
  88. })
  89. throw err
  90. }
  91. if (!clsiCookiesEnabled) {
  92. return
  93. }
  94. const serverId = cookieManager._parseServerIdFromResponse(res)
  95. try {
  96. await cookieManager.promises.setServerId(
  97. projectId,
  98. userId,
  99. compileGroup,
  100. compileBackendClass,
  101. serverId,
  102. null
  103. )
  104. return serverId
  105. } catch (err) {
  106. logger.warn(
  107. { err, projectId },
  108. 'error setting server id via populate request'
  109. )
  110. throw err
  111. }
  112. }
  113. function _parseServerIdFromResponse(response) {
  114. const cookies = Cookie.parse(response.headers['set-cookie']?.[0] || '')
  115. return cookies?.[Settings.clsiCookie.key]
  116. }
  117. async function checkIsLoadSheddingEvent(
  118. clsiserverid,
  119. compileGroup,
  120. compileBackendClass
  121. ) {
  122. let status
  123. try {
  124. const params = new URLSearchParams({
  125. clsiserverid,
  126. compileGroup,
  127. compileBackendClass,
  128. }).toString()
  129. const { response, body } = await fetchStringWithResponse(
  130. `${Settings.apis.clsi.url}/instance-state?${params}`,
  131. {
  132. method: 'GET',
  133. signal: AbortSignal.timeout(30_000),
  134. }
  135. )
  136. status =
  137. response.status === 200 && body === `${clsiserverid},UP\n`
  138. ? 'load-shedding'
  139. : 'cycle'
  140. } catch (err) {
  141. if (err instanceof RequestFailedError && err.response.status === 404) {
  142. status = 'cycle'
  143. } else {
  144. status = 'error'
  145. logger.warn({ err, clsiserverid }, 'cannot probe clsi VM')
  146. }
  147. }
  148. Metrics.inc('clsi-lb-switch-backend', 1, { status })
  149. }
  150. function _getTTLInSeconds(clsiServerId) {
  151. return (clsiServerId || '').includes('-reg-')
  152. ? Settings.clsiCookie.ttlInSecondsRegular
  153. : Settings.clsiCookie.ttlInSeconds
  154. }
  155. async function setServerId(
  156. projectId,
  157. userId,
  158. compileGroup,
  159. compileBackendClass,
  160. serverId,
  161. previous
  162. ) {
  163. if (!clsiCookiesEnabled) {
  164. return
  165. }
  166. if (serverId == null) {
  167. // We don't get a cookie back if it hasn't changed
  168. return await rclient.expire(
  169. buildKey(projectId, userId, compileBackendClass),
  170. _getTTLInSeconds(previous)
  171. )
  172. }
  173. if (!previous) {
  174. // Initial assignment of a user+project or after clearing cache.
  175. Metrics.inc('clsi-lb-assign-initial-backend')
  176. } else {
  177. await checkIsLoadSheddingEvent(
  178. previous,
  179. compileGroup,
  180. compileBackendClass
  181. )
  182. }
  183. if (rclientSecondary != null) {
  184. await _setServerIdInRedis(
  185. rclientSecondary,
  186. projectId,
  187. userId,
  188. compileBackendClass,
  189. serverId
  190. ).catch(() => {})
  191. }
  192. await _setServerIdInRedis(
  193. rclient,
  194. projectId,
  195. userId,
  196. compileBackendClass,
  197. serverId
  198. )
  199. }
  200. async function _setServerIdInRedis(
  201. rclient,
  202. projectId,
  203. userId,
  204. compileBackendClass,
  205. serverId
  206. ) {
  207. await rclient.setex(
  208. buildKey(projectId, userId, compileBackendClass),
  209. _getTTLInSeconds(serverId),
  210. serverId
  211. )
  212. }
  213. async function clearServerId(projectId, userId, compileBackendClass) {
  214. if (!clsiCookiesEnabled) {
  215. return
  216. }
  217. try {
  218. await rclient.del(
  219. buildKey(projectId, userId, compileBackendClass),
  220. buildOldKey(projectId, userId)
  221. )
  222. } catch (err) {
  223. // redis errors need wrapping as the instance may be shared
  224. throw new OError(
  225. 'Failed to clear clsi persistence',
  226. { projectId, userId },
  227. err
  228. )
  229. }
  230. }
  231. const cookieManager = {
  232. _parseServerIdFromResponse,
  233. promises: {
  234. getServerId,
  235. clearServerId,
  236. _populateServerIdViaRequest,
  237. setServerId,
  238. },
  239. }
  240. return cookieManager
  241. }
  242. module.exports = ClsiCookieManagerFactory