ConnectedUsersManager.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import async from 'async'
  2. import Settings from '@overleaf/settings'
  3. import logger from '@overleaf/logger'
  4. import redis from '@overleaf/redis-wrapper'
  5. import OError from '@overleaf/o-error'
  6. import Metrics from '@overleaf/metrics'
  7. const rclient = redis.createClient(Settings.redis.realtime)
  8. const Keys = Settings.redis.realtime.key_schema
  9. const ONE_HOUR_IN_S = 60 * 60
  10. const ONE_DAY_IN_S = ONE_HOUR_IN_S * 24
  11. const FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4
  12. const USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4
  13. const REFRESH_TIMEOUT_IN_S = 10 // only show clients which have responded to a refresh request in the last 10 seconds
  14. function recordProjectNotEmptySinceMetric(res, status) {
  15. const diff = Date.now() / 1000 - parseInt(res, 10)
  16. const BUCKETS = [
  17. 0,
  18. ONE_HOUR_IN_S,
  19. 2 * ONE_HOUR_IN_S,
  20. ONE_DAY_IN_S,
  21. 2 * ONE_DAY_IN_S,
  22. 7 * ONE_DAY_IN_S,
  23. 30 * ONE_DAY_IN_S,
  24. ]
  25. Metrics.histogram('project_not_empty_since', diff, BUCKETS, { status })
  26. }
  27. export default {
  28. countConnectedClients(projectId, callback) {
  29. rclient.scard(Keys.clientsInProject({ project_id: projectId }), callback)
  30. },
  31. // Use the same method for when a user connects, and when a user sends a cursor
  32. // update. This way we don't care if the connected_user key has expired when
  33. // we receive a cursor update.
  34. updateUserPosition(projectId, clientId, user, cursorData, callback) {
  35. logger.debug({ projectId, clientId }, 'marking user as joined or connected')
  36. const multi = rclient.multi()
  37. multi.sadd(Keys.clientsInProject({ project_id: projectId }), clientId)
  38. multi.scard(Keys.clientsInProject({ project_id: projectId }))
  39. multi.expire(
  40. Keys.clientsInProject({ project_id: projectId }),
  41. FOUR_DAYS_IN_S
  42. )
  43. multi.hset(
  44. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  45. 'last_updated_at',
  46. Date.now()
  47. )
  48. multi.hset(
  49. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  50. 'user_id',
  51. user._id
  52. )
  53. multi.hset(
  54. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  55. 'first_name',
  56. user.first_name || ''
  57. )
  58. multi.hset(
  59. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  60. 'last_name',
  61. user.last_name || ''
  62. )
  63. multi.hset(
  64. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  65. 'email',
  66. user.email || ''
  67. )
  68. if (cursorData) {
  69. multi.hset(
  70. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  71. 'cursorData',
  72. JSON.stringify(cursorData)
  73. )
  74. }
  75. multi.expire(
  76. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  77. USER_TIMEOUT_IN_S
  78. )
  79. multi.exec(function (err, res) {
  80. if (err) {
  81. err = new OError('problem marking user as connected').withCause(err)
  82. return callback(err)
  83. }
  84. const [, nConnectedClients] = res
  85. Metrics.inc('editing_session_mode', 1, {
  86. method: cursorData ? 'update' : 'connect',
  87. status: nConnectedClients === 1 ? 'single' : 'multi',
  88. })
  89. callback(null)
  90. })
  91. },
  92. refreshClient(projectId, clientId) {
  93. logger.debug({ projectId, clientId }, 'refreshing connected client')
  94. const multi = rclient.multi()
  95. multi.hset(
  96. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  97. 'last_updated_at',
  98. Date.now()
  99. )
  100. multi.expire(
  101. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  102. USER_TIMEOUT_IN_S
  103. )
  104. multi.exec(function (err) {
  105. if (err) {
  106. logger.err(
  107. { err, projectId, clientId },
  108. 'problem refreshing connected client'
  109. )
  110. }
  111. })
  112. },
  113. markUserAsDisconnected(projectId, clientId, callback) {
  114. logger.debug({ projectId, clientId }, 'marking user as disconnected')
  115. const multi = rclient.multi()
  116. multi.srem(Keys.clientsInProject({ project_id: projectId }), clientId)
  117. multi.scard(Keys.clientsInProject({ project_id: projectId }))
  118. multi.expire(
  119. Keys.clientsInProject({ project_id: projectId }),
  120. FOUR_DAYS_IN_S
  121. )
  122. multi.del(
  123. Keys.connectedUser({ project_id: projectId, client_id: clientId })
  124. )
  125. multi.exec(function (err, res) {
  126. if (err) {
  127. err = new OError('problem marking user as disconnected').withCause(err)
  128. return callback(err)
  129. }
  130. const [, nConnectedClients] = res
  131. const status =
  132. nConnectedClients === 0
  133. ? 'empty'
  134. : nConnectedClients === 1
  135. ? 'single'
  136. : 'multi'
  137. Metrics.inc('editing_session_mode', 1, {
  138. method: 'disconnect',
  139. status,
  140. })
  141. if (status === 'empty') {
  142. rclient.getdel(Keys.projectNotEmptySince({ projectId }), (err, res) => {
  143. if (err) {
  144. logger.warn(
  145. { err, projectId },
  146. 'could not collect projectNotEmptySince'
  147. )
  148. } else if (res) {
  149. recordProjectNotEmptySinceMetric(res, status)
  150. }
  151. })
  152. } else {
  153. // Only populate projectNotEmptySince when more clients remain connected.
  154. const nowInSeconds = Math.ceil(Date.now() / 1000).toString()
  155. // We can go back to SET GET after upgrading to redis 7.0+
  156. const multi = rclient.multi()
  157. multi.get(Keys.projectNotEmptySince({ projectId }))
  158. multi.set(
  159. Keys.projectNotEmptySince({ projectId }),
  160. nowInSeconds,
  161. 'NX',
  162. 'EX',
  163. 31 * ONE_DAY_IN_S
  164. )
  165. multi.exec((err, res) => {
  166. if (err) {
  167. logger.warn(
  168. { err, projectId },
  169. 'could not get/set projectNotEmptySince'
  170. )
  171. } else if (res[0]) {
  172. recordProjectNotEmptySinceMetric(res[0], status)
  173. }
  174. })
  175. }
  176. callback(null)
  177. })
  178. },
  179. _getConnectedUser(projectId, clientId, callback) {
  180. rclient.hgetall(
  181. Keys.connectedUser({ project_id: projectId, client_id: clientId }),
  182. function (err, result) {
  183. if (err) {
  184. err = new OError('problem fetching connected user details', {
  185. other_client_id: clientId,
  186. }).withCause(err)
  187. return callback(err)
  188. }
  189. if (!(result && result.user_id)) {
  190. result = {
  191. connected: false,
  192. client_id: clientId,
  193. }
  194. } else {
  195. result.connected = true
  196. result.client_id = clientId
  197. result.client_age =
  198. (Date.now() - parseInt(result.last_updated_at, 10)) / 1000
  199. if (result.cursorData) {
  200. try {
  201. result.cursorData = JSON.parse(result.cursorData)
  202. } catch (e) {
  203. OError.tag(e, 'error parsing cursorData JSON', {
  204. other_client_id: clientId,
  205. cursorData: result.cursorData,
  206. })
  207. return callback(e)
  208. }
  209. }
  210. }
  211. callback(err, result)
  212. }
  213. )
  214. },
  215. getConnectedUsers(projectId, callback) {
  216. const self = this
  217. rclient.smembers(
  218. Keys.clientsInProject({ project_id: projectId }),
  219. function (err, results) {
  220. if (err) {
  221. err = new OError('problem getting clients in project').withCause(err)
  222. return callback(err)
  223. }
  224. const jobs = results.map(
  225. clientId => cb => self._getConnectedUser(projectId, clientId, cb)
  226. )
  227. async.series(jobs, function (err, users) {
  228. if (err) {
  229. OError.tag(err, 'problem getting connected users')
  230. return callback(err)
  231. }
  232. users = users.filter(
  233. user =>
  234. user && user.connected && user.client_age < REFRESH_TIMEOUT_IN_S
  235. )
  236. callback(null, users)
  237. })
  238. }
  239. )
  240. },
  241. }