Welcome to mirror list, hosted at ThFree Co, Russian Federation.

awareness_session.rb « models « app - gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 0b6529846307705d3ad8fe158582146ab3e92537 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# 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|
      Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
        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
    end

    nil
  end

  def leave(user)
    user_key = user_sessions_key(user.id)

    with_redis do |redis|
      Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
        redis.pipelined do |pipeline|
          pipeline.srem?(user_key, id_i)
          pipeline.zrem(users_key, user.id)
        end
      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 =
        Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
          redis.pipelined do |pipeline|
            pipeline.scard(user_key)
            pipeline.zcard(users_key)
          end
        end

      Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
        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
    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