redis-mutex 1.3.5 → 2.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.
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: