locksy 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43b6b34290b0dedbc0793205bb5cbb1d6e5442ebbcc241998529ed5e4d73d913
4
+ data.tar.gz: b59b6d7e9c999093e30a6119bf96c782d66db865f803ac2c0f2054b76e0e9a27
5
+ SHA512:
6
+ metadata.gz: 06f41badc92181f8d4d28e66fa59723488f78110aa3fbfcc7d974b89953db30684c529b5d08d6d0705900abdcc3f4959267f41bcb0a0e7acd1c6a3b77c547f41
7
+ data.tar.gz: ffe4a652c57acbe76680eb1204f647c0fcf8cf378f7839d4778fe3e6f16279cd3189f59f4ee18f275dd496a8363b4a43c715ec93f433e177a997bd912eeb7922
@@ -0,0 +1,76 @@
1
+ require_relative './lock_interface'
2
+ require_relative './errors'
3
+
4
+ require 'securerandom'
5
+ require 'socket'
6
+
7
+ module Locksy
8
+ class BaseLock < LockInterface
9
+ attr_reader :owner, :default_expiry, :default_extension, :lock_name
10
+ attr_writer :logger
11
+
12
+ # allow injection of a clock to assist testing
13
+ # do not call or set this outside of tests
14
+ attr_writer :_clock
15
+
16
+ # add a class-level flag to allow children to know to stop loops etc.
17
+ @_shutting_down = false
18
+
19
+ def initialize(lock_name: generate_default_lock_name, owner: generate_default_owner,
20
+ default_expiry: 10, default_extension: 10, logger: nil, **_args)
21
+ @owner = owner
22
+ @default_expiry = default_expiry
23
+ @default_extension = default_extension
24
+ @lock_name = lock_name
25
+ @logger = logger
26
+ end
27
+
28
+ # disabling here because we have a no-op implementation... we're expecting this
29
+ # class to be extended
30
+ # rubocop:disable Style/EmptyMethod
31
+ def obtain_lock(expire_after: default_expiry, **_args); end
32
+
33
+ def refresh_lock(expire_after: default_extension, **_args); end
34
+
35
+ def release_lock(**_args); end
36
+ # rubocop:enable Style/EmptyMethod
37
+
38
+ def with_lock(expire_after: default_expiry, **args)
39
+ if (lock_obtained = obtain_lock(expire_after: expire_after, **args))
40
+ yield
41
+ end
42
+ ensure
43
+ release_lock if lock_obtained
44
+ end
45
+
46
+ def self.shutting_down?
47
+ @_shutting_down
48
+ end
49
+
50
+ at_exit do
51
+ @_shutting_down = true
52
+ end
53
+
54
+ protected
55
+
56
+ def now
57
+ (@_clock ||= Time).now.to_f
58
+ end
59
+
60
+ def logger
61
+ @logger ||= Logger.new
62
+ end
63
+
64
+ def expiry(after)
65
+ now + after
66
+ end
67
+
68
+ def generate_default_owner
69
+ "#{Thread.current.object_id}-#{Process.pid}@#{Socket.gethostname}"
70
+ end
71
+
72
+ def generate_default_lock_name
73
+ "#{SecureRandom.base64(12)}.#{generate_default_owner}"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,136 @@
1
+ require_relative './base_lock'
2
+ require 'forwardable'
3
+
4
+ module Locksy
5
+ class DynamoDB < BaseLock
6
+ extend Forwardable
7
+
8
+ attr_reader :dynamo_client, :table_name
9
+
10
+ def_delegators 'self.class'.to_sym, :default_table, :default_client
11
+
12
+ def initialize(dynamo_client: default_client, table_name: default_table, **_args)
13
+ # lazy-load the gem to avoid forcing a dependency on the implementation
14
+ require 'aws-sdk-dynamodb'
15
+ @dynamo_client = dynamo_client
16
+ @table_name = table_name
17
+ @_timeout_stopper = ConditionVariable.new
18
+ @_timeout_mutex = Mutex.new
19
+ super
20
+ end
21
+
22
+ def obtain_lock(expire_after: default_expiry, wait_for: nil, **_args)
23
+ stop_waiting_at = wait_for ? now + wait_for : nil
24
+ expire_at = expiry(expire_after)
25
+ dynamo_client.put_item \
26
+ ({ table_name: table_name,
27
+ item: { id: lock_name, expires: expire_at, lock_owner: owner },
28
+ condition_expression: '(attribute_not_exists(expires) OR expires < :expires) ' \
29
+ 'OR (attribute_not_exists(lock_owner) OR lock_owner = :owner)',
30
+ expression_attribute_values: { ':expires' => now, ':owner' => owner } })
31
+ expire_at
32
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
33
+ if stop_waiting_at && stop_waiting_at > now
34
+ # Retry at a maximum of 1/2 of the remaining time until the
35
+ # current lock expires or the remaining time from the what the
36
+ # caller was willing to wait, subject to a minimum of 0.1s to
37
+ # prevent busy looping.
38
+ _wait_for_timeout \
39
+ ( if (current = retrieve_current_lock).nil?
40
+ 0.1
41
+ else
42
+ [stop_waiting_at - now, [(current[:expires] - now) / 2, 0.1].max].min
43
+ end)
44
+ retry unless self.class.shutting_down?
45
+ end
46
+ raise build_not_owned_error_from_remote
47
+ end
48
+
49
+ def release_lock
50
+ dynamo_client.delete_item \
51
+ ({ table_name: table_name,
52
+ key: { id: lock_name },
53
+ condition_expression: '(attribute_not_exists(lock_owner) OR lock_owner = :owner) ' \
54
+ 'OR (attribute_not_exists(expires) OR expires < :expires)',
55
+ expression_attribute_values: { ':owner' => owner, ':expires' => now } })
56
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
57
+ raise build_not_owned_error_from_remote
58
+ end
59
+
60
+ def refresh_lock(expire_after: default_extension, **_args)
61
+ expire_at = expiry(expire_after)
62
+ dynamo_client.update_item \
63
+ ({ table_name: table_name,
64
+ key: { id: lock_name },
65
+ update_expression: 'SET expires = :expires',
66
+ condition_expression: 'attribute_exists(expires) AND expires > :now ' \
67
+ 'AND lock_owner = :owner',
68
+ expression_attribute_values: { ':expires' => expire_at,
69
+ ':owner' => owner,
70
+ ':now' => now } })
71
+ expire_at
72
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
73
+ obtain_lock expire_after: expire_after
74
+ end
75
+
76
+ def create_table
77
+ dynamo_client.create_table(table_name: table_name,
78
+ key_schema: [{ attribute_name: 'id', key_type: 'HASH' }],
79
+ attribute_definitions: [{ attribute_name: 'id',
80
+ attribute_type: 'S' }],
81
+ provisioned_throughput: { read_capacity_units: 10,
82
+ write_capacity_units: 10 })
83
+ rescue Aws::DynamoDB::Errors::ResourceInUseException => ex
84
+ unless ex.message == 'Cannot create preexisting table' ||
85
+ ex.message.start_with?('Table already exists')
86
+ raise ex
87
+ end
88
+ end
89
+
90
+ def force_unlock!
91
+ dynamo_client.delete_item(table_name: table_name, key: { id: lock_name })
92
+ end
93
+
94
+ class << self
95
+ attr_writer :default_client, :default_table
96
+
97
+ def default_table
98
+ @default_table ||= 'default_locks'
99
+ end
100
+
101
+ def default_client
102
+ @default_client ||= create_client
103
+ end
104
+
105
+ def create_client(**args)
106
+ # require at runtime to avoid a gem dependency
107
+ require 'aws-sdk-dynamodb'
108
+ Aws::DynamoDB::Client.new(**args)
109
+ end
110
+ end
111
+
112
+ def _wait_for_timeout(timeout)
113
+ @_timeout_mutex.synchronize { @_timeout_stopper.wait(@_timeout_mutex, timeout) }
114
+ end
115
+
116
+ def _interrupt_waiting
117
+ @_timeout_mutex.synchronize { @_timeout_stopper.broadcast }
118
+ end
119
+
120
+ private
121
+
122
+ def retrieve_current_lock
123
+ item = dynamo_client.get_item(table_name: table_name, key: { id: lock_name }).item
124
+ return nil if item.nil?
125
+ { lock_name: item['id'], owner: item['lock_owner'], expires: item['expires'] }
126
+ end
127
+
128
+ def build_not_owned_error_from_remote
129
+ current = retrieve_current_lock || {}
130
+ LockNotOwnedError.new(lock: self, current_owner: current['owner'],
131
+ current_expiry: current['expires'])
132
+ rescue RuntimeError # in the case that there is a different error raised, ignore it
133
+ LockNotOwnedError.new(lock: self)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,19 @@
1
+ module Locksy
2
+ class LockNotOwnedError < RuntimeError
3
+ attr_reader :lock, :current_owner, :current_expiry
4
+
5
+ def initialize(msg = nil, lock:, current_owner: nil, current_expiry: nil)
6
+ @lock = lock
7
+ @current_owner = current_owner
8
+ @current_expiry = current_expiry
9
+
10
+ if msg.nil?
11
+ msg = "Unable to manipulate lock #{lock.lock_name} for #{lock.owner}."
12
+ msg += " Lock currently owned by #{current_owner}." if current_owner
13
+ msg += " Lock unnavailable until #{current_expiry}." if current_expiry
14
+ end
15
+
16
+ super msg
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,41 @@
1
+ module Locksy
2
+ # AbstractLock allows us to declare the interface for locks.
3
+ # Any locks created should follow this interface
4
+ #
5
+ # disabling this cop because we are declaring this as an interface and deliberately not using here
6
+ # rubocop:disable Lint/UnusedMethodArgument
7
+ class LockInterface
8
+ attr_reader :owner, :default_expiry, :default_extension, :lock_name
9
+ attr_writer :logger
10
+
11
+ def initialize(lock_name: generate_default_lock_name, owner: generate_default_owner,
12
+ default_expiry: nil, default_extension: nil, logger: nil, **_args)
13
+ raise NotImplementedError.new 'This is an abstract class - instantiation is not supported'
14
+ end
15
+
16
+ # should return a boolean denoting lock obtained (true) or not (false)
17
+ def obtain_lock(expire_after: default_expiry, **_args)
18
+ raise NotImplementedError.new 'Obtaining a lock is not supported'
19
+ end
20
+
21
+ # should raise a LockNotOwnedError if the lock is not owned by the requested owner
22
+ def refresh_lock(expire_after: default_extension, **_args)
23
+ raise NotImplementedError.new 'Refreshing a lock is not supported'
24
+ end
25
+
26
+ # should raise a LockNotOwnedError if the lock is not owned by the requested owner
27
+ def release_lock(**_args)
28
+ raise NotImplementedError.new 'Releasing a lock is not supported'
29
+ end
30
+
31
+ # should raise a LockNotOwnedError if the lock is not owned by the requested owner
32
+ def with_lock(expire_after: default_expiry, **_args)
33
+ raise NotImplementedError.new 'Working with a lock is not supported'
34
+ end
35
+
36
+ protected
37
+
38
+ attr_reader :logger, :generate_default_owner, :generate_default_lock_name
39
+ end
40
+ # rubocop:enable Lint/UnusedMethodArgument
41
+ end
@@ -0,0 +1,88 @@
1
+ require_relative './base_lock'
2
+
3
+ module Locksy
4
+ class Memory < BaseLock
5
+ @_singleton_mutex = Mutex.new
6
+ @_datastore = {}
7
+ @_data_change = ConditionVariable.new
8
+
9
+ def obtain_lock(expire_after: default_expiry, wait_for: nil, **_args)
10
+ stop_waiting_at = wait_for ? now + wait_for : nil
11
+ begin
12
+ current = nil
13
+ self.class._synchronize do
14
+ current = _in_mutex_retrieve_lock
15
+ self.class._datastore[lock_name] = [owner, expiry(expire_after)]
16
+ self.class._notify_data_change
17
+ end
18
+ rescue LockNotOwnedError => ex
19
+ if stop_waiting_at && stop_waiting_at > now
20
+ # Maximum wait time for the condition variable before retrying
21
+ # Because it is possible that a condition variable will not be
22
+ # triggered, or may be triggered by something that is not what
23
+ # was expected.
24
+ # Retry at a maximum of 1/2 of the remaining time until the
25
+ # current lock expires or the remaining time from the what the
26
+ # caller was willing to wait, subject to a minimum of 0.1s to
27
+ # prevent busy looping.
28
+ cv_timeout = [stop_waiting_at - now, [(ex.current_expiry - now) / 2, 0.1].max].min
29
+ self.class._synchronize { self.class._wait_for_data_change(cv_timeout) }
30
+ retry unless self.class.shutting_down?
31
+ end
32
+ raise ex
33
+ end
34
+ end
35
+
36
+ def release_lock
37
+ self.class._synchronize do
38
+ _in_mutex_retrieve_lock
39
+ self.class._datastore.delete lock_name
40
+ self.class._notify_data_change
41
+ end
42
+ end
43
+
44
+ def refresh_lock(expire_after: default_extension, **_args)
45
+ obtain_lock expire_after: expire_after
46
+ end
47
+
48
+ class << self
49
+ attr_reader :_datastore
50
+
51
+ # This is needed to allow tests to inject and control the condition variable
52
+ attr_writer :_data_change
53
+
54
+ def release_all!
55
+ _synchronize { @_datastore = {} }
56
+ end
57
+
58
+ def _synchronize(&blk)
59
+ @_singleton_mutex.synchronize(&blk)
60
+ end
61
+
62
+ # THIS IS DANGEROUS... CALL ONLY WHEN SYNCHRONIZED IN THE MUTEX
63
+ def _wait_for_data_change(timeout = nil)
64
+ @_data_change.wait(@_singleton_mutex, timeout)
65
+ end
66
+
67
+ # THIS IS DANGEROUS... CALL ONLY WHEN SYNCHRONIZED IN THE MUTEX
68
+ def _notify_data_change
69
+ @_data_change.broadcast
70
+ end
71
+ end
72
+
73
+ at_exit do
74
+ _synchronize { _notify_data_change }
75
+ end
76
+
77
+ private
78
+
79
+ def _in_mutex_retrieve_lock
80
+ current = self.class._datastore[lock_name]
81
+ if current && current[0] != owner && current[1] > now
82
+ raise LockNotOwnedError.new(lock: self, current_owner: current[0],
83
+ current_expiry: current[1])
84
+ end
85
+ current
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ require_relative '../locksy'
2
+
3
+ module Locksy
4
+ VERSION = '0.0.1'.freeze
5
+ end
data/lib/locksy.rb ADDED
@@ -0,0 +1,2 @@
1
+ # Forward module declaration
2
+ module Locksy; end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: locksy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - dan@52degreesnorth.com
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-10-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: byebug
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-its
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.57.2
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.57.2
97
+ - !ruby/object:Gem::Dependency
98
+ name: aws-sdk-dynamodb
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description: A tool to provide distributed locking
112
+ email:
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - lib/locksy.rb
118
+ - lib/locksy/base_lock.rb
119
+ - lib/locksy/dynamodb.rb
120
+ - lib/locksy/errors.rb
121
+ - lib/locksy/lock_interface.rb
122
+ - lib/locksy/memory.rb
123
+ - lib/locksy/version.rb
124
+ homepage: https://github.com/danleyden/locksy
125
+ licenses:
126
+ - MIT
127
+ metadata: {}
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubyforge_project:
144
+ rubygems_version: 2.7.10
145
+ signing_key:
146
+ specification_version: 4
147
+ summary: A tool to provide distributed locking
148
+ test_files: []