Class: Gitlab::ExclusiveLease

Inherits:
Object
  • Object
show all
Defined in:
lib/gitlab/exclusive_lease.rb

Overview

This class implements an ‘exclusive lease’. We call it a ‘lease’ because it has a set expiry time. We call it ‘exclusive’ because only one caller may obtain a lease for a given key at a time. The implementation is intended to work across GitLab processes and across servers. It is a cheap alternative to using SQL queries and updates: you do not need to change the SQL schema to start using ExclusiveLease.

Constant Summary collapse

LeaseWithinTransactionError =
Class.new(StandardError)
PREFIX =
'gitlab:exclusive_lease'
NoKey =
Class.new(ArgumentError)
LUA_CANCEL_SCRIPT =
<<~EOS
  local key, uuid = KEYS[1], ARGV[1]
  if redis.call("get", key) == uuid then
    redis.call("del", key)
  end
EOS
LUA_RENEW_SCRIPT =
<<~EOS
  local key, uuid, ttl = KEYS[1], ARGV[1], ARGV[2]
  if redis.call("get", key) == uuid then
    redis.call("expire", key, ttl)
    return uuid
  end
EOS

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key, timeout:, uuid: nil) ⇒ ExclusiveLease

Returns a new instance of ExclusiveLease.



109
110
111
112
113
# File 'lib/gitlab/exclusive_lease.rb', line 109

def initialize(key, timeout:, uuid: nil)
  @redis_shared_state_key = self.class.redis_shared_state_key(key)
  @timeout = timeout
  @uuid = uuid || SecureRandom.uuid
end

Class Method Details

.cancel(key, uuid) ⇒ Object



63
64
65
66
67
68
69
70
# File 'lib/gitlab/exclusive_lease.rb', line 63

def self.cancel(key, uuid)
  return unless key.present?
  return unless uuid.present?

  Gitlab::Redis::SharedState.with do |redis|
    redis.eval(LUA_CANCEL_SCRIPT, keys: [ensure_prefixed_key(key)], argv: [uuid])
  end
end

.ensure_prefixed_key(key) ⇒ Object

Raises:



76
77
78
79
80
# File 'lib/gitlab/exclusive_lease.rb', line 76

def self.ensure_prefixed_key(key)
  raise NoKey unless key.present?

  key.start_with?(PREFIX) ? key : redis_shared_state_key(key)
end

.get_uuid(key) ⇒ Object



35
36
37
38
39
# File 'lib/gitlab/exclusive_lease.rb', line 35

def self.get_uuid(key)
  Gitlab::Redis::SharedState.with do |redis|
    redis.get(redis_shared_state_key(key)) || false
  end
end

.redis_shared_state_key(key) ⇒ Object



72
73
74
# File 'lib/gitlab/exclusive_lease.rb', line 72

def self.redis_shared_state_key(key)
  "#{PREFIX}:#{key}"
end

.reset_all!(scope = '*') ⇒ Object

Removes any existing exclusive_lease from redis Don’t run this in a live system without making sure no one is using the leases



84
85
86
87
88
89
90
# File 'lib/gitlab/exclusive_lease.rb', line 84

def self.reset_all!(scope = '*')
  Gitlab::Redis::SharedState.with do |redis|
    redis.scan_each(match: redis_shared_state_key(scope)).each do |key|
      redis.del(key)
    end
  end
end

.set_skip_transaction_check_flag(flag = nil) ⇒ Object



92
93
94
# File 'lib/gitlab/exclusive_lease.rb', line 92

def self.set_skip_transaction_check_flag(flag = nil)
  Thread.current[:skip_transaction_check_for_exclusive_lease] = flag
end

.skip_transaction_check?Boolean

Returns:

  • (Boolean)


96
97
98
# File 'lib/gitlab/exclusive_lease.rb', line 96

def self.skip_transaction_check?
  Thread.current[:skip_transaction_check_for_exclusive_lease]
end

.skipping_transaction_checkObject



100
101
102
103
104
105
106
107
# File 'lib/gitlab/exclusive_lease.rb', line 100

def self.skipping_transaction_check
  previous_skip_transaction_check = skip_transaction_check?
  set_skip_transaction_check_flag(true)

  yield
ensure
  set_skip_transaction_check_flag(previous_skip_transaction_check)
end

.throttle(key, group: nil, period: 1.hour, count: 1, &block) ⇒ Object

yield to the block at most count times per period

Defaults to once per hour.

For example:

# toot the train horn at most every 20min:
throttle(locomotive.id, count: 3, period: 1.hour) { toot_train_horn }
# Brake suddenly at most once every minute:
throttle(locomotive.id, period: 1.minute) { brake_suddenly }
# Specify a uniqueness group:
throttle(locomotive.id, group: :locomotive_brake) { brake_suddenly }

If a group is not specified, each block will get a separate group to itself.



55
56
57
58
59
60
61
# File 'lib/gitlab/exclusive_lease.rb', line 55

def self.throttle(key, group: nil, period: 1.hour, count: 1, &block)
  group ||= block.source_location.join(':')

  return if new("el:throttle:#{group}:#{key}", timeout: period.to_i / count).waiting?

  yield
end

Instance Method Details

#cancelObject

Gives up this lease, allowing it to be obtained by others.



170
171
172
# File 'lib/gitlab/exclusive_lease.rb', line 170

def cancel
  self.class.cancel(@redis_shared_state_key, @uuid)
end

#exists?Boolean

Returns true if the key for this lease is set.

Returns:

  • (Boolean)


152
153
154
155
156
# File 'lib/gitlab/exclusive_lease.rb', line 152

def exists?
  Gitlab::Redis::SharedState.with do |redis|
    redis.exists?(@redis_shared_state_key) # rubocop:disable CodeReuse/ActiveRecord
  end
end

#renewObject

Try to renew an existing lease. Return lease UUID on success, false if the lease is taken by a different UUID or inexistent.



144
145
146
147
148
149
# File 'lib/gitlab/exclusive_lease.rb', line 144

def renew
  Gitlab::Redis::SharedState.with do |redis|
    result = redis.eval(LUA_RENEW_SCRIPT, keys: [@redis_shared_state_key], argv: [@uuid, @timeout.to_i])
    result == @uuid
  end
end

#report_lock_attempt_inside_transactionObject



126
127
128
129
130
131
132
133
134
135
# File 'lib/gitlab/exclusive_lease.rb', line 126

def report_lock_attempt_inside_transaction
  return unless ::ApplicationRecord.inside_transaction? || ::Ci::ApplicationRecord.inside_transaction?

  raise LeaseWithinTransactionError,
    "Exclusive lease cannot be obtained within a transaction as it could lead to idle transactions."
rescue LeaseWithinTransactionError => e
  Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
    e, issue_url: "https://gitlab.com/gitlab-org/gitlab/-/issues/440368"
  )
end

#try_obtainObject

Try to obtain the lease. Return lease UUID on success, false if the lease is already taken.



117
118
119
120
121
122
123
124
# File 'lib/gitlab/exclusive_lease.rb', line 117

def try_obtain
  report_lock_attempt_inside_transaction unless self.class.skip_transaction_check?

  # Performing a single SET is atomic
  Gitlab::Redis::SharedState.with do |redis|
    redis.set(@redis_shared_state_key, @uuid, nx: true, ex: @timeout) && @uuid
  end
end

#ttlObject

Returns the TTL of the Redis key.

This method will return ‘nil` if no TTL could be obtained.



161
162
163
164
165
166
167
# File 'lib/gitlab/exclusive_lease.rb', line 161

def ttl
  Gitlab::Redis::SharedState.with do |redis|
    ttl = redis.ttl(@redis_shared_state_key)

    ttl if ttl > 0
  end
end

#waiting?Boolean

This lease is waiting to obtain

Returns:

  • (Boolean)


138
139
140
# File 'lib/gitlab/exclusive_lease.rb', line 138

def waiting?
  !try_obtain
end