master_lock 0.1.0 → 0.8.0

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