locksy 0.0.1

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 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: []