redis-mutex 3.0.0 → 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -1
- data/README.md +37 -15
- data/lib/redis-mutex.rb +1 -4
- data/lib/redis_mutex.rb +132 -0
- data/lib/redis_mutex/macro.rb +56 -0
- data/redis-mutex.gemspec +2 -2
- data/spec/redis_mutex_macro_spec.rb +12 -12
- data/spec/redis_mutex_spec.rb +60 -60
- data/spec/spec_helper.rb +3 -4
- metadata +20 -20
- data/lib/redis/mutex.rb +0 -134
- data/lib/redis/mutex/macro.rb +0 -58
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 253615b378afcd470a670355dc6924fc18ce9bad
|
4
|
+
data.tar.gz: 22fdcf12b682dd3550a6a73b2ef1b0aa96aaa5b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e4e348e87e282e279e3bb4a780f420484365ceaa101438de710f7b0fe5ed9843278d3615579c343497d23041980614fbd2772c2a85f86ec4029800e92104b888
|
7
|
+
data.tar.gz: 06897c56818022fae3b8b97a8a391f1d72e06887916fb08ed81d58f3b37341063a4c15feff782f82ed6a7072a342a66d501a7e44e4e213f6774c489a8b83602e
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -13,7 +13,7 @@ Synopsis
|
|
13
13
|
In the following example, only one thread / process / server can enter the locked block at one time.
|
14
14
|
|
15
15
|
```ruby
|
16
|
-
|
16
|
+
RedisMutex.with_lock(:your_lock_name) do
|
17
17
|
# do something exclusively
|
18
18
|
end
|
19
19
|
```
|
@@ -21,7 +21,7 @@ end
|
|
21
21
|
or
|
22
22
|
|
23
23
|
```ruby
|
24
|
-
mutex =
|
24
|
+
mutex = RedisMutex.new(:your_lock_name)
|
25
25
|
if mutex.lock
|
26
26
|
# do something exclusively
|
27
27
|
mutex.unlock
|
@@ -40,13 +40,21 @@ Or if you want to immediately receive `false` on an unsuccessful locking attempt
|
|
40
40
|
Changelog
|
41
41
|
---------
|
42
42
|
|
43
|
+
### v4.0
|
44
|
+
|
45
|
+
`redis-mutex` 4.0 has brought a few backward incompatible changes to follow the major upgrade of the underlying `redis-classy` gem.
|
46
|
+
|
47
|
+
* The base class `Redis::Mutex` is now `RedisMutex`.
|
48
|
+
* `Redis::Classy.db = Redis.new` is now `RedisClassy.redis = Redis.new`.
|
49
|
+
|
43
50
|
### v3.0
|
44
51
|
|
45
52
|
* Ruby 2.0 or later is required.
|
53
|
+
* `auto_mutex` now takes `:on` for additional key scoping.
|
46
54
|
|
47
55
|
### v2.0
|
48
56
|
|
49
|
-
* **Exception-based control flow**: Added `lock!` and `unlock!`, which raises an exception when fails to acquire a lock. Raises `
|
57
|
+
* **Exception-based control flow**: Added `lock!` and `unlock!`, which raises an exception when fails to acquire a lock. Raises `RedisMutex::LockError` and `RedisMutex::UnlockError` respectively.
|
50
58
|
* **INCOMPATIBLE CHANGE**: `#lock` no longer accepts a block. Use `#with_lock` instead, which uses `lock!` internally and returns the value of block.
|
51
59
|
* `unlock` returns boolean values for success / failure, for consistency with `lock`.
|
52
60
|
|
@@ -67,7 +75,7 @@ gem 'redis-mutex'
|
|
67
75
|
Register the Redis server: (e.g. in `config/initializers/redis_mutex.rb` for Rails)
|
68
76
|
|
69
77
|
```ruby
|
70
|
-
|
78
|
+
RedisClassy.redis = Redis.new
|
71
79
|
```
|
72
80
|
|
73
81
|
Note that Redis Mutex uses the `redis-classy` gem internally to organize keys in an isolated namespace.
|
@@ -75,7 +83,7 @@ Note that Redis Mutex uses the `redis-classy` gem internally to organize keys in
|
|
75
83
|
There are a number of methods:
|
76
84
|
|
77
85
|
```ruby
|
78
|
-
mutex =
|
86
|
+
mutex = RedisMutex.new(key, options) # Configure a mutex lock
|
79
87
|
mutex.lock # Try to acquire the lock, returns false when failed
|
80
88
|
mutex.lock! # Try to acquire the lock, raises exception when failed
|
81
89
|
mutex.unlock # Try to release the lock, returns false when failed
|
@@ -84,20 +92,20 @@ mutex.locked? # Find out if resource already locked
|
|
84
92
|
mutex.with_lock # Try to acquire the lock, execute the block, then return the value of the block.
|
85
93
|
# Raises exception when failed to acquire the lock.
|
86
94
|
|
87
|
-
|
88
|
-
|
95
|
+
RedisMutex.sweep # Remove all expired locks
|
96
|
+
RedisMutex.with_lock(key, options) # Shortcut to new + with_lock
|
89
97
|
```
|
90
98
|
|
91
99
|
The key argument can be symbol, string, or any Ruby objects that respond to `id` method, where the key is automatically set as
|
92
|
-
`TheClass:id`. For any given key, `
|
93
|
-
object with id of `123`, the actual key in Redis will be `
|
94
|
-
is the feature of `
|
100
|
+
`TheClass:id`. For any given key, `RedisMutex:` prefix will be automatically prepended. For instance, if you pass a `Room`
|
101
|
+
object with id of `123`, the actual key in Redis will be `RedisMutex:Room:123`. The automatic prefixing and instance binding
|
102
|
+
is the feature of `RedisClassy` - for more internal details, refer to [Redis Classy](https://github.com/kenn/redis-classy).
|
95
103
|
|
96
104
|
The initialize method takes several options.
|
97
105
|
|
98
106
|
```ruby
|
99
107
|
:block => 1 # Specify in seconds how long you want to wait for the lock to be released.
|
100
|
-
#
|
108
|
+
# Specify 0 if you need non-blocking sematics and return false immediately. (default: 1)
|
101
109
|
:sleep => 0.1 # Specify in seconds how long the polling interval should be when :block is given.
|
102
110
|
# It is NOT recommended to go below 0.01. (default: 0.1)
|
103
111
|
:expire => 10 # Specify in seconds when the lock should be considered stale when something went wrong
|
@@ -114,11 +122,11 @@ class RoomController < ApplicationController
|
|
114
122
|
before_filter { @room = Room.find(params[:id]) }
|
115
123
|
|
116
124
|
def enter
|
117
|
-
|
125
|
+
RedisMutex.with_lock(@room) do # key => "Room:123"
|
118
126
|
# do something exclusively
|
119
127
|
end
|
120
128
|
render text: 'success!'
|
121
|
-
rescue
|
129
|
+
rescue RedisMutex::LockError
|
122
130
|
render text: 'failed to acquire lock!'
|
123
131
|
end
|
124
132
|
end
|
@@ -129,7 +137,7 @@ put the `unlock` method in the `ensure` clause.
|
|
129
137
|
|
130
138
|
```ruby
|
131
139
|
def enter
|
132
|
-
mutex =
|
140
|
+
mutex = RedisMutex.new('non-blocking', block: 0, expire: 10.minutes)
|
133
141
|
if mutex.lock
|
134
142
|
begin
|
135
143
|
# do something exclusively
|
@@ -153,7 +161,7 @@ If you give a proc object to the `after_failure` option, it will get called afte
|
|
153
161
|
|
154
162
|
```ruby
|
155
163
|
class JobController < ApplicationController
|
156
|
-
include
|
164
|
+
include RedisMutex::Macro
|
157
165
|
auto_mutex :run, block: 0, after_failure: lambda { render text: 'failed to acquire lock!' }
|
158
166
|
|
159
167
|
def run
|
@@ -162,3 +170,17 @@ class JobController < ApplicationController
|
|
162
170
|
end
|
163
171
|
end
|
164
172
|
```
|
173
|
+
|
174
|
+
Also you can specify method arguments with the `on` option. The following creates a mutex key named `ItunesVerifier#perform:123456`, so that the same method can run in parallel as long as the `transaction_id` is different.
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
class ItunesVerifier
|
178
|
+
include Sidekiq::Worker
|
179
|
+
include RedisMutex::Macro
|
180
|
+
auto_mutex :perform, on: [:transaction_id]
|
181
|
+
|
182
|
+
def perform(transaction_id)
|
183
|
+
...
|
184
|
+
end
|
185
|
+
end
|
186
|
+
```
|
data/lib/redis-mutex.rb
CHANGED
data/lib/redis_mutex.rb
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
class RedisMutex < RedisClassy
|
2
|
+
#
|
3
|
+
# Options
|
4
|
+
#
|
5
|
+
# :block => Specify in seconds how long you want to wait for the lock to be released. Speficy 0
|
6
|
+
# if you need non-blocking sematics and return false immediately. (default: 1)
|
7
|
+
# :sleep => Specify in seconds how long the polling interval should be when :block is given.
|
8
|
+
# It is recommended that you do NOT go below 0.01. (default: 0.1)
|
9
|
+
# :expire => Specify in seconds when the lock should forcibly be removed when something went wrong
|
10
|
+
# with the one who held the lock. (default: 10)
|
11
|
+
#
|
12
|
+
autoload :Macro, 'redis_mutex/macro'
|
13
|
+
|
14
|
+
DEFAULT_EXPIRE = 10
|
15
|
+
LockError = Class.new(StandardError)
|
16
|
+
UnlockError = Class.new(StandardError)
|
17
|
+
AssertionError = Class.new(StandardError)
|
18
|
+
|
19
|
+
def initialize(object, options={})
|
20
|
+
super(object.is_a?(String) || object.is_a?(Symbol) ? object : "#{object.class.name}:#{object.id}")
|
21
|
+
@block = options[:block] || 1
|
22
|
+
@sleep = options[:sleep] || 0.1
|
23
|
+
@expire = options[:expire] || DEFAULT_EXPIRE
|
24
|
+
end
|
25
|
+
|
26
|
+
def lock
|
27
|
+
self.class.raise_assertion_error if block_given?
|
28
|
+
@locking = false
|
29
|
+
|
30
|
+
if @block > 0
|
31
|
+
# Blocking mode
|
32
|
+
start_at = Time.now
|
33
|
+
while Time.now - start_at < @block
|
34
|
+
@locking = true and break if try_lock
|
35
|
+
sleep @sleep
|
36
|
+
end
|
37
|
+
else
|
38
|
+
# Non-blocking mode
|
39
|
+
@locking = try_lock
|
40
|
+
end
|
41
|
+
@locking
|
42
|
+
end
|
43
|
+
|
44
|
+
def try_lock
|
45
|
+
now = Time.now.to_f
|
46
|
+
@expires_at = now + @expire # Extend in each blocking loop
|
47
|
+
|
48
|
+
loop do
|
49
|
+
return true if setnx(@expires_at) # Success, the lock has been acquired
|
50
|
+
end until old_value = get # Repeat if unlocked before get
|
51
|
+
|
52
|
+
return false if old_value.to_f > now # Check if the lock is still effective
|
53
|
+
|
54
|
+
# The lock has expired but wasn't released... BAD!
|
55
|
+
return true if getset(@expires_at).to_f <= now # Success, we acquired the previously expired lock
|
56
|
+
return false # Dammit, it seems that someone else was even faster than us to remove the expired lock!
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns true if resource is locked. Note that nil.to_f returns 0.0
|
60
|
+
def locked?
|
61
|
+
get.to_f > Time.now.to_f
|
62
|
+
end
|
63
|
+
|
64
|
+
def unlock(force = false)
|
65
|
+
# Since it's possible that the operations in the critical section took a long time,
|
66
|
+
# we can't just simply release the lock. The unlock method checks if @expires_at
|
67
|
+
# remains the same, and do not release when the lock timestamp was overwritten.
|
68
|
+
|
69
|
+
if get == @expires_at.to_s or force
|
70
|
+
# Redis#del with a single key returns '1' or nil
|
71
|
+
!!del
|
72
|
+
else
|
73
|
+
false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def with_lock
|
78
|
+
if lock!
|
79
|
+
begin
|
80
|
+
@result = yield
|
81
|
+
ensure
|
82
|
+
unlock
|
83
|
+
end
|
84
|
+
end
|
85
|
+
@result
|
86
|
+
end
|
87
|
+
|
88
|
+
def lock!
|
89
|
+
lock or raise LockError, "failed to acquire lock #{key.inspect}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def unlock!(force = false)
|
93
|
+
unlock(force) or raise UnlockError, "failed to release lock #{key.inspect}"
|
94
|
+
end
|
95
|
+
|
96
|
+
class << self
|
97
|
+
def sweep
|
98
|
+
return 0 if (all_keys = keys).empty?
|
99
|
+
|
100
|
+
now = Time.now.to_f
|
101
|
+
values = mget(*all_keys)
|
102
|
+
|
103
|
+
expired_keys = all_keys.zip(values).select do |key, time|
|
104
|
+
time && time.to_f <= now
|
105
|
+
end
|
106
|
+
|
107
|
+
expired_keys.each do |key, _|
|
108
|
+
# Make extra sure that anyone haven't extended the lock
|
109
|
+
del(key) if getset(key, now + DEFAULT_EXPIRE).to_f <= now
|
110
|
+
end
|
111
|
+
|
112
|
+
expired_keys.size
|
113
|
+
end
|
114
|
+
|
115
|
+
def lock(object, options = {})
|
116
|
+
raise_assertion_error if block_given?
|
117
|
+
new(object, options).lock
|
118
|
+
end
|
119
|
+
|
120
|
+
def lock!(object, options = {})
|
121
|
+
new(object, options).lock!
|
122
|
+
end
|
123
|
+
|
124
|
+
def with_lock(object, options = {}, &block)
|
125
|
+
new(object, options).with_lock(&block)
|
126
|
+
end
|
127
|
+
|
128
|
+
def raise_assertion_error
|
129
|
+
raise AssertionError, 'block syntax has been removed from #lock, use #with_lock instead'
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
class RedisMutex
|
2
|
+
module Macro
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
base.class_eval do
|
6
|
+
class << self
|
7
|
+
attr_accessor :auto_mutex_methods
|
8
|
+
end
|
9
|
+
@auto_mutex_methods = {}
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
def auto_mutex(target, options={})
|
15
|
+
self.auto_mutex_methods[target] = options
|
16
|
+
end
|
17
|
+
|
18
|
+
def method_added(target)
|
19
|
+
return if target.to_s =~ /^auto_mutex_methods/
|
20
|
+
return unless self.auto_mutex_methods[target]
|
21
|
+
without_method = "#{target}_without_auto_mutex"
|
22
|
+
with_method = "#{target}_with_auto_mutex"
|
23
|
+
after_method = "#{target}_after_failure"
|
24
|
+
return if method_defined?(without_method)
|
25
|
+
options = self.auto_mutex_methods[target]
|
26
|
+
|
27
|
+
if options[:after_failure].is_a?(Proc)
|
28
|
+
define_method(after_method, &options[:after_failure])
|
29
|
+
end
|
30
|
+
target_argument_names = instance_method(target.to_sym).parameters.map(&:last)
|
31
|
+
on_arguments = Array(options[:on])
|
32
|
+
mutex_arguments = on_arguments & target_argument_names
|
33
|
+
unknown_arguments = on_arguments - target_argument_names
|
34
|
+
if unknown_arguments.any?
|
35
|
+
raise ArgumentError, "You are trying to lock on unknown arguments: #{unknown_arguments.join(', ')}"
|
36
|
+
end
|
37
|
+
|
38
|
+
define_method(with_method) do |*args|
|
39
|
+
named_arguments = Hash[target_argument_names.zip(args)]
|
40
|
+
arguments = mutex_arguments.map { |name| named_arguments.fetch(name) }
|
41
|
+
key = self.class.name << '#' << target.to_s << ":" << arguments.join(':')
|
42
|
+
begin
|
43
|
+
RedisMutex.with_lock(key, options) do
|
44
|
+
send(without_method, *args)
|
45
|
+
end
|
46
|
+
rescue RedisMutex::LockError
|
47
|
+
send(after_method, *args) if respond_to?(after_method)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
alias_method without_method, target
|
52
|
+
alias_method target, with_method
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/redis-mutex.gemspec
CHANGED
@@ -12,9 +12,9 @@ Gem::Specification.new do |gem|
|
|
12
12
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
13
13
|
gem.name = "redis-mutex"
|
14
14
|
gem.require_paths = ["lib"]
|
15
|
-
gem.version = '
|
15
|
+
gem.version = '4.0.0' # retrieve this value by: Gem.loaded_specs['redis-mutex'].version.to_s
|
16
16
|
|
17
|
-
gem.add_runtime_dependency "redis-classy", "~>
|
17
|
+
gem.add_runtime_dependency "redis-classy", "~> 2.0"
|
18
18
|
gem.add_development_dependency "rspec"
|
19
19
|
gem.add_development_dependency "bundler"
|
20
20
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
class C
|
4
|
-
include
|
4
|
+
include RedisMutex::Macro
|
5
5
|
auto_mutex :run_singularly, :block => 0, :after_failure => lambda {|id| return "failure: #{id}" }
|
6
6
|
|
7
7
|
def run_singularly(id)
|
@@ -22,7 +22,7 @@ class C
|
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
25
|
-
describe
|
25
|
+
describe RedisMutex::Macro do
|
26
26
|
|
27
27
|
def race(a, b)
|
28
28
|
t1 = Thread.new(&a)
|
@@ -38,32 +38,32 @@ describe Redis::Mutex::Macro do
|
|
38
38
|
|
39
39
|
it 'adds auto_mutex' do
|
40
40
|
race(
|
41
|
-
proc { C.new.run_singularly(1).
|
42
|
-
proc { C.new.run_singularly(2).
|
41
|
+
proc { expect(C.new.run_singularly(1)).to eq("success: 1") },
|
42
|
+
proc { expect(C.new.run_singularly(2)).to eq("failure: 2") })
|
43
43
|
end
|
44
44
|
|
45
45
|
it 'adds auto_mutex on different args' do
|
46
46
|
race(
|
47
|
-
proc { C.new.run_singularly_on_args(1, :'2', object_arg).
|
48
|
-
proc { C.new.run_singularly_on_args(2, :'2', object_arg).
|
47
|
+
proc { expect(C.new.run_singularly_on_args(1, :'2', object_arg)).to eq("success: 1") },
|
48
|
+
proc { expect(C.new.run_singularly_on_args(2, :'2', object_arg)).to eq("success: 2") })
|
49
49
|
end
|
50
50
|
|
51
51
|
it 'adds auto_mutex on same args' do
|
52
52
|
race(
|
53
|
-
proc { C.new.run_singularly_on_args(1, :'2', object_arg).
|
54
|
-
proc { C.new.run_singularly_on_args(1, :'2', object_arg).
|
53
|
+
proc { expect(C.new.run_singularly_on_args(1, :'2', object_arg)).to eq("success: 1") },
|
54
|
+
proc { expect(C.new.run_singularly_on_args(1, :'2', object_arg)).to eq("failure: 1") })
|
55
55
|
end
|
56
56
|
|
57
57
|
it 'adds auto_mutex on different keyword args' do
|
58
58
|
race(
|
59
|
-
proc { C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg).
|
60
|
-
proc { C.new.run_singularly_on_keyword_args(id: 2, foo: :'2', bar: object_arg).
|
59
|
+
proc { expect(C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg)).to eq("success: 1") },
|
60
|
+
proc { expect(C.new.run_singularly_on_keyword_args(id: 2, foo: :'2', bar: object_arg)).to eq("success: 2") })
|
61
61
|
end
|
62
62
|
|
63
63
|
it 'adds auto_mutex on same keyword args' do
|
64
64
|
race(
|
65
|
-
proc { C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg).
|
66
|
-
proc { C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg).
|
65
|
+
proc { expect(C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg)).to eq("success: 1") },
|
66
|
+
proc { expect(C.new.run_singularly_on_keyword_args(id: 1, foo: :'2', bar: object_arg)).to eq("failure: 1") })
|
67
67
|
end
|
68
68
|
|
69
69
|
it 'raise exception if there is no such argument' do
|
data/spec/redis_mutex_spec.rb
CHANGED
@@ -2,152 +2,152 @@ require 'spec_helper'
|
|
2
2
|
|
3
3
|
SHORT_MUTEX_OPTIONS = { :block => 0.1, :sleep => 0.02 }
|
4
4
|
|
5
|
-
describe
|
5
|
+
describe RedisMutex do
|
6
6
|
before do
|
7
|
-
|
7
|
+
RedisClassy.flushdb
|
8
8
|
end
|
9
9
|
|
10
10
|
after :all do
|
11
|
-
|
12
|
-
|
11
|
+
RedisClassy.flushdb
|
12
|
+
RedisClassy.quit
|
13
13
|
end
|
14
14
|
|
15
15
|
it 'locks the universe' do
|
16
|
-
mutex1 =
|
17
|
-
mutex1.lock.
|
16
|
+
mutex1 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
17
|
+
expect(mutex1.lock).to be_truthy
|
18
18
|
|
19
|
-
mutex2 =
|
20
|
-
mutex2.lock.
|
19
|
+
mutex2 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
20
|
+
expect(mutex2.lock).to be_falsey
|
21
21
|
end
|
22
22
|
|
23
23
|
it 'fails to lock when the lock is taken' do
|
24
|
-
mutex1 =
|
24
|
+
mutex1 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
25
25
|
|
26
|
-
mutex2 =
|
27
|
-
mutex2.lock.
|
26
|
+
mutex2 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
27
|
+
expect(mutex2.lock).to be_truthy # mutex2 beats us to it
|
28
28
|
|
29
|
-
mutex1.lock.
|
29
|
+
expect(mutex1.lock).to be_falsey # fail
|
30
30
|
end
|
31
31
|
|
32
32
|
it 'unlocks only once' do
|
33
|
-
mutex =
|
34
|
-
mutex.lock.
|
33
|
+
mutex = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
34
|
+
expect(mutex.lock).to be_truthy
|
35
35
|
|
36
|
-
mutex.unlock.
|
37
|
-
mutex.unlock.
|
36
|
+
expect(mutex.unlock).to be_truthy # successfully released the lock
|
37
|
+
expect(mutex.unlock).to be_falsey # the lock no longer exists
|
38
38
|
end
|
39
39
|
|
40
40
|
it 'prevents accidental unlock from outside' do
|
41
|
-
mutex1 =
|
42
|
-
mutex1.lock.
|
41
|
+
mutex1 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
42
|
+
expect(mutex1.lock).to be_truthy
|
43
43
|
|
44
|
-
mutex2 =
|
45
|
-
mutex2.unlock.
|
44
|
+
mutex2 = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
45
|
+
expect(mutex2.unlock).to be_falsey
|
46
46
|
end
|
47
47
|
|
48
48
|
it 'sets expiration' do
|
49
49
|
start = Time.now
|
50
50
|
expires_in = 10
|
51
|
-
mutex =
|
51
|
+
mutex = RedisMutex.new(:test_lock, :expire => expires_in)
|
52
52
|
mutex.with_lock do
|
53
|
-
mutex.get.to_f.
|
53
|
+
expect(mutex.get.to_f).to be_within(1.0).of((start + expires_in).to_f)
|
54
54
|
end
|
55
|
-
mutex.get.
|
55
|
+
expect(mutex.get).to be_nil # key should have been cleaned up
|
56
56
|
end
|
57
57
|
|
58
58
|
it 'overwrites a lock when existing lock is expired' do
|
59
59
|
# stale lock from the far past
|
60
|
-
|
60
|
+
RedisMutex.on(:test_lock).set(Time.now - 60)
|
61
61
|
|
62
|
-
mutex =
|
63
|
-
mutex.lock.
|
62
|
+
mutex = RedisMutex.new(:test_lock)
|
63
|
+
expect(mutex.lock).to be_truthy
|
64
64
|
end
|
65
65
|
|
66
66
|
it 'fails to unlock the key if it took too long past expiration' do
|
67
|
-
mutex =
|
68
|
-
mutex.lock.
|
67
|
+
mutex = RedisMutex.new(:test_lock, :expire => 0.1, :block => 0)
|
68
|
+
expect(mutex.lock).to be_truthy
|
69
69
|
sleep 0.2 # lock expired
|
70
70
|
|
71
71
|
# someone overwrites the expired lock
|
72
|
-
mutex2 =
|
73
|
-
mutex2.lock.
|
72
|
+
mutex2 = RedisMutex.new(:test_lock, :expire => 10, :block => 0)
|
73
|
+
expect(mutex2.lock).to be_truthy
|
74
74
|
|
75
75
|
mutex.unlock
|
76
|
-
mutex.get.
|
76
|
+
expect(mutex.get).not_to be_nil # lock should still be there
|
77
77
|
end
|
78
78
|
|
79
79
|
it 'ensures unlocking when something goes wrong in the block' do
|
80
|
-
mutex =
|
80
|
+
mutex = RedisMutex.new(:test_lock)
|
81
81
|
begin
|
82
82
|
mutex.with_lock do
|
83
83
|
raise "Something went wrong!"
|
84
84
|
end
|
85
85
|
rescue RuntimeError
|
86
|
-
mutex.get.
|
86
|
+
expect(mutex.get).to be_nil
|
87
87
|
end
|
88
88
|
end
|
89
89
|
|
90
90
|
it 'resets locking state on reuse' do
|
91
|
-
mutex =
|
92
|
-
mutex.lock.
|
93
|
-
mutex.lock.
|
91
|
+
mutex = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
92
|
+
expect(mutex.lock).to be_truthy
|
93
|
+
expect(mutex.lock).to be_falsey
|
94
94
|
end
|
95
95
|
|
96
96
|
it 'tells about lock\'s state' do
|
97
|
-
mutex =
|
97
|
+
mutex = RedisMutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
|
98
98
|
mutex.lock
|
99
99
|
|
100
|
-
mutex.
|
100
|
+
expect(mutex).to be_locked
|
101
101
|
|
102
102
|
mutex.unlock
|
103
|
-
mutex.
|
103
|
+
expect(mutex).not_to be_locked
|
104
104
|
end
|
105
105
|
|
106
106
|
it 'tells that resource is not locked when lock is expired' do
|
107
|
-
mutex =
|
107
|
+
mutex = RedisMutex.new(:test_lock, :expire => 0.1)
|
108
108
|
mutex.lock
|
109
109
|
|
110
110
|
sleep 0.2 # lock expired now
|
111
111
|
|
112
|
-
mutex.
|
112
|
+
expect(mutex).not_to be_locked
|
113
113
|
end
|
114
114
|
|
115
115
|
it 'returns value of block' do
|
116
|
-
|
116
|
+
expect(RedisMutex.with_lock(:test_lock) { :test_result }).to eq(:test_result)
|
117
117
|
end
|
118
118
|
|
119
119
|
it 'requires block for #with_lock' do
|
120
|
-
expect {
|
120
|
+
expect { RedisMutex.with_lock(:test_lock) }.to raise_error(LocalJumpError) #=> no block given (yield)
|
121
121
|
end
|
122
122
|
|
123
123
|
it 'raises LockError if lock not obtained' do
|
124
|
-
expect {
|
125
|
-
expect {
|
124
|
+
expect { RedisMutex.lock!(:test_lock, SHORT_MUTEX_OPTIONS) }.to_not raise_error
|
125
|
+
expect { RedisMutex.lock!(:test_lock, SHORT_MUTEX_OPTIONS) }.to raise_error(RedisMutex::LockError)
|
126
126
|
end
|
127
127
|
|
128
128
|
it 'raises UnlockError if lock not obtained' do
|
129
|
-
mutex =
|
130
|
-
mutex.lock.
|
131
|
-
mutex.unlock.
|
132
|
-
expect { mutex.unlock! }.to raise_error(
|
129
|
+
mutex = RedisMutex.new(:test_lock)
|
130
|
+
expect(mutex.lock).to be_truthy
|
131
|
+
expect(mutex.unlock).to be_truthy
|
132
|
+
expect { mutex.unlock! }.to raise_error(RedisMutex::UnlockError)
|
133
133
|
end
|
134
134
|
|
135
135
|
it 'raises AssertionError when block is given to #lock' do
|
136
136
|
# instance method
|
137
|
-
mutex =
|
138
|
-
expect { mutex.lock {} }.to raise_error(
|
137
|
+
mutex = RedisMutex.new(:test_lock)
|
138
|
+
expect { mutex.lock {} }.to raise_error(RedisMutex::AssertionError)
|
139
139
|
|
140
140
|
# class method
|
141
|
-
expect {
|
141
|
+
expect { RedisMutex.lock(:test_lock) {} }.to raise_error(RedisMutex::AssertionError)
|
142
142
|
end
|
143
143
|
|
144
144
|
it 'sweeps expired locks' do
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
145
|
+
RedisMutex.on(:past).set(Time.now.to_f - 60)
|
146
|
+
RedisMutex.on(:present).set(Time.now.to_f)
|
147
|
+
RedisMutex.on(:future).set(Time.now.to_f + 60)
|
148
|
+
expect(RedisMutex.keys.size).to eq(3)
|
149
|
+
expect(RedisMutex.sweep).to eq(2)
|
150
|
+
expect(RedisMutex.keys.size).to eq(1)
|
151
151
|
end
|
152
152
|
|
153
153
|
describe 'stress test' do
|
@@ -155,8 +155,8 @@ describe Redis::Mutex do
|
|
155
155
|
|
156
156
|
def run(id)
|
157
157
|
print "invoked worker #{id}...\n"
|
158
|
-
|
159
|
-
mutex =
|
158
|
+
RedisClassy.redis.client.reconnect
|
159
|
+
mutex = RedisMutex.new(:test_lock, :expire => 1, :block => 10, :sleep => 0.01)
|
160
160
|
result = 0
|
161
161
|
LOOP_NUM.times do |i|
|
162
162
|
mutex.with_lock do
|
data/spec/spec_helper.rb
CHANGED
@@ -6,9 +6,8 @@ require 'redis-mutex'
|
|
6
6
|
|
7
7
|
RSpec.configure do |config|
|
8
8
|
# Use database 15 for testing so we don't accidentally step on you real data.
|
9
|
-
|
10
|
-
unless
|
11
|
-
|
12
|
-
exit!
|
9
|
+
RedisClassy.redis = Redis.new(db: 15)
|
10
|
+
unless RedisClassy.keys.empty?
|
11
|
+
abort '[ERROR]: Redis database 15 not empty! If you are sure, run "rake flushdb" beforehand.'
|
13
12
|
end
|
14
13
|
end
|
metadata
CHANGED
@@ -1,69 +1,69 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: redis-mutex
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 4.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kenn Ejima
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-11-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis-classy
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - ~>
|
17
|
+
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '2.0'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- - ~>
|
24
|
+
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '2.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: rspec
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- -
|
31
|
+
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
33
|
version: '0'
|
34
34
|
type: :development
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- -
|
38
|
+
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: bundler
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
|
-
- -
|
45
|
+
- - ">="
|
46
46
|
- !ruby/object:Gem::Version
|
47
47
|
version: '0'
|
48
48
|
type: :development
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
|
-
- -
|
52
|
+
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
56
|
name: rake
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
|
-
- -
|
59
|
+
- - ">="
|
60
60
|
- !ruby/object:Gem::Version
|
61
61
|
version: '0'
|
62
62
|
type: :development
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
|
-
- -
|
66
|
+
- - ">="
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '0'
|
69
69
|
description: Distrubuted mutex using Redis
|
@@ -73,16 +73,16 @@ executables: []
|
|
73
73
|
extensions: []
|
74
74
|
extra_rdoc_files: []
|
75
75
|
files:
|
76
|
-
- .gitignore
|
77
|
-
- .rspec
|
78
|
-
- .travis.yml
|
76
|
+
- ".gitignore"
|
77
|
+
- ".rspec"
|
78
|
+
- ".travis.yml"
|
79
79
|
- Gemfile
|
80
80
|
- LICENSE
|
81
81
|
- README.md
|
82
82
|
- Rakefile
|
83
83
|
- lib/redis-mutex.rb
|
84
|
-
- lib/
|
85
|
-
- lib/
|
84
|
+
- lib/redis_mutex.rb
|
85
|
+
- lib/redis_mutex/macro.rb
|
86
86
|
- redis-mutex.gemspec
|
87
87
|
- spec/redis_mutex_macro_spec.rb
|
88
88
|
- spec/redis_mutex_spec.rb
|
@@ -96,17 +96,17 @@ require_paths:
|
|
96
96
|
- lib
|
97
97
|
required_ruby_version: !ruby/object:Gem::Requirement
|
98
98
|
requirements:
|
99
|
-
- -
|
99
|
+
- - ">="
|
100
100
|
- !ruby/object:Gem::Version
|
101
101
|
version: '0'
|
102
102
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
103
|
requirements:
|
104
|
-
- -
|
104
|
+
- - ">="
|
105
105
|
- !ruby/object:Gem::Version
|
106
106
|
version: '0'
|
107
107
|
requirements: []
|
108
108
|
rubyforge_project:
|
109
|
-
rubygems_version: 2.
|
109
|
+
rubygems_version: 2.2.2
|
110
110
|
signing_key:
|
111
111
|
specification_version: 4
|
112
112
|
summary: Distrubuted mutex using Redis
|
data/lib/redis/mutex.rb
DELETED
@@ -1,134 +0,0 @@
|
|
1
|
-
class Redis
|
2
|
-
#
|
3
|
-
# Options
|
4
|
-
#
|
5
|
-
# :block => Specify in seconds how long you want to wait for the lock to be released. Speficy 0
|
6
|
-
# if you need non-blocking sematics and return false immediately. (default: 1)
|
7
|
-
# :sleep => Specify in seconds how long the polling interval should be when :block is given.
|
8
|
-
# It is recommended that you do NOT go below 0.01. (default: 0.1)
|
9
|
-
# :expire => Specify in seconds when the lock should forcibly be removed when something went wrong
|
10
|
-
# with the one who held the lock. (default: 10)
|
11
|
-
#
|
12
|
-
class Mutex < Redis::Classy
|
13
|
-
autoload :Macro, 'redis/mutex/macro'
|
14
|
-
|
15
|
-
DEFAULT_EXPIRE = 10
|
16
|
-
LockError = Class.new(StandardError)
|
17
|
-
UnlockError = Class.new(StandardError)
|
18
|
-
AssertionError = Class.new(StandardError)
|
19
|
-
|
20
|
-
def initialize(object, options={})
|
21
|
-
super(object.is_a?(String) || object.is_a?(Symbol) ? object : "#{object.class.name}:#{object.id}")
|
22
|
-
@block = options[:block] || 1
|
23
|
-
@sleep = options[:sleep] || 0.1
|
24
|
-
@expire = options[:expire] || DEFAULT_EXPIRE
|
25
|
-
end
|
26
|
-
|
27
|
-
def lock
|
28
|
-
self.class.raise_assertion_error if block_given?
|
29
|
-
@locking = false
|
30
|
-
|
31
|
-
if @block > 0
|
32
|
-
# Blocking mode
|
33
|
-
start_at = Time.now
|
34
|
-
while Time.now - start_at < @block
|
35
|
-
@locking = true and break if try_lock
|
36
|
-
sleep @sleep
|
37
|
-
end
|
38
|
-
else
|
39
|
-
# Non-blocking mode
|
40
|
-
@locking = try_lock
|
41
|
-
end
|
42
|
-
@locking
|
43
|
-
end
|
44
|
-
|
45
|
-
def try_lock
|
46
|
-
now = Time.now.to_f
|
47
|
-
@expires_at = now + @expire # Extend in each blocking loop
|
48
|
-
|
49
|
-
loop do
|
50
|
-
return true if setnx(@expires_at) # Success, the lock has been acquired
|
51
|
-
end until old_value = get # Repeat if unlocked before get
|
52
|
-
|
53
|
-
return false if old_value.to_f > now # Check if the lock is still effective
|
54
|
-
|
55
|
-
# The lock has expired but wasn't released... BAD!
|
56
|
-
return true if getset(@expires_at).to_f <= now # Success, we acquired the previously expired lock
|
57
|
-
return false # Dammit, it seems that someone else was even faster than us to remove the expired lock!
|
58
|
-
end
|
59
|
-
|
60
|
-
# Returns true if resource is locked. Note that nil.to_f returns 0.0
|
61
|
-
def locked?
|
62
|
-
get.to_f > Time.now.to_f
|
63
|
-
end
|
64
|
-
|
65
|
-
def unlock(force = false)
|
66
|
-
# Since it's possible that the operations in the critical section took a long time,
|
67
|
-
# we can't just simply release the lock. The unlock method checks if @expires_at
|
68
|
-
# remains the same, and do not release when the lock timestamp was overwritten.
|
69
|
-
|
70
|
-
if get == @expires_at.to_s or force
|
71
|
-
# Redis#del with a single key returns '1' or nil
|
72
|
-
!!del
|
73
|
-
else
|
74
|
-
false
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def with_lock
|
79
|
-
if lock!
|
80
|
-
begin
|
81
|
-
@result = yield
|
82
|
-
ensure
|
83
|
-
unlock
|
84
|
-
end
|
85
|
-
end
|
86
|
-
@result
|
87
|
-
end
|
88
|
-
|
89
|
-
def lock!
|
90
|
-
lock or raise LockError, "failed to acquire lock #{key.inspect}"
|
91
|
-
end
|
92
|
-
|
93
|
-
def unlock!(force = false)
|
94
|
-
unlock(force) or raise UnlockError, "failed to release lock #{key.inspect}"
|
95
|
-
end
|
96
|
-
|
97
|
-
class << self
|
98
|
-
def sweep
|
99
|
-
return 0 if (all_keys = keys).empty?
|
100
|
-
|
101
|
-
now = Time.now.to_f
|
102
|
-
values = mget(*all_keys)
|
103
|
-
|
104
|
-
expired_keys = all_keys.zip(values).select do |key, time|
|
105
|
-
time && time.to_f <= now
|
106
|
-
end
|
107
|
-
|
108
|
-
expired_keys.each do |key, _|
|
109
|
-
# Make extra sure that anyone haven't extended the lock
|
110
|
-
del(key) if getset(key, now + DEFAULT_EXPIRE).to_f <= now
|
111
|
-
end
|
112
|
-
|
113
|
-
expired_keys.size
|
114
|
-
end
|
115
|
-
|
116
|
-
def lock(object, options = {})
|
117
|
-
raise_assertion_error if block_given?
|
118
|
-
new(object, options).lock
|
119
|
-
end
|
120
|
-
|
121
|
-
def lock!(object, options = {})
|
122
|
-
new(object, options).lock!
|
123
|
-
end
|
124
|
-
|
125
|
-
def with_lock(object, options = {}, &block)
|
126
|
-
new(object, options).with_lock(&block)
|
127
|
-
end
|
128
|
-
|
129
|
-
def raise_assertion_error
|
130
|
-
raise AssertionError, 'block syntax has been removed from #lock, use #with_lock instead'
|
131
|
-
end
|
132
|
-
end
|
133
|
-
end
|
134
|
-
end
|
data/lib/redis/mutex/macro.rb
DELETED
@@ -1,58 +0,0 @@
|
|
1
|
-
class Redis
|
2
|
-
class Mutex
|
3
|
-
module Macro
|
4
|
-
def self.included(base)
|
5
|
-
base.extend ClassMethods
|
6
|
-
base.class_eval do
|
7
|
-
class << self
|
8
|
-
attr_accessor :auto_mutex_methods
|
9
|
-
end
|
10
|
-
@auto_mutex_methods = {}
|
11
|
-
end
|
12
|
-
end
|
13
|
-
|
14
|
-
module ClassMethods
|
15
|
-
def auto_mutex(target, options={})
|
16
|
-
self.auto_mutex_methods[target] = options
|
17
|
-
end
|
18
|
-
|
19
|
-
def method_added(target)
|
20
|
-
return if target.to_s =~ /^auto_mutex_methods/
|
21
|
-
return unless self.auto_mutex_methods[target]
|
22
|
-
without_method = "#{target}_without_auto_mutex"
|
23
|
-
with_method = "#{target}_with_auto_mutex"
|
24
|
-
after_method = "#{target}_after_failure"
|
25
|
-
return if method_defined?(without_method)
|
26
|
-
options = self.auto_mutex_methods[target]
|
27
|
-
|
28
|
-
if options[:after_failure].is_a?(Proc)
|
29
|
-
define_method(after_method, &options[:after_failure])
|
30
|
-
end
|
31
|
-
target_argument_names = instance_method(target.to_sym).parameters.map(&:last)
|
32
|
-
on_arguments = Array(options[:on])
|
33
|
-
mutex_arguments = on_arguments & target_argument_names
|
34
|
-
unknown_arguments = on_arguments - target_argument_names
|
35
|
-
if unknown_arguments.any?
|
36
|
-
raise ArgumentError, "You are trying to lock on unknown arguments: #{unknown_arguments.join(', ')}"
|
37
|
-
end
|
38
|
-
|
39
|
-
define_method(with_method) do |*args|
|
40
|
-
named_arguments = Hash[target_argument_names.zip(args)]
|
41
|
-
arguments = mutex_arguments.map { |name| named_arguments.fetch(name) }
|
42
|
-
key = self.class.name << '#' << target.to_s << ":" << arguments.join(':')
|
43
|
-
begin
|
44
|
-
Redis::Mutex.with_lock(key, options) do
|
45
|
-
send(without_method, *args)
|
46
|
-
end
|
47
|
-
rescue Redis::Mutex::LockError
|
48
|
-
send(after_method, *args) if respond_to?(after_method)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
alias_method without_method, target
|
53
|
-
alias_method target, with_method
|
54
|
-
end
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|