master_lock 0.1.0 → 0.8.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/Gemfile +4 -0
- data/LICENSE.txt +1 -1
- data/README.md +5 -5
- data/lib/master_lock.rb +143 -1
- data/lib/master_lock/redis_lock.rb +97 -0
- data/lib/master_lock/redis_scripts.rb +25 -0
- data/lib/master_lock/registry.rb +86 -0
- data/lib/master_lock/version.rb +1 -1
- metadata +6 -5
- data/bin/console +0 -14
- data/bin/setup +0 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 321dbc834c9f475dac38543343c696ae1eafda08
|
4
|
+
data.tar.gz: 5f764d38a83de5434bf318944de4dc4b478202e1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 67f9681ccef00d1fa3e4956524fc419dbb097b770ebb985cbdf16a4f584ecdaaee7220ff4ab68d9699723ebef24131002ee5cc0ec4b42338e79114618c4c145a
|
7
|
+
data.tar.gz: e6b55e755805fc89ad8c26a942e3d6be4770631285d1ed207ad250c2dc5ad95c7d3b77315ec48035e1cd2a317e8dae4235f87f340af349eebe1ea938a4e9bfe9
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
# MasterLock
|
2
2
|
|
3
|
-
|
3
|
+
[](https://travis-ci.org/coinbase/master_lock)
|
4
|
+
[](https://coveralls.io/github/coinbase/master_lock?branch=master)
|
5
|
+
[](https://badge.fury.io/rb/master_lock)
|
4
6
|
|
5
|
-
|
7
|
+
MasterLock is a Ruby library for interprocess locking using Redis. Critical sections of code can be wrapped in a MasterLock block that ensures only one thread will run the code at a time. The locks are resilient to process failures by expiring after the thread obtaining them dies.
|
6
8
|
|
7
9
|
## Installation
|
8
10
|
|
@@ -32,10 +34,8 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
32
34
|
|
33
35
|
## Contributing
|
34
36
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
36
|
-
|
37
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/coinbase/master_lock.
|
37
38
|
|
38
39
|
## License
|
39
40
|
|
40
41
|
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
41
|
-
|
data/lib/master_lock.rb
CHANGED
@@ -1,4 +1,146 @@
|
|
1
|
-
require
|
1
|
+
require 'master_lock/version'
|
2
2
|
|
3
|
+
require 'socket'
|
4
|
+
|
5
|
+
# MasterLock is a system for interprocess locking. Resources can be locked by a
|
6
|
+
# string identifier such that only one thread may have the lock at a time. Lock
|
7
|
+
# state and owners are stored on a Redis server shared by all processes. Locks
|
8
|
+
# are held until either the block of synchronized code completes or the thread
|
9
|
+
# that obtained the lock is killed. To prevent the locks from being held
|
10
|
+
# indefinitely in the event that the process dies without releasing them, the
|
11
|
+
# locks have an expiration time in Redis. While the thread owning the lock is
|
12
|
+
# alive, a separate thread will extend the lifetime of the locks so that they
|
13
|
+
# do not expire even when the code in the critical section takes a long time to
|
14
|
+
# execute.
|
3
15
|
module MasterLock
|
16
|
+
class UnconfiguredError < StandardError; end
|
17
|
+
class NotStartedError < StandardError; end
|
18
|
+
class LockNotAcquiredError < StandardError; end
|
19
|
+
|
20
|
+
DEFAULT_ACQUIRE_TIMEOUT = 5
|
21
|
+
DEFAULT_EXTEND_INTERVAL = 15
|
22
|
+
DEFAULT_KEY_PREFIX = "masterlock".freeze
|
23
|
+
DEFAULT_SLEEP_TIME = 5
|
24
|
+
DEFAULT_TTL = 60
|
25
|
+
|
26
|
+
Config = Struct.new(
|
27
|
+
:acquire_timeout,
|
28
|
+
:extend_interval,
|
29
|
+
:hostname,
|
30
|
+
:key_prefix,
|
31
|
+
:process_id,
|
32
|
+
:redis,
|
33
|
+
:sleep_time,
|
34
|
+
:ttl
|
35
|
+
)
|
36
|
+
|
37
|
+
class << self
|
38
|
+
# Obtain a mutex around a critical section of code. Only one thread on any
|
39
|
+
# machine can execute the given block at a time. Returns the result of the
|
40
|
+
# block.
|
41
|
+
#
|
42
|
+
# @param key [String] the unique identifier for the locked resource
|
43
|
+
# @option options [Fixnum] :ttl (60) the length of time in seconds before
|
44
|
+
# the lock expires
|
45
|
+
# @option options [Fixnum] :acquire_timeout (5) the length of time to wait
|
46
|
+
# to acquire the lock before timing out
|
47
|
+
# @option options [Fixnum] :extend_interval (15) the amount of time in
|
48
|
+
# seconds that may pass before extending the lock
|
49
|
+
# @option options [Boolean] :if if this option is falsey, the block will be
|
50
|
+
# executed without obtaining the lock
|
51
|
+
# @option options [Boolean] :unless if this option is truthy, the block will
|
52
|
+
# be executed without obtaining the lock
|
53
|
+
# @raise [UnconfiguredError] if a required configuration variable is unset
|
54
|
+
# @raise [NotStartedError] if called before {#start}
|
55
|
+
# @raise [LockNotAcquiredError] if the lock cannot be acquired before the
|
56
|
+
# timeout
|
57
|
+
def synchronize(key, options = {})
|
58
|
+
check_configured
|
59
|
+
raise NotStartedError unless @registry
|
60
|
+
|
61
|
+
ttl = options[:ttl] || config.ttl
|
62
|
+
acquire_timeout = options[:acquire_timeout] || config.acquire_timeout
|
63
|
+
extend_interval = options[:extend_interval] || config.extend_interval
|
64
|
+
|
65
|
+
raise ArgumentError, "extend_interval cannot be negative" if extend_interval < 0
|
66
|
+
raise ArgumentError, "ttl must be greater extend_interval" if ttl <= extend_interval
|
67
|
+
|
68
|
+
if (options.include?(:if) && !options[:if]) ||
|
69
|
+
(options.include?(:unless) && options[:unless])
|
70
|
+
return yield
|
71
|
+
end
|
72
|
+
|
73
|
+
lock = RedisLock.new(
|
74
|
+
redis: config.redis,
|
75
|
+
key: redis_key(key),
|
76
|
+
ttl: ttl,
|
77
|
+
owner: generate_owner
|
78
|
+
)
|
79
|
+
if !lock.acquire(timeout: acquire_timeout)
|
80
|
+
raise LockNotAcquiredError, key
|
81
|
+
end
|
82
|
+
|
83
|
+
registration =
|
84
|
+
@registry.register(lock, extend_interval)
|
85
|
+
begin
|
86
|
+
yield
|
87
|
+
ensure
|
88
|
+
@registry.unregister(registration)
|
89
|
+
lock.release # TODO: Check result of this
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Starts the background thread to manage and extend currently held locks.
|
94
|
+
# The thread remains alive for the lifetime of the process. This must be
|
95
|
+
# called before any locks may be acquired.
|
96
|
+
def start
|
97
|
+
@registry = Registry.new
|
98
|
+
Thread.new do
|
99
|
+
loop do
|
100
|
+
@registry.extend_locks
|
101
|
+
sleep(config.sleep_time)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# @return [Config] MasterLock configuration settings
|
107
|
+
def config
|
108
|
+
if !defined?(@config)
|
109
|
+
@config = Config.new
|
110
|
+
@config.acquire_timeout = DEFAULT_ACQUIRE_TIMEOUT
|
111
|
+
@config.extend_interval = DEFAULT_EXTEND_INTERVAL
|
112
|
+
@config.hostname = Socket.gethostname
|
113
|
+
@config.key_prefix = DEFAULT_KEY_PREFIX
|
114
|
+
@config.process_id = Process.pid
|
115
|
+
@config.sleep_time = DEFAULT_SLEEP_TIME
|
116
|
+
@config.ttl = DEFAULT_TTL
|
117
|
+
end
|
118
|
+
@config
|
119
|
+
end
|
120
|
+
|
121
|
+
# Configure MasterLock using block syntax. Simply yields {#config} to the
|
122
|
+
# block.
|
123
|
+
#
|
124
|
+
# @yield [Config] the configuration
|
125
|
+
def configure
|
126
|
+
yield config
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def check_configured
|
132
|
+
raise UnconfiguredError, "redis must be configured" unless config.redis
|
133
|
+
end
|
134
|
+
|
135
|
+
def generate_owner
|
136
|
+
"#{config.hostname}:#{config.process_id}:#{Thread.current.object_id}"
|
137
|
+
end
|
138
|
+
|
139
|
+
def redis_key(key)
|
140
|
+
"#{config.key_prefix}:#{key}"
|
141
|
+
end
|
142
|
+
end
|
4
143
|
end
|
144
|
+
|
145
|
+
require 'master_lock/redis_lock'
|
146
|
+
require 'master_lock/registry'
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'master_lock/redis_scripts'
|
2
|
+
|
3
|
+
module MasterLock
|
4
|
+
# RedisLock implements a mutex in Redis according to the strategy documented
|
5
|
+
# at http://redis.io/commands/SET#patterns. The lock has a string identifier
|
6
|
+
# and when acquired will be registered to an owner, also identified by a
|
7
|
+
# string. Locks have an expiration time, after which they will be released
|
8
|
+
# automatically so that unexpected failures do not result in locks getting
|
9
|
+
# stuck.
|
10
|
+
class RedisLock
|
11
|
+
DEFAULT_SLEEP_INTERVAL = 0.1
|
12
|
+
|
13
|
+
# @return [Redis] the Redis connection used to manage lock
|
14
|
+
attr_reader :redis
|
15
|
+
|
16
|
+
# @return [String] the unique identifier for the locked resource
|
17
|
+
attr_reader :key
|
18
|
+
|
19
|
+
# @return [String] the identity of the owner acquiring the lock
|
20
|
+
attr_reader :owner
|
21
|
+
|
22
|
+
# @return [Fixnum] the lifetime of the lock in seconds
|
23
|
+
attr_reader :ttl
|
24
|
+
|
25
|
+
def initialize(
|
26
|
+
redis:,
|
27
|
+
key:,
|
28
|
+
owner:,
|
29
|
+
ttl:,
|
30
|
+
sleep_interval: DEFAULT_SLEEP_INTERVAL
|
31
|
+
)
|
32
|
+
@redis = redis
|
33
|
+
@key = key
|
34
|
+
@owner = owner
|
35
|
+
@ttl = ttl
|
36
|
+
@sleep_interval = sleep_interval
|
37
|
+
end
|
38
|
+
|
39
|
+
# Attempt to acquire the lock. If the lock is already held, this will
|
40
|
+
# attempt multiple times to acquire the lock until the timeout period is up.
|
41
|
+
#
|
42
|
+
# @param [Fixnum] how long to wait to acquire the lock before failing
|
43
|
+
# @return [Boolean] whether the lock was acquired successfully
|
44
|
+
def acquire(timeout:)
|
45
|
+
timeout_time = Time.now + timeout
|
46
|
+
loop do
|
47
|
+
locked = redis.set(key, owner, nx: true, px: ttl_ms)
|
48
|
+
return true if locked
|
49
|
+
return false if Time.now >= timeout_time
|
50
|
+
sleep(@sleep_interval)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Extend the expiration time of the lock if still held by this owner. If the
|
55
|
+
# lock is no longer held by the owner, this method will fail and return
|
56
|
+
# false. The lock lifetime is extended by the configured ttl.
|
57
|
+
#
|
58
|
+
# @return [Boolean] whether the lock was extended successfully
|
59
|
+
def extend
|
60
|
+
result = eval_script(
|
61
|
+
RedisScripts::EXTEND_SCRIPT,
|
62
|
+
RedisScripts::EXTEND_SCRIPT_HASH,
|
63
|
+
keys: [key],
|
64
|
+
argv: [owner, ttl_ms]
|
65
|
+
)
|
66
|
+
result != 0
|
67
|
+
end
|
68
|
+
|
69
|
+
# Release the lock if still held by this owner. If the lock is no longer
|
70
|
+
# held by the owner, this method will fail and return false.
|
71
|
+
#
|
72
|
+
# @return [Boolean] whether the lock was released successfully
|
73
|
+
def release
|
74
|
+
result = eval_script(
|
75
|
+
RedisScripts::RELEASE_SCRIPT,
|
76
|
+
RedisScripts::RELEASE_SCRIPT_HASH,
|
77
|
+
keys: [key],
|
78
|
+
argv: [owner]
|
79
|
+
)
|
80
|
+
result != 0
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def ttl_ms
|
86
|
+
(ttl * 1000).to_i
|
87
|
+
end
|
88
|
+
|
89
|
+
def eval_script(script, script_hash, keys:, argv:)
|
90
|
+
begin
|
91
|
+
redis.evalsha(script_hash, keys: keys, argv: argv)
|
92
|
+
rescue Redis::CommandError
|
93
|
+
redis.eval(script, keys: keys, argv: argv)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'digest/sha1'
|
2
|
+
|
3
|
+
module MasterLock
|
4
|
+
module RedisScripts
|
5
|
+
RELEASE_SCRIPT = <<EOS
|
6
|
+
if redis.call("GET", KEYS[1]) == ARGV[1]
|
7
|
+
then
|
8
|
+
return redis.call("DEL", KEYS[1])
|
9
|
+
else
|
10
|
+
return 0
|
11
|
+
end
|
12
|
+
EOS
|
13
|
+
RELEASE_SCRIPT_HASH = Digest::SHA1.hexdigest(RELEASE_SCRIPT)
|
14
|
+
|
15
|
+
EXTEND_SCRIPT = <<EOS
|
16
|
+
if redis.call("GET", KEYS[1]) == ARGV[1]
|
17
|
+
then
|
18
|
+
return redis.call("PEXPIRE", KEYS[1], tonumber(ARGV[2]))
|
19
|
+
else
|
20
|
+
return 0
|
21
|
+
end
|
22
|
+
EOS
|
23
|
+
EXTEND_SCRIPT_HASH = Digest::SHA1.hexdigest(EXTEND_SCRIPT)
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module MasterLock
|
2
|
+
# When MasterLock acquires a lock, it registers it with a global registry.
|
3
|
+
# MasterLock will periodically renew all locks that are registered as long as
|
4
|
+
# the thread that acquired the lock is still alive and has not explicitly
|
5
|
+
# released the lock yet. If there is a failure to renew the lock, MasterLock
|
6
|
+
# identifies the lock as having already been released.
|
7
|
+
class Registry
|
8
|
+
Registration = Struct.new(
|
9
|
+
:lock,
|
10
|
+
:mutex,
|
11
|
+
:thread,
|
12
|
+
:acquired_at,
|
13
|
+
:released,
|
14
|
+
:extend_interval
|
15
|
+
)
|
16
|
+
|
17
|
+
# @return [Array<Registration>] currently registered locks
|
18
|
+
attr_reader :locks
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
@locks = []
|
22
|
+
@locks_mutex = Mutex.new
|
23
|
+
end
|
24
|
+
|
25
|
+
# Register a lock to be renewed every extend_interval seconds.
|
26
|
+
#
|
27
|
+
# @param lock [#extend] a currently held lock that can be extended
|
28
|
+
# @param extend_interval [Fixnum] the interval in seconds after before the
|
29
|
+
# lock is extended
|
30
|
+
# @return [Registration] the receipt of registration
|
31
|
+
def register(lock, extend_interval)
|
32
|
+
registration = Registration.new
|
33
|
+
registration.lock = lock
|
34
|
+
registration.mutex = Mutex.new
|
35
|
+
registration.thread = Thread.current
|
36
|
+
registration.acquired_at = Time.now
|
37
|
+
registration.extend_interval = extend_interval
|
38
|
+
registration.released = false
|
39
|
+
@locks_mutex.synchronize do
|
40
|
+
locks << registration
|
41
|
+
end
|
42
|
+
registration
|
43
|
+
end
|
44
|
+
|
45
|
+
# Unregister a lock that has been registered.
|
46
|
+
#
|
47
|
+
# @param registration [Registration] the registration returned by the call
|
48
|
+
# to {#register}
|
49
|
+
def unregister(registration)
|
50
|
+
registration.mutex.synchronize do
|
51
|
+
registration.released = true
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Extend all currently registered locks that have been held longer than the
|
56
|
+
# extend_interval since they were last acquired/extended. If any locks have
|
57
|
+
# expired (should not happen), it will release them.
|
58
|
+
def extend_locks
|
59
|
+
# Make a local copy of the locks array to avoid accessing it outside of the mutex.
|
60
|
+
locks_copy = @locks_mutex.synchronize { locks.dup }
|
61
|
+
locks_copy.each { |registration| extend_lock(registration) }
|
62
|
+
@locks_mutex.synchronize do
|
63
|
+
locks.delete_if(&:released)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def extend_lock(registration)
|
70
|
+
registration.mutex.synchronize do
|
71
|
+
time = Time.now
|
72
|
+
if !registration.thread.alive?
|
73
|
+
registration.released = true
|
74
|
+
elsif !registration.released &&
|
75
|
+
registration.acquired_at + registration.extend_interval < time
|
76
|
+
if registration.lock.extend
|
77
|
+
registration.acquired_at = time
|
78
|
+
else
|
79
|
+
registration.released = true
|
80
|
+
# TODO: Notify of failure somehow
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
data/lib/master_lock/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: master_lock
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jim Posen
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-
|
11
|
+
date: 2016-12-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -83,9 +83,10 @@ files:
|
|
83
83
|
- LICENSE.txt
|
84
84
|
- README.md
|
85
85
|
- Rakefile
|
86
|
-
- bin/console
|
87
|
-
- bin/setup
|
88
86
|
- lib/master_lock.rb
|
87
|
+
- lib/master_lock/redis_lock.rb
|
88
|
+
- lib/master_lock/redis_scripts.rb
|
89
|
+
- lib/master_lock/registry.rb
|
89
90
|
- lib/master_lock/version.rb
|
90
91
|
- master_lock.gemspec
|
91
92
|
homepage: https://github.com/coinbase/master_lock
|
@@ -108,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
109
|
version: '0'
|
109
110
|
requirements: []
|
110
111
|
rubyforge_project:
|
111
|
-
rubygems_version: 2.5.
|
112
|
+
rubygems_version: 2.5.2
|
112
113
|
signing_key:
|
113
114
|
specification_version: 4
|
114
115
|
summary: Inter-process locking library using Redis.
|
data/bin/console
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
require "bundler/setup"
|
4
|
-
require "master_lock"
|
5
|
-
|
6
|
-
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
-
# with your gem easier. You can also use a different console, if you like.
|
8
|
-
|
9
|
-
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
# require "pry"
|
11
|
-
# Pry.start
|
12
|
-
|
13
|
-
require "irb"
|
14
|
-
IRB.start
|