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