pmckee11-redis-lock 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|