redis-mutex 1.3.5 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
- Redis::Mutex.lock(:your_lock_name)
16
+ Redis::Mutex.with_lock(:your_lock_name) do
17
17
  # do something exclusively
18
18
  end
19
19
  ```
@@ -26,7 +26,7 @@ if mutex.lock
26
26
  # do something exclusively
27
27
  mutex.unlock
28
28
  else
29
- puts "failed to obtain lock!"
29
+ puts "failed to acquire lock!"
30
30
  end
31
31
  ```
32
32
 
@@ -37,6 +37,13 @@ that you can configure any of these timing values, as explained later.
37
37
 
38
38
  Or if you want to immediately receive `false` on an unsuccessful locking attempt, you can change the mutex mode to **non-blocking**.
39
39
 
40
+ Changes in v2.0
41
+ ---------------
42
+
43
+ * **Exception-based control flow**: Added `lock!` and `unlock!`, which raises an exception when fails to acquire a lock. Raises `Redis::Mutex::LockError` and `Redis::Mutex::UnlockError` respectively.
44
+ * **INCOMPATIBLE CHANGE**: `#lock` no longer accepts a block. Use `#with_lock` instead, which uses `lock!` internally and returns the value of block.
45
+ * `unlock` returns boolean values for success / failure, for consistency with `lock`.
46
+
40
47
  Install
41
48
  -------
42
49
 
@@ -48,7 +55,7 @@ Usage
48
55
  In Gemfile:
49
56
 
50
57
  ```ruby
51
- gem "redis-mutex"
58
+ gem 'redis-mutex'
52
59
  ```
53
60
 
54
61
  Register the Redis server: (e.g. in `config/initializers/redis_mutex.rb` for Rails)
@@ -59,15 +66,19 @@ Redis::Classy.db = Redis.new(:host => 'localhost')
59
66
 
60
67
  Note that Redis Mutex uses the `redis-classy` gem internally to organize keys in an isolated namespace.
61
68
 
62
- There are four methods - `new`, `lock`, `unlock` and `sweep`:
69
+ There are a number of methods:
63
70
 
64
71
  ```ruby
65
72
  mutex = Redis::Mutex.new(key, options) # Configure a mutex lock
66
- mutex.lock # Try to obtain the lock
67
- mutex.unlock # Release the lock if it's not expired
68
- Redis::Mutex.sweep # Forcibly remove all locks
69
-
70
- Redis::Mutex.lock(key, options) # Shortcut to new + lock
73
+ mutex.lock # Try to acquire the lock
74
+ mutex.unlock # Try to release the lock
75
+ mutex.lock! # Try to acquire the lock, raises exception when failed
76
+ mutex.unlock! # Try to release the lock, raises exception when failed
77
+ mutex.with_lock # Try to acquire the lock, execute the block, then return the value of the block.
78
+ # Raises exception when failed to acquire the lock.
79
+
80
+ Redis::Mutex.sweep # Remove all expired locks
81
+ Redis::Mutex.with_lock(key, options) # Shortcut to new + with_lock
71
82
  ```
72
83
 
73
84
  The key argument can be symbol, string, or any Ruby objects that respond to `id` method, where the key is automatically set as
@@ -81,12 +92,12 @@ The initialize method takes several options.
81
92
  :block => 1 # Specify in seconds how long you want to wait for the lock to be released.
82
93
  # Speficy 0 if you need non-blocking sematics and return false immediately. (default: 1)
83
94
  :sleep => 0.1 # Specify in seconds how long the polling interval should be when :block is given.
84
- # It is recommended that you do NOT go below 0.01. (default: 0.1)
85
- :expire => 10 # Specify in seconds when the lock should forcibly be removed when something went wrong
86
- # with the one who held the lock. (default: 10)
95
+ # It is NOT recommended to go below 0.01. (default: 0.1)
96
+ :expire => 10 # Specify in seconds when the lock should be considered stale when something went wrong
97
+ # with the one who held the lock and failed to unlock. (default: 10)
87
98
  ```
88
99
 
89
- The lock method returns `true` when the lock has been successfully obtained, or returns `false` when the attempts failed after
100
+ The lock method returns `true` when the lock has been successfully acquired, or returns `false` when the attempts failed after
90
101
  the seconds specified with **:block**. When 0 is given to **:block**, it is set to **non-blocking** mode and immediately returns `false`.
91
102
 
92
103
  In the following Rails example, only one request can enter to a given room.
@@ -96,29 +107,31 @@ class RoomController < ApplicationController
96
107
  before_filter { @room = Room.find(params[:id]) }
97
108
 
98
109
  def enter
99
- success = Redis::Mutex.lock(@room) do # key => "Room:123"
110
+ Redis::Mutex.with_lock(@room) do # key => "Room:123"
100
111
  # do something exclusively
101
112
  end
102
- render :text => success ? 'success!' : 'failed to obtain lock!'
113
+ render text: 'success!'
114
+ rescue Redis::Mutex::LockError
115
+ render text: 'failed to acquire lock!'
103
116
  end
104
117
  end
105
118
  ```
106
119
 
107
- Note that you need to explicitly call the unlock method when you don't use the block syntax, and it is recommended to
108
- put the `unlock` method in the `ensure` clause unless you're sure your code won't raise any exception.
120
+ Note that you need to explicitly call the `unlock` method when you don't use `with_lock` and its block syntax. Also it is recommended to
121
+ put the `unlock` method in the `ensure` clause.
109
122
 
110
123
  ```ruby
111
124
  def enter
112
- mutex = Redis::Mutex.new('non-blocking', :block => 0, :expire => 10.minutes)
125
+ mutex = Redis::Mutex.new('non-blocking', block: 0, expire: 10.minutes)
113
126
  if mutex.lock
114
127
  begin
115
128
  # do something exclusively
116
129
  ensure
117
130
  mutex.unlock
118
131
  end
119
- render :text => 'success!'
132
+ render text: 'success!'
120
133
  else
121
- render :text => 'failed to obtain lock!'
134
+ render text: 'failed to acquire lock!'
122
135
  end
123
136
  end
124
137
  ```
@@ -134,11 +147,11 @@ If you give a proc object to the `after_failure` option, it will get called afte
134
147
  ```ruby
135
148
  class JobController < ApplicationController
136
149
  include Redis::Mutex::Macro
137
- auto_mutex :run, :block => 0, :after_failure => lambda { render :text => "failed to obtain lock!" }
138
-
150
+ auto_mutex :run, block: 0, after_failure: lambda { render text: 'failed to acquire lock!' }
151
+
139
152
  def run
140
153
  # do something exclusively
141
- render :text => "success!"
154
+ render text: 'success!'
142
155
  end
143
156
  end
144
157
  ```
data/lib/redis/mutex.rb CHANGED
@@ -12,8 +12,10 @@ class Redis
12
12
  class Mutex < Redis::Classy
13
13
  autoload :Macro, 'redis/mutex/macro'
14
14
 
15
- attr_reader :locking
16
15
  DEFAULT_EXPIRE = 10
16
+ LockError = Class.new(StandardError)
17
+ UnlockError = Class.new(StandardError)
18
+ AssertionError = Class.new(StandardError)
17
19
 
18
20
  def initialize(object, options={})
19
21
  super(object.is_a?(String) || object.is_a?(Symbol) ? object : "#{object.class.name}:#{object.id}")
@@ -23,6 +25,7 @@ class Redis
23
25
  end
24
26
 
25
27
  def lock
28
+ self.class.raise_assertion_error if block_given?
26
29
  @locking = false
27
30
 
28
31
  if @block > 0
@@ -30,66 +33,92 @@ class Redis
30
33
  start_at = Time.now
31
34
  while Time.now - start_at < @block
32
35
  @locking = true and break if try_lock
33
- Kernel.sleep @sleep
36
+ sleep @sleep
34
37
  end
35
38
  else
36
39
  # Non-blocking mode
37
40
  @locking = try_lock
38
41
  end
39
- success = @locking # Backup
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
+ return true if setnx(@expires_at) # Success, the lock has been acquired
49
+ return false if get.to_f > now # Check if the lock is still effective
50
+
51
+ # The lock has expired but wasn't released... BAD!
52
+ return true if getset(@expires_at).to_f <= now # Success, we acquired the previously expired lock
53
+ return false # Dammit, it seems that someone else was even faster than us to remove the expired lock!
54
+ end
55
+
56
+ def unlock(force = false)
57
+ # Since it's possible that the operations in the critical section took a long time,
58
+ # we can't just simply release the lock. The unlock method checks if @expires_at
59
+ # remains the same, and do not release when the lock timestamp was overwritten.
60
+
61
+ if get == @expires_at.to_s or force
62
+ # Redis#del with a single key returns '1' or nil
63
+ !!del
64
+ else
65
+ false
66
+ end
67
+ end
40
68
 
41
- if block_given? and @locking
69
+ def with_lock
70
+ if lock!
42
71
  begin
43
- yield
72
+ @result = yield
44
73
  ensure
45
- # Since it's possible that the yielded operation took a long time, we can't just simply
46
- # Release the lock. The unlock method checks if the expires_at remains the same that you
47
- # set, and do not release it when the lock timestamp was overwritten.
48
74
  unlock
49
75
  end
50
76
  end
51
-
52
- success
77
+ @result
53
78
  end
54
79
 
55
- def try_lock
56
- now = Time.now.to_f
57
- @expires_at = now + @expire # Extend in each blocking loop
58
- return true if self.setnx(@expires_at) # Success, the lock has been acquired
59
- return false if self.get.to_f > now # Check if the lock is still effective
60
-
61
- # The lock has expired but wasn't released... BAD!
62
- return true if self.getset(@expires_at).to_f <= now # Success, we acquired the previously expired lock
63
- return false # Dammit, it seems that someone else was even faster than us to remove the expired lock!
80
+ def lock!
81
+ lock or raise LockError, "failed to acquire lock #{key.inspect}"
64
82
  end
65
83
 
66
- def unlock(force=false)
67
- @locking = false
68
- self.del if self.get == @expires_at.to_s or force # Release the lock if it seems to be yours
84
+ def unlock!(force = false)
85
+ unlock(force) or raise UnlockError, "failed to release lock #{key.inspect}"
69
86
  end
70
87
 
71
88
  class << self
72
89
  def sweep
73
- return 0 if (all_keys = self.keys).empty?
90
+ return 0 if (all_keys = keys).empty?
74
91
 
75
92
  now = Time.now.to_f
76
- values = self.mget(*all_keys)
93
+ values = mget(*all_keys)
77
94
 
78
- expired_keys = [].tap do |array|
79
- all_keys.each_with_index do |key, i|
80
- array << key if !values[i].nil? and values[i].to_f <= now
81
- end
95
+ expired_keys = all_keys.zip(values).select do |key, time|
96
+ time && time.to_f <= now
82
97
  end
83
98
 
84
- expired_keys.each do |key|
85
- self.del(key) if self.getset(key, now + DEFAULT_EXPIRE).to_f <= now # Make extra sure that anyone haven't extended the lock
99
+ expired_keys.each do |key, _|
100
+ # Make extra sure that anyone haven't extended the lock
101
+ del(key) if getset(key, now + DEFAULT_EXPIRE).to_f <= now
86
102
  end
87
103
 
88
104
  expired_keys.size
89
105
  end
90
106
 
91
- def lock(object, options={}, &block)
92
- new(object, options).lock(&block)
107
+ def lock(object, options = {})
108
+ raise_assertion_error if block_given?
109
+ new(object, options).lock
110
+ end
111
+
112
+ def lock!(object, options = {})
113
+ new(object, options).lock!
114
+ end
115
+
116
+ def with_lock(object, options = {}, &block)
117
+ new(object, options).with_lock(&block)
118
+ end
119
+
120
+ def raise_assertion_error
121
+ raise AssertionError, 'block syntax has been removed from #lock, use #with_lock instead'
93
122
  end
94
123
  end
95
124
  end
@@ -31,17 +31,14 @@ class Redis
31
31
 
32
32
  define_method(with_method) do |*args|
33
33
  key = self.class.name << '#' << target.to_s
34
- response = nil
35
34
 
36
- success = Redis::Mutex.lock(key, options) do
37
- response = send(without_method, *args)
35
+ begin
36
+ Redis::Mutex.with_lock(key, options) do
37
+ send(without_method, *args)
38
+ end
39
+ rescue Redis::Mutex::LockError
40
+ send(after_method, *args) if respond_to?(after_method)
38
41
  end
39
-
40
- if !success and respond_to?(after_method)
41
- response = send(after_method, *args)
42
- end
43
-
44
- response
45
42
  end
46
43
 
47
44
  alias_method without_method, target
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 = '1.3.5' # retrieve this value by: Gem.loaded_specs['redis-mutex'].version.to_s
15
+ gem.version = '2.0.0' # retrieve this value by: Gem.loaded_specs['redis-mutex'].version.to_s
16
16
 
17
- gem.add_runtime_dependency "redis-classy", "~> 1.0"
17
+ gem.add_runtime_dependency "redis-classy", "~> 1.2"
18
18
  gem.add_development_dependency "rspec"
19
19
  gem.add_development_dependency "bundler"
20
20
 
@@ -1,49 +1,79 @@
1
- require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
1
+ require 'spec_helper'
2
+
3
+ SHORT_MUTEX_OPTIONS = { :block => 0.1, :sleep => 0.02 }
4
+
5
+ class C
6
+ include Redis::Mutex::Macro
7
+ auto_mutex :run_singularly, :block => 0, :after_failure => lambda {|id| return "failure: #{id}" }
8
+
9
+ def run_singularly(id)
10
+ sleep 0.1
11
+ return "success: #{id}"
12
+ end
13
+ end
2
14
 
3
15
  describe Redis::Mutex do
4
16
  before do
5
17
  Redis::Classy.flushdb
6
- @short_mutex_options = { :block => 0.1, :sleep => 0.02 }
7
18
  end
8
19
 
9
- after do
20
+ after :all do
10
21
  Redis::Classy.flushdb
22
+ Redis::Classy.quit
11
23
  end
12
24
 
13
- it "should set the value to the expiration" do
25
+ it 'locks the universe' do
26
+ mutex1 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
27
+ mutex1.lock.should be_true
28
+
29
+ mutex2 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
30
+ mutex2.lock.should be_false
31
+ end
32
+
33
+ it 'fails to lock when the lock is taken' do
34
+ mutex1 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
35
+
36
+ mutex2 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
37
+ mutex2.lock.should be_true # mutex2 beats us to it
38
+
39
+ mutex1.lock.should be_false # fail
40
+ end
41
+
42
+ it 'unlocks only once' do
43
+ mutex = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
44
+ mutex.lock.should be_true
45
+
46
+ mutex.unlock.should be_true # successfully released the lock
47
+ mutex.unlock.should be_false # the lock no longer exists
48
+ end
49
+
50
+ it 'prevents accidental unlock from outside' do
51
+ mutex1 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
52
+ mutex1.lock.should be_true
53
+
54
+ mutex2 = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
55
+ mutex2.unlock.should be_false
56
+ end
57
+
58
+ it 'sets expiration' do
14
59
  start = Time.now
15
60
  expires_in = 10
16
61
  mutex = Redis::Mutex.new(:test_lock, :expire => expires_in)
17
- mutex.lock do
62
+ mutex.with_lock do
18
63
  mutex.get.to_f.should be_within(1.0).of((start + expires_in).to_f)
19
64
  end
20
- # key should have been cleaned up
21
- mutex.get.should be_nil
65
+ mutex.get.should be_nil # key should have been cleaned up
22
66
  end
23
67
 
24
- it "should get a lock when existing lock is expired" do
25
- mutex = Redis::Mutex.new(:test_lock)
26
- # locked in the far past
68
+ it 'overwrites a lock when existing lock is expired' do
69
+ # stale lock from the far past
27
70
  Redis::Mutex.set(:test_lock, Time.now - 60)
28
71
 
72
+ mutex = Redis::Mutex.new(:test_lock)
29
73
  mutex.lock.should be_true
30
- mutex.get.should_not be_nil
31
- mutex.unlock
32
- mutex.get.should be_nil
33
- end
34
-
35
- it "should not get a lock when existing lock is still effective" do
36
- mutex = Redis::Mutex.new(:test_lock, @short_mutex_options)
37
-
38
- # someone beats us to it
39
- mutex2 = Redis::Mutex.new(:test_lock, @short_mutex_options)
40
- mutex2.lock
41
-
42
- mutex.lock.should be_false # should not have the lock
43
- mutex.get.should_not be_nil # lock value should still be set
44
74
  end
45
75
 
46
- it "should not remove the key if lock is held past expiration" do
76
+ it 'fails to unlock the key if it took too long past expiration' do
47
77
  mutex = Redis::Mutex.new(:test_lock, :expire => 0.1, :block => 0)
48
78
  mutex.lock.should be_true
49
79
  sleep 0.2 # lock expired
@@ -56,38 +86,67 @@ describe Redis::Mutex do
56
86
  mutex.get.should_not be_nil # lock should still be there
57
87
  end
58
88
 
59
- it "should ensure unlock when something goes wrong in the block" do
89
+ it 'ensures unlocking when something goes wrong in the block' do
60
90
  mutex = Redis::Mutex.new(:test_lock)
61
91
  begin
62
- mutex.lock do
92
+ mutex.with_lock do
63
93
  raise "Something went wrong!"
64
94
  end
65
- rescue
66
- mutex.locking.should be_false
95
+ rescue RuntimeError
96
+ mutex.get.should be_nil
67
97
  end
68
98
  end
69
99
 
70
- it "should reset locking state on reuse" do
71
- mutex = Redis::Mutex.new(:test_lock, @short_mutex_options)
100
+ it 'resets locking state on reuse' do
101
+ mutex = Redis::Mutex.new(:test_lock, SHORT_MUTEX_OPTIONS)
72
102
  mutex.lock.should be_true
73
103
  mutex.lock.should be_false
74
104
  end
75
105
 
76
- describe Redis::Mutex::Macro do
77
- it "should add auto_mutex" do
106
+ it 'returns value of block' do
107
+ Redis::Mutex.with_lock(:test_lock) { :test_result }.should == :test_result
108
+ end
78
109
 
79
- class C
80
- include Redis::Mutex::Macro
81
- auto_mutex :run_singularly, :block => 0, :after_failure => lambda {|id| return "failure: #{id}" }
110
+ it 'requires block for #with_lock' do
111
+ expect { Redis::Mutex.with_lock(:test_lock) }.to raise_error(LocalJumpError) #=> no block given (yield)
112
+ end
82
113
 
83
- def run_singularly(id)
84
- sleep 0.1
85
- return "success: #{id}"
86
- end
87
- end
114
+ it 'raises LockError if lock not obtained' do
115
+ expect { Redis::Mutex.lock!(:test_lock, SHORT_MUTEX_OPTIONS) }.to_not raise_error
116
+ expect { Redis::Mutex.lock!(:test_lock, SHORT_MUTEX_OPTIONS) }.to raise_error(Redis::Mutex::LockError)
117
+ end
88
118
 
119
+ it 'raises UnlockError if lock not obtained' do
120
+ mutex = Redis::Mutex.new(:test_lock)
121
+ mutex.lock.should be_true
122
+ mutex.unlock.should be_true
123
+ expect { mutex.unlock! }.to raise_error(Redis::Mutex::UnlockError)
124
+ end
125
+
126
+ it 'raises AssertionError when block is given to #lock' do
127
+ # instance method
128
+ mutex = Redis::Mutex.new(:test_lock)
129
+ expect { mutex.lock {} }.to raise_error(Redis::Mutex::AssertionError)
130
+
131
+ # class method
132
+ expect { Redis::Mutex.lock(:test_lock) {} }.to raise_error(Redis::Mutex::AssertionError)
133
+ end
134
+
135
+ it 'sweeps expired locks' do
136
+ Redis::Mutex.set(:past, Time.now.to_f - 60)
137
+ Redis::Mutex.set(:present, Time.now.to_f)
138
+ Redis::Mutex.set(:future, Time.now.to_f + 60)
139
+ Redis::Mutex.keys.size.should == 3
140
+ Redis::Mutex.sweep.should == 2
141
+ Redis::Mutex.keys.size.should == 1
142
+ end
143
+
144
+ describe Redis::Mutex::Macro do
145
+ it 'adds auto_mutex' do
89
146
  t1 = Thread.new { C.new.run_singularly(1).should == "success: 1" }
90
- sleep 0.01 # In most cases t1 wins, but make sure to give it a head start, not exceeding the sleep inside the method
147
+ # In most cases t1 wins, but make sure to give it a head start,
148
+ # not exceeding the sleep inside the method.
149
+ sleep 0.01
91
150
  t2 = Thread.new { C.new.run_singularly(2).should == "failure: 2" }
92
151
  t1.join
93
152
  t2.join
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-mutex
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.5
4
+ version: 2.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-05-26 00:00:00.000000000 Z
12
+ date: 2012-10-12 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: redis-classy
@@ -18,7 +18,7 @@ dependencies:
18
18
  requirements:
19
19
  - - ~>
20
20
  - !ruby/object:Gem::Version
21
- version: '1.0'
21
+ version: '1.2'
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ~>
28
28
  - !ruby/object:Gem::Version
29
- version: '1.0'
29
+ version: '1.2'
30
30
  - !ruby/object:Gem::Dependency
31
31
  name: rspec
32
32
  requirement: !ruby/object:Gem::Requirement
@@ -107,19 +107,24 @@ required_ruby_version: !ruby/object:Gem::Requirement
107
107
  - - ! '>='
108
108
  - !ruby/object:Gem::Version
109
109
  version: '0'
110
+ segments:
111
+ - 0
112
+ hash: -331406254001747474
110
113
  required_rubygems_version: !ruby/object:Gem::Requirement
111
114
  none: false
112
115
  requirements:
113
116
  - - ! '>='
114
117
  - !ruby/object:Gem::Version
115
118
  version: '0'
119
+ segments:
120
+ - 0
121
+ hash: -331406254001747474
116
122
  requirements: []
117
123
  rubyforge_project:
118
- rubygems_version: 1.8.19
124
+ rubygems_version: 1.8.24
119
125
  signing_key:
120
126
  specification_version: 3
121
127
  summary: Distrubuted mutex using Redis
122
128
  test_files:
123
129
  - spec/redis_mutex_spec.rb
124
130
  - spec/spec_helper.rb
125
- has_rdoc: