redis_queued_locks 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +90 -38
  3. data/.claude/rules/acquirer.md +12 -7
  4. data/.claude/rules/arguments.md +2 -2
  5. data/.claude/rules/logic.md +5 -4
  6. data/.claude/rules/tests.md +4 -4
  7. data/.claude/rules/type-checking.md +2 -3
  8. data/.claude/rules/visitors.md +8 -6
  9. data/.claude/skills/rql-code-review/SKILL.md +25 -20
  10. data/.ruby-version +1 -1
  11. data/CHANGELOG.md +30 -1
  12. data/CLAUDE.md +5 -4
  13. data/README.md +259 -103
  14. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +4 -4
  15. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +582 -276
  16. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +29 -12
  17. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +36 -27
  18. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +13 -13
  19. data/lib/redis_queued_locks/acquirer/lock_series/instr_visitor.rb +243 -0
  20. data/lib/redis_queued_locks/acquirer/lock_series/log_visitor.rb +472 -0
  21. data/lib/redis_queued_locks/acquirer/lock_series/release_lock_series.rb +181 -0
  22. data/lib/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rb +1051 -0
  23. data/lib/redis_queued_locks/acquirer/lock_series.rb +957 -0
  24. data/lib/redis_queued_locks/acquirer.rb +1 -1
  25. data/lib/redis_queued_locks/client.rb +55 -25
  26. data/lib/redis_queued_locks/errors.rb +8 -0
  27. data/lib/redis_queued_locks/version.rb +2 -2
  28. data/redis_queued_locks.gemspec +21 -14
  29. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +1 -1
  30. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +106 -5
  31. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +1 -1
  32. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  33. data/sig/redis_queued_locks/acquirer/lock_series/instr_visitor.rbs +102 -0
  34. data/sig/redis_queued_locks/acquirer/lock_series/log_visitor.rbs +154 -0
  35. data/sig/redis_queued_locks/acquirer/lock_series/release_lock_series.rbs +37 -0
  36. data/sig/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rbs +198 -0
  37. data/sig/redis_queued_locks/acquirer/lock_series.rbs +106 -0
  38. data/sig/redis_queued_locks/client.rbs +70 -3
  39. data/sig/redis_queued_locks/errors.rbs +6 -0
  40. metadata +32 -11
  41. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +0 -61
  42. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +0 -104
  43. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +0 -513
