ConnectedUsersManager.js 7.5 KB

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