distributed-lock-google-cloud-storage 1.0.0

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