# frozen_string_literal: true # A Redis backed session store for real-time collaboration. A session is defined # by its documents and the users that join this session. An online user can have # two states within the session: "active" and "away". # # By design, session must eventually be cleaned up. If this doesn't happen # explicitly, all keys used within the session model must have an expiry # timestamp set. class AwarenessSession # rubocop:disable Gitlab/NamespacedClass # An awareness session expires automatically after 1 hour of no activity SESSION_LIFETIME = 1.hour private_constant :SESSION_LIFETIME # Expire user awareness keys after some time of inactivity USER_LIFETIME = 1.hour private_constant :USER_LIFETIME PRESENCE_LIFETIME = 10.minutes private_constant :PRESENCE_LIFETIME KEY_NAMESPACE = "gitlab:awareness" private_constant :KEY_NAMESPACE class << self def for(value = nil) # Creates a unique value for situations where we have no unique value to # create a session with. This could be when creating a new issue, a new # merge request, etc. value = SecureRandom.uuid unless value.present? # We use SHA-256 based session identifiers (similar to abbreviated git # hashes). There is always a chance for Hash collisions (birthday # problem), we therefore have to pick a good tradeoff between the amount # of data stored and the probability of a collision. # # The approximate probability for a collision can be calculated: # # p ~= n^2 / 2m # ~= (2^18)^2 / (2 * 16^15) # ~= 2^36 / 2^61 # # n is the number of awareness sessions and m the number of possibilities # for each item. For a hex number, this is 16^c, where c is the number of # characters. With 260k (~2^18) sessions, the probability for a collision # is ~2^-25. # # The number of 15 is selected carefully. The integer representation fits # nicely into a signed 64 bit integer and eventually allows Redis to # optimize its memory usage. 16 chars would exceed the space for # this datatype. id = Digest::SHA256.hexdigest(value.to_s)[0, 15] AwarenessSession.new(id) end end def initialize(id) @id = id end def join(user) user_key = user_sessions_key(user.id) with_redis do |redis| redis.pipelined do |pipeline| pipeline.sadd(user_key, id_i) pipeline.expire(user_key, USER_LIFETIME.to_i) pipeline.zadd(users_key, timestamp.to_f, user.id) # We also mark for expiry when a session key is created (first user joins), # because some users might never actively leave a session and the key could # therefore become stale, w/o us noticing. reset_session_expiry(pipeline) end end nil end def leave(user) user_key = user_sessions_key(user.id) with_redis do |redis| redis.pipelined do |pipeline| pipeline.srem(user_key, id_i) pipeline.zrem(users_key, user.id) end # cleanup orphan sessions and users # # this needs to be a second pipeline due to the delete operations being # dependent on the result of the cardinality checks user_sessions_count, session_users_count = redis.pipelined do |pipeline| pipeline.scard(user_key) pipeline.zcard(users_key) end redis.pipelined do |pipeline| pipeline.del(user_key) unless user_sessions_count > 0 unless session_users_count > 0 pipeline.del(users_key) @id = nil end end end nil end def present?(user, threshold: PRESENCE_LIFETIME) with_redis do |redis| user_timestamp = redis.zscore(users_key, user.id) break false unless user_timestamp.present? timestamp - user_timestamp < threshold end end def away?(user, threshold: PRESENCE_LIFETIME) !present?(user, threshold: threshold) end # Updates the last_activity timestamp for a user in this session def touch!(user) with_redis do |redis| redis.pipelined do |pipeline| pipeline.zadd(users_key, timestamp.to_f, user.id) # extend the session lifetime due to user activity reset_session_expiry(pipeline) end end nil end def size with_redis do |redis| redis.zcard(users_key) end end def to_param id&.to_s end def to_s "awareness_session=#{id}" end def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) users_with_last_activity.filter do |_user, last_activity| user_online?(last_activity, threshold: threshold) end end def users User.where(id: user_ids) end def users_with_last_activity # where in (x, y, [...z]) is a set and does not maintain any order, we need # to make sure to establish a stable order for both, the pairs returned from # redis and the ActiveRecord query. Using IDs in ascending order. user_ids, last_activities = user_ids_with_last_activity .sort_by(&:first) .transpose return [] if user_ids.blank? users = User.where(id: user_ids).order(id: :asc) users.zip(last_activities) end private attr_reader :id def user_online?(last_activity, threshold:) last_activity.to_i + threshold.to_i > Time.zone.now.to_i end # converts session id from hex to integer representation def id_i Integer(id, 16) if id.present? end def users_key "#{KEY_NAMESPACE}:session:#{id}:users" end def user_sessions_key(user_id) "#{KEY_NAMESPACE}:user:#{user_id}:sessions" end def with_redis Gitlab::Redis::SharedState.with do |redis| yield redis if block_given? end end def timestamp Time.now.to_i end def user_ids with_redis do |redis| redis.zrange(users_key, 0, -1) end end # Returns an array of tuples, where the first element in the tuple represents # the user ID and the second part the last_activity timestamp. def user_ids_with_last_activity pairs = with_redis do |redis| redis.zrange(users_key, 0, -1, with_scores: true) end # map data type of score (float) to Time pairs.map do |user_id, score| [user_id, Time.zone.at(score.to_i)] end end # We want sessions to cleanup automatically after a certain period of # inactivity. This sets the expiry timestamp for this session to # [SESSION_LIFETIME]. def reset_session_expiry(redis) redis.expire(users_key, SESSION_LIFETIME) nil end end