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 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DistributedLock
4
+ module GoogleCloudStorage
5
+ module Version
6
+ VERSION_STRING = '1.0.0'
7
+ end
8
+ end
9
+ 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: []