mlanett-redis-lock 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/Gemfile +15 -0
- data/Guardfile +6 -0
- data/LICENSE +22 -0
- data/README.md +61 -0
- data/Rakefile +7 -0
- data/lib/redis-lock.rb +225 -0
- data/lib/redis-lock/version.rb +5 -0
- data/redis-lock.gemspec +19 -0
- data/spec/helper.rb +21 -0
- data/spec/redis_lock_spec.rb +111 -0
- data/spec/redis_spec.rb +46 -0
- data/spec/support/redis.rb +37 -0
- data/test/stress.rb +109 -0
- metadata +76 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in redis-lock.gemspec
|
4
|
+
gemspec
|
5
|
+
|
6
|
+
group :development do
|
7
|
+
gem "guard-rspec"
|
8
|
+
gem "rb-fsevent" # for guard
|
9
|
+
gem "rspec"
|
10
|
+
gem "ruby-debug19", require: false
|
11
|
+
end
|
12
|
+
|
13
|
+
group :test do
|
14
|
+
gem "simplecov", require: false
|
15
|
+
end
|
data/Guardfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Mark Lanett
|
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,61 @@
|
|
1
|
+
# Redis::Lock
|
2
|
+
|
3
|
+
This gem implements a pessimistic lock using Redis.
|
4
|
+
It correctly handles timeouts and vanishing lock owners (such as machine failures)
|
5
|
+
|
6
|
+
This uses setnx, but not the setnx algorithm described in the redis cookbook, which is not robust.
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add this line to your application's Gemfile:
|
11
|
+
|
12
|
+
gem 'redis-lock'
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install redis-lock
|
21
|
+
|
22
|
+
## Background
|
23
|
+
|
24
|
+
A lock needs an expected lifetime.
|
25
|
+
If the owner of a lock disappears (due to machine failure, network failure, process death),
|
26
|
+
you want the lock to expire and another owner to be able to acquire the lock.
|
27
|
+
At the same time, the owner of a lock should be able to extend its lifetime.
|
28
|
+
Thus, you can acquire a lock with a conservative estimate on lifetime, and extend it as necessary,
|
29
|
+
rather than acquiring the lock with a very long lifetime which will result in long waits in the event of failures.
|
30
|
+
|
31
|
+
A lock needs an owner. Redis::Lock defaults to using an owner id of HOSTNAME:PID.
|
32
|
+
|
33
|
+
A lock may need more than one attempt to acquire it. Redis::Lock offers a timeout; this defaults to 1 second.
|
34
|
+
It uses exponential backoff with sleeps so it's fairly safe to use longer timeouts.
|
35
|
+
|
36
|
+
## Usage
|
37
|
+
|
38
|
+
This gem adds lock() and unlock() to Redis instances.
|
39
|
+
lock() takes a block and is safer than using lock() and unlock() separately.
|
40
|
+
lock() takes a key and lifetime and optionally a timeout (otherwise defaulting to 1 second).
|
41
|
+
|
42
|
+
redis.lock("test") { do_something }
|
43
|
+
|
44
|
+
## Problems
|
45
|
+
|
46
|
+
Why do other gems get this wrong?
|
47
|
+
|
48
|
+
You need to be able to handle race conditions while acquiring the lock.
|
49
|
+
You need to be able to handle the owner of the lock failing to release it.
|
50
|
+
You need to be able to detect stale locks.
|
51
|
+
You need to handle race conditions while cleaning the stale lock and acquiring a new one.
|
52
|
+
The code which cleans the stale lock may not be able to assume it gets the new one.
|
53
|
+
The code which cleans the stale lock must not interfere with a different owner acquiring the lock.
|
54
|
+
|
55
|
+
## Contributing
|
56
|
+
|
57
|
+
1. Fork it
|
58
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
59
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
60
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
61
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/lib/redis-lock.rb
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
require "redis"
|
2
|
+
require "redis-lock/version"
|
3
|
+
|
4
|
+
class Redis
|
5
|
+
|
6
|
+
class Lock
|
7
|
+
|
8
|
+
class LockNotAcquired < StandardError
|
9
|
+
end
|
10
|
+
|
11
|
+
attr :redis
|
12
|
+
attr :key
|
13
|
+
attr :okey # key with redis namespace
|
14
|
+
attr :oval
|
15
|
+
attr :xkey # expiration key with redis namespace
|
16
|
+
attr :xval
|
17
|
+
attr :life, true # how long we expect to keep this lock locked
|
18
|
+
attr :logger, true
|
19
|
+
|
20
|
+
# @param redis is a Redis instance
|
21
|
+
# @param key is a unique string identifying the object to lock, e.g. "user-1"
|
22
|
+
# @param options[:life] may be set, but defaults to 1 minute
|
23
|
+
# @param options[:owner] may be set, but defaults to HOSTNAME:PID
|
24
|
+
def initialize( redis, key, options = {} )
|
25
|
+
check_keys( options, :owner, :life )
|
26
|
+
@redis = redis
|
27
|
+
@key = key
|
28
|
+
@okey = "lock:owner:#{key}"
|
29
|
+
@oval = options[:owner] || "#{`hostname`.strip}:#{Process.pid}"
|
30
|
+
@xkey = "lock:expire:#{key}"
|
31
|
+
@life = options[:life] || 60
|
32
|
+
end
|
33
|
+
|
34
|
+
def lock( timeout = 1, &block )
|
35
|
+
do_lock_with_timeout(timeout) or raise LockNotAcquired.new(key)
|
36
|
+
if block then
|
37
|
+
begin
|
38
|
+
block.call
|
39
|
+
ensure
|
40
|
+
release_lock
|
41
|
+
end
|
42
|
+
end
|
43
|
+
self
|
44
|
+
end
|
45
|
+
|
46
|
+
def extend_life( new_life )
|
47
|
+
do_extend( new_life ) or raise LockNotAcquired.new(key)
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
def unlock
|
52
|
+
release_lock
|
53
|
+
self
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
# queries
|
58
|
+
#
|
59
|
+
|
60
|
+
def locked?( now = Time.now.to_i )
|
61
|
+
# read both in a transaction in a multi to ensure we have a consistent view
|
62
|
+
result = redis.multi do |multi|
|
63
|
+
multi.get( okey )
|
64
|
+
multi.get( xkey )
|
65
|
+
end
|
66
|
+
result && result.size == 2 && is_locked?( result[0], result[1], now )
|
67
|
+
end
|
68
|
+
|
69
|
+
#
|
70
|
+
# internal api
|
71
|
+
#
|
72
|
+
|
73
|
+
def do_lock_with_timeout( timeout )
|
74
|
+
locked = false
|
75
|
+
with_timeout(timeout) { locked = do_lock }
|
76
|
+
locked
|
77
|
+
end
|
78
|
+
|
79
|
+
# @returns true if locked, false otherwise
|
80
|
+
def do_lock( tries = 2 )
|
81
|
+
# We need to set both owner and expire at the same time
|
82
|
+
# If the existing lock is stale, we delete it and try again once
|
83
|
+
|
84
|
+
loop do
|
85
|
+
new_xval = Time.now.to_i + life
|
86
|
+
result = redis.mapped_msetnx okey => oval, xkey => new_xval
|
87
|
+
|
88
|
+
if result == 1 then
|
89
|
+
log :debug, "do_lock() success"
|
90
|
+
@xval = new_xval
|
91
|
+
return true
|
92
|
+
|
93
|
+
else
|
94
|
+
log :debug, "do_lock() failed"
|
95
|
+
# consider the possibility that this lock is stale
|
96
|
+
tries -= 1
|
97
|
+
next if tries > 0 && stale_key?
|
98
|
+
return false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def do_extend( new_life, my_owner = oval )
|
104
|
+
# We use watch and a transaction to ensure we only change a lock we own
|
105
|
+
# The transaction fails if the watched variable changed
|
106
|
+
# Use my_owner = oval to make testing easier.
|
107
|
+
new_xval = Time.now.to_i + new_life
|
108
|
+
with_watch( okey ) do
|
109
|
+
owner = redis.get( okey )
|
110
|
+
if owner == my_owner then
|
111
|
+
result = redis.multi do |multi|
|
112
|
+
multi.set( xkey, new_xval )
|
113
|
+
end
|
114
|
+
if result && result.size == 1 then
|
115
|
+
log :debug, "do_extend() success"
|
116
|
+
@xval = new_xval
|
117
|
+
return true
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
return false
|
122
|
+
end
|
123
|
+
|
124
|
+
# Only actually deletes it if we own it.
|
125
|
+
# There may be strange cases where we fail to delete it, in which case expiration will solve the problem.
|
126
|
+
def release_lock( my_owner = oval )
|
127
|
+
# Use my_owner = oval to make testing easier.
|
128
|
+
with_watch( okey, xkey ) do
|
129
|
+
owner = redis.get( okey )
|
130
|
+
if owner == my_owner then
|
131
|
+
redis.multi do |multi|
|
132
|
+
multi.del( okey )
|
133
|
+
multi.del( xkey )
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def stale_key?( now = Time.now.to_i )
|
140
|
+
# Check if expiration exists and is it stale?
|
141
|
+
# If so, delete it.
|
142
|
+
# watch() both keys so we can detect if they change while we do this
|
143
|
+
# multi() will fail if keys have changed after watch()
|
144
|
+
# Thus, we snapshot consistency at the time of watch()
|
145
|
+
# Note: inside a watch() we get one and only one multi()
|
146
|
+
with_watch( okey, xkey ) do
|
147
|
+
owner = redis.get( okey )
|
148
|
+
expire = redis.get( xkey )
|
149
|
+
if is_deleteable?( owner, expire, now ) then
|
150
|
+
result = redis.multi do |r|
|
151
|
+
r.del( okey )
|
152
|
+
r.del( xkey )
|
153
|
+
end
|
154
|
+
# If anything changed then multi() fails and returns nil
|
155
|
+
if result && result.size == 2 then
|
156
|
+
log :info, "Deleted stale key from #{owner}"
|
157
|
+
return true
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end # watch
|
161
|
+
# Not stale
|
162
|
+
return false
|
163
|
+
end
|
164
|
+
|
165
|
+
# Calls block until it returns true or times out. Uses exponential backoff.
|
166
|
+
# @param block should return true if successful, false otherwise
|
167
|
+
# @returns true if successful, false otherwise
|
168
|
+
def with_timeout( timeout, &block )
|
169
|
+
expire = Time.now + timeout.to_f
|
170
|
+
sleepy = 0.125
|
171
|
+
# this looks inelegant compared to while Time.now < expire, but does not oversleep
|
172
|
+
loop do
|
173
|
+
return true if block.call
|
174
|
+
log :debug, "Timeout" and return false if Time.now + sleepy > expire
|
175
|
+
sleep(sleepy)
|
176
|
+
sleepy *= 2
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def with_watch( *args, &block )
|
181
|
+
# Note: watch() gets cleared by a multi() but it's safe to call unwatch() anyway.
|
182
|
+
redis.watch( *args )
|
183
|
+
begin
|
184
|
+
block.call
|
185
|
+
ensure
|
186
|
+
redis.unwatch
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# @returns true if the lock exists and is owned by the given owner
|
191
|
+
def is_locked?( owner, expiration, now = Time.now.to_i )
|
192
|
+
owner == oval && ! is_deleteable?( owner, expiration, now )
|
193
|
+
end
|
194
|
+
|
195
|
+
# @returns true if this is a broken or expired lock
|
196
|
+
def is_deleteable?( owner, expiration, now = Time.now.to_i )
|
197
|
+
expiration = expiration.to_i
|
198
|
+
( owner || expiration > 0 ) && ( ! owner || expiration < now )
|
199
|
+
end
|
200
|
+
|
201
|
+
def log( level, *messages )
|
202
|
+
if logger then
|
203
|
+
logger.send(level) { "[#{Time.now.strftime "%Y%m%d%H%M%S"} #{oval}] #{messages.join(' ')}" }
|
204
|
+
end
|
205
|
+
self
|
206
|
+
end
|
207
|
+
|
208
|
+
def check_keys( set, *keys )
|
209
|
+
extra = set.keys - keys
|
210
|
+
raise "Unknown Option #{extra.first}" if extra.size > 0
|
211
|
+
end
|
212
|
+
|
213
|
+
end # Lock
|
214
|
+
|
215
|
+
# Convenience methods
|
216
|
+
|
217
|
+
def lock( key, timeout = 1, options = {}, &block )
|
218
|
+
Lock.new( self, key, options ).lock( timeout, &block )
|
219
|
+
end
|
220
|
+
|
221
|
+
def unlock( key )
|
222
|
+
Lock( self, key ).unlock
|
223
|
+
end
|
224
|
+
|
225
|
+
end # Redis
|
data/redis-lock.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/redis-lock/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Mark Lanett"]
|
6
|
+
gem.email = ["mark.lanett@gmail.com"]
|
7
|
+
gem.description = %q{Pessimistic locking using Redis}
|
8
|
+
gem.summary = %q{Pessimistic locking using Redis}
|
9
|
+
gem.homepage = ""
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "mlanett-redis-lock"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Redis::Lock::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency "redis"
|
19
|
+
end
|
data/spec/helper.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
require "bundler/setup" # set up gem paths
|
4
|
+
require "ruby-debug" # because sometimes you need it
|
5
|
+
|
6
|
+
require "simplecov" # code coverage
|
7
|
+
SimpleCov.start # must be loaded before our own code
|
8
|
+
|
9
|
+
require "redis-lock" # load this gem
|
10
|
+
require "support/redis" # simple helpers for testing
|
11
|
+
|
12
|
+
RSpec.configure do |spec|
|
13
|
+
# @see https://www.relishapp.com/rspec/rspec-core/docs/helper-methods/define-helper-methods-in-a-module
|
14
|
+
spec.include RedisClient, redis: true
|
15
|
+
|
16
|
+
# nuke the Redis database around each run
|
17
|
+
# @see https://www.relishapp.com/rspec/rspec-core/docs/hooks/around-hooks
|
18
|
+
spec.around( :each, redis: true ) do |example|
|
19
|
+
with_clean_redis { example.run }
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
describe Redis::Lock, redis: true do
|
4
|
+
|
5
|
+
let(:non) { nil }
|
6
|
+
let(:her) { "Alice" }
|
7
|
+
let(:him) { "Bob" }
|
8
|
+
let(:hers) { Redis::Lock.new( redis, "alpha", owner: her ) }
|
9
|
+
let(:her_same) { Redis::Lock.new( redis, "alpha", owner: her ) }
|
10
|
+
let(:his) { Redis::Lock.new( redis, "alpha", owner: him ) }
|
11
|
+
let(:his_other) { Redis::Lock.new( redis, "beta", owner: him ) }
|
12
|
+
let(:past ) { 1 }
|
13
|
+
let(:present) { 2 }
|
14
|
+
let(:future ) { 3 }
|
15
|
+
|
16
|
+
it "can acquire and release a lock" do
|
17
|
+
hers.lock do
|
18
|
+
hers.should be_locked
|
19
|
+
end
|
20
|
+
hers.should_not be_locked
|
21
|
+
end
|
22
|
+
|
23
|
+
it "can prevent other use of a lock" do
|
24
|
+
hers.lock do
|
25
|
+
expect { his.lock.unlock }.to raise_exception
|
26
|
+
end
|
27
|
+
expect { his.lock.unlock }.to_not raise_exception
|
28
|
+
end
|
29
|
+
|
30
|
+
it "can lock two different items at the same time" do
|
31
|
+
his.lock do
|
32
|
+
expect { his_other.lock.unlock }.to_not raise_exception
|
33
|
+
his.should be_locked
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
it "does not support nesting" do
|
38
|
+
hers.lock do
|
39
|
+
expect { her_same.lock }.to raise_exception
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
it "can acquire a lock" do
|
44
|
+
hers.do_lock.should be_true
|
45
|
+
end
|
46
|
+
|
47
|
+
it "can release a lock" do
|
48
|
+
hers.lock.release_lock
|
49
|
+
end
|
50
|
+
|
51
|
+
it "can use a timeout" do
|
52
|
+
hers.with_timeout(1) { true }.should be_true
|
53
|
+
hers.with_timeout(1) { false }.should be_false
|
54
|
+
# a few attempts are OK
|
55
|
+
results = [ false, false, true ]
|
56
|
+
hers.with_timeout(1) { results.shift }.should be_true
|
57
|
+
# this is too many attemps
|
58
|
+
results = [ false, false, false, false, false, true ]
|
59
|
+
hers.with_timeout(1) { results.shift }.should be_false
|
60
|
+
end
|
61
|
+
|
62
|
+
it "does not take too long to time out" do
|
63
|
+
start = Time.now.to_f
|
64
|
+
hers.with_timeout(1) { false }
|
65
|
+
time = Time.now.to_f - start
|
66
|
+
time.should be_within(0.2).of(1.0)
|
67
|
+
end
|
68
|
+
|
69
|
+
it "can time out an expired lock" do
|
70
|
+
hers.life = 1
|
71
|
+
hers.lock
|
72
|
+
# don't unlock it, let hers time out
|
73
|
+
expect { his.lock(10).unlock }.to_not raise_exception
|
74
|
+
end
|
75
|
+
|
76
|
+
it "can extend the life of a lock" do
|
77
|
+
hers.life = 1
|
78
|
+
hers.lock
|
79
|
+
hers.extend_life(100)
|
80
|
+
expect { his.lock(10).unlock }.to raise_exception
|
81
|
+
hers.unlock
|
82
|
+
end
|
83
|
+
|
84
|
+
it "can determine if it is locked" do
|
85
|
+
hers.is_locked?( non, nil, present ).should be_false
|
86
|
+
hers.is_locked?( non, future, present ).should be_false
|
87
|
+
hers.is_locked?( non, past, present ).should be_false
|
88
|
+
hers.is_locked?( her, nil, present ).should be_false
|
89
|
+
hers.is_locked?( her, future, present ).should be_true # the only valid case
|
90
|
+
hers.is_locked?( her, past, present ).should be_false
|
91
|
+
hers.is_locked?( him, nil, present ).should be_false
|
92
|
+
hers.is_locked?( him, future, present ).should be_false
|
93
|
+
hers.is_locked?( him, past, present ).should be_false
|
94
|
+
# We leave [ present, present ] to be unspecified.
|
95
|
+
end
|
96
|
+
|
97
|
+
it "can detect broken or expired locks" do
|
98
|
+
hers.is_deleteable?( non, nil, present ).should be_false # no lock => not expired
|
99
|
+
|
100
|
+
hers.is_deleteable?( non, future, present ).should be_true # broken => expired
|
101
|
+
hers.is_deleteable?( non, past, present ).should be_true # broken => expired
|
102
|
+
hers.is_deleteable?( her, nil, present ).should be_true # broken => expired
|
103
|
+
|
104
|
+
hers.is_deleteable?( her, future, present ).should be_false # current; not expired
|
105
|
+
|
106
|
+
hers.is_deleteable?( her, past, present ).should be_true # expired
|
107
|
+
|
108
|
+
# We leave [ present, present ] to be unspecified.
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
data/spec/redis_spec.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
# These are here to be sure Redis works the way we expect.
|
4
|
+
|
5
|
+
describe Redis, redis: true do
|
6
|
+
|
7
|
+
it "can do a multi setnx" do
|
8
|
+
redis.mapped_msetnx "one" => "uno", "two" => "dos"
|
9
|
+
redis.get("one").should eq("uno")
|
10
|
+
redis.get("two").should eq("dos")
|
11
|
+
end
|
12
|
+
|
13
|
+
it "can delete multiple items" do
|
14
|
+
redis.set "one", "uno"
|
15
|
+
redis.set "two", "dos"
|
16
|
+
x = redis.multi do |multi|
|
17
|
+
multi.del "one"
|
18
|
+
multi.del "two"
|
19
|
+
end
|
20
|
+
x.should eq( [1,1] )
|
21
|
+
redis.get("one").should be_nil
|
22
|
+
redis.get("two").should be_nil
|
23
|
+
end
|
24
|
+
|
25
|
+
it "can detect multi success" do
|
26
|
+
redis.set "one", "uno"
|
27
|
+
with_watch( redis, "one" ) do
|
28
|
+
x = redis.multi do |multi|
|
29
|
+
multi.del "one"
|
30
|
+
end
|
31
|
+
x.should eq([1])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
it "can detect multi failures" do
|
36
|
+
redis.set "one", "uno"
|
37
|
+
with_watch( redis, "one" ) do
|
38
|
+
x = redis.multi do |multi|
|
39
|
+
multi.del "one"
|
40
|
+
other.set "one", "ichi"
|
41
|
+
end
|
42
|
+
x.should be_nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
require "redis"
|
4
|
+
|
5
|
+
module RedisClient
|
6
|
+
|
7
|
+
TEST_REDIS = { url: "redis://127.0.0.1:6379/1" }
|
8
|
+
|
9
|
+
def redis
|
10
|
+
@redis ||= ::Redis.connect(TEST_REDIS)
|
11
|
+
end
|
12
|
+
|
13
|
+
def other
|
14
|
+
@other ||= ::Redis.connect(TEST_REDIS)
|
15
|
+
end
|
16
|
+
|
17
|
+
def with_watch( redis, *args )
|
18
|
+
redis.watch( *args )
|
19
|
+
begin
|
20
|
+
yield
|
21
|
+
ensure
|
22
|
+
redis.unwatch
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_clean_redis(&block)
|
27
|
+
redis.client.disconnect # auto connect after fork
|
28
|
+
other.client.disconnect # auto connect after fork
|
29
|
+
redis.flushall # clean before run
|
30
|
+
yield
|
31
|
+
ensure
|
32
|
+
redis.flushall # clean up after run
|
33
|
+
redis.quit # quit (close) connection
|
34
|
+
other.quit # quit (close) connection
|
35
|
+
end
|
36
|
+
|
37
|
+
end # RedisClient
|
data/test/stress.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup" # set up gem paths
|
4
|
+
require "redis-lock" # load this gem
|
5
|
+
require "optparse"
|
6
|
+
require "ostruct"
|
7
|
+
|
8
|
+
options = OpenStruct.new({
|
9
|
+
forks: 30,
|
10
|
+
tries: 10,
|
11
|
+
sleep: 2,
|
12
|
+
keys: 5
|
13
|
+
})
|
14
|
+
|
15
|
+
TEST_REDIS = { url: "redis://127.0.0.1:6379/1" }
|
16
|
+
|
17
|
+
OptionParser.new do |opts|
|
18
|
+
opts.banner = "Usage: #{__FILE__} --forks F --tries T --sleep S"
|
19
|
+
opts.on( "-f", "--forks FORKS", "How many processes to fork" ) { |i| options.forks = i.to_i }
|
20
|
+
opts.on( "-t", "--tries TRIES", "How many attempts each process should try" ) { |i| options.tries = i.to_i }
|
21
|
+
opts.on( "-s", "--sleep SLEEP", "How long processes should sleep/work" ) { |i| options.sleep = i.to_i }
|
22
|
+
opts.on( "-k", "--keys KEYS", "How many keys a process should run through" ) { |i| options.keys = i.to_i }
|
23
|
+
opts.on( "-h", "--help", "Display this usage summary" ) { puts opts; exit }
|
24
|
+
end.parse!
|
25
|
+
|
26
|
+
class Thing
|
27
|
+
attr :id
|
28
|
+
attr :activity
|
29
|
+
def initialize( id, activity )
|
30
|
+
@id = id
|
31
|
+
@activity = activity
|
32
|
+
end
|
33
|
+
def process
|
34
|
+
Kernel.sleep( rand activity )
|
35
|
+
end
|
36
|
+
end # Thing
|
37
|
+
|
38
|
+
class Runner
|
39
|
+
|
40
|
+
attr :options
|
41
|
+
|
42
|
+
def initialize( options )
|
43
|
+
@options = options
|
44
|
+
end
|
45
|
+
|
46
|
+
def redis
|
47
|
+
@redis ||= ::Redis.connect(TEST_REDIS)
|
48
|
+
end
|
49
|
+
|
50
|
+
def test( key, time )
|
51
|
+
redis.lock( key, time, life: time*2 ) do
|
52
|
+
val1 = rand(65536)
|
53
|
+
redis.set( "#{key}:widget", val1 )
|
54
|
+
Kernel.sleep( time )
|
55
|
+
val2 = redis.get("#{key}:widget").to_i
|
56
|
+
expect( val1, val2 )
|
57
|
+
end
|
58
|
+
true
|
59
|
+
rescue => x
|
60
|
+
# STDERR.puts "Failed due to #{x.inspect}"
|
61
|
+
false
|
62
|
+
end
|
63
|
+
|
64
|
+
def run
|
65
|
+
keys = Hash[ (0...options.keys).map { |i| [ i, "key:#{i}" ] } ] # i => key:i
|
66
|
+
fails = Hash[ (0...options.keys).map { |i| [ i, 0 ] } ] # i => 0
|
67
|
+
stats = OpenStruct.new( ok: 0, fails: 0 )
|
68
|
+
while keys.size > 0 do
|
69
|
+
i = keys.keys.sample
|
70
|
+
if test( keys[i], (options.sleep) ) then
|
71
|
+
keys.delete(i)
|
72
|
+
stats.ok += 1
|
73
|
+
else
|
74
|
+
fails[i] += 1
|
75
|
+
stats.fails += 1
|
76
|
+
if fails[i] >= options.tries then
|
77
|
+
keys.delete(i)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
puts "[#{Process.pid}] Complete; Ok: #{stats.ok}, Failures: #{stats.fails}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def launch
|
85
|
+
Kernel.fork do
|
86
|
+
GC.copy_on_write_friendly = true if ( GC.copy_on_write_friendly? rescue false )
|
87
|
+
run
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def expect( val1, val2 )
|
92
|
+
if val1 != val2 then
|
93
|
+
STDERR.puts "[#{Process.pid}] Value mismatch"
|
94
|
+
Kernel.abort
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
|
100
|
+
# main
|
101
|
+
|
102
|
+
redis = ::Redis.connect(TEST_REDIS)
|
103
|
+
redis.flushall # clean before run
|
104
|
+
redis.client.disconnect # don't keep when forking
|
105
|
+
|
106
|
+
options.forks.times do
|
107
|
+
Runner.new( options ).launch
|
108
|
+
end
|
109
|
+
Process.waitall
|
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mlanett-redis-lock
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Mark Lanett
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-02-04 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: redis
|
16
|
+
requirement: &70233994826820 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70233994826820
|
25
|
+
description: Pessimistic locking using Redis
|
26
|
+
email:
|
27
|
+
- mark.lanett@gmail.com
|
28
|
+
executables: []
|
29
|
+
extensions: []
|
30
|
+
extra_rdoc_files: []
|
31
|
+
files:
|
32
|
+
- .gitignore
|
33
|
+
- .rspec
|
34
|
+
- Gemfile
|
35
|
+
- Guardfile
|
36
|
+
- LICENSE
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- lib/redis-lock.rb
|
40
|
+
- lib/redis-lock/version.rb
|
41
|
+
- redis-lock.gemspec
|
42
|
+
- spec/helper.rb
|
43
|
+
- spec/redis_lock_spec.rb
|
44
|
+
- spec/redis_spec.rb
|
45
|
+
- spec/support/redis.rb
|
46
|
+
- test/stress.rb
|
47
|
+
homepage: ''
|
48
|
+
licenses: []
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
54
|
+
none: false
|
55
|
+
requirements:
|
56
|
+
- - ! '>='
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: '0'
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
60
|
+
none: false
|
61
|
+
requirements:
|
62
|
+
- - ! '>='
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '0'
|
65
|
+
requirements: []
|
66
|
+
rubyforge_project:
|
67
|
+
rubygems_version: 1.8.15
|
68
|
+
signing_key:
|
69
|
+
specification_version: 3
|
70
|
+
summary: Pessimistic locking using Redis
|
71
|
+
test_files:
|
72
|
+
- spec/helper.rb
|
73
|
+
- spec/redis_lock_spec.rb
|
74
|
+
- spec/redis_spec.rb
|
75
|
+
- spec/support/redis.rb
|
76
|
+
- test/stress.rb
|