ClsiCookieManager.mjs 5.8 KB

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