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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 66672f888e12006963d8dabbd231b62d140d9a1a
4
- data.tar.gz: db57a3095ea591040a5e92a44c0479da528c1248
3
+ metadata.gz: 321dbc834c9f475dac38543343c696ae1eafda08
4
+ data.tar.gz: 5f764d38a83de5434bf318944de4dc4b478202e1
5
5
  SHA512:
6
- metadata.gz: abf1344d1976edbb88d59c542c0de4c936467589a14c32b015f47616e535dbd9015e8de0966afbfb16a5a7822f71d9baccac1f5e12d009b33f4ad8538df153e9
7
- data.tar.gz: 277bddc942ef6085421e1d6efca315e9dd70b13271cc5a71de360a1d092c3f5593777245b4a846ac4881ebc9ff678133276c92c70dbb196446f5ae5c2572bfcc
6
+ metadata.gz: 67f9681ccef00d1fa3e4956524fc419dbb097b770ebb985cbdf16a4f584ecdaaee7220ff4ab68d9699723ebef24131002ee5cc0ec4b42338e79114618c4c145a
7
+ data.tar.gz: e6b55e755805fc89ad8c26a942e3d6be4770631285d1ed207ad250c2dc5ad95c7d3b77315ec48035e1cd2a317e8dae4235f87f340af349eebe1ea938a4e9bfe9
data/.travis.yml CHANGED
@@ -3,5 +3,5 @@ language: ruby
3
3
  rvm:
4
4
  - 2.3.1
5
5
  services:
6
- - redis_server
6
+ - redis-server
7
7
  before_install: gem install bundler -v 1.13.1
data/Gemfile CHANGED
@@ -2,3 +2,7 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in master_lock.gemspec
4
4
  gemspec
5
+
6
+ group :test do
7
+ gem 'coveralls', '~> 0.8', require: false
8
+ end
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2016 Jim Posen
3
+ Copyright (c) 2016 Coinbase, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # MasterLock
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/master_lock`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ [![Build Status](https://travis-ci.org/coinbase/master_lock.svg?branch=master)](https://travis-ci.org/coinbase/master_lock)
4
+ [![Coverage Status](https://coveralls.io/repos/github/coinbase/master_lock/badge.svg?branch=master)](https://coveralls.io/github/coinbase/master_lock?branch=master)
5
+ [![Gem Version](https://badge.fury.io/rb/master_lock.svg)](https://badge.fury.io/rb/master_lock)
4
6
 
5
- TODO: Delete this and the text above, and describe your gem
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/[USERNAME]/master_lock.
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 "master_lock/version"
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
@@ -1,3 +1,3 @@
1
1
  module MasterLock
2
- VERSION = "0.1.0"
2
+ VERSION = "0.8.0"
3
3
  end
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.1.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-10-01 00:00:00.000000000 Z
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.1
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
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here