distributed-lock-google-cloud-storage 1.0.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 +7 -0
- data/lib/distributed-lock-google-cloud-storage/constants.rb +16 -0
- data/lib/distributed-lock-google-cloud-storage/errors.rb +11 -0
- data/lib/distributed-lock-google-cloud-storage/lock.rb +573 -0
- data/lib/distributed-lock-google-cloud-storage/utils.rb +154 -0
- data/lib/distributed-lock-google-cloud-storage/version.rb +9 -0
- data/lib/distributed-lock-google-cloud-storage.rb +6 -0
- metadata +62 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6262a932fc426a59ec6e6e4515fb925d282e04eb972fd78eb75daa0a81602a98
|
4
|
+
data.tar.gz: 408ae99f1f70e1e13a3f7bd9ee37384291e85dccc933b167259e3863a669043d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d6d47baecddc7660d441c1ef72b9a33337d5763b90e3f7b85a22499ecd3bd2ac2b82649175e36d0e15b38c89bc45722334716d512c8c34e1947cffc72fd7a7f2
|
7
|
+
data.tar.gz: 4abf33f73e4a443a6c93fdcecabe0f7efefccbcea7d56fd448a9448596d8834b93850963c5b39a1a97a9557fead5bb0c9fcd3e807d56a67a37d1d8f545fa5896
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DistributedLock
|
4
|
+
module GoogleCloudStorage
|
5
|
+
# The default lock time-to-live, in seconds.
|
6
|
+
DEFAULT_TTL = 5 * 60
|
7
|
+
|
8
|
+
DEFAULT_TTL_REFRESH_INTERVAL_DIVIDER = 8
|
9
|
+
|
10
|
+
DEFAULT_MAX_REFRESH_FAILS = 3
|
11
|
+
|
12
|
+
DEFAULT_BACKOFF_MIN = 1
|
13
|
+
DEFAULT_BACKOFF_MAX = 30
|
14
|
+
DEFAULT_BACKOFF_MULTIPLIER = 2
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DistributedLock
|
4
|
+
module GoogleCloudStorage
|
5
|
+
class Error < StandardError; end
|
6
|
+
class AlreadyLockedError < Error; end
|
7
|
+
class NotLockedError < Error; end
|
8
|
+
class LockUnhealthyError < Error; end
|
9
|
+
class TimeoutError < Error; end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,573 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'stringio'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'google/cloud/storage'
|
7
|
+
require_relative 'constants'
|
8
|
+
require_relative 'errors'
|
9
|
+
require_relative 'utils'
|
10
|
+
|
11
|
+
module DistributedLock
|
12
|
+
module GoogleCloudStorage
|
13
|
+
class Lock
|
14
|
+
DEFAULT_INSTANCE_IDENTITY_PREFIX_WITHOUT_PID = SecureRandom.hex(12).freeze
|
15
|
+
|
16
|
+
include Utils
|
17
|
+
|
18
|
+
|
19
|
+
# Generates a sane default instance identity prefix string. The result is identical across multiple calls
|
20
|
+
# in the same process. It supports forking, so that calling this method in a forked child process
|
21
|
+
# automatically returns a different value than when called from the parent process.
|
22
|
+
#
|
23
|
+
# The result doesn't include a thread identitier, which is why we call this a prefix.
|
24
|
+
#
|
25
|
+
# @return [String]
|
26
|
+
def self.default_instance_identity_prefix
|
27
|
+
"#{DEFAULT_INSTANCE_IDENTITY_PREFIX_WITHOUT_PID}-#{Process.pid}"
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
# Creates a new Lock instance.
|
32
|
+
#
|
33
|
+
# Under the hood we'll instantiate a
|
34
|
+
# [Google::Cloud::Storage::Bucket](https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/Bucket.html)
|
35
|
+
# object for accessing the bucket. You can customize the project ID, authentication method, etc. through
|
36
|
+
# `cloud_storage_options` and `cloud_storage_bucket_options`.
|
37
|
+
#
|
38
|
+
# @param bucket_name [String] The name of a Cloud Storage bucket in which to place the lock.
|
39
|
+
# This bucket must already exist.
|
40
|
+
# @param path [String] The object path within the bucket to use for locking.
|
41
|
+
# @param instance_identity_prefix [String] A unique identifier for the client of this lock, excluding its thread
|
42
|
+
# identity. Learn more in the readme, section "Instant recovery from stale locks".
|
43
|
+
# @param thread_safe [Boolean] Whether this Lock instance should be thread-safe. When true, the thread's
|
44
|
+
# identity will be included in the instance identity.
|
45
|
+
# @param logger A Logger-compatible object to log progress to. See also the note about thread-safety.
|
46
|
+
# @param logger_mutex A Mutex to synchronize multithreaded writes to the logger.
|
47
|
+
# @param ttl [Numeric] The lock is considered stale if it's age (in seconds) is older than this value.
|
48
|
+
# This value should be generous, in the order of minutes.
|
49
|
+
# @param refresh_interval [Numeric, nil]
|
50
|
+
# We'll refresh the lock's timestamp every `refresh_interval` seconds. This value should be many
|
51
|
+
# times smaller than `ttl`, in order to account for network delays, temporary network errors,
|
52
|
+
# and events that cause the lock to become unhealthy.
|
53
|
+
#
|
54
|
+
# This value must be smaller than `ttl / max_refresh_fails`.
|
55
|
+
#
|
56
|
+
# Default: `ttl / (max_refresh_fails * 3)`
|
57
|
+
# @param max_refresh_fails [Integer]
|
58
|
+
# The lock will be declared unhealthy if refreshing fails with a temporary error this many times consecutively.
|
59
|
+
# If refreshing fails with a permanent error, then the lock is immediately declared unhealthy regardless of this value.
|
60
|
+
# @param backoff_min [Numeric] Minimum amount of time, in seconds, to back off when
|
61
|
+
# waiting for a lock to become available. Must be at least 0.
|
62
|
+
# @param backoff_max [Numeric] Maximum amount of time, in seconds, to back off when
|
63
|
+
# waiting for a lock to become available. Must be at least `backoff_min`.
|
64
|
+
# @param backoff_multiplier [Numeric] Factor to increase the backoff time by, each time
|
65
|
+
# when acquiring the lock fails. Must be at least 0.
|
66
|
+
# @param object_acl [String, nil] A predefined set of access control to apply to the Cloud Storage
|
67
|
+
# object. See the `acl` parameter in
|
68
|
+
# [https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/Bucket.html#create_file-instance_method](Google::Cloud::Storage::Bucket#create_file)
|
69
|
+
# for acceptable values.
|
70
|
+
# @param cloud_storage_options [Hash, nil] Additional options to pass to
|
71
|
+
# {https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage.html#new-class_method Google::Cloud::Storage.new}.
|
72
|
+
# See its documentation to learn which options are available.
|
73
|
+
# @param cloud_storage_bucket_options [Hash, nil] Additional options to pass to
|
74
|
+
# {https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/Project.html#bucket-instance_method Google::Cloud::Storage::Project#bucket}.
|
75
|
+
# See its documentation to learn which options are available.
|
76
|
+
#
|
77
|
+
# @note The logger must either be thread-safe, or all writes to this logger by anything besides
|
78
|
+
# this `Lock` instance must be synchronized through `logger_mutex`. This is because the logger will be
|
79
|
+
# written to by a background thread.
|
80
|
+
# @raise [ArgumentError] When an invalid argument is detected.
|
81
|
+
def initialize(bucket_name:, path:, instance_identity_prefix: self.class.default_instance_identity_prefix,
|
82
|
+
thread_safe: true, logger: Logger.new($stderr), logger_mutex: Mutex.new,
|
83
|
+
ttl: DEFAULT_TTL, refresh_interval: nil, max_refresh_fails: DEFAULT_MAX_REFRESH_FAILS,
|
84
|
+
backoff_min: DEFAULT_BACKOFF_MIN, backoff_max: DEFAULT_BACKOFF_MAX,
|
85
|
+
backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
|
86
|
+
object_acl: nil, cloud_storage_options: nil, cloud_storage_bucket_options: nil)
|
87
|
+
|
88
|
+
check_refresh_interval_allowed!(ttl, refresh_interval, max_refresh_fails)
|
89
|
+
check_backoff_min!(backoff_min)
|
90
|
+
check_backoff_max!(backoff_max, backoff_min)
|
91
|
+
check_backoff_multiplier!(backoff_multiplier)
|
92
|
+
|
93
|
+
|
94
|
+
### Read-only variables (safe to access concurrently) ###
|
95
|
+
|
96
|
+
@bucket_name = bucket_name
|
97
|
+
@path = path
|
98
|
+
@instance_identity_prefix = instance_identity_prefix
|
99
|
+
@thread_safe = thread_safe
|
100
|
+
@logger = logger
|
101
|
+
@logger_mutex = logger_mutex
|
102
|
+
@ttl = ttl
|
103
|
+
@refresh_interval = refresh_interval || ttl.to_f / (max_refresh_fails * 3)
|
104
|
+
@max_refresh_fails = max_refresh_fails
|
105
|
+
@backoff_min = backoff_min
|
106
|
+
@backoff_max = backoff_max
|
107
|
+
@backoff_multiplier = backoff_multiplier
|
108
|
+
@object_acl = object_acl
|
109
|
+
|
110
|
+
@client = create_gcloud_storage_client(cloud_storage_options)
|
111
|
+
@bucket = get_gcloud_storage_bucket(@client, bucket_name, cloud_storage_bucket_options)
|
112
|
+
|
113
|
+
@state_mutex = Mutex.new
|
114
|
+
@refresher_cond = ConditionVariable.new
|
115
|
+
|
116
|
+
|
117
|
+
### Read-write variables protected by @state_mutex ###
|
118
|
+
|
119
|
+
@owner = nil
|
120
|
+
@metageneration = nil
|
121
|
+
@refresher_thread = nil
|
122
|
+
|
123
|
+
# The refresher generation is incremented every time we shutdown
|
124
|
+
# the refresher thread. It allows the refresher thread to know
|
125
|
+
# whether it's being shut down (and thus shouldn't access/modify
|
126
|
+
# state).
|
127
|
+
@refresher_generation = 0
|
128
|
+
end
|
129
|
+
|
130
|
+
# Returns whether this Lock instance's internal state believes that the lock
|
131
|
+
# is currently held by this instance. Does not check whether the lock is stale.
|
132
|
+
#
|
133
|
+
# @return [Boolean]
|
134
|
+
def locked_according_to_internal_state?
|
135
|
+
@state_mutex.synchronize do
|
136
|
+
unsynced_locked_according_to_internal_state?
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns whether the server believes that the lock is currently held by somebody.
|
141
|
+
# Does not check whether the lock is stale.
|
142
|
+
#
|
143
|
+
# @return [Boolean]
|
144
|
+
# @raise [Google::Cloud::Error]
|
145
|
+
def locked_according_to_server?
|
146
|
+
!@bucket.file(@path).nil?
|
147
|
+
end
|
148
|
+
|
149
|
+
# Returns whether this Lock instance's internal state believes that the lock
|
150
|
+
# is held by the current Lock instance in the calling thread.
|
151
|
+
#
|
152
|
+
# @return [Boolean]
|
153
|
+
def owned_according_to_internal_state?
|
154
|
+
@state_mutex.synchronize do
|
155
|
+
unsynced_owned_according_to_internal_state?
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Returns whether the server believes that the lock is held by the current
|
160
|
+
# Lock instance in the calling thread.
|
161
|
+
#
|
162
|
+
# @return [Boolean]
|
163
|
+
# @raise [Google::Cloud::Error]
|
164
|
+
def owned_according_to_server?
|
165
|
+
file = @bucket.file(@path)
|
166
|
+
return false if file.nil?
|
167
|
+
file.metadata['identity'] == identity
|
168
|
+
end
|
169
|
+
|
170
|
+
# Obtains the lock. If the lock is stale, resets it automatically. If the lock is already
|
171
|
+
# obtained by some other instance identity, waits until it becomes available,
|
172
|
+
# or until timeout.
|
173
|
+
#
|
174
|
+
# @param timeout [Numeric] The timeout in seconds.
|
175
|
+
# @return [void]
|
176
|
+
# @raise [AlreadyLockedError] This Lock instance — according to its internal state — believes
|
177
|
+
# that it's already holding the lock.
|
178
|
+
# @raise [TimeoutError] Failed to acquire the lock within `timeout` seconds.
|
179
|
+
# @raise [Google::Cloud::Error]
|
180
|
+
def lock(timeout: 2 * @ttl)
|
181
|
+
raise AlreadyLockedError, 'Already locked' if owned_according_to_internal_state?
|
182
|
+
|
183
|
+
file = retry_with_backoff_until_success(timeout,
|
184
|
+
retry_logger: method(:log_lock_retry),
|
185
|
+
backoff_min: @backoff_min,
|
186
|
+
backoff_max: @backoff_max,
|
187
|
+
backoff_multiplier: @backoff_multiplier) do
|
188
|
+
|
189
|
+
log_debug { 'Acquiring lock' }
|
190
|
+
if (file = create_lock_object)
|
191
|
+
log_debug { 'Successfully acquired lock' }
|
192
|
+
[:success, file]
|
193
|
+
else
|
194
|
+
log_debug { 'Error acquiring lock. Investigating why...' }
|
195
|
+
file = @bucket.file(@path)
|
196
|
+
if file.nil?
|
197
|
+
log_warn { 'Lock was deleted right after having created it. Retrying.' }
|
198
|
+
:retry_immediately
|
199
|
+
elsif file.metadata['identity'] == identity
|
200
|
+
log_warn { 'Lock was already owned by this instance, but was abandoned. Resetting lock' }
|
201
|
+
delete_lock_object(file.metageneration)
|
202
|
+
:retry_immediately
|
203
|
+
else
|
204
|
+
if lock_stale?(file)
|
205
|
+
log_warn { 'Lock is stale. Resetting lock' }
|
206
|
+
delete_lock_object(file.metageneration)
|
207
|
+
else
|
208
|
+
log_debug { 'Lock was already acquired, and is not stale' }
|
209
|
+
end
|
210
|
+
:error
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
refresher_generation = nil
|
216
|
+
@state_mutex.synchronize do
|
217
|
+
@owner = identity
|
218
|
+
@metageneration = file.metageneration
|
219
|
+
spawn_refresher_thread
|
220
|
+
refresher_generation = @refresher_generation
|
221
|
+
end
|
222
|
+
log_debug { "Locked. refresher_generation=#{refresher_generation}, metageneration=#{file.metageneration}" }
|
223
|
+
nil
|
224
|
+
end
|
225
|
+
|
226
|
+
# Releases the lock and stops refreshing the lock in the background.
|
227
|
+
#
|
228
|
+
# @return [Boolean] True if the lock object was actually deleted, false if the lock object
|
229
|
+
# was already deleted.
|
230
|
+
# @raise [NotLockedError] This Lock instance — according to its internal state — believes
|
231
|
+
# that it isn't currently holding the lock.
|
232
|
+
# @raise [Google::Cloud::Error]
|
233
|
+
def unlock
|
234
|
+
refresher_generation = nil
|
235
|
+
metageneration = nil
|
236
|
+
thread = nil
|
237
|
+
|
238
|
+
@state_mutex.synchronize do
|
239
|
+
raise NotLockedError, 'Not locked' if !unsynced_locked_according_to_internal_state?
|
240
|
+
refresher_generation = @refresher_generation
|
241
|
+
thread = shutdown_refresher_thread
|
242
|
+
metageneration = @metageneration
|
243
|
+
@owner = nil
|
244
|
+
@metageneration = nil
|
245
|
+
end
|
246
|
+
|
247
|
+
thread.join
|
248
|
+
result = delete_lock_object(metageneration)
|
249
|
+
log_debug { "Unlocked. refresher_generation=#{refresher_generation}, metageneration=#{metageneration}" }
|
250
|
+
result
|
251
|
+
end
|
252
|
+
|
253
|
+
# Obtains the lock, runs the block, and releases the lock when the block completes.
|
254
|
+
#
|
255
|
+
# If the lock is stale, resets it automatically. If the lock is already
|
256
|
+
# obtained by some other instance identity, waits until it becomes available,
|
257
|
+
# or until timeout.
|
258
|
+
#
|
259
|
+
# Accepts the same parameters as #lock.
|
260
|
+
#
|
261
|
+
# @return The block's return value.
|
262
|
+
# @raise [AlreadyLockedError] This Lock instance — according to its internal state — believes
|
263
|
+
# that it's already holding the lock.
|
264
|
+
# @raise [TimeoutError] Failed to acquire the lock within `timeout` seconds.
|
265
|
+
def synchronize(...)
|
266
|
+
lock(...)
|
267
|
+
begin
|
268
|
+
yield
|
269
|
+
ensure
|
270
|
+
unlock
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
# Pretends like we've never obtained this lock, abandoning our internal state about the lock.
|
275
|
+
#
|
276
|
+
# Shuts down background lock refreshing, and ensures that
|
277
|
+
# #locked_according_to_internal_state? returns false.
|
278
|
+
#
|
279
|
+
# Does not modify any server data, so #locked_according_to_server? may still return true.
|
280
|
+
#
|
281
|
+
# @return [void]
|
282
|
+
def abandon
|
283
|
+
refresher_generation = nil
|
284
|
+
thread = nil
|
285
|
+
|
286
|
+
@state_mutex.synchronize do
|
287
|
+
if unsynced_locked_according_to_internal_state?
|
288
|
+
refresher_generation = @refresher_generation
|
289
|
+
thread = shutdown_refresher_thread
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
if thread
|
294
|
+
log_debug { "Abandoning locked lock" }
|
295
|
+
thread.join
|
296
|
+
log_debug { "Done abandoned locked lock. refresher_generation=#{refresher_generation}" }
|
297
|
+
else
|
298
|
+
log_debug { "Abandoning unlocked lock" }
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
# Returns whether the lock is healthy. A lock is considered healthy until
|
303
|
+
# we fail to refresh the lock too many times consecutively.
|
304
|
+
#
|
305
|
+
# Failure to refresh could happen for many reasons, including but not limited
|
306
|
+
# to: network problems, the lock object being forcefully deleted by someone else.
|
307
|
+
#
|
308
|
+
# "Too many" is defined by the `max_refresh_fails` argument passed to the constructor.
|
309
|
+
#
|
310
|
+
# It only makes sense to call this method after having obtained this lock.
|
311
|
+
#
|
312
|
+
# @return [Boolean]
|
313
|
+
# @raise [NotLockedError] This lock was not obtained.
|
314
|
+
def healthy?
|
315
|
+
@state_mutex.synchronize do
|
316
|
+
raise NotLockedError, 'Not locked' if !unsynced_locked_according_to_internal_state?
|
317
|
+
@refresher_thread.alive?
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# Checks whether the lock is healthy. See #healthy? for the definition of "healthy".
|
322
|
+
#
|
323
|
+
# It only makes sense to call this method after having obtained this lock.
|
324
|
+
#
|
325
|
+
# @return [void]
|
326
|
+
# @raise [LockUnhealthyError] When an unhealthy state is detected.
|
327
|
+
# @raise [NotLockedError] This lock was not obtained.
|
328
|
+
def check_health!
|
329
|
+
raise LockUnhealthyError, 'Lock is not healthy' if !healthy?
|
330
|
+
end
|
331
|
+
|
332
|
+
|
333
|
+
private
|
334
|
+
|
335
|
+
# @param ttl [Numeric]
|
336
|
+
# @param refresh_interval [Numeric]
|
337
|
+
# @param max_refresh_fails [Integer]
|
338
|
+
# @return [void]
|
339
|
+
# @raise [ArgumentError]
|
340
|
+
def check_refresh_interval_allowed!(ttl, refresh_interval, max_refresh_fails)
|
341
|
+
if refresh_interval && refresh_interval >= ttl.to_f / max_refresh_fails
|
342
|
+
raise ArgumentError, 'refresh_interval must be smaller than ttl / max_refresh_fails'
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
# @param backoff_min [Numeric]
|
347
|
+
# @return [void]
|
348
|
+
# @raise [ArgumentError]
|
349
|
+
def check_backoff_min!(backoff_min)
|
350
|
+
if backoff_min < 0
|
351
|
+
raise ArgumentError, 'backoff_min must be at least 0'
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# @param backoff_max [Numeric]
|
356
|
+
# @param backoff_min [Numeric]
|
357
|
+
# @return [void]
|
358
|
+
# @raise [ArgumentError]
|
359
|
+
def check_backoff_max!(backoff_max, backoff_min)
|
360
|
+
if backoff_max < backoff_min
|
361
|
+
raise ArgumentError, 'backoff_max may not be smaller than backoff_min'
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# @param backoff_multiplier [Numeric]
|
366
|
+
# @return [void]
|
367
|
+
# @raise [ArgumentError]
|
368
|
+
def check_backoff_multiplier!(backoff_multiplier)
|
369
|
+
if backoff_multiplier < 0
|
370
|
+
raise ArgumentError, 'backoff_multiplier must be at least 0'
|
371
|
+
end
|
372
|
+
end
|
373
|
+
|
374
|
+
|
375
|
+
# @param options [Hash]
|
376
|
+
# @return [Google::Cloud::Storage::Project]
|
377
|
+
def create_gcloud_storage_client(options)
|
378
|
+
options ||= {}
|
379
|
+
Google::Cloud::Storage.new(**options)
|
380
|
+
end
|
381
|
+
|
382
|
+
# @param client [Google::Cloud::Storage::Project]
|
383
|
+
# @param bucket_name [String]
|
384
|
+
# @param options [Hash]
|
385
|
+
# @return [Google::Cloud::Storage::Bucket]
|
386
|
+
def get_gcloud_storage_bucket(client, bucket_name, options)
|
387
|
+
options ||= {}
|
388
|
+
client.bucket(bucket_name, skip_lookup: true, **options)
|
389
|
+
end
|
390
|
+
|
391
|
+
# @return [String]
|
392
|
+
def identity
|
393
|
+
result = @instance_identity_prefix
|
394
|
+
result = "#{result}/thr-#{Thread.current.object_id.to_s(36)}" if @thread_safe
|
395
|
+
result
|
396
|
+
end
|
397
|
+
|
398
|
+
def unsynced_locked_according_to_internal_state?
|
399
|
+
!@owner.nil?
|
400
|
+
end
|
401
|
+
|
402
|
+
def unsynced_owned_according_to_internal_state?
|
403
|
+
@owner == identity
|
404
|
+
end
|
405
|
+
|
406
|
+
# Creates the lock object in Cloud Storage. Returns a Google::Cloud::Storage::File
|
407
|
+
# on success, or nil if object already exists.
|
408
|
+
#
|
409
|
+
# @return [Google::Cloud::Storage::File, nil]
|
410
|
+
def create_lock_object
|
411
|
+
@bucket.create_file(
|
412
|
+
StringIO.new,
|
413
|
+
@path,
|
414
|
+
acl: @object_acl,
|
415
|
+
cache_control: 'no-store',
|
416
|
+
metadata: {
|
417
|
+
expires_at: (Time.now + @ttl).to_f,
|
418
|
+
identity: identity,
|
419
|
+
},
|
420
|
+
if_generation_match: 0,
|
421
|
+
)
|
422
|
+
rescue Google::Cloud::FailedPreconditionError
|
423
|
+
nil
|
424
|
+
end
|
425
|
+
|
426
|
+
# @param expected_metageneration [Integer]
|
427
|
+
# @return [Boolean] True if deletion was successful or if file did
|
428
|
+
# not exist, false if the metageneration did not match.
|
429
|
+
def delete_lock_object(expected_metageneration)
|
430
|
+
file = @bucket.file(@path, skip_lookup: true)
|
431
|
+
file.delete(if_metageneration_match: expected_metageneration)
|
432
|
+
rescue Google::Cloud::NotFoundError
|
433
|
+
false
|
434
|
+
rescue Google::Cloud::FailedPreconditionError
|
435
|
+
false
|
436
|
+
end
|
437
|
+
|
438
|
+
# @param file [Google::Cloud::Storage::File]
|
439
|
+
# @return [Boolean]
|
440
|
+
def lock_stale?(file)
|
441
|
+
Time.now.to_f > file.metadata['expires_at'].to_f
|
442
|
+
end
|
443
|
+
|
444
|
+
# @param sleep_time [Numeric]
|
445
|
+
# @return [void]
|
446
|
+
def log_lock_retry(sleep_time)
|
447
|
+
log_info do
|
448
|
+
sprintf("Unable to acquire lock. Will try again in %.1f seconds", sleep_time)
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
# @return [void]
|
453
|
+
def log_error(&block)
|
454
|
+
@logger_mutex.synchronize do
|
455
|
+
@logger.error(&block)
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
# @return [void]
|
460
|
+
def log_warn(&block)
|
461
|
+
@logger_mutex.synchronize do
|
462
|
+
@logger.warn(&block)
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
# @return [void]
|
467
|
+
def log_info(&block)
|
468
|
+
@logger_mutex.synchronize do
|
469
|
+
@logger.info(&block)
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
# @return [void]
|
474
|
+
def log_debug(&block)
|
475
|
+
@logger_mutex.synchronize do
|
476
|
+
@logger.debug(&block)
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
# @return [void]
|
481
|
+
def spawn_refresher_thread
|
482
|
+
@refresher_thread = Thread.new(@refresher_generation) do |refresher_generation|
|
483
|
+
refresher_thread_main(refresher_generation)
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
# Signals (but does not wait for) the refresher thread to shut down.
|
488
|
+
#
|
489
|
+
# @return [Thread]
|
490
|
+
def shutdown_refresher_thread
|
491
|
+
thread = @refresher_thread
|
492
|
+
@refresher_generation += 1
|
493
|
+
@refresher_cond.signal
|
494
|
+
@refresher_thread = nil
|
495
|
+
thread
|
496
|
+
end
|
497
|
+
|
498
|
+
# @param [Integer] refresher_generation
|
499
|
+
# @return [void]
|
500
|
+
def refresher_thread_main(refresher_generation)
|
501
|
+
params = {
|
502
|
+
mutex: @state_mutex,
|
503
|
+
cond: @refresher_cond,
|
504
|
+
interval: @refresh_interval,
|
505
|
+
max_failures: @max_refresh_fails,
|
506
|
+
check_quit: lambda { @refresher_generation != refresher_generation },
|
507
|
+
schedule_calculated: lambda { |timeout|
|
508
|
+
log_debug { sprintf("Next lock refresh in %.1fs", timeout) }
|
509
|
+
}
|
510
|
+
}
|
511
|
+
|
512
|
+
result, permanent = work_regularly(**params) do
|
513
|
+
refresh_lock(refresher_generation)
|
514
|
+
end
|
515
|
+
|
516
|
+
if !result
|
517
|
+
if permanent
|
518
|
+
log_error do
|
519
|
+
"Lock refresh failed permanently." \
|
520
|
+
' Declaring lock as unhealthy'
|
521
|
+
end
|
522
|
+
else
|
523
|
+
log_error do
|
524
|
+
"Lock refresh failed #{@max_refresh_fails} times in succession." \
|
525
|
+
' Declaring lock as unhealthy'
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
529
|
+
log_debug { 'Exiting refresher thread' }
|
530
|
+
end
|
531
|
+
|
532
|
+
# @param [Integer] refresher_generation
|
533
|
+
# @return [Boolean, Array<true, :permanent>]
|
534
|
+
def refresh_lock(refresher_generation)
|
535
|
+
permanent_failure = false
|
536
|
+
|
537
|
+
metageneration = @state_mutex.synchronize do
|
538
|
+
return true if @refresher_generation != refresher_generation
|
539
|
+
@metageneration
|
540
|
+
end
|
541
|
+
|
542
|
+
log_info { 'Refreshing lock' }
|
543
|
+
begin
|
544
|
+
file = @bucket.file(@path, skip_lookup: true)
|
545
|
+
begin
|
546
|
+
file.update(if_metageneration_match: metageneration) do |f|
|
547
|
+
f.metadata['expires_at'] = (Time.now + @ttl).to_f
|
548
|
+
end
|
549
|
+
rescue Google::Cloud::FailedPreconditionError
|
550
|
+
permanent_failure = :permanent
|
551
|
+
raise 'Lock object has an unexpected metageneration number'
|
552
|
+
rescue Google::Cloud::NotFoundError
|
553
|
+
permanent_failure = :permanent
|
554
|
+
raise 'Lock object has been unexpectedly deleted'
|
555
|
+
end
|
556
|
+
|
557
|
+
@state_mutex.synchronize do
|
558
|
+
if @refresher_generation != refresher_generation
|
559
|
+
log_debug { 'Abort refreshing lock' }
|
560
|
+
return true
|
561
|
+
end
|
562
|
+
@metageneration = file.metageneration
|
563
|
+
end
|
564
|
+
log_debug { "Done refreshing lock. metageneration=#{file.metageneration}" }
|
565
|
+
true
|
566
|
+
rescue => e
|
567
|
+
log_error { "Error refreshing lock: #{e}" }
|
568
|
+
[false, permanent_failure]
|
569
|
+
end
|
570
|
+
end
|
571
|
+
end
|
572
|
+
end
|
573
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DistributedLock
|
4
|
+
module GoogleCloudStorage
|
5
|
+
module Utils
|
6
|
+
private
|
7
|
+
|
8
|
+
# Queries the monotonic clock and returns its timestamp, in seconds.
|
9
|
+
#
|
10
|
+
# @return [Float]
|
11
|
+
def monotonic_time
|
12
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
# Runs the given block until it succeeds, or until we timeout.
|
17
|
+
#
|
18
|
+
# The block must return `:success`, `[:success, any value]`, `:error` or
|
19
|
+
# `:retry_immediately`.
|
20
|
+
#
|
21
|
+
# Every time the block returns `:error`, we sleep for an amount of time and
|
22
|
+
# then we try again. This sleep time increases exponentially but is subject
|
23
|
+
# to random jitter.
|
24
|
+
#
|
25
|
+
# If the block returns `:retry_immediately` then we retry immediately without
|
26
|
+
# sleeping.
|
27
|
+
#
|
28
|
+
# If the block returns `[:success, any value]` then this function returns `any value`.
|
29
|
+
# If the block returns just `:success` then this function returns nil.
|
30
|
+
#
|
31
|
+
# @param timeout [Numeric]
|
32
|
+
# @param retry_logger [#call] Will be called every time we retry. The parameter
|
33
|
+
# passed is a Float, indicating the seconds that we'll sleep until the next retry.
|
34
|
+
# @param backoff_min [Numeric]
|
35
|
+
# @param backoff_max [Numeric]
|
36
|
+
# @param backoff_multiplier [Numeric]
|
37
|
+
# @yield
|
38
|
+
# @return The block's return value, or nil.
|
39
|
+
def retry_with_backoff_until_success(timeout,
|
40
|
+
retry_logger: nil,
|
41
|
+
backoff_min: DEFAULT_BACKOFF_MIN,
|
42
|
+
backoff_max: DEFAULT_BACKOFF_MAX,
|
43
|
+
backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER)
|
44
|
+
|
45
|
+
sleep_time = backoff_min
|
46
|
+
deadline = monotonic_time + timeout
|
47
|
+
|
48
|
+
while true
|
49
|
+
result, retval = yield
|
50
|
+
case result
|
51
|
+
when :success
|
52
|
+
return retval
|
53
|
+
when :error
|
54
|
+
raise TimeoutError if monotonic_time >= deadline
|
55
|
+
retry_logger.call(sleep_time) if retry_logger
|
56
|
+
sleep(sleep_time)
|
57
|
+
sleep_time = calc_sleep_time(sleep_time, backoff_min, backoff_max, backoff_multiplier)
|
58
|
+
when :retry_immediately
|
59
|
+
raise TimeoutError if monotonic_time >= deadline
|
60
|
+
else
|
61
|
+
raise "Bug: block returned unknown result #{result.inspect}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# See https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
67
|
+
# for a discussion on jittering approaches. The best choices are "Full Jitter"
|
68
|
+
# (fewest contended calls) and "Decorrelated Jitter" (lowest completion time).
|
69
|
+
# We choose "Decorrelated Jitter" because we care most about completion time:
|
70
|
+
# Google can probably handle a slight increase in contended calls.
|
71
|
+
#
|
72
|
+
# @param last_value [Numeric]
|
73
|
+
# @param backoff_min [Numeric]
|
74
|
+
# @param backoff_max [Numeric]
|
75
|
+
# @param backoff_multiplier [Numeric]
|
76
|
+
# @return [Numeric]
|
77
|
+
def calc_sleep_time(last_value, backoff_min, backoff_max, backoff_multiplier)
|
78
|
+
result = rand(backoff_min.to_f .. (last_value * backoff_multiplier).to_f)
|
79
|
+
[result, backoff_max].min
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
# Once every `interval` seconds, yields the given block, until `check_quit` returns true
|
84
|
+
# until the block temporarily fails `max_failures` times in succession, or until the block
|
85
|
+
# permanently fails.
|
86
|
+
#
|
87
|
+
# Sleeping between yields works by sleeping on the given condition variable. You can thus
|
88
|
+
# force this function to wake up (e.g. to tell it that it should quit) by signalling the
|
89
|
+
# condition variable.
|
90
|
+
#
|
91
|
+
# We take the lock while the block is being yielded. We release the lock while we're
|
92
|
+
# sleeping.
|
93
|
+
#
|
94
|
+
# @param mutex [Mutex]
|
95
|
+
# @param cond [ConditionVariable]
|
96
|
+
# @param interval [Numeric]
|
97
|
+
# @param max_failures [Integer]
|
98
|
+
# @param check_quit [#call] Will be called regularly to check whether we should stop.
|
99
|
+
# This callable must return a Boolean.
|
100
|
+
# @param schedule_calculated [#call, nil]
|
101
|
+
# @yield
|
102
|
+
# @yieldreturn [Boolean, Array<false, :permanent>] `true` to indicate success,
|
103
|
+
# `false` to indicate temporary failure, `[false, :permanent]` to indicate permanent failure.
|
104
|
+
# @return [Boolean, Array<false, :permanent>] `true` if this function stopped because `check_quit` returned true,
|
105
|
+
# `false` if this function stopped because the block temporarily failed `max_failures` times in succession,
|
106
|
+
# `[false, :permanent]` if this function stopped because the block failed permanently.
|
107
|
+
def work_regularly(mutex:, cond:, interval:, max_failures:, check_quit:, schedule_calculated: nil)
|
108
|
+
fail_count = 0
|
109
|
+
next_time = monotonic_time + interval
|
110
|
+
permanent_failure = false
|
111
|
+
|
112
|
+
mutex.synchronize do
|
113
|
+
while !check_quit.call && fail_count < max_failures && !permanent_failure
|
114
|
+
timeout = [0, next_time - monotonic_time].max
|
115
|
+
schedule_calculated.call(timeout) if schedule_calculated
|
116
|
+
wait_on_condition_variable(mutex, cond, timeout)
|
117
|
+
break if check_quit.call
|
118
|
+
|
119
|
+
# Timed out; perform work now
|
120
|
+
next_time = monotonic_time + interval
|
121
|
+
mutex.unlock
|
122
|
+
begin
|
123
|
+
result, permanent = yield
|
124
|
+
ensure
|
125
|
+
mutex.lock
|
126
|
+
end
|
127
|
+
|
128
|
+
if result
|
129
|
+
fail_count = 0
|
130
|
+
elsif permanent == :permanent
|
131
|
+
permanent_failure = true
|
132
|
+
else
|
133
|
+
fail_count += 1
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
if permanent_failure
|
139
|
+
[false, :permanent]
|
140
|
+
else
|
141
|
+
fail_count < max_failures
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# @param mutex [Mutex]
|
146
|
+
# @param cond [ConditionVariable]
|
147
|
+
# @param timeout [Numeric]
|
148
|
+
# @return [void]
|
149
|
+
def wait_on_condition_variable(mutex, cond, timeout)
|
150
|
+
cond.wait(mutex, timeout)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,6 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'distributed-lock-google-cloud-storage/constants'
|
4
|
+
require_relative 'distributed-lock-google-cloud-storage/errors'
|
5
|
+
require_relative 'distributed-lock-google-cloud-storage/utils'
|
6
|
+
require_relative 'distributed-lock-google-cloud-storage/lock'
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: distributed-lock-google-cloud-storage
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hongli Lai
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-09-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: google-cloud-storage
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.32'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.32'
|
27
|
+
description: A distributed lock based on Google Cloud Storage
|
28
|
+
email: honglilai@gmail.com
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- lib/distributed-lock-google-cloud-storage.rb
|
34
|
+
- lib/distributed-lock-google-cloud-storage/constants.rb
|
35
|
+
- lib/distributed-lock-google-cloud-storage/errors.rb
|
36
|
+
- lib/distributed-lock-google-cloud-storage/lock.rb
|
37
|
+
- lib/distributed-lock-google-cloud-storage/utils.rb
|
38
|
+
- lib/distributed-lock-google-cloud-storage/version.rb
|
39
|
+
homepage: https://github.com/FooBarWidget/distributed-lock-google-cloud-storage-ruby
|
40
|
+
licenses:
|
41
|
+
- MIT
|
42
|
+
metadata: {}
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: '2.7'
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
requirements: []
|
58
|
+
rubygems_version: 3.1.6
|
59
|
+
signing_key:
|
60
|
+
specification_version: 4
|
61
|
+
summary: Distributed lock based on Google Cloud Storage
|
62
|
+
test_files: []
|