-
Notifications
You must be signed in to change notification settings - Fork 79
/
client.rb
190 lines (163 loc) · 6.1 KB
/
client.rb
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
require 'redis'
require 'securerandom'
module Redlock
class Client
DEFAULT_REDIS_URLS = ['redis://localhost:6379']
DEFAULT_REDIS_TIMEOUT = 0.1
DEFAULT_RETRY_COUNT = 3
DEFAULT_RETRY_DELAY = 200
CLOCK_DRIFT_FACTOR = 0.01
# Create a distributed lock manager implementing redlock algorithm.
# Params:
# +servers+:: The array of redis connection URLs or Redis connection instances. Or a mix of both.
# +options+:: You can override the default value for `retry_count` and `retry_delay`.
# * `retry_count` being how many times it'll try to lock a resource (default: 3)
# * `retry_delay` being how many ms to sleep before try to lock again (default: 200)
# * `redis_timeout` being how the Redis timeout will be set in seconds (default: 0.1)
def initialize(servers = DEFAULT_REDIS_URLS, options = {})
redis_timeout = options[:redis_timeout] || DEFAULT_REDIS_TIMEOUT
@servers = servers.map do |server|
if server.is_a?(String)
RedisInstance.new(url: server, timeout: redis_timeout)
else
RedisInstance.new(server)
end
end
@quorum = servers.length / 2 + 1
@retry_count = options[:retry_count] || DEFAULT_RETRY_COUNT
@retry_delay = options[:retry_delay] || DEFAULT_RETRY_DELAY
end
# Locks a resource for a given time.
# Params:
# +resource+:: the resource (or key) string to be locked.
# +ttl+:: The time-to-live in ms for the lock.
# +options+:: Hash of optional parameters
# * +extend+: A lock ("lock_info") to extend.
# * +extend_only_if_life+: If +extend+ is given, only acquire lock if currently held
# +block+:: an optional block to be executed; after its execution, the lock (if successfully
# acquired) is automatically unlocked.
def lock(resource, ttl, options = {}, &block)
lock_info = try_lock_instances(resource, ttl, options)
if block_given?
begin
yield lock_info
!!lock_info
ensure
unlock(lock_info) if lock_info
end
else
lock_info
end
end
# Unlocks a resource.
# Params:
# +lock_info+:: the lock that has been acquired when you locked the resource.
def unlock(lock_info)
@servers.each { |s| s.unlock(lock_info[:resource], lock_info[:value]) }
end
# Locks a resource, executing the received block only after successfully acquiring the lock,
# and returning its return value as a result.
# See Redlock::Client#lock for parameters.
def lock!(*args)
fail 'No block passed' unless block_given?
lock(*args) do |lock_info|
raise LockError, 'failed to acquire lock' unless lock_info
return yield
end
end
private
class RedisInstance
UNLOCK_SCRIPT = <<-eos
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
eos
# thanks to https://github.com/sbertrang/redis-distlock/blob/master/lib/Redis/DistLock.pm
# also https://github.com/sbertrang/redis-distlock/issues/2 which proposes the value-checking
# and @maltoe for https://github.com/leandromoreira/redlock-rb/pull/20#discussion_r38903633
LOCK_SCRIPT = <<-eos
if (redis.call("exists", KEYS[1]) == 0 and ARGV[3] == "yes") or redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
end
eos
def initialize(connection)
if connection.respond_to?(:client)
@redis = connection
else
@redis = Redis.new(connection)
end
load_scripts
end
def lock(resource, val, ttl, allow_new_lock)
recover_from_script_flush do
@redis.evalsha @lock_script_sha, keys: [resource], argv: [val, ttl, allow_new_lock]
end
end
def unlock(resource, val)
recover_from_script_flush do
@redis.evalsha @unlock_script_sha, keys: [resource], argv: [val]
end
rescue
# Nothing to do, unlocking is just a best-effort attempt.
end
private
def load_scripts
@unlock_script_sha = @redis.script(:load, UNLOCK_SCRIPT)
@lock_script_sha = @redis.script(:load, LOCK_SCRIPT)
end
def recover_from_script_flush
retry_on_noscript = true
begin
yield
rescue Redis::CommandError => e
# When somebody has flushed the Redis instance's script cache, we might
# want to reload our scripts. Only attempt this once, though, to avoid
# going into an infinite loop.
if retry_on_noscript && e.message.include?('NOSCRIPT')
load_scripts
retry_on_noscript = false
retry
else
raise
end
end
end
end
def try_lock_instances(resource, ttl, options)
tries = options[:extend] ? 1 : @retry_count
tries.times do
lock_info = lock_instances(resource, ttl, options)
return lock_info if lock_info
# Wait a random delay before retrying
sleep(rand(@retry_delay).to_f / 1000)
end
false
end
def lock_instances(resource, ttl, options)
value = options.fetch(:extend, { value: SecureRandom.uuid })[:value]
allow_new_lock = (options[:extend_life] || options[:extend_only_if_life]) ? 'no' : 'yes'
locked, time_elapsed = timed do
@servers.select { |s| s.lock resource, value, ttl, allow_new_lock }.size
end
validity = ttl - time_elapsed - drift(ttl)
if locked >= @quorum && validity >= 0
{ validity: validity, resource: resource, value: value }
else
@servers.each { |s| s.unlock(resource, value) }
false
end
end
def drift(ttl)
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min drift
# for small TTLs.
(ttl * CLOCK_DRIFT_FACTOR).to_i + 2
end
def timed
start_time = (Time.now.to_f * 1000).to_i
[yield, (Time.now.to_f * 1000).to_i - start_time]
end
end
end