mlanett-redis-lock 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|