redis-mutex 3.0.0 → 4.0.0
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.
- 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
|