Class: Gitlab::ExclusiveLease
- Inherits:
-
Object
- Object
- Gitlab::ExclusiveLease
- 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
- .cancel(key, uuid) ⇒ Object
- .ensure_prefixed_key(key) ⇒ Object
- .get_uuid(key) ⇒ Object
- .redis_shared_state_key(key) ⇒ Object
-
.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.
- .set_skip_transaction_check_flag(flag = nil) ⇒ Object
- .skip_transaction_check? ⇒ Boolean
- .skipping_transaction_check ⇒ Object
-
.throttle(key, group: nil, period: 1.hour, count: 1, &block) ⇒ Object
yield to the block at most count times per period.
Instance Method Summary collapse
-
#cancel ⇒ Object
Gives up this lease, allowing it to be obtained by others.
-
#exists? ⇒ Boolean
Returns true if the key for this lease is set.
-
#initialize(key, timeout:, uuid: nil) ⇒ ExclusiveLease
constructor
A new instance of ExclusiveLease.
-
#renew ⇒ Object
Try to renew an existing lease.
- #report_lock_attempt_inside_transaction ⇒ Object
-
#try_obtain ⇒ Object
Try to obtain the lease.
-
#ttl ⇒ Object
Returns the TTL of the Redis key.
-
#waiting? ⇒ Boolean
This lease is waiting to obtain.
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
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
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_check ⇒ Object
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
#cancel ⇒ Object
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.
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 |
#renew ⇒ Object
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_transaction ⇒ Object
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_obtain ⇒ Object
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 |
#ttl ⇒ Object
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
138 139 140 |
# File 'lib/gitlab/exclusive_lease.rb', line 138 def waiting? !try_obtain end |