bfg-redis-lock 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ vendor/bundle
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in redis-lock.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 BiddingForGood
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,80 @@
1
+ # Redis::Lock
2
+
3
+ Yet another gem for pessimistic locking using Redis.
4
+
5
+ The gem uses unique identifiers for the lock values instead of timestamps as described in [the Redis SETNX documentation](http://redis.io/commands/setnx).
6
+ This avoids any issues with the clocks on the client and server not being exactly in sync. While this shouldn't occur, it does and the implementation described here could fail if the client is more than 1 second out of sync with the server.
7
+
8
+ The gem uses a combination of [SETNX](http://redis.io/commands/setnx) and [EXPIRES](http://redis.io/commands/expires) instead of [GETSET](http://redis.io/commands/getset) as described in the lock implementation on the redis site. If a client crashes between issuing the
9
+ SETNX and the EXPIRES commands, the next client that attempts to get a lock will set the EXPIRES on the lock and wait as normal
10
+ ( just in case there is a legitimate lock in use and something else happened ).
11
+
12
+ When attempting to remove the lock, the client verifies that they are still the lock owner before removing it. The gem watches the lock key while
13
+ attempting to remove the lock to catch the case where the lock expires and is acquired by another client between checking ownership and deleting the lock.
14
+ If the lock is changed while attempting to remove the lock, the removal process will be tried again.
15
+
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ gem 'bfg-redis-lock'
22
+
23
+ And then execute:
24
+
25
+ $ bundle
26
+
27
+ Or install it yourself as:
28
+
29
+ $ gem install bfg-redis-lock
30
+
31
+ ## Usage
32
+
33
+ require 'redis'
34
+ require 'redis-lock'
35
+
36
+ Once required, you can do things like:
37
+
38
+ redis = Redis.new
39
+ redis.lock "my_key" do |lock|
40
+ # do something while locked
41
+ end
42
+
43
+ The block form above ensures that the lock is released after the block has been executed. Alternatively, you can choose to not provide a block and
44
+ work directly with the lock:
45
+
46
+ redis = Redis.new
47
+ lock = redis.lock "my_key"
48
+
49
+ # do some stuff
50
+
51
+ lock.unlock
52
+
53
+ If you would like, you can specify a timeout for acquiring the lock as well as the lock duration in seconds. The defaults are 5 seconds for acquiring a lock and 10 seconds for the lock duration:
54
+
55
+ redis = Redis.new
56
+ redis.lock "my_key", :acquire_timeout => 2, :lock_duration => 5 do |lock|
57
+ # do something
58
+ end
59
+
60
+ If the lock can't be acquired before the timeout, a LockError will be raised:
61
+
62
+ redis = Redis.new
63
+ lock = redis.lock "my_key", :lock_duration => 30
64
+ redis.lock "my_key", :acquire_timeout => 1 # raises a LockError after one second of attempting to acquire the lock
65
+
66
+ You can extend a lock if you are the owner:
67
+
68
+ redis = Redis.new
69
+ lock = redis.lock "my_key"
70
+ lock.extend_lock 30 # extend lock_duration to be 30 seconds from now
71
+
72
+ If you are no longer the lock owner, a LockError will be raised.
73
+
74
+ ## Contributing
75
+
76
+ 1. Fork it
77
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
78
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
79
+ 4. Push to the branch (`git push origin my-new-feature`)
80
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new do |t|
5
+ # Put spec opts in a file named .rspec in root
6
+ end
7
+
8
+ task :default => :spec
data/lib/redis-lock.rb ADDED
@@ -0,0 +1,192 @@
1
+ require "redis"
2
+ require "redis-lock/version"
3
+ require "securerandom"
4
+
5
+ class Redis
6
+
7
+ class Lock
8
+
9
+ class LockError < StandardError
10
+ end
11
+
12
+ attr_reader :redis
13
+ attr_reader :id
14
+ attr_reader :lockname
15
+ attr_reader :acquire_timeout
16
+ attr_reader :lock_duration
17
+ attr_reader :logger
18
+ attr_accessor :before_delete_callback
19
+ attr_accessor :before_extend_callback
20
+
21
+ def initialize(redis, lock_name, options = {})
22
+ @redis = redis
23
+ @lockname = "lock:#{lock_name}"
24
+ @acquire_timeout = options[:acquire_timeout] || 5
25
+ @lock_duration = options[:lock_duration] || 10
26
+ @logger = options[:logger]
27
+
28
+ # generate a unique UUID for this lock
29
+ @id = SecureRandom.uuid
30
+ end
31
+
32
+ def lock(&block)
33
+ acquire_lock or raise LockError.new(lockname)
34
+
35
+ if block
36
+ begin
37
+ block.call(self)
38
+ ensure
39
+ release_lock
40
+ end
41
+ end
42
+
43
+ self
44
+ end
45
+
46
+ def unlock
47
+ release_lock
48
+ self
49
+ end
50
+
51
+ def acquire_lock
52
+ try_until = Time.now + acquire_timeout
53
+
54
+ # loop until now + timeout trying to get the lock
55
+ while Time.now < try_until
56
+ log :debug, "attempting to acquire lock #{lockname}"
57
+
58
+ # try and obtain the lock
59
+ if redis.setnx(lockname, id)
60
+ log :info, "lock #{lockname} acquired for #{id}"
61
+ # lock was obtained, so add an expiration
62
+ add_expiration
63
+ return true
64
+ elsif missing_expiration?
65
+ # if no expiration, client that obtained lock likely crashed - add an expiration
66
+ # and wait
67
+ log :debug, "expiration missing on lock #{lockname}"
68
+ add_expiration
69
+ end
70
+
71
+ # didn't get the lock, sleep briefly and try again
72
+ sleep(0.001)
73
+ end
74
+
75
+ # was never able to get the lock - give up
76
+ return false
77
+ end
78
+
79
+ def extend_lock(extend_by = 10)
80
+ begin
81
+ with_watch do
82
+ if lock_owner?
83
+ log :debug, "we are the lock owner - extending lock by #{extend_by} seconds"
84
+
85
+ # check if we want to do a callback
86
+ if before_extend_callback
87
+ log :debug, "calling callback"
88
+ before_extend_callback.call(redis)
89
+ end
90
+
91
+ redis.multi do |multi|
92
+ multi.expire lockname, extend_by
93
+ end
94
+
95
+ # we extended the lock, return the lock
96
+ return self
97
+ end
98
+
99
+ log :debug, "we aren't the lock owner - raising LockError"
100
+
101
+ # we aren't the lock owner anymore - raise LockError
102
+ raise LockError.new("unable to extend #{lockname} - no longer the lock owner")
103
+ end
104
+ rescue LockError => e
105
+ raise e
106
+ rescue StandardError => e
107
+ log :warn, "#{lockname} changed while attempting to release key - retrying"
108
+ # try extending the lock again, just in case
109
+ extend_lock extend_by
110
+ end
111
+ end
112
+
113
+ def release_lock
114
+ # we are going to watch the lock key while attempting to remove it, so we can
115
+ # retry removing the lock if the lock is changed while we are removing it.
116
+ release_with_watch do
117
+
118
+ log :debug, "releasing #{lockname}..."
119
+
120
+ # make sure we still own the lock
121
+ if lock_owner?
122
+ log :debug, "we are the lock owner"
123
+
124
+ # check if we want to do a callback
125
+ if before_delete_callback
126
+ log :debug, "calling callback"
127
+ before_delete_callback.call(redis)
128
+ end
129
+
130
+ redis.multi do |multi|
131
+ multi.del lockname
132
+ end
133
+ return true
134
+ end
135
+
136
+ # we weren't the owner of the lock anymore - just return
137
+ return false
138
+
139
+ end
140
+ end
141
+
142
+ def locked?
143
+ lock_owner?
144
+ end
145
+
146
+ def missing_expiration?
147
+ redis.ttl(lockname) == -1
148
+ end
149
+
150
+ def add_expiration()
151
+ log :debug, "adding expiration of #{lock_duration} seconds to #{lockname}"
152
+ redis.expire(lockname, lock_duration)
153
+ end
154
+
155
+ def lock_owner?
156
+ log :debug, "our id: #{id} - lock owner: #{redis.get(lockname)}"
157
+ redis.get(lockname) == id
158
+ end
159
+
160
+ def release_with_watch(&block)
161
+ with_watch do
162
+ begin
163
+ block.call
164
+ rescue => e
165
+ log :warn, "#{lockname} changed while attempting to release key - retrying"
166
+ release_with_watch &block
167
+ end
168
+ end
169
+ end
170
+
171
+ def with_watch(&block)
172
+ redis.watch lockname
173
+ begin
174
+ block.call
175
+ ensure
176
+ redis.unwatch
177
+ end
178
+ end
179
+
180
+ def log(level, message)
181
+ if logger
182
+ logger.send(level) { message }
183
+ end
184
+ end
185
+
186
+ end # Lock class
187
+
188
+ def lock(key, options = {}, &block)
189
+ Lock.new(self, key, options).lock(&block)
190
+ end
191
+
192
+ end
@@ -0,0 +1,5 @@
1
+ class Redis
2
+ class Lock
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'redis-lock/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "bfg-redis-lock"
8
+ gem.version = Redis::Lock::VERSION
9
+ gem.authors = ["Stuart Garner"]
10
+ gem.email = ["stuart@biddingforgood.com"]
11
+ gem.summary = %q{A pessimistic redis lock implementation.'}
12
+ gem.description = <<-DESC
13
+ A pessimistic redis lock implementation that doesn't use timestamps, works with the latest redis client, and properly handles removing locks.
14
+ DESC
15
+ gem.homepage = "https://github.com/BiddingForGood/redis-lock"
16
+
17
+ gem.files = `git ls-files`.split($/)
18
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
+ gem.require_paths = ["lib"]
21
+
22
+ gem.add_dependency "redis", "~> 3.0.0"
23
+
24
+ gem.add_development_dependency "rake", "~> 0.9.2"
25
+ gem.add_development_dependency "rspec", "~> 2.12.0"
26
+ end
@@ -0,0 +1,180 @@
1
+ require 'spec_helper'
2
+ require 'logger'
3
+ require 'benchmark'
4
+
5
+ describe Redis::Lock do
6
+
7
+ let(:redis) { Redis.new }
8
+
9
+ before(:each) do
10
+ redis.del "lock:test"
11
+ end
12
+
13
+ it "responds to lock" do
14
+ redis.should respond_to(:lock)
15
+ end
16
+
17
+ it "can acquire and release a lock" do
18
+ lock = redis.lock "test"
19
+
20
+ redis.get("lock:test").should eq(lock.id)
21
+ lock.should be_locked
22
+
23
+ lock.unlock
24
+
25
+ redis.get("lock:test").should be_nil
26
+ lock.should_not be_locked
27
+ end
28
+
29
+ it "processes a provided block and ensures that the lock is release when completed" do
30
+ lock = redis.lock "test" do |lock|
31
+ redis.set "test", "hello"
32
+ lock.should be_locked
33
+ end
34
+
35
+ redis.get("test").should eq("hello")
36
+ lock.should_not be_locked
37
+ end
38
+
39
+ it "prevents other clients from obtaining a lock" do
40
+ lock = redis.lock "test", :lock_duration => 10
41
+ expect { redis.lock "test", :acquire_timeout => 1 }.to raise_exception
42
+ lock.unlock
43
+ end
44
+
45
+ it "expires the locks appropriately" do
46
+ lock = redis.lock "test", :lock_duration => 1
47
+ sleep(2)
48
+ lock.should_not be_locked
49
+ end
50
+
51
+ it "handles clients crashing between obtaining a lock and setting the expires" do
52
+ redis.set "lock:test", "xxx"
53
+
54
+ lock = redis.lock("test", :acquire_timeout => 5, :lock_duration => 1)
55
+ lock.should be_locked
56
+
57
+ redis.get("lock:test").should_not eq("xxx")
58
+ redis.get("lock:test").should eq(lock.id)
59
+ redis.ttl("lock:test").should_not eq(-1)
60
+ lock.unlock
61
+ end
62
+
63
+ it "doesn't remove the lock if the lock expires before complete and another client aquires the lock" do
64
+ lock1 = redis.lock "test", :lock_duration => 1
65
+ lock2 = redis.lock "test", :acquire_timeout => 3
66
+ lock1.unlock
67
+
68
+ lock1.should_not be_locked
69
+ redis.get("lock:test").should eq(lock2.id)
70
+ lock2.should be_locked
71
+
72
+ lock2.unlock
73
+ end
74
+
75
+ it "retries removing the lock when another client changes it during delete" do
76
+ callback = Proc.new do |redis|
77
+ redis.incr "retry_count"
78
+
79
+ # mess with the lock using another client
80
+ unless redis.get("retry_count").to_i > 1
81
+ Redis.new.expires "lock:test", 60
82
+ end
83
+ end
84
+
85
+ redis.set "retry_count", 0
86
+ lock = redis.lock "test"
87
+ lock.before_delete_callback = callback
88
+ lock.unlock
89
+
90
+ redis.get("retry_count").should eq(2.to_s)
91
+ lock.should_not be_locked
92
+ end
93
+
94
+ it "doesn't remove the lock when another client changes it" do
95
+ callback = Proc.new do |redis|
96
+ redis.incr "retry_count"
97
+
98
+ # mess with the lock using another client
99
+ unless redis.get("retry_count").to_i > 1
100
+ Redis.new.set "lock:test", "xxx"
101
+ end
102
+ end
103
+
104
+ redis.set "retry_count", 0
105
+ lock = redis.lock "test"
106
+ lock.before_delete_callback = callback
107
+ lock.unlock
108
+
109
+ redis.get("retry_count").should eq(1.to_s)
110
+ lock.should_not be_locked
111
+ redis.get("lock:test").should eq("xxx")
112
+
113
+ redis.del("lock:test")
114
+ end
115
+
116
+ it "can extend a lock we own" do
117
+ lock = redis.lock "test", :lock_duration => 10
118
+ lock.extend_lock 30
119
+
120
+ redis.ttl("lock:test").should eq(30)
121
+
122
+ lock.unlock
123
+ end
124
+
125
+ it "can't extend a lock we don't own" do
126
+ lock1 = redis.lock "test", :lock_duration => 1
127
+ lock2 = redis.lock "test"
128
+
129
+ expect { lock1.extend_lock 30 }.to raise_exception
130
+
131
+ lock1.unlock
132
+ lock2.unlock
133
+ end
134
+
135
+ it "will retry the lock extension if the key changes while we are doing the extension" do
136
+ callback = Proc.new do |redis|
137
+ redis.incr "retry_count"
138
+
139
+ # mess with the lock using another client
140
+ unless redis.get("retry_count").to_i > 1
141
+ Redis.new.expires "lock:test", 60
142
+ end
143
+ end
144
+
145
+ redis.set "retry_count", 0
146
+ lock = redis.lock "test", :lock_duration => 10
147
+ lock.before_extend_callback = callback
148
+ lock.extend_lock 30
149
+
150
+ redis.get("retry_count").should eq(2.to_s)
151
+ lock.should be_locked
152
+
153
+ lock.unlock
154
+ end
155
+
156
+ it "can run a lot of times without any conflicts" do
157
+ redis.set "num_locks", 0
158
+ threads = []
159
+ logger = Logger.new(STDOUT)
160
+ # logger.level = Logger::INFO
161
+ logger.level = Logger::WARN
162
+
163
+ time = Benchmark.realtime do
164
+ 10.times do
165
+ threads << Thread.new do
166
+ 10.times do
167
+ Redis.new.lock("test", :lock_duration => 1, :logger => logger) do |lock|
168
+ lock.redis.incr "num_locks"
169
+ end
170
+ sleep(0.1)
171
+ end
172
+ end
173
+ end
174
+ threads.each { |t| t.join }
175
+ end
176
+
177
+ redis.get("num_locks").should eq(100.to_s)
178
+ end
179
+
180
+ end
@@ -0,0 +1,16 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ require 'redis-lock'
5
+
6
+ RSpec.configure do |config|
7
+ config.treat_symbols_as_metadata_keys_with_true_values = true
8
+ config.run_all_when_everything_filtered = true
9
+ config.filter_run :focus
10
+
11
+ # Run specs in random order to surface order dependencies. If you find an
12
+ # order dependency and want to debug it, you can fix the order by providing
13
+ # the seed, which is printed after each run.
14
+ # --seed 1234
15
+ config.order = 'random'
16
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bfg-redis-lock
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Stuart Garner
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-12-06 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: redis
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 3.0.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 3.0.0
30
+ - !ruby/object:Gem::Dependency
31
+ name: rake
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 0.9.2
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 0.9.2
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: 2.12.0
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: 2.12.0
62
+ description: ! ' A pessimistic redis lock implementation that doesn''t use timestamps,
63
+ works with the latest redis client, and properly handles removing locks.
64
+
65
+ '
66
+ email:
67
+ - stuart@biddingforgood.com
68
+ executables: []
69
+ extensions: []
70
+ extra_rdoc_files: []
71
+ files:
72
+ - .gitignore
73
+ - .rspec
74
+ - Gemfile
75
+ - LICENSE.txt
76
+ - README.md
77
+ - Rakefile
78
+ - lib/redis-lock.rb
79
+ - lib/redis-lock/version.rb
80
+ - redis-lock.gemspec
81
+ - spec/redis-lock_spec.rb
82
+ - spec/spec_helper.rb
83
+ homepage: https://github.com/BiddingForGood/redis-lock
84
+ licenses: []
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ none: false
91
+ requirements:
92
+ - - ! '>='
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ! '>='
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubyforge_project:
103
+ rubygems_version: 1.8.23
104
+ signing_key:
105
+ specification_version: 3
106
+ summary: A pessimistic redis lock implementation.'
107
+ test_files:
108
+ - spec/redis-lock_spec.rb
109
+ - spec/spec_helper.rb