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 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
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ .DS_Store
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mario-redis-lock.gemspec
4
+ gemspec
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