pmckee11-redis-lock 0.1.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 +7 -0
- data/.gitignore +3 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +30 -0
- data/LICENSE.txt +22 -0
- data/README.md +28 -0
- data/Rakefile +7 -0
- data/lib/redis-lock.rb +97 -0
- data/lib/redis-lock/version.rb +5 -0
- data/redis-lock.gemspec +23 -0
- data/spec/redis-lock_spec.rb +160 -0
- data/spec/spec_helper.rb +2 -0
- metadata +91 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 83a23888f2a8facee8e44efa6bfed1787ccbaaff
|
4
|
+
data.tar.gz: c90fec2dd78ab7d4deb6539cb3c526771371e5b4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9ad344a0f17edd109ac36d756ac6fdb0bb928442223828c88666757d8ecfe4a1c142cd59f5d006f9c2fb3c7ea87ca5adf89f68f28f7e181c8df3539d968fc274
|
7
|
+
data.tar.gz: d147966d672dba6e0723759b89a40d87e16200d583a13bdb88efc7b62a2ccac049046745c805f34ce77f4011e747ee072c3bff4c5fa04dd5a7a399151541b7a2
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
pmckee11-redis-lock (0.1.0)
|
5
|
+
redis (~> 3.0, >= 3.0.5)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
diff-lcs (1.2.5)
|
11
|
+
redis (3.2.0)
|
12
|
+
rspec (3.1.0)
|
13
|
+
rspec-core (~> 3.1.0)
|
14
|
+
rspec-expectations (~> 3.1.0)
|
15
|
+
rspec-mocks (~> 3.1.0)
|
16
|
+
rspec-core (3.1.7)
|
17
|
+
rspec-support (~> 3.1.0)
|
18
|
+
rspec-expectations (3.1.2)
|
19
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
20
|
+
rspec-support (~> 3.1.0)
|
21
|
+
rspec-mocks (3.1.3)
|
22
|
+
rspec-support (~> 3.1.0)
|
23
|
+
rspec-support (3.1.2)
|
24
|
+
|
25
|
+
PLATFORMS
|
26
|
+
ruby
|
27
|
+
|
28
|
+
DEPENDENCIES
|
29
|
+
pmckee11-redis-lock!
|
30
|
+
rspec
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Peter McKee
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# Redis::Lock
|
2
|
+
|
3
|
+
This gem implements robust pessimistic locking as described at http://redis.io/commands/set using the ruby redis client.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'pmckee11-redis-lock', require: 'redis-lock'
|
10
|
+
|
11
|
+
and then run bundler.
|
12
|
+
|
13
|
+
Or run
|
14
|
+
|
15
|
+
$ gem install pmckee11-redis-lock
|
16
|
+
|
17
|
+
## Background
|
18
|
+
|
19
|
+
This implements a distributed lock with a timeout almost exactly as described in the redis documentation.
|
20
|
+
There are a few other redis lock implementations in ruby, but none of them seemed to be using the newer features in redis that can yield a performance improvement (e.g. the expanded SET parameters and Lua scripting). Using this gem requires a redis 2.6.12 server, allowing it to leverage those newer features to make fewer round trips and provide better performance
|
21
|
+
|
22
|
+
## Contributing
|
23
|
+
|
24
|
+
1. Fork it
|
25
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
26
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
27
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
28
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/lib/redis-lock.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
require "redis"
|
2
|
+
require "securerandom"
|
3
|
+
|
4
|
+
class Redis
|
5
|
+
class Lock
|
6
|
+
|
7
|
+
attr_reader :redis
|
8
|
+
attr_reader :key
|
9
|
+
|
10
|
+
class AcquireLockTimeOut < StandardError
|
11
|
+
end
|
12
|
+
|
13
|
+
UNLOCK_LUA_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] then redis.call('del',KEYS[1]) end"
|
14
|
+
|
15
|
+
# @param redis is a Redis instance
|
16
|
+
# @param key String for a unique name of the lock to acquire
|
17
|
+
# @param options[:auto_release_time] Int for the max number of seconds a lock can be held before it is auto released
|
18
|
+
# @param options[:base_sleep] Int for the number of millis to sleep after the first time a lock is not acquired
|
19
|
+
# (successive reattempts will be made with exponential back off)
|
20
|
+
def initialize(redis, key, options = {})
|
21
|
+
@redis = redis
|
22
|
+
@key = "lock:#{key}"
|
23
|
+
@auto_release_time = options[:auto_release_time] || 30
|
24
|
+
@base_sleep_in_secs = (options[:base_sleep] || 100) / 1000.0
|
25
|
+
# Unique token set as the redis value of @key when locked by this instance
|
26
|
+
@instance_name = SecureRandom.hex
|
27
|
+
# If lock was called and unlock has not yet been called, this is set to the time the lock was acquired
|
28
|
+
@time_locked = nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# Acquire the lock. If a block is provided, the lock is acquired before yielding to the block and released once the
|
32
|
+
# block is returned.
|
33
|
+
# @param acquire_timeout Int for max number of seconds to spend acquiring the lock before raising an error
|
34
|
+
def lock(acquire_timeout = 10, &block)
|
35
|
+
raise AcquireLockTimeOut.new unless attempt_lock(acquire_timeout)
|
36
|
+
if block
|
37
|
+
begin
|
38
|
+
yield(self)
|
39
|
+
ensure
|
40
|
+
unlock
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Releases the lock if it is held by this instance. By default, this method relies on the expiration time of the key
|
46
|
+
# as a performance optimization when possible. If this is undesirable for some reason, set force_remote to true.
|
47
|
+
# @param force_remote Boolean for whether to explicitly delete on the redis server instead of relying on expiration
|
48
|
+
def unlock(force_remote = false)
|
49
|
+
# unlock is a no-op if we never called lock
|
50
|
+
if @time_locked
|
51
|
+
if Time.now < @time_locked + @auto_release_time || force_remote
|
52
|
+
@redis.eval(UNLOCK_LUA_SCRIPT, [@key], [@instance_name])
|
53
|
+
end
|
54
|
+
@time_locked = nil
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Determines whether or not the lock is held by this instance. By default, this method relies on the expiration time
|
59
|
+
# of the key as a performance optimization when possible. If this is undesirable for some reason, set force_remote
|
60
|
+
# to true.
|
61
|
+
# @param force_remote Boolean for whether to verify with a call to the redis server instead of using the lock time
|
62
|
+
# @return Boolean that is true if this lock instance currently holds the lock
|
63
|
+
def locked?(force_remote = false)
|
64
|
+
if @time_locked
|
65
|
+
if force_remote
|
66
|
+
return @redis.get(@key) == @instance_name
|
67
|
+
end
|
68
|
+
if Time.now < @time_locked + @auto_release_time
|
69
|
+
return true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
return false
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
# @param acquire_timeout Int for the number of seconds to spend attempting to acquire the lock
|
78
|
+
# @return true if locked, false otherwise
|
79
|
+
def attempt_lock(acquire_timeout)
|
80
|
+
locked = false
|
81
|
+
sleep_time = @base_sleep_in_secs
|
82
|
+
when_to_timeout = Time.now + acquire_timeout
|
83
|
+
until locked
|
84
|
+
locked = @redis.set(@key, @instance_name, :nx => true, :ex => @auto_release_time)
|
85
|
+
unless locked
|
86
|
+
return false if Time.now > when_to_timeout
|
87
|
+
sleep(sleep_time)
|
88
|
+
# exponentially back off, but ensure that we take all of our wait time without going over
|
89
|
+
sleep_time = [sleep_time * 2, when_to_timeout - Time.now].min
|
90
|
+
end
|
91
|
+
end
|
92
|
+
@time_locked = Time.now
|
93
|
+
return true
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
data/redis-lock.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'redis-lock/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "pmckee11-redis-lock"
|
8
|
+
gem.version = Redis::Lock::VERSION
|
9
|
+
gem.authors = ["Peter McKee"]
|
10
|
+
gem.email = ["pmckee11@gmail.com"]
|
11
|
+
gem.description = %q{Distributed lock using ruby redis}
|
12
|
+
gem.summary = %q{Distributed lock using ruby redis}
|
13
|
+
gem.homepage = "https://github.com/pmckee11/redis-lock"
|
14
|
+
gem.license = "MIT"
|
15
|
+
|
16
|
+
gem.files = `git ls-files`.split($/)
|
17
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
18
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
19
|
+
gem.require_paths = ["lib"]
|
20
|
+
|
21
|
+
gem.add_dependency "redis", '~> 3.0', '>= 3.0.5'
|
22
|
+
gem.add_development_dependency "rspec"
|
23
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Redis::Lock do
|
4
|
+
let(:key) {"key"}
|
5
|
+
let(:lock) {Redis::Lock.new(@redis, key)}
|
6
|
+
|
7
|
+
before(:all) do
|
8
|
+
@redis = Redis.new
|
9
|
+
end
|
10
|
+
|
11
|
+
before(:each) do
|
12
|
+
@redis.flushdb
|
13
|
+
end
|
14
|
+
|
15
|
+
after(:each) do
|
16
|
+
@redis.flushdb
|
17
|
+
end
|
18
|
+
|
19
|
+
after(:all) do
|
20
|
+
@redis.quit
|
21
|
+
end
|
22
|
+
|
23
|
+
context "#lock" do
|
24
|
+
it "should set an appropriate key in redis" do
|
25
|
+
lock.lock
|
26
|
+
@redis.get("lock:#{key}").should_not be_nil
|
27
|
+
end
|
28
|
+
|
29
|
+
context "when a block is provided" do
|
30
|
+
it "locks before yielding and releases after" do
|
31
|
+
lock.lock do |l|
|
32
|
+
l.should == lock
|
33
|
+
@redis.get("lock:#{key}").should_not be_nil
|
34
|
+
end
|
35
|
+
@redis.get("lock:#{key}").should be_nil
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context "when acquire_timeout is provided" do
|
40
|
+
it "times out after the given timeout with an appropriate error" do
|
41
|
+
other_lock = Redis::Lock.new(@redis, key)
|
42
|
+
time = Time.now
|
43
|
+
lock.lock
|
44
|
+
begin
|
45
|
+
other_lock.lock(2)
|
46
|
+
fail()
|
47
|
+
rescue => e
|
48
|
+
(Time.now - time).should be_within(0.1).of(2.0)
|
49
|
+
e.should be_a(Redis::Lock::AcquireLockTimeOut)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context "when initialized with auto_release_time" do
|
55
|
+
it "sets the redis key with an appropriate expiration" do
|
56
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 7)
|
57
|
+
@redis.should_receive(:set).with("lock:#{key}", an_instance_of(String), :nx => true, :ex => 7).and_return(true)
|
58
|
+
other_lock.lock
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context "when initialized with base_sleep" do
|
63
|
+
it "retries with exponential back off starting at base_sleep millis" do
|
64
|
+
other_lock = Redis::Lock.new(@redis, key, :base_sleep => 25)
|
65
|
+
other_lock.should_receive(:sleep).with(0.025).ordered
|
66
|
+
other_lock.should_receive(:sleep).with(0.05).ordered
|
67
|
+
other_lock.should_receive(:sleep).with(0.1).ordered
|
68
|
+
other_lock.should_receive(:sleep).with(0.2).ordered
|
69
|
+
other_lock.should_receive(:sleep) do |sleep_time|
|
70
|
+
sleep_time.should == 0.4
|
71
|
+
lock.unlock
|
72
|
+
end.ordered
|
73
|
+
lock.lock
|
74
|
+
other_lock.lock
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
context "#unlock" do
|
80
|
+
it "should delete an appropriate key from redis" do
|
81
|
+
lock.lock
|
82
|
+
lock.unlock
|
83
|
+
@redis.get("lock:#{key}").should be_nil
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should not delete a lock held by another instance" do
|
87
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 1)
|
88
|
+
other_lock.lock
|
89
|
+
sleep(1.1)
|
90
|
+
lock.lock
|
91
|
+
other_lock.unlock
|
92
|
+
@redis.get("lock:#{key}").should_not be_nil
|
93
|
+
end
|
94
|
+
|
95
|
+
context "when the instance has not been locked" do
|
96
|
+
it "is a no op" do
|
97
|
+
@redis.should_not_receive(:eval)
|
98
|
+
lock.unlock
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
context "when the instance lock has expired based on the lock time" do
|
103
|
+
it "is a no op" do
|
104
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 1)
|
105
|
+
other_lock.lock
|
106
|
+
sleep(1)
|
107
|
+
@redis.should_not_receive(:eval)
|
108
|
+
other_lock.unlock
|
109
|
+
end
|
110
|
+
|
111
|
+
context "but force_remote is true" do
|
112
|
+
it "makes a redis call" do
|
113
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 1)
|
114
|
+
other_lock.lock
|
115
|
+
sleep(1)
|
116
|
+
@redis.should_receive(:eval).with(Redis::Lock::UNLOCK_LUA_SCRIPT, ["lock:#{key}"], instance_of(Array)).once
|
117
|
+
other_lock.unlock(true)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
context "#locked?" do
|
124
|
+
it "correctly determines if the instance holds the lock" do
|
125
|
+
lock.locked?.should be_false
|
126
|
+
lock.lock
|
127
|
+
lock.locked?.should be_true
|
128
|
+
lock.unlock
|
129
|
+
lock.locked?.should be_false
|
130
|
+
end
|
131
|
+
|
132
|
+
context "when the instance has not been locked" do
|
133
|
+
it "is a no op" do
|
134
|
+
@redis.should_not_receive(:eval)
|
135
|
+
lock.unlock
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context "when the instance lock has expired based on the lock time" do
|
140
|
+
it "is a no op" do
|
141
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 1)
|
142
|
+
other_lock.lock
|
143
|
+
sleep(1)
|
144
|
+
@redis.should_not_receive(:get)
|
145
|
+
other_lock.locked?.should be_false
|
146
|
+
end
|
147
|
+
|
148
|
+
context "but force_remote is true" do
|
149
|
+
it "makes a redis call" do
|
150
|
+
other_lock = Redis::Lock.new(@redis, key, :auto_release_time => 1)
|
151
|
+
other_lock.lock
|
152
|
+
sleep(1)
|
153
|
+
@redis.should_receive(:get).once.and_return(nil)
|
154
|
+
other_lock.locked?(true).should be_false
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pmckee11-redis-lock
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Peter McKee
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-12-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.0'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 3.0.5
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '3.0'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 3.0.5
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: rspec
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '0'
|
47
|
+
description: Distributed lock using ruby redis
|
48
|
+
email:
|
49
|
+
- pmckee11@gmail.com
|
50
|
+
executables: []
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- ".gitignore"
|
55
|
+
- Gemfile
|
56
|
+
- Gemfile.lock
|
57
|
+
- LICENSE.txt
|
58
|
+
- README.md
|
59
|
+
- Rakefile
|
60
|
+
- lib/redis-lock.rb
|
61
|
+
- lib/redis-lock/version.rb
|
62
|
+
- redis-lock.gemspec
|
63
|
+
- spec/redis-lock_spec.rb
|
64
|
+
- spec/spec_helper.rb
|
65
|
+
homepage: https://github.com/pmckee11/redis-lock
|
66
|
+
licenses:
|
67
|
+
- MIT
|
68
|
+
metadata: {}
|
69
|
+
post_install_message:
|
70
|
+
rdoc_options: []
|
71
|
+
require_paths:
|
72
|
+
- lib
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
requirements: []
|
84
|
+
rubyforge_project:
|
85
|
+
rubygems_version: 2.2.2
|
86
|
+
signing_key:
|
87
|
+
specification_version: 4
|
88
|
+
summary: Distributed lock using ruby redis
|
89
|
+
test_files:
|
90
|
+
- spec/redis-lock_spec.rb
|
91
|
+
- spec/spec_helper.rb
|