@@ -0,0 +1,957 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lock series: a group of locks that are obtained atomically, held together and released together
4
+ # (the second part of the lock acquisition core next to `RedisQueuedLocks::Acquirer::AcquireLock`:
5
+ # it reuses the mixins of `AcquireLock`):
6
+ # - all or nothing: one transaction obtains all locks of the series with the same ttl at the same
7
+ # moment; the attempt that can not obtain some lock obtains nothing (see `TryToLockSeries`);
8
+ # - nothing is held while the series waits for its locks, so the series can not hold locks
9
+ # longer than its `ttl` and can not block other acquirers with partially obtained locks;
10
+ # - the lock series are deadlock-free for any order of the lock names (one position of the lock
11
+ # request in all queues of the series, see `TryToLockSeries`);
12
+ # - the series limits are the limits of the whole series: `timeout`, `retry_count`, `queue_ttl`;
13
+ # - one `rw_mode`, one `priority`, one `ttl` for all locks of the series;
14
+ # - the locks of the series are classic locks with the classic keys: lock info, `#unlock`,
15
+ # `#extend_lock_ttl`, the swarm and other operations work with each of them separately;
16
+ # - reentrant locks (`conflict_strategy`) are processed per lock: they are not obtained and not
17
+ # released by the series;
18
+ #
19
+ # @api private
20
+ # @since 1.23.0
21
+ # @version 1.23.0
22
+ # rubocop:disable Metrics/ModuleLength
23
+ # rubocop:disable Metrics/ClassLength
24
+ # rubocop:disable Metrics/BlockNesting
25
+ module RedisQueuedLocks::Acquirer::LockSeries
26
+ require_relative 'lock_series/log_visitor'
27
+ require_relative 'lock_series/instr_visitor'
28
+ require_relative 'lock_series/release_lock_series'
29
+ require_relative 'lock_series/try_to_lock_series'
30
+
31
+ # @since 1.23.0
32
+ extend RedisQueuedLocks::Utilities
33
+ # @since 1.23.0
34
+ extend RedisQueuedLocks::Acquirer::AcquireLock::DelayExecution
35
+ # @since 1.23.0
36
+ extend RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
37
+ # @since 1.23.0
38
+ extend TryToLockSeries
39
+
40
+ class << self
41
+ # @param redis [RedisClient]
42
+ # Redis connection client.
43
+ # @param lock_names [Array<String>]
44
+ # Controlled resources (a duplicated name is the same lock). At least one lock name.
45
+ # @option detailed_result [Boolean]
46
+ # Return the detailed result of the series (the block result, times, lock keys).
47
+ # @option process_id [Integer,String]
48
+ # The process that want to acquire the locks.
49
+ # @option thread_id [Integer,String]
50
+ # The process's thread that want to acquire the locks.
51
+ # @option fiber_id [Integer,String]
52
+ # A current fiber that want to acquire the locks.
53
+ # @option ractor_id [Integer,String]
54
+ # The current ractor that want to acquire the locks.
55
+ # @option ttl [Integer]
56
+ # The time to live of each lock of the series (in milliseconds). A positive Integer.
57
+ # @option queue_ttl [Integer]
58
+ # Lifetime of the lock request of the series (in seconds).
59
+ # @option lock_index_enabled [Boolean]
60
+ # Maintain the lock index (`config['lock_index_enabled']`, the client-wide mode).
61
+ # @option lock_index_grace_period [Integer]
62
+ # The lock index score margin over the lock key lifetime (in milliseconds).
63
+ # @option timeout [Integer,NilClass]
64
+ # Time period whe should try to acquire the whole series (in seconds). Nil means "without
65
+ # timeout" (`0` is the same as `nil`: `Timeout.timeout(0)` sets no timeout).
66
+ # @option timed [Boolean]
67
+ # Limit the invocation time period of the passed block of code by the `ttl`.
68
+ # @option retry_count [Integer,NilClass]
69
+ # How many times we should try to acquire the whole series. Nil means "infinite retries".
70
+ # @option retry_delay [Integer]
71
+ # A time-interval between the each retry (in milliseconds).
72
+ # @option retry_jitter [Integer]
73
+ # Time-shift range for retry-delay (in milliseconds).
74
+ # @option raise_errors [Boolean]
75
+ # Raise errors on exceptional cases.
76
+ # @option instrumenter [#notify]
77
+ # See RedisQueuedLocks::Instrument::ActiveSupport for example.
78
+ # @option identity [String]
79
+ # Unique acquire identifier (see `AcquireLock.acquire_lock`).
80
+ # @option fail_fast [Boolean]
81
+ # Should the required locks to be checked before the try and exit immidetly if any lock is
82
+ # already obtained.
83
+ # @option meta [NilClass,Hash<String|Symbol,Any>]
84
+ # - A custom metadata wich will be passed to the data of each lock of the series;
85
+ # - Metadata can not contain reserved lock data keys;
86
+ # @option detailed_acq_timeout_error [Boolean]
87
+ # Add the state of the lock that blocks the series to the acquirement timeout error.
88
+ # @option logger [::Logger,#debug]
89
+ # Logger object used from the configuration layer (see config['logger']).
90
+ # @option log_lock_try [Boolean]
91
+ # Should be logged the each try of the series acquiring (see `config['log_lock_try']`).
92
+ # @option instrument [NilClass,Any]
93
+ # Custom instrumentation data wich will be passed to the instrumenter's payload.
94
+ # @option conflict_strategy [Symbol]
95
+ # The conflict strategy of each lock of the series (see `AcquireLock.acquire_lock`).
96
+ # @option rw_mode [Symbol]
97
+ # The mode of all locks of the series: `:write` or `:read` (see `AcquireLock.acquire_lock`).
98
+ # @option access_strategy [Symbol]
99
+ # The way in which the locks will be obtained (see `AcquireLock.acquire_lock`).
100
+ # @option priority [Integer,Symbol]
101
+ # The lock request priority of the series (see `AcquireLock.acquire_lock`).
102
+ # @option lock_priorities [Hash<Symbol,Integer>]
103
+ # The priority names (`config['lock_priorities']`): a priority name => its Integer priority.
104
+ # @option log_sampling_enabled [Boolean]
105
+ # See `AcquireLock.acquire_lock`.
106
+ # @option log_sampling_percent [Integer]
107
+ # See `AcquireLock.acquire_lock`.
108
+ # @option log_sampler [#sampling_happened?,Module<RedisQueuedLocks::Logging::Sampler>]
109
+ # See `AcquireLock.acquire_lock`.
110
+ # @option log_sample_this [Boolean]
111
+ # See `AcquireLock.acquire_lock`.
112
+ # @option instr_sampling_enabled [Boolean]
113
+ # See `AcquireLock.acquire_lock`.
114
+ # @option instr_sampling_percent [Integer]
115
+ # See `AcquireLock.acquire_lock`.
116
+ # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
117
+ # See `AcquireLock.acquire_lock`.
118
+ # @option instr_sample_this [Boolean]
119
+ # See `AcquireLock.acquire_lock`.
120
+ # @yield A block of code that should be executed under all obtained locks.
121
+ # @yieldreturn [Any]
122
+ # @return [Hash<Symbol,Any>,yield]
123
+ # - Format: { ok: true/false, result: Any };
124
+ # - If block is given the result of block's yield will be returned;
125
+ # - `detailed_result: true`: the detailed result of the obtained series;
126
+ #
127
+ # @api private
128
+ # @since 1.23.0
129
+ # @version 1.23.1
130
+ # rubocop:disable Metrics/MethodLength
131
+ def lock_series(
132
+ redis,
133
+ lock_names,
134
+ detailed_result:,
135
+ process_id:,
136
+ thread_id:,
137
+ fiber_id:,
138
+ ractor_id:,
139
+ ttl:,
140
+ queue_ttl:,
141
+ lock_index_enabled:,
142
+ lock_index_grace_period:,
143
+ timeout:,
144
+ timed:,
145
+ retry_count:,
146
+ retry_delay:,
147
+ retry_jitter:,
148
+ raise_errors:,
149
+ instrumenter:,
150
+ identity:,
151
+ fail_fast:,
152
+ meta:,
153
+ detailed_acq_timeout_error:,
154
+ instrument:,
155
+ logger:,
156
+ log_lock_try:,
157
+ conflict_strategy:,
158
+ rw_mode:,
159
+ access_strategy:,
160
+ priority:,
161
+ lock_priorities:,
162
+ log_sampling_enabled:,
163
+ log_sampling_percent:,
164
+ log_sampler:,
165
+ log_sample_this:,
166
+ instr_sampling_enabled:,
167
+ instr_sampling_percent:,
168
+ instr_sampler:,
169
+ instr_sample_this:,
170
+ &block
171
+ )
172
+ # Step 0: Prevent argument type incompatabilities (the same checks as in
173
+ # RedisQueuedLocks::Acquirer::AcquireLock, duplicated on purpose)
174
+ # Step 0.1: prevent :lock_names incompatibilities
175
+ if lock_names.empty?
176
+ raise(
177
+ RedisQueuedLocks::ArgumentError,
178
+ "`:lock_names` argument should contain at least one lock name, got #{lock_names.inspect}."
179
+ )
180
+ end
181
+
182
+ # Step 0.1: prevent :meta incompatabiltiies (type)
183
+ case meta # NOTE: do not ask why case/when is used here
184
+ when Hash, NilClass then nil
185
+ else
186
+ raise(
187
+ RedisQueuedLocks::ArgumentError,
188
+ "`:meta` argument should be a type of NilClass or Hash, got #{meta.class}."
189
+ )
190
+ end
191
+
192
+ # Step 0.1 (RW): prevent :rw_mode incompatabilities
193
+ case rw_mode # NOTE: (steep) case/when narrows `Symbol` to `RQL::readWriteMode` below
194
+ when :read, :write then nil
195
+ else
196
+ raise(
197
+ RedisQueuedLocks::ArgumentError,
198
+ "`:rw_mode` argument should be `:read` or `:write`, " \
199
+ "got #{rw_mode.inspect}."
200
+ )
201
+ end
202
+
203
+ # Step 0.1: prevent numeric argument incompatibilities (the same ranges as their config keys)
204
+ if !ttl.is_a?(::Integer) || ttl <= 0
205
+ raise(
206
+ RedisQueuedLocks::ArgumentError,
207
+ "`:ttl` argument should be a positive Integer, got #{ttl.inspect}."
208
+ )
209
+ end
210
+ if !queue_ttl.is_a?(::Integer) || queue_ttl <= 0
211
+ raise(
212
+ RedisQueuedLocks::ArgumentError,
213
+ "`:queue_ttl` argument should be a positive Integer, got #{queue_ttl.inspect}."
214
+ )
215
+ end
216
+ if timeout != nil && (!timeout.is_a?(::Integer) || timeout < 0)
217
+ raise(
218
+ RedisQueuedLocks::ArgumentError,
219
+ "`:timeout` argument should be nil or a non-negative Integer, got #{timeout.inspect}."
220
+ )
221
+ end
222
+ if retry_count != nil && (!retry_count.is_a?(::Integer) || retry_count < 0)
223
+ raise(
224
+ RedisQueuedLocks::ArgumentError,
225
+ "`:retry_count` argument should be nil or a non-negative Integer, " \
226
+ "got #{retry_count.inspect}."
227
+ )
228
+ end
229
+ if !retry_delay.is_a?(::Integer) || retry_delay < 0
230
+ raise(
231
+ RedisQueuedLocks::ArgumentError,
232
+ "`:retry_delay` argument should be a non-negative Integer, got #{retry_delay.inspect}."
233
+ )
234
+ end
235
+ if !retry_jitter.is_a?(::Integer) || retry_jitter < 0
236
+ raise(
237
+ RedisQueuedLocks::ArgumentError,
238
+ "`:retry_jitter` argument should be a non-negative Integer, got #{retry_jitter.inspect}."
239
+ )
240
+ end
241
+ if !lock_index_grace_period.is_a?(::Integer) || lock_index_grace_period < 0
242
+ raise(
243
+ RedisQueuedLocks::ArgumentError,
244
+ "`:lock_index_grace_period` argument should be a non-negative Integer, " \
245
+ "got #{lock_index_grace_period.inspect}."
246
+ )
247
+ end
248
+
249
+ # Step 0.1: prevent :priority incompatibilities (the same range as its config key)
250
+ # - a Symbol is a priority name of `config['lock_priorities']`: it is resolved to its
251
+ # Integer priority (an unknown name stays a Symbol and fails the check);
252
+ priority = lock_priorities.fetch(priority, priority) if priority.is_a?(::Symbol)
253
+ if !priority.is_a?(::Integer) ||
254
+ priority < RedisQueuedLocks::Resource::MIN_LOCK_PRIORITY ||
255
+ priority > RedisQueuedLocks::Resource::MAX_LOCK_PRIORITY
256
+ raise(
257
+ RedisQueuedLocks::ArgumentError,
258
+ "`:priority` argument should be an Integer from " \
259
+ "#{RedisQueuedLocks::Resource::MIN_LOCK_PRIORITY} to " \
260
+ "#{RedisQueuedLocks::Resource::MAX_LOCK_PRIORITY} or a priority name of " \
261
+ "`config['lock_priorities']` (#{lock_priorities.keys.map(&:inspect).join(', ')}), " \
262
+ "got #{priority.inspect}."
263
+ )
264
+ end
265
+
266
+ # Step 0.2: prevent :meta incompatabiltiies (structure)
267
+ # - Symbol keys are written to the lock data as Strings (`Symbol#name`, as redis-client
268
+ # does it) and would overwrite the reserved lock data fields too;
269
+ if meta.is_a?(::Hash) && (meta.any? do |key, _value|
270
+ meta_key = key.is_a?(::Symbol) ? key.name : key
271
+ meta_key == 'acq_id' ||
272
+ meta_key == 'hst_id' ||
273
+ meta_key == 'ts' ||
274
+ meta_key == 'ini_ttl' ||
275
+ meta_key == 'lock_key' ||
276
+ meta_key == 'rem_ttl' ||
277
+ meta_key == 'spc_ext_ttl' ||
278
+ meta_key == 'spc_cnt' ||
279
+ meta_key == 'l_spc_ext_ini_ttl' ||
280
+ meta_key == 'l_spc_ext_ts' ||
281
+ meta_key == 'l_spc_ts' ||
282
+ meta_key == 'rw_mode'
283
+ end)
284
+ raise(
285
+ RedisQueuedLocks::ArgumentError,
286
+ '`:meta` keys can not overlap reserved lock data keys ' \
287
+ '"acq_id", "hst_id", "ts", "ini_ttl", "lock_key", "rem_ttl", "spc_cnt", ' \
288
+ '"spc_ext_ttl", "l_spc_ext_ini_ttl", "l_spc_ext_ts", "l_spc_ts", "rw_mode"'
289
+ )
290
+ end
291
+
292
+ # Step 1: prepare the requirements of the series (lock keys, acquirer, lock request position)
293
+ # NOTE: a duplicated lock name is the same lock (the series is obtained by one transaction
294
+ # and the lock state is checked once per lock);
295
+ lock_names = lock_names.uniq(&:to_s)
296
+ read_mode = (rw_mode == :read)
297
+ acquirer_id = RedisQueuedLocks::Resource.acquirer_identifier(
298
+ process_id,
299
+ thread_id,
300
+ fiber_id,
301
+ ractor_id,
302
+ identity
303
+ )
304
+ host_id = RedisQueuedLocks::Resource.host_identifier(
305
+ process_id,
306
+ thread_id,
307
+ ractor_id,
308
+ identity
309
+ )
310
+ lock_keys = lock_names.map do |lock_name|
311
+ RedisQueuedLocks::Resource.prepare_lock_key(lock_name)
312
+ end
313
+ lock_key_queues = lock_names.map do |lock_name|
314
+ RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
315
+ end
316
+ read_lock_key_queues = lock_names.map do |lock_name|
317
+ RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
318
+ end
319
+ lock_readers_keys = lock_names.map do |lock_name|
320
+ RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
321
+ end
322
+ # NOTE: (RW) read lock data keys are required by read lock requests only
323
+ # @type var read_lock_keys: Array[String?]
324
+ read_lock_keys = lock_names.map do |lock_name|
325
+ RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id) if read_mode
326
+ end
327
+ # NOTE: (RW) the lock state keys: write lock requests watch the readers registries too, read
328
+ # lock requests do not (readers should not invalidate transactions of each other);
329
+ watched_keys = read_mode ? lock_keys : (lock_keys + lock_readers_keys)
330
+
331
+ # NOTE: one position in all lock request queues of the series: the lock requests are ordered
332
+ # in the same way in all queues, so they can not wait for each other in a cycle;
333
+ acquirer_position = RedisQueuedLocks::Resource.calc_initial_acquirer_position(priority)
334
+
335
+ log_sampled = RedisQueuedLocks::Logging.should_log?(
336
+ log_sampling_enabled,
337
+ log_sample_this,
338
+ log_sampling_percent,
339
+ log_sampler
340
+ )
341
+ instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(
342
+ instr_sampling_enabled,
343
+ instr_sample_this,
344
+ instr_sampling_percent,
345
+ instr_sampler
346
+ )
347
+
348
+ # Step X: intermediate result observer
349
+ # @type var acq_process: Hash[Symbol,untyped]
350
+ acq_process = {
351
+ should_try: true,
352
+ tries: 0,
353
+ acquired: false,
354
+ result: nil,
355
+ failed_lock_name: nil,
356
+ ts: nil, # NOTE: see `try_to_lock_series`
357
+ lock_processes: nil, # NOTE: see `try_to_lock_series`
358
+ held_rw_modes: nil, # NOTE: see `try_to_lock_series`
359
+ try_start_time: nil,
360
+ acq_time: nil, # NOTE: in milliseconds
361
+ acq_end_time: nil,
362
+ rel_ts: nil,
363
+ hold_time: nil # NOTE: in milliseconds
364
+ }
365
+ # @type var reentrant_lock_keys: Array[String]
366
+ reentrant_lock_keys = []
367
+ # NOTE: (observability) the locks with the reported intermediate obtaining (once per lock)
368
+ # @type var reported_ready_locks: Array[bool]
369
+ reported_ready_locks = Array.new(lock_names.size, false)
370
+ # @type var yield_result: untyped
371
+ yield_result = nil
372
+
373
+ LogVisitor.start_lock_series_obtaining(
374
+ logger, log_sampled, lock_keys,
375
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
376
+ )
377
+
378
+ # Step 2: try to obtain the series with timeout
379
+ # NOTE: the acquirement start time is defined out of the timeout block: the failed
380
+ # acquirement reports the acquirement time too (Step 3.b);
381
+ acq_start_time = clock_gettime
382
+
383
+ begin
384
+ begin
385
+ ::Timeout.timeout(timeout, RedisQueuedLocks::LockAcquirementIntermediateTimeoutError) do
386
+ # Step 2.1: cyclically try to obtain the series
387
+ while acq_process[:should_try]
388
+ LogVisitor.start_try_to_lock_series_cycle(
389
+ logger, log_sampled, lock_keys,
390
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
391
+ )
392
+
393
+ # Step 2.X: check the actual score: is it in queue ttl limit or not?
394
+ if RedisQueuedLocks::Resource.dead_score_reached?(
395
+ acquirer_position, priority, queue_ttl
396
+ )
397
+ # Step 2.X.X: dead score reached => re-queue the lock request of the series with
398
+ # the new score in all its queues (one position of the series);
399
+ acquirer_position =
400
+ RedisQueuedLocks::Resource.calc_initial_acquirer_position(priority)
401
+
402
+ LogVisitor.dead_score_reached__reset_series_position(
403
+ logger, log_sampled, lock_keys,
404
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
405
+ )
406
+ end
407
+
408
+ acq_process[:try_start_time] = clock_gettime
409
+
410
+ attempt = try_to_lock_series(
411
+ redis,
412
+ logger,
413
+ log_lock_try,
414
+ lock_keys,
415
+ rw_mode,
416
+ lock_key_queues,
417
+ read_lock_key_queues,
418
+ lock_readers_keys,
419
+ read_lock_keys,
420
+ lock_names,
421
+ watched_keys,
422
+ acquirer_id,
423
+ host_id,
424
+ acquirer_position,
425
+ priority,
426
+ ttl,
427
+ queue_ttl,
428
+ lock_index_enabled,
429
+ lock_index_grace_period,
430
+ fail_fast,
431
+ conflict_strategy,
432
+ access_strategy,
433
+ meta,
434
+ acq_process[:tries] > 0, # NOTE: retry attempt (see "pre-check" in the attempt)
435
+ acq_process,
436
+ log_sampled
437
+ )
438
+ acq_end_time = clock_gettime
439
+ acq_time = ((acq_end_time - acq_start_time) / 1_000.0).ceil(2)
440
+
441
+ # Step X: save the intermediate results to the result observer
442
+ acq_process[:result] = attempt[:result]
443
+ acq_process[:failed_lock_name] = attempt[:lock_name] if attempt[:lock_name]
444
+
445
+ # Step 2.1: analyze an acquirement attempt
446
+ if attempt[:ok]
447
+ # Step 2.1.a: successfully acquired
448
+ acq_process[:acq_time] = acq_time
449
+ acq_process[:acq_end_time] = acq_end_time
450
+ acq_process[:should_try] = false
451
+ acq_process[:acquired] = true
452
+ end
453
+
454
+ # Step X: (observability) the intermediate obtaining of the locks of the series: the
455
+ # lock passed all checks of an attempt (nothing is held: the locks are obtained
456
+ # together, by the obtaining transaction of the attempt):
457
+ # - once per lock of the series (never per attempt), up to the lock that blocks
458
+ # the series, out of the WATCH block of the attempt;
459
+ if log_sampled || instr_sampled
460
+ ready_ts = Time.now.to_f
461
+ attempt[:ready_locks].each do |lock_index|
462
+ next if reported_ready_locks[lock_index]
463
+
464
+ reported_ready_locks[lock_index] = true
465
+ LogVisitor.lock_series_intermediate_lock_obtained(
466
+ logger, log_sampled, lock_keys[lock_index], lock_keys, queue_ttl, acquirer_id,
467
+ host_id, acq_time, access_strategy, rw_mode, priority
468
+ )
469
+ InstrVisitor.lock_series_intermediate_lock_obtained(
470
+ instrumenter, instr_sampled, lock_keys[lock_index], lock_keys, rw_mode,
471
+ priority, ttl, acquirer_id, host_id, ready_ts, acq_time, instrument
472
+ )
473
+ end
474
+ end
475
+
476
+ unless attempt[:ok]
477
+ # Step 2.2: failed to acquire. anylize each case and act in accordance
478
+ case attempt[:result]
479
+ when :fail_fast_no_try # Step 2.2.a: fail without try
480
+ acq_process[:should_try] = false
481
+ when :conflict_dead_lock, :conflict_lock_upgrade # Step 2.2.b: fail after conflict
482
+ acq_process[:tries] += 1
483
+ acq_process[:should_try] = false
484
+ else
485
+ acq_process[:tries] += 1 # Step RETRY: possible retry case
486
+
487
+ if fail_fast # Step RETRY.A: fail after try
488
+ acq_process[:should_try] = false
489
+ acq_process[:result] = :fail_fast_after_try
490
+ elsif retry_count != nil && acq_process[:tries] >= retry_count
491
+ # Step RETRY.B: fail cuz the retry count is reached
492
+ acq_process[:should_try] = false
493
+ acq_process[:result] = :retry_limit_reached
494
+ else
495
+ # Step RETRY.X: no significant failures => retry easily :)
496
+ # NOTE:
497
+ # delay the exceution in order to prevent chaotic lock-acquire attempts
498
+ # and to allow other processes and threads to obtain the locks too.
499
+ delay_execution(retry_delay, retry_jitter)
500
+ end
501
+ end
502
+ end
503
+ end
504
+ end
505
+ rescue RedisQueuedLocks::LockAcquirementIntermediateTimeoutError
506
+ # NOTE: the acquirement time limit is reached: it is a failed acquirement (see Step 3.b)
507
+ # (the timeout can come right after the obtaining: `acq_process[:acquired]` decides);
508
+ nil
509
+ end
510
+
511
+ # Step 3.a: acquired successfully => run logic or return the result of acquirement
512
+ if acq_process[:acquired]
513
+ # @type var lock_processes: Array[Symbol?]
514
+ lock_processes = acq_process[:lock_processes]
515
+ # NOTE: (observability) the full obtaining of the locks of the series; reentrant locks are
516
+ # not obtained by the series (the series events report them);
517
+ lock_keys.each_with_index do |lock_key, index|
518
+ if lock_processes[index] == :lock_obtaining
519
+ LogVisitor.lock_series_lock_obtained(
520
+ logger, log_sampled, lock_key, lock_keys, queue_ttl, acquirer_id, host_id,
521
+ acq_process[:acq_time], access_strategy, rw_mode, priority
522
+ )
523
+ InstrVisitor.lock_series_lock_obtained(
524
+ instrumenter, instr_sampled, lock_key, lock_keys, rw_mode, priority, ttl,
525
+ acquirer_id, host_id, acq_process[:ts], acq_process[:acq_time], instrument
526
+ )
527
+ else
528
+ reentrant_lock_keys << lock_key
529
+ end
530
+ end
531
+
532
+ LogVisitor.lock_series_obtained(
533
+ logger, log_sampled, lock_keys,
534
+ queue_ttl, acquirer_id, host_id, acq_process[:acq_time], access_strategy,
535
+ rw_mode, priority, reentrant_lock_keys
536
+ )
537
+ InstrVisitor.lock_series_obtained(
538
+ instrumenter, instr_sampled, lock_keys, rw_mode, priority, ttl, acquirer_id,
539
+ host_id, acq_process[:ts], acq_process[:acq_time], reentrant_lock_keys, instrument
540
+ )
541
+
542
+ # NOTE: the budget of a `timed` block (see `Resource::REDIS_TIMESHIFT_ERROR`):
543
+ # - the lock ttl starts inside the attempt (write: `EXEC` of the transaction,
544
+ # read: `TIME` of the lock state read), this moment is unknown on the client:
545
+ # the time is counted from the start of the successful attempt (it is
546
+ # earlier, the budget can only be shorter than the real lifetime of the lock);
547
+ # - the Redis time error is a margin: it decreases the budget;
548
+ ttl_shift = (
549
+ (clock_gettime - acq_process[:try_start_time]) / 1_000.0 +
550
+ RedisQueuedLocks::Resource::REDIS_TIMESHIFT_ERROR
551
+ ).ceil(2)
552
+
553
+ yield_result = yield_lock_series(
554
+ lock_keys, acquirer_id, host_id, timed, ttl_shift, ttl, meta, &block
555
+ )
556
+ end
557
+ ensure
558
+ if !acq_process[:acquired]
559
+ # Step 3.b.X: the series is not obtained (a failure or an interruption of any kind)
560
+ drop_failed_lock_series(
561
+ redis, logger, lock_keys, rw_mode, lock_key_queues, read_lock_key_queues,
562
+ lock_names, queue_ttl, acquirer_id, host_id, access_strategy, priority, acq_process,
563
+ log_sampled
564
+ )
565
+ elsif block_given?
566
+ # Step 3.a.X: release the locks of the series after the block of code (on any failure too)
567
+ spent_time = ((clock_gettime - acq_process[:acq_end_time]) / 1_000.0).ceil
568
+ decreased_ttl = ttl - spent_time - RedisQueuedLocks::Resource::REDIS_TIMESHIFT_ERROR
569
+
570
+ complete_lock_series_release(
571
+ redis, lock_names, acq_process[:lock_processes], acq_process[:held_rw_modes],
572
+ acquirer_id, decreased_ttl
573
+ )
574
+
575
+ acq_process[:rel_ts] = Time.now.to_f
576
+ acq_process[:hold_time] =
577
+ ((clock_gettime - acq_process[:acq_end_time]) / 1_000.0).ceil(2)
578
+
579
+ LogVisitor.expire_lock_series(
580
+ logger, log_sampled, lock_keys,
581
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
582
+ )
583
+ # NOTE: an extended lock is the reentrant lock that was extended by the attempt
584
+ if log_sampled && decreased_ttl > 0
585
+ extended_lock_keys = lock_keys.select.with_index do |_lock_key, index|
586
+ acq_process[:lock_processes][index] == :extendable_conflict_work_through
587
+ end
588
+ unless extended_lock_keys.empty?
589
+ LogVisitor.decrease_lock_series(
590
+ logger, log_sampled, extended_lock_keys, decreased_ttl,
591
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
592
+ )
593
+ end
594
+ end
595
+ InstrVisitor.lock_series_hold_and_release(
596
+ instrumenter, instr_sampled, lock_keys, rw_mode, priority, ttl, acquirer_id,
597
+ host_id, acq_process[:rel_ts], acq_process[:acq_time], acq_process[:hold_time],
598
+ reentrant_lock_keys, instrument
599
+ )
600
+ end
601
+ end
602
+
603
+ if acq_process[:acquired]
604
+ if detailed_result
605
+ {
606
+ yield_result: yield_result,
607
+ locks_release_strategy: block_given? ? :immediate_release_after_yield : :redis_key_ttl,
608
+ locks_released_at: acq_process[:rel_ts],
609
+ locks_acq_time: acq_process[:acq_time],
610
+ locks_hold_time: acq_process[:hold_time],
611
+ lock_series: lock_names,
612
+ rql_lock_series: lock_keys
613
+ }
614
+ elsif block_given?
615
+ yield_result
616
+ else
617
+ {
618
+ ok: true,
619
+ result: {
620
+ lock_series: lock_names,
621
+ rql_lock_series: lock_keys,
622
+ acq_id: acquirer_id,
623
+ hst_id: host_id,
624
+ ts: acq_process[:ts],
625
+ ttl: ttl
626
+ }
627
+ }
628
+ end
629
+ else
630
+ # Step 3.b: the series is not obtained
631
+ # NOTE: the loop is interrupted by the acquirement timeout only (see the `rescue` above):
632
+ # all other interruptions are raised through the `ensure` part;
633
+ acq_process[:result] = :timeout_reached if acq_process[:should_try]
634
+
635
+ # @type var failure_result: Symbol
636
+ failure_result = acq_process[:result]
637
+ # @type var failed_lock_name: String?
638
+ failed_lock_name = acq_process[:failed_lock_name]
639
+ failed_lock_key =
640
+ failed_lock_name ? RedisQueuedLocks::Resource.prepare_lock_key(failed_lock_name) : nil
641
+ acq_time = ((clock_gettime - acq_start_time) / 1_000.0).ceil(2)
642
+ failed_ts = Time.now.to_f
643
+
644
+ # NOTE: the lock that blocks the series (unknown without an attempt with an obstacle)
645
+ if failed_lock_key
646
+ LogVisitor.lock_series_lock_obtaining_failed(
647
+ logger, log_sampled, failed_lock_key, lock_keys, queue_ttl, acquirer_id, host_id,
648
+ acq_time, access_strategy, rw_mode, priority, failure_result
649
+ )
650
+ InstrVisitor.lock_series_lock_obtaining_failed(
651
+ instrumenter, instr_sampled, failed_lock_key, lock_keys, rw_mode, priority, ttl,
652
+ acquirer_id, host_id, failed_ts, acq_time, failure_result, instrument
653
+ )
654
+ end
655
+ LogVisitor.lock_series_obtaining_failed(
656
+ logger, log_sampled, lock_keys, queue_ttl, acquirer_id, host_id, acq_time,
657
+ access_strategy, rw_mode, priority, failure_result, failed_lock_key
658
+ )
659
+ InstrVisitor.lock_series_obtaining_failed(
660
+ instrumenter, instr_sampled, lock_keys, rw_mode, priority, ttl, acquirer_id,
661
+ host_id, failed_ts, acq_time, failure_result, failed_lock_key, instrument
662
+ )
663
+
664
+ if raise_errors
665
+ raise_lock_series_error(
666
+ redis, failure_result, lock_keys, failed_lock_name, failed_lock_key, acquirer_id,
667
+ conflict_strategy, retry_count, timeout, detailed_acq_timeout_error
668
+ )
669
+ end
670
+
671
+ # @type var detailed_errors: Hash[String, { ok: bool, result: Symbol }]
672
+ detailed_errors = {}
673
+ if failed_lock_name
674
+ detailed_errors[failed_lock_name] = { ok: false, result: failure_result }
675
+ end
676
+ # NOTE: nothing is obtained by the failed series (all or nothing)
677
+ # @type var acquired_locks: Array[String]
678
+ acquired_locks = []
679
+
680
+ {
681
+ ok: false,
682
+ result: {
683
+ error: :failed_to_acquire_lock_series,
684
+ detailed_errors:,
685
+ lock_series: lock_names,
686
+ acquired_locks:,
687
+ missing_locks: lock_names,
688
+ failed_on_lock: failed_lock_name
689
+ }
690
+ }
691
+ end
692
+ end
693
+ # rubocop:enable Metrics/MethodLength
694
+
695
+ private
696
+
697
+ # Runs the block of code under the obtained series (`timed`: limited by the `ttl`).
698
+ #
699
+ # @param lock_keys [Array<String>]
700
+ # @param acquirer_id [String]
701
+ # @param host_id [String]
702
+ # @param timed [Boolean]
703
+ # @param ttl_shift [Numeric] In milliseconds.
704
+ # @param ttl [Integer] In milliseconds.
705
+ # @param meta [NilClass,Hash<String|Symbol,Any>]
706
+ # @yield Custom logic that should be invoked under the obtained locks.
707
+ # @yieldreturn [Any]
708
+ # @return [Any,NilClass] nil is returned when no block parametr is provided.
709
+ #
710
+ # @api private
711
+ # @since 1.23.0
712
+ def yield_lock_series(lock_keys, acquirer_id, host_id, timed, ttl_shift, ttl, meta, &block)
713
+ return unless block_given?
714
+ return yield unless timed
715
+
716
+ # NOTE: (steep) see `YieldExpire#yield_expire`: `::Integer - ::Integer|::Float`
717
+ # steep:ignore:start
718
+ # @type var timeout: Float
719
+ timeout = ((ttl - ttl_shift) / 1_000.0).yield_self do |time|
720
+ # NOTE: time in <seconds> cuz Ruby's Timeout requires <seconds>
721
+ (time < 0) ? 0.0 : time
722
+ end
723
+ # steep:ignore:end
724
+
725
+ yield_with_timeout(timeout, lock_keys.join(', '), ttl, acquirer_id, host_id, meta, &block)
726
+ end
727
+
728
+ # Releases the series: an interruption of the release itself (`Timeout.timeout` of the outer
729
+ # logic, `Thread#kill`, a lost connection) is completed by the second release: an interruption
730
+ # is delivered once and the release is idempotent (it checks the owner and works with own read
731
+ # locks only); the error of the first release is raised as is.
732
+ #
733
+ # @param redis [RedisClient]
734
+ # @param lock_names [Array<String>]
735
+ # @param lock_processes [Array<Symbol,NilClass>] See `release_lock_series`.
736
+ # @param held_rw_modes [Array<Symbol>] See `release_lock_series`.
737
+ # @param acquirer_id [String]
738
+ # @param decreased_ttl [Integer] See `release_lock_series`.
739
+ # @return [void]
740
+ #
741
+ # @api private
742
+ # @since 1.23.0
743
+ def complete_lock_series_release(
744
+ redis,
745
+ lock_names,
746
+ lock_processes,
747
+ held_rw_modes,
748
+ acquirer_id,
749
+ decreased_ttl
750
+ )
751
+ is_series_released = false
752
+
753
+ begin
754
+ release_lock_series(
755
+ redis, lock_names, lock_processes, held_rw_modes, acquirer_id, decreased_ttl
756
+ )
757
+ is_series_released = true
758
+ ensure
759
+ unless is_series_released
760
+ run_non_critical do
761
+ release_lock_series(
762
+ redis, lock_names, lock_processes, held_rw_modes, acquirer_id, decreased_ttl
763
+ )
764
+ end
765
+ end
766
+ end
767
+ end
768
+
769
+ # Drops the traces of the series that is not obtained (a failure or an interruption of any
770
+ # kind: the acquirement timeout, an error, `Timeout.timeout` of the outer logic, `Thread#kill`):
771
+ # - the obtaining transaction of an interrupted attempt can be applied by Redis without
772
+ # the reply: its locks are released (only the locks that are obtained by this transaction
773
+ # and only if they are owned by the current acquirer: nothing is released otherwise; the
774
+ # ttl of the extended reentrant locks is not decreased: the extension is unknown);
775
+ # - the lock requests are dropped from all lock request queues of the series;
776
+ # - errors are not raised here (they would replace the original error of the interrupted
777
+ # acquirement): dead lock requests are dropped by other lock requests, locks are released
778
+ # by their ttl;
779
+ #
780
+ # @param redis [RedisClient]
781
+ # @param logger [::Logger,#debug]
782
+ # @param lock_keys [Array<String>]
783
+ # @param rw_mode [Symbol]
784
+ # @param lock_key_queues [Array<String>]
785
+ # @param read_lock_key_queues [Array<String>]
786
+ # @param lock_names [Array<String>]
787
+ # @param queue_ttl [Integer]
788
+ # @param acquirer_id [String]
789
+ # @param host_id [String]
790
+ # @param access_strategy [Symbol]
791
+ # @param priority [Integer]
792
+ # @param acq_process [Hash<Symbol,Any>]
793
+ # @param log_sampled [Boolean]
794
+ # @return [void]
795
+ #
796
+ # @api private
797
+ # @since 1.23.0
798
+ def drop_failed_lock_series(
799
+ redis,
800
+ logger,
801
+ lock_keys,
802
+ rw_mode,
803
+ lock_key_queues,
804
+ read_lock_key_queues,
805
+ lock_names,
806
+ queue_ttl,
807
+ acquirer_id,
808
+ host_id,
809
+ access_strategy,
810
+ priority,
811
+ acq_process,
812
+ log_sampled
813
+ )
814
+ run_non_critical do
815
+ if acq_process[:lock_processes]
816
+ complete_lock_series_release(
817
+ redis, lock_names, acq_process[:lock_processes], acq_process[:held_rw_modes],
818
+ acquirer_id, 0
819
+ )
820
+ LogVisitor.expire_lock_series(
821
+ logger, log_sampled, lock_keys,
822
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
823
+ )
824
+ end
825
+ end
826
+
827
+ # NOTE: the failed "no try" attempt queues nothing
828
+ return if acq_process[:result] == :fail_fast_no_try
829
+
830
+ run_non_critical do
831
+ request_queues = (rw_mode == :read) ? read_lock_key_queues : lock_key_queues
832
+ redis.pipelined do |pipeline|
833
+ request_queues.each { |request_queue| pipeline.call('ZREM', request_queue, acquirer_id) }
834
+ end
835
+ LogVisitor.dequeue_from_lock_series_queues(
836
+ logger, log_sampled, lock_keys,
837
+ queue_ttl, acquirer_id, host_id, access_strategy, rw_mode, priority
838
+ )
839
+ end
840
+ end
841
+
842
+ # Raises the error of the series that is not obtained (`raise_errors: true`): the same errors
843
+ # as in the single lock acquirement.
844
+ #
845
+ # @param redis [RedisClient]
846
+ # @param failure_result [Symbol]
847
+ # @param lock_keys [Array<String>]
848
+ # @param failed_lock_name [String,NilClass]
849
+ # @param failed_lock_key [String,NilClass]
850
+ # @param acquirer_id [String]
851
+ # @param conflict_strategy [Symbol]
852
+ # @param retry_count [Integer,NilClass]
853
+ # @param timeout [Integer,NilClass]
854
+ # @param detailed_acq_timeout_error [Boolean]
855
+ # @return [void]
856
+ #
857
+ # @raise [RedisQueuedLocks::LockAlreadyObtainedError]
858
+ # @raise [RedisQueuedLocks::ConflictLockObtainError]
859
+ # @raise [RedisQueuedLocks::LockAcquirementRetryLimitError]
860
+ # @raise [RedisQueuedLocks::LockAcquirementTimeoutError]
861
+ #
862
+ # @api private
863
+ # @since 1.23.0
864
+ # rubocop:disable Metrics/MethodLength
865
+ def raise_lock_series_error(
866
+ redis,
867
+ failure_result,
868
+ lock_keys,
869
+ failed_lock_name,
870
+ failed_lock_key,
871
+ acquirer_id,
872
+ conflict_strategy,
873
+ retry_count,
874
+ timeout,
875
+ detailed_acq_timeout_error
876
+ )
877
+ series_description = "the lock series #{lock_keys.inspect}"
878
+ series_description += " (blocked by \"#{failed_lock_key}\")" if failed_lock_key
879
+
880
+ case failure_result
881
+ when :fail_fast_no_try, :fail_fast_after_try
882
+ raise(
883
+ RedisQueuedLocks::LockAlreadyObtainedError,
884
+ "Lock \"#{failed_lock_key || lock_keys.join(', ')}\" is already obtained."
885
+ )
886
+ when :conflict_dead_lock
887
+ raise(
888
+ RedisQueuedLocks::ConflictLockObtainError,
889
+ "Lock Conflict: trying to acquire the lock \"#{failed_lock_key}\" " \
890
+ "that is already acquired by the current acquirer (acq_id: \"#{acquirer_id}\")."
891
+ )
892
+ when :conflict_lock_upgrade
893
+ raise(
894
+ RedisQueuedLocks::ConflictLockObtainError,
895
+ "Lock Conflict: trying to acquire the write lock \"#{failed_lock_key}\" " \
896
+ "while the current acquirer holds its read lock (acq_id: \"#{acquirer_id}\"): " \
897
+ "read-to-write lock upgrade is not supported " \
898
+ "(conflict strategy: :#{conflict_strategy})."
899
+ )
900
+ when :retry_limit_reached
901
+ raise(
902
+ RedisQueuedLocks::LockAcquirementRetryLimitError,
903
+ "Failed to acquire #{series_description} " \
904
+ "for the given retry_count limit (#{retry_count} times)."
905
+ )
906
+ else
907
+ details =
908
+ if detailed_acq_timeout_error && failed_lock_name
909
+ " Details: #{blocking_lock_details(redis, failed_lock_name)}"
910
+ end
911
+ raise(
912
+ RedisQueuedLocks::LockAcquirementTimeoutError,
913
+ "Failed to acquire #{series_description} " \
914
+ "for the given <#{timeout} seconds> timeout.#{details}"
915
+ )
916
+ end
917
+ end
918
+ # rubocop:enable Metrics/MethodLength
919
+
920
+ # The raw state of the lock that blocks the series (the detailed acquirement timeout error).
921
+ #
922
+ # @param redis [RedisClient]
923
+ # @param failed_lock_name [String]
924
+ # @return [String]
925
+ #
926
+ # @api private
927
+ # @since 1.23.0
928
+ def blocking_lock_details(redis, failed_lock_name)
929
+ # NOTE: (pipeline layout) [0] the write lock data, [1] the readers registry,
930
+ # [2] write lock requests, [3] read lock requests;
931
+ # @type var lock_state: Array[untyped]
932
+ lock_state = redis.pipelined do |pipeline|
933
+ pipeline.call('HGETALL', RedisQueuedLocks::Resource.prepare_lock_key(failed_lock_name))
934
+ pipeline.call(
935
+ 'ZRANGE', RedisQueuedLocks::Resource.prepare_lock_readers(failed_lock_name),
936
+ '0', '-1', 'WITHSCORES'
937
+ )
938
+ pipeline.call(
939
+ 'ZRANGE', RedisQueuedLocks::Resource.prepare_lock_queue(failed_lock_name),
940
+ '0', '-1', 'WITHSCORES'
941
+ )
942
+ pipeline.call(
943
+ 'ZRANGE', RedisQueuedLocks::Resource.prepare_read_lock_queue(failed_lock_name),
944
+ '0', '-1', 'WITHSCORES'
945
+ )
946
+ end
947
+
948
+ "<Lock Data> => #{lock_state[0].to_h.inspect}; " \
949
+ "<Readers Data> => #{lock_state[1].inspect}; " \
950
+ "<Queue Data> => #{lock_state[2].inspect}; " \
951
+ "<Read Queue Data> => #{lock_state[3].inspect};"
952
+ end
953
+ end
954
+ end
955
+ # rubocop:enable Metrics/ModuleLength
956
+ # rubocop:enable Metrics/ClassLength
957
+ # rubocop:enable Metrics/BlockNesting