mario-redis-lock 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +234 -0
- data/Rakefile +74 -0
- data/lib/redis_lock.rb +125 -0
- data/mario-redis-lock.gemspec +26 -0
- data/spec/redis_lock_spec.rb +247 -0
- metadata +98 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 95b3e9086bb1024011a80e88878afff422e0fc5a
|
4
|
+
data.tar.gz: 0c44db03346780a1c8ad274f59978ef4e0f985f4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5e5b5faed6e289c517d75b30652b817f30ff89bc4560645b59129cdfab7ecdec3b14ec01f8fe46131d1f5b1c6ee02b18eaa853966a9bb534cd5df07824753915
|
7
|
+
data.tar.gz: 2c4f6b91b32b0881074a76d0e6679c40bd18b098a9cdb7fd9b7b712d7a0697a9d5e8ad0aed44f8245ddb272607ec0e1ecfd5f8355e35109134139a86e9c00681
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Mario Izquierdo
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
# RedisLock
|
2
|
+
|
3
|
+
Yet another distributed lock for Ruby using Redis, with emphasis in the documentation.
|
4
|
+
|
5
|
+
|
6
|
+
## Why another redis lock gem?
|
7
|
+
|
8
|
+
Other redis locks for ruby: [redis-mutex](https://rubygems.org/gems/redis-mutex), [mlanett-redis-lock](https://rubygems.org/gems/mlanett-redis-lock), [redis-lock](https://rubygems.org/gems/redis-lock), [jashmenn-redis-lock](https://rubygems.org/gems/jashmenn-redis-lock), [ruby_redis_lock](https://rubygems.org/gems/ruby_redis_lock), [robust-redis-lock](https://rubygems.org/gems/robust-redis-lock), [bfg-redis-lock](https://rubygems.org/gems/bfg-redis-lock), etc.
|
9
|
+
|
10
|
+
Looking at those other gems I realized that it was not easy to know what was exactly going on with the locks. Then I made this one to be simple but explicit, to be used with confidence in my high scale production applications.
|
11
|
+
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
Requirements:
|
16
|
+
|
17
|
+
* [Redis](http://redis.io/) >= 2.6.12
|
18
|
+
* [redis gem](https://rubygems.org/gems/redis) >= 3.0.5
|
19
|
+
|
20
|
+
The required versions are because I use the new syntax for the SET command to easily implement the robust algorithm described in the [SET command documentation](http://redis.io/commands/set).
|
21
|
+
|
22
|
+
To install with bundler, add this line to your application's Gemfile:
|
23
|
+
|
24
|
+
gem 'mario-redis-lock'
|
25
|
+
|
26
|
+
Ot install it yourself as:
|
27
|
+
|
28
|
+
$ gem install mario-redis-lock
|
29
|
+
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
Acquire the lock to do "exclusive stuff":
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
RedisLock.adquire do |lock|
|
37
|
+
if lock.acquired?
|
38
|
+
do_exclusive_stuff # you are the one with the lock, hooray!
|
39
|
+
else
|
40
|
+
oh_well # someone else has the lock
|
41
|
+
end
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
Or (equivalent)
|
46
|
+
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
lock = RedisLock.new
|
50
|
+
if lock.acquire
|
51
|
+
begin
|
52
|
+
do_exclusive_stuff # you are the one with the lock, hooray!
|
53
|
+
ensure
|
54
|
+
lock.release
|
55
|
+
end
|
56
|
+
else
|
57
|
+
oh_well # someone else has the lock
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
The class method `RedisLock.adquire(options, &block)` is more concise and releases the lock at the end of the block, even if `do_exclusive_stuff` raises an exception.
|
62
|
+
But the second alternative is a little more flexible.
|
63
|
+
|
64
|
+
|
65
|
+
### Options
|
66
|
+
|
67
|
+
* **redis**: (default `Redis.new`) an instance of Redis, or an options hash to initialize an instance of Redis (see [redis gem](https://rubygems.org/gems/redis)). You can also pass anything that "quaks" like redis, for example an instance of [mock_redis](https://rubygems.org/gems/mock_redis), for testing purposes.
|
68
|
+
* **key**: (default `"RedisLock::default"`) Redis key used for the lock. If you need multiple locks, use a different (unique) key for each lock.
|
69
|
+
* **autorelease**: (default `10.0`) seconds to automatically release (expire) the lock after being acquired. Make sure to give enough time for your "exclusive stuff" to be executed, otherwise other processes could get the lock and start messing with the "exclusive stuff" before this one is done. The autorelease time is important, even when manually doing `lock.realease`, because the process could crash before releasing the lock. Autorelease (expiration time) guarantees that the lock will always be released.
|
70
|
+
* **retry**: (default `true`) boolean to enable/disable consecutive acquire retries in the same `acquire` call. If true, use `retry_timeout` and `retry_sleep` to specify how long and hot often should the `acquire` method be blocking the thread until is able to get the lock.
|
71
|
+
* **retry_timeout**: (default `10.0`) time in seconds to specify how long should this thread be waiting for the lock to be released. Note that the execution thread is put to sleep while waiting. For a non-blocking approach, set `retry` to false.
|
72
|
+
* **retry_sleep**: (default `0.1`) seconds to sleep between retries. For example: `RedisLock.adquire(retry_timeout: 10.0, retry_sleep: 0.1) do |lock|`, in the worst case scenario, will do 99 or 100 retries (one every 100 milliseconds, plus a little extra for the acquire attempt) during 10 seconds, and finally yield with `lock.acquired? == false`.
|
73
|
+
|
74
|
+
Configure the default values with `RedisLock.configure`:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
RedisLock.configure do |defaults|
|
78
|
+
defaults.redis = Redis.new
|
79
|
+
defaults.key = "RedisLock::default"
|
80
|
+
defaults.autorelease = 10.0
|
81
|
+
defaults.retry = true
|
82
|
+
defaults.retry_timeout = 10.0
|
83
|
+
defaults.retry_sleep = 0.1
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
A good place to set defaults in a Rails app would be in an initializer `conf/initializers/redis_lock.rb`.
|
88
|
+
|
89
|
+
Options can be set to other than the defaults when calling `RedisLock.acquire`:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
RedisLock.acquire(key: 'exclusive_stuff', retry: false) do |lock|
|
93
|
+
if lock.acquired?
|
94
|
+
do_exclusive_stuff
|
95
|
+
end
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
Or when creating a new lock instance:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
lock = RedisLock.new(key: 'exclusive_stuff', retry: false)
|
103
|
+
if lock.acquire
|
104
|
+
begin
|
105
|
+
do_exclusive_stuff
|
106
|
+
ensure
|
107
|
+
lock.release
|
108
|
+
end
|
109
|
+
end
|
110
|
+
```
|
111
|
+
|
112
|
+
### Example: Shared Photo Booth that can only take one photo at a time
|
113
|
+
|
114
|
+
If we have a `PhotoBooth` shared resource, we can use a `RedisLock` to ensure it is used only by one thread at a time:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
require 'redis_lock'
|
118
|
+
require 'photo_booth' # made up shared resource
|
119
|
+
|
120
|
+
RedisLock.configure do |c|
|
121
|
+
c.redis = {url: "redis://:p4ssw0rd@10.0.1.1:6380/15"}
|
122
|
+
c.key = 'photo_booth_lock'
|
123
|
+
|
124
|
+
c.autorelease = 60 # assume it never takes more than one minute to make a picture
|
125
|
+
c.retry_timeout = 300 # retry for 5 minutes
|
126
|
+
c.retry_sleep = 1 # retry once every second
|
127
|
+
end
|
128
|
+
|
129
|
+
RedisLock.acquire do |lock|
|
130
|
+
if lock.acquired?
|
131
|
+
PhotoBooth.take_photo
|
132
|
+
else
|
133
|
+
raise "I'm bored of waiting and I'm getting out"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
```
|
137
|
+
|
138
|
+
This script can be executed from many different places at the same time, as far as they have access to the shared PhotoBooth and Redis instances. Only one photo will be taken at a time.
|
139
|
+
Note that the options `autorelease`, `retry_timeout` and `retry_sleep` should be tuned differently depending on the frequency of the operation and the known speed of the `PhotoBooth.take_photo` operation.
|
140
|
+
|
141
|
+
|
142
|
+
### Example: Avoid the Dog-Pile effec when invalidating some cached value
|
143
|
+
|
144
|
+
The Dog-Pile effect is a specific case of the [Thundering Herd problem](http://en.wikipedia.org/wiki/Thundering_herd_problem),
|
145
|
+
that happens when a cached value expires and suddenly too many threads try to calculate the new value at the same time.
|
146
|
+
|
147
|
+
Sometimes, the calculation takes expensive resources and it is just fine to do it from just one thread.
|
148
|
+
|
149
|
+
Assume you have a simple cache, a `fetch` function that uses a redis instance.
|
150
|
+
|
151
|
+
Without the lock:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
# Retrieve the cached value from the redis key.
|
155
|
+
# If the key is not available, execute the block
|
156
|
+
# and store the new calculated value in the redis key with an expiration time.
|
157
|
+
def fetch(redis, key, expire, &block)
|
158
|
+
val = redis.get(key)
|
159
|
+
if not val
|
160
|
+
val = block.call
|
161
|
+
redis.setex(key, expire, val) unless val.nil? # do not set anything if the value is nil
|
162
|
+
end
|
163
|
+
val
|
164
|
+
end
|
165
|
+
```
|
166
|
+
|
167
|
+
Whith this method, it is easy to optimize slow operations by caching them in Redis.
|
168
|
+
For example, if you want to do a `heavy_database_query`:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
require 'redis'
|
172
|
+
redis = Redis.new(url: "redis://:p4ssw0rd@host:6380")
|
173
|
+
|
174
|
+
val = fetch redis, 'heavy_query', 10 do
|
175
|
+
heavy_database_query # Recalculate if not cached (SLOW)
|
176
|
+
end
|
177
|
+
|
178
|
+
puts val
|
179
|
+
```
|
180
|
+
|
181
|
+
But this fetch could block the database if executed from too many threads, because when the Redis key expires all of them will do the `heavy_database_query` at the same time.
|
182
|
+
|
183
|
+
Avoid this problem with a `RedisLock`:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
require 'redis'
|
187
|
+
require 'redis_lock'
|
188
|
+
redis = Redis.new(url: "redis://:p4ssw0rd@host:6380")
|
189
|
+
|
190
|
+
RedisLock.configure do |c|
|
191
|
+
c.redis = redis
|
192
|
+
c.key = 'heavy_query_lock'
|
193
|
+
c.autorelease = 20 # assume it never takes more than 20 seconds to do the slow query
|
194
|
+
c.retry = false # try to acquire only once, if the lock is already taken then the new value should be cached again soon
|
195
|
+
end
|
196
|
+
|
197
|
+
def fetch_with_lock(retries = 10)
|
198
|
+
val = fetch redis, 'heavy_query', 10 do
|
199
|
+
# If we need to recalculate val,
|
200
|
+
# use a lock to make sure that heavy_database_query is only done by one process
|
201
|
+
RedisLock.acquire do |lock|
|
202
|
+
if lock.acquired?
|
203
|
+
heavy_database_query
|
204
|
+
else
|
205
|
+
nil # do not store in cache and return val = nil
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# Try again if cache miss, and the lock was acquired by other process.
|
211
|
+
if val.nil? and retries > 0
|
212
|
+
fetch_with_lock(retries - 1)
|
213
|
+
else
|
214
|
+
val
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
val = fetch_with_lock()
|
219
|
+
puts val
|
220
|
+
```
|
221
|
+
|
222
|
+
In this case, the script could be executed from as many threads as we want at the same time, because the heavy_database_query is done only once while the other threads wait until the value is cached again or the lock is released.
|
223
|
+
|
224
|
+
|
225
|
+
## Contributing
|
226
|
+
|
227
|
+
1. Fork it ( http://github.com/marioizquierdo/redis-lock/fork )
|
228
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
229
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
230
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
231
|
+
5. Create new Pull Request
|
232
|
+
|
233
|
+
Make sure you have installed Redis in localhost:6379. The DB 15 will be used for tests (and flushed after every test).
|
234
|
+
There is a rake task to play with an example: `rake smoke_and_pass`
|
data/Rakefile
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rake/testtask"
|
3
|
+
|
4
|
+
Rake::TestTask.new do |t|
|
5
|
+
t.pattern = "spec/**/*_spec.rb"
|
6
|
+
end
|
7
|
+
|
8
|
+
desc 'Flush the test database (15)'
|
9
|
+
task :flushdb do
|
10
|
+
redis_test_db.flushdb
|
11
|
+
end
|
12
|
+
|
13
|
+
desc 'Test app: The lock is the joint, only one thread can smoke at a time'
|
14
|
+
task :smoke_and_pass do
|
15
|
+
threads = (ENV['threads'] || 6).to_i
|
16
|
+
puts "The big smoke starts with #{threads} threads"
|
17
|
+
puts "Use ctr+c to EXIT"
|
18
|
+
|
19
|
+
RedisLock.configure do |conf|
|
20
|
+
conf.redis = redis_test_db
|
21
|
+
conf.key = 'the_joint'
|
22
|
+
conf.autorelease = 0.6
|
23
|
+
conf.retry_timeout = 0.5
|
24
|
+
conf.retry_sleep = 0.1
|
25
|
+
end
|
26
|
+
|
27
|
+
ts = []
|
28
|
+
threads.times do |i|
|
29
|
+
ts << Thread.new do
|
30
|
+
smoker(i)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
ts.each do |t|
|
35
|
+
t.join
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def smoker(id)
|
40
|
+
loop do
|
41
|
+
color_puts id, " Thread##{id} wants to smoke"
|
42
|
+
lock = RedisLock.new
|
43
|
+
if lock.acquire
|
44
|
+
color_puts id, "joint >> Thread##{id} grabs the joint from the table"
|
45
|
+
sleep (rand < 0.5 ? 0.3 : 0.4) # SMOKE for 0.3 or 0.4 seconds
|
46
|
+
if rand < 0.1
|
47
|
+
color_puts id, "CRASH!!! Thread##{id} DIED while smoking. The joint will come back to the table when autoreleased"
|
48
|
+
break
|
49
|
+
else
|
50
|
+
color_puts id, " << Thread##{id} returs the joint to the table\n"
|
51
|
+
lock.release
|
52
|
+
end
|
53
|
+
|
54
|
+
else
|
55
|
+
color_puts id, " !! Thread##{id} could not get the joint. Try again later"
|
56
|
+
end
|
57
|
+
|
58
|
+
sleep 0.3 + rand(4).to_f/10 # WAIT for next try
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Wrap text with ANSI colors
|
63
|
+
def color_puts(id, text)
|
64
|
+
if id < 10
|
65
|
+
print "\033[0;3#{id+1}m#{text}\033[0m\n"
|
66
|
+
else
|
67
|
+
print "#{text}\n"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def redis_test_db
|
72
|
+
require 'redis'
|
73
|
+
@redis_test_db ||= Redis.new(db: 15)
|
74
|
+
end
|
data/lib/redis_lock.rb
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
require 'redis' # redis gem required
|
2
|
+
require 'securerandom' # SecureRandom (from stdlib)
|
3
|
+
|
4
|
+
class RedisLock
|
5
|
+
|
6
|
+
# Gem version
|
7
|
+
VERSION = "1.0.0"
|
8
|
+
|
9
|
+
# Original defaults
|
10
|
+
DEFAULT_ATTRS = {
|
11
|
+
redis: nil, # Redis instance with defaults
|
12
|
+
key: 'RedisLock::default', # Redis key to store the lock
|
13
|
+
autorelease: 10.0, # seconds to expire
|
14
|
+
retry: true, # false to only try to acquire once
|
15
|
+
retry_timeout: 10.0, # max number of seconds to keep doing retries if the lock is not available
|
16
|
+
retry_sleep: 0.1 # seconds to sleep before the nex retry
|
17
|
+
}
|
18
|
+
|
19
|
+
# Attributes
|
20
|
+
DEFAULT_ATTRS.keys.each do |attr|
|
21
|
+
attr_accessor attr
|
22
|
+
end
|
23
|
+
attr_accessor :acquired_token # if the lock was successfully acquired, this is the token used to identify the lock. False otherwise.
|
24
|
+
attr_accessor :last_acquire_retries # info about how many times had to retry to acquire the lock on the last call to acquire. First try counts as 0
|
25
|
+
|
26
|
+
# Restore original defaults
|
27
|
+
def self.configure_restore_defaults
|
28
|
+
@@config = Struct.new(*DEFAULT_ATTRS.keys).new(*DEFAULT_ATTRS.values)
|
29
|
+
end
|
30
|
+
self.configure_restore_defaults # apply defaults
|
31
|
+
|
32
|
+
# Configure defaults
|
33
|
+
def self.configure
|
34
|
+
yield @@config
|
35
|
+
end
|
36
|
+
|
37
|
+
# Acquire a lock. Use options to override defaults.
|
38
|
+
# This method makes sure to release the lock as soon as the block is finalized.
|
39
|
+
def self.acquire(opts={}, &block)
|
40
|
+
lock = RedisLock.new(opts)
|
41
|
+
if lock.acquire
|
42
|
+
begin
|
43
|
+
block.call(lock)
|
44
|
+
ensure
|
45
|
+
lock.release
|
46
|
+
end
|
47
|
+
else
|
48
|
+
block.call(lock)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def initialize(opts={})
|
53
|
+
# Check if options are valid
|
54
|
+
allowed_opts = DEFAULT_ATTRS.keys
|
55
|
+
invalid_opts = opts.keys - allowed_opts
|
56
|
+
raise ArgumentError.new("Invalid options: #{invalid_opts.inspect}. Please use one of #{allowed_opts.inspect} ") unless invalid_opts.empty?
|
57
|
+
|
58
|
+
# Set attributes from options or defaults
|
59
|
+
self.redis = opts[:redis] || @@config.redis || Redis.new
|
60
|
+
self.redis = Redis.new(redis) if redis.is_a? Hash # allow to use Redis options instead of a redis instance
|
61
|
+
self.key = opts[:key] || @@config.key
|
62
|
+
self.autorelease = opts[:autorelease] || @@config.autorelease
|
63
|
+
self.retry = opts.include?(:retry) ? opts[:retry] : @@config.retry
|
64
|
+
self.retry_timeout = opts[:retry_timeout] || @@config.retry_timeout
|
65
|
+
self.retry_sleep = opts[:retry_sleep] || @@config.retry_sleep
|
66
|
+
end
|
67
|
+
|
68
|
+
# Try to acquire the lock.
|
69
|
+
# Retrun true on success, false on failure (someone else has the lock)
|
70
|
+
def acquire
|
71
|
+
@first_try_time ||= Time.now
|
72
|
+
@token ||= SecureRandom.uuid # token is used to make sure that we own the lock when releasing it
|
73
|
+
@retries ||= 0
|
74
|
+
|
75
|
+
# Lock using a redis key, if not exists (NX) with an expiration time (EX).
|
76
|
+
# NOTE that the NX and EX options are not supported by REDIS versions older than 2.6.12
|
77
|
+
# See lock pattern: http://redis.io/commands/SET
|
78
|
+
expire = (autorelease * 1000).to_i # to milliseconds
|
79
|
+
if redis.set(key, @token, nx: true, px: expire)
|
80
|
+
self.acquired_token = @token # assign acquired_token
|
81
|
+
|
82
|
+
else
|
83
|
+
self.acquired_token = nil # clear acquired_token, to make the acquired? method return false
|
84
|
+
|
85
|
+
# Wait and try again if retry option is set and didn't timeout
|
86
|
+
if self.retry and (Time.now - @first_try_time) < retry_timeout
|
87
|
+
sleep retry_sleep # wait
|
88
|
+
@retries += 1
|
89
|
+
return acquire # and try again
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
self.last_acquire_retries = @retries
|
94
|
+
@retries = nil # reset retries
|
95
|
+
@first_try_time = nil # reset timestamp
|
96
|
+
|
97
|
+
return self.acquired?
|
98
|
+
end
|
99
|
+
|
100
|
+
# Release the lock.
|
101
|
+
# Returns a Symbol with the status of the operation:
|
102
|
+
# * :success if properly released
|
103
|
+
# * :already_released if the lock was already released or expired (other process could be using it now)
|
104
|
+
# * :not_acquired if the lock was not acquired (no release action was made because it was not needed)
|
105
|
+
def release
|
106
|
+
if acquired?
|
107
|
+
script = 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return nil end'
|
108
|
+
ret = redis.eval(script, [key], [self.acquired_token])
|
109
|
+
self.acquired_token = nil # cleanup acquired token
|
110
|
+
if ret == nil
|
111
|
+
:already_released
|
112
|
+
else
|
113
|
+
:success
|
114
|
+
end
|
115
|
+
else
|
116
|
+
:not_acquired
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Check if last lock acquisition was successful.
|
121
|
+
# Note that it doesn't track autorelease, if the lock is naturally expired, this value will still be true.
|
122
|
+
def acquired?
|
123
|
+
!!self.acquired_token # acquired_token is only set on success
|
124
|
+
end
|
125
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
|
5
|
+
require 'redis_lock'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "mario-redis-lock"
|
9
|
+
spec.version = RedisLock::VERSION
|
10
|
+
spec.authors = ["Mario Izquierdo"]
|
11
|
+
spec.email = ["tomario@gmail.com"]
|
12
|
+
spec.summary = %q{Yet another distributed lock for Ruby using Redis.}
|
13
|
+
spec.description = %q{Yet another distributed lock for Ruby using Redis, with emphasis in the documentation. Requires Redis >= 2.6.12, because it uses the new syntax for SET to easily implement the robust algorithm described in the SET command documentation (http://redis.io/commands/set).}
|
14
|
+
spec.homepage = "https://github.com/marioizquierdo/mario-redis-lock"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0")
|
18
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
19
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_runtime_dependency 'redis', '>= 3.0.5' # Needed support for SET with EX, PX, NX, XX options: https://github.com/redis/redis-rb/pull/343
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.5"
|
25
|
+
spec.add_development_dependency "rake"
|
26
|
+
end
|
@@ -0,0 +1,247 @@
|
|
1
|
+
require 'minitest'
|
2
|
+
require 'minitest/autorun'
|
3
|
+
require 'redis'
|
4
|
+
require_relative '../lib/redis_lock'
|
5
|
+
|
6
|
+
# Use local redis db 15 for tests
|
7
|
+
REDIS = Redis.new(db: 15)
|
8
|
+
unless REDIS.keys.empty?
|
9
|
+
puts '[ERROR]: Redis database 15 will be used for tests but is not empty! If you are sure, run "rake flushdb" beforehand.'
|
10
|
+
exit!
|
11
|
+
end
|
12
|
+
|
13
|
+
describe RedisLock do
|
14
|
+
before do
|
15
|
+
REDIS.flushdb # cleanup redis
|
16
|
+
RedisLock.configure do |conf|
|
17
|
+
conf.redis = REDIS # use test Redis instance
|
18
|
+
conf.retry = false # do not retry by default, is more convenient for fast tests
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
after do
|
23
|
+
RedisLock.configure_restore_defaults # restore defaults
|
24
|
+
end
|
25
|
+
|
26
|
+
Minitest.after_run do
|
27
|
+
REDIS.flushdb # cleanup redis
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
describe "configure" do
|
32
|
+
it "changes the defaults" do
|
33
|
+
RedisLock.configure do |conf|
|
34
|
+
conf.key = "mykey"
|
35
|
+
conf.autorelease = 11
|
36
|
+
conf.retry = true
|
37
|
+
conf.retry_timeout = 11
|
38
|
+
conf.retry_sleep = 11
|
39
|
+
end
|
40
|
+
lock = RedisLock.new
|
41
|
+
lock.key.must_equal "mykey"
|
42
|
+
lock.autorelease.must_equal 11
|
43
|
+
lock.retry.must_equal true
|
44
|
+
lock.retry_timeout.must_equal 11
|
45
|
+
lock.retry_sleep.must_equal 11
|
46
|
+
end
|
47
|
+
it "raises an error if setting an invalid option" do
|
48
|
+
proc do
|
49
|
+
RedisLock.configure do |conf|
|
50
|
+
conf.nonexistingattr = "blabla"
|
51
|
+
end
|
52
|
+
end.must_raise NoMethodError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "acquire" do
|
57
|
+
describe "with bad redis connection" do
|
58
|
+
it "raises a Redis::CannotConnectError" do
|
59
|
+
proc { RedisLock.acquire(redis: {url: "redis://localhost:1111/15"}){|l| }}.must_raise Redis::CannotConnectError
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
it "holds the lock" do
|
64
|
+
RedisLock.acquire do |lock|
|
65
|
+
lock.acquired?.must_equal true
|
66
|
+
RedisLock.acquire do |lock|
|
67
|
+
lock.acquired?.must_equal false
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
it "releases the lock at the end of the block" do
|
72
|
+
RedisLock.acquire do |lock|
|
73
|
+
lock.acquired?.must_equal true
|
74
|
+
end
|
75
|
+
RedisLock.acquire do |lock|
|
76
|
+
lock.acquired?.must_equal true
|
77
|
+
end
|
78
|
+
end
|
79
|
+
it "overrides the default config with passed options" do
|
80
|
+
RedisLock.acquire(key: 'override', autorelease: 5555) do |lock|
|
81
|
+
lock.key.must_equal 'override'
|
82
|
+
lock.autorelease.must_equal 5555
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
describe "initialize" do
|
88
|
+
it "uses conf as default values" do
|
89
|
+
RedisLock.configure do |conf|
|
90
|
+
conf.key = "mykey"
|
91
|
+
conf.autorelease = 111
|
92
|
+
end
|
93
|
+
lock = RedisLock.new
|
94
|
+
lock.key.must_equal "mykey"
|
95
|
+
lock.autorelease.must_equal 111
|
96
|
+
end
|
97
|
+
it "uses options to set values different than the defaults" do
|
98
|
+
RedisLock.configure do |conf|
|
99
|
+
conf.key = "mykey"
|
100
|
+
conf.autorelease = 111
|
101
|
+
end
|
102
|
+
lock = RedisLock.new(key: 'override', autorelease: 5555)
|
103
|
+
lock.key.must_equal 'override'
|
104
|
+
lock.autorelease.must_equal 5555
|
105
|
+
end
|
106
|
+
it "initializes a lock that is not acquired" do
|
107
|
+
lock = RedisLock.new
|
108
|
+
lock.acquired?.must_equal false
|
109
|
+
end
|
110
|
+
it "raises an ArgumentError if using invalid options" do
|
111
|
+
proc do
|
112
|
+
RedisLock.new(nonexistingattr: "blabla")
|
113
|
+
end.must_raise ArgumentError
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
describe "#acquire" do
|
118
|
+
describe "with bad redis connection" do
|
119
|
+
it "raises a Redis::CannotConnectError" do
|
120
|
+
proc do
|
121
|
+
lock = RedisLock.new(redis: {url: "redis://localhost:1111/15"})
|
122
|
+
lock.acquire
|
123
|
+
end.must_raise Redis::CannotConnectError
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
it "holds the lock and returs true if acquired" do
|
128
|
+
lock1 = RedisLock.new
|
129
|
+
lock1.acquire.must_equal true
|
130
|
+
lock1.acquired?.must_equal true
|
131
|
+
|
132
|
+
lock2 = RedisLock.new
|
133
|
+
lock2.acquire.must_equal false # already acquired by lock1
|
134
|
+
lock2.acquired?.must_equal false
|
135
|
+
end
|
136
|
+
|
137
|
+
it "holds the lock in the specified key" do
|
138
|
+
RedisLock.new(key: 'key1').acquire.must_equal true
|
139
|
+
RedisLock.new(key: 'key1').acquire.must_equal false # same key1, already in use
|
140
|
+
RedisLock.new(key: 'key2').acquire.must_equal true # key2 is free
|
141
|
+
RedisLock.new(key: 'key2').acquire.must_equal false # but not anymore
|
142
|
+
end
|
143
|
+
|
144
|
+
it "sets autorelease expiration time" do
|
145
|
+
RedisLock.configure do |conf|
|
146
|
+
conf.autorelease = 0.005
|
147
|
+
end
|
148
|
+
|
149
|
+
RedisLock.new.acquire.must_equal true
|
150
|
+
RedisLock.new.acquire.must_equal false # already acquired
|
151
|
+
sleep 0.010
|
152
|
+
RedisLock.new.acquire.must_equal true # autoreleased
|
153
|
+
RedisLock.new.acquire.must_equal false # already acquired
|
154
|
+
RedisLock.new.acquire.must_equal false # already acquired
|
155
|
+
sleep 0.010
|
156
|
+
RedisLock.new.acquire.must_equal true # autoreleased again
|
157
|
+
end
|
158
|
+
|
159
|
+
it "retries with retry, retry_timeout and retry_sleep" do
|
160
|
+
RedisLock.new.acquire # locked to make next acquisitions fail, to force them use the retries
|
161
|
+
|
162
|
+
# No retries if retry: false
|
163
|
+
lock = RedisLock.new(retry: false)
|
164
|
+
lock.acquire.must_equal false
|
165
|
+
lock.last_acquire_retries.must_equal 0
|
166
|
+
|
167
|
+
# Number of retries depends on retry_sleep
|
168
|
+
lock = RedisLock.new(retry: true, retry_timeout: 0.03, retry_sleep: 0.01)
|
169
|
+
lock.acquire.must_equal false
|
170
|
+
lock.last_acquire_retries.must_be_within_delta 2, 1 # it should around 1..3 retries
|
171
|
+
|
172
|
+
# Number with less retry_sleep time, there should be more retries
|
173
|
+
lock = RedisLock.new(retry: true, retry_timeout: 0.03, retry_sleep: 0.001)
|
174
|
+
lock.acquire.must_equal false
|
175
|
+
lock.last_acquire_retries.must_be_within_delta 20, 10 # it should around 10..30 retries
|
176
|
+
|
177
|
+
# retry_timeout stops execution
|
178
|
+
time = Time.now
|
179
|
+
lock = RedisLock.new(retry: true, retry_timeout: 0.03, retry_sleep: 0.01) # small retry_sleep should allow for many more retries
|
180
|
+
lock.acquire.must_equal false
|
181
|
+
(Time.now - time).must_be_within_delta 0.03, 0.01
|
182
|
+
|
183
|
+
# If the lock becomes available, it stops retrying
|
184
|
+
time = Time.now
|
185
|
+
lock = RedisLock.new(key: 'key2', autorelease: 0.03)
|
186
|
+
lock.acquire.must_equal true
|
187
|
+
lock2 = RedisLock.new(key: 'key2', retry: true, retry_timeout: 1, retry_sleep: 0.01)
|
188
|
+
lock2.acquire.must_equal true # it was able to get it after it was autoreleased
|
189
|
+
lock2.last_acquire_retries.must_be_within_delta 2, 1 # only a few retries because it was available righ after expired
|
190
|
+
(Time.now - time).must_be_within_delta 0.03, 0.01
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
describe "#release" do
|
195
|
+
it 'releases the lock and returns :success' do
|
196
|
+
lock = RedisLock.new
|
197
|
+
lock2 = RedisLock.new
|
198
|
+
|
199
|
+
lock.acquire.must_equal true
|
200
|
+
lock2.acquire.must_equal false
|
201
|
+
|
202
|
+
lock.release.must_equal :success
|
203
|
+
lock2.acquire.must_equal true
|
204
|
+
lock.acquire.must_equal false
|
205
|
+
|
206
|
+
lock2.release.must_equal :success
|
207
|
+
lock.acquire.must_equal true
|
208
|
+
end
|
209
|
+
|
210
|
+
it 'returns :not_acquired if the lock is not acquired first' do
|
211
|
+
lock = RedisLock.new
|
212
|
+
lock.acquired?.must_equal false
|
213
|
+
lock.release.must_equal :not_acquired
|
214
|
+
lock.acquire.must_equal true
|
215
|
+
lock.acquired?.must_equal true
|
216
|
+
lock.release.must_equal :success
|
217
|
+
lock.acquired?.must_equal false
|
218
|
+
lock.release.must_equal :not_acquired # we don't hold the lock anymore
|
219
|
+
end
|
220
|
+
|
221
|
+
it 'returns :already_released if the lock expired before releasing it' do
|
222
|
+
lock = RedisLock.new(autorelease: 0.001)
|
223
|
+
lock.acquire.must_equal true
|
224
|
+
sleep 0.002
|
225
|
+
lock.release.must_equal :already_released
|
226
|
+
lock.acquired?.must_equal false
|
227
|
+
end
|
228
|
+
|
229
|
+
it 'returns :already_released if the lock expired, and does not remove it from any process that might be using the lock' do
|
230
|
+
lock = RedisLock.new(autorelease: 0.001)
|
231
|
+
lock.acquire.must_equal true
|
232
|
+
sleep 0.002
|
233
|
+
lock2 = RedisLock.new
|
234
|
+
lock2.acquire.must_equal true # now the lock is owned by lock2
|
235
|
+
|
236
|
+
lock.release.must_equal :already_released # lock releases
|
237
|
+
lock.acquired?.must_equal false
|
238
|
+
|
239
|
+
lock2.acquired?.must_equal true # but lock2 still holds the lock
|
240
|
+
RedisLock.new.acquire.must_equal false
|
241
|
+
RedisLock.new.acquire.must_equal false # that is why the lock can not be acquired by others
|
242
|
+
|
243
|
+
lock2.release.must_equal :success # once lock2 releases
|
244
|
+
RedisLock.new.acquire.must_equal true # others can finally get the lock
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
metadata
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mario-redis-lock
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mario Izquierdo
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-09-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.0.5
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.0.5
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.5'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ~>
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Yet another distributed lock for Ruby using Redis, with emphasis in the
|
56
|
+
documentation. Requires Redis >= 2.6.12, because it uses the new syntax for SET
|
57
|
+
to easily implement the robust algorithm described in the SET command documentation
|
58
|
+
(http://redis.io/commands/set).
|
59
|
+
email:
|
60
|
+
- tomario@gmail.com
|
61
|
+
executables: []
|
62
|
+
extensions: []
|
63
|
+
extra_rdoc_files: []
|
64
|
+
files:
|
65
|
+
- .gitignore
|
66
|
+
- Gemfile
|
67
|
+
- LICENSE.txt
|
68
|
+
- README.md
|
69
|
+
- Rakefile
|
70
|
+
- lib/redis_lock.rb
|
71
|
+
- mario-redis-lock.gemspec
|
72
|
+
- spec/redis_lock_spec.rb
|
73
|
+
homepage: https://github.com/marioizquierdo/mario-redis-lock
|
74
|
+
licenses:
|
75
|
+
- MIT
|
76
|
+
metadata: {}
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - '>='
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
requirements: []
|
92
|
+
rubyforge_project:
|
93
|
+
rubygems_version: 2.0.14
|
94
|
+
signing_key:
|
95
|
+
specification_version: 4
|
96
|
+
summary: Yet another distributed lock for Ruby using Redis.
|
97
|
+
test_files:
|
98
|
+
- spec/redis_lock_spec.rb
|