redis_queued_locks 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +96 -23
  3. data/.claude/rules/acquirer.md +39 -20
  4. data/.claude/rules/arguments.md +3 -2
  5. data/.claude/rules/git.md +3 -2
  6. data/.claude/rules/logic.md +32 -8
  7. data/.claude/rules/swarm.md +14 -9
  8. data/.claude/rules/tests.md +12 -3
  9. data/.claude/rules/type-checking.md +2 -1
  10. data/.claude/rules/visitors.md +10 -11
  11. data/.claude/skills/rql-code-review/SKILL.md +386 -0
  12. data/.claude/skills/rql-code-review/templates/report.en.md +31 -0
  13. data/.claude/skills/rql-code-review/templates/report.ru.md +31 -0
  14. data/CHANGELOG.md +194 -0
  15. data/CLAUDE.md +6 -3
  16. data/README.md +502 -92
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +1 -0
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +42 -0
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +45 -0
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +1 -0
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +695 -128
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/with_acq_timeout.rb +2 -2
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +1 -0
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +8 -6
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +134 -26
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +229 -24
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +189 -18
  28. data/lib/redis_queued_locks/acquirer/is_locked.rb +0 -1
  29. data/lib/redis_queued_locks/acquirer/is_queued.rb +0 -1
  30. data/lib/redis_queued_locks/acquirer/keys.rb +151 -7
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +39 -41
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +3 -2
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +5 -4
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +79 -20
  35. data/lib/redis_queued_locks/acquirer/locks.rb +212 -83
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +11 -18
  37. data/lib/redis_queued_locks/acquirer/queues.rb +123 -50
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +253 -0
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +177 -61
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +84 -25
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +274 -70
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/acquirer.rb +1 -0
  44. data/lib/redis_queued_locks/client.rb +164 -36
  45. data/lib/redis_queued_locks/config.rb +16 -13
  46. data/lib/redis_queued_locks/errors.rb +34 -4
  47. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  48. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  49. data/lib/redis_queued_locks/resource.rb +113 -6
  50. data/lib/redis_queued_locks/swarm/flush_zombies.rb +333 -61
  51. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  52. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +3 -2
  53. data/lib/redis_queued_locks/swarm/zombie_info.rb +203 -53
  54. data/lib/redis_queued_locks/swarm.rb +97 -5
  55. data/lib/redis_queued_locks/utilities.rb +1 -1
  56. data/lib/redis_queued_locks/version.rb +2 -2
  57. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +14 -0
  58. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +13 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +107 -1
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -3
  61. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +3 -1
  62. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +19 -1
  63. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +20 -4
  64. data/sig/redis_queued_locks/acquirer/keys.rbs +11 -1
  65. data/sig/redis_queued_locks/acquirer/locks.rbs +15 -6
  66. data/sig/redis_queued_locks/acquirer/queues.rbs +9 -2
  67. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +52 -0
  68. data/sig/redis_queued_locks/acquirer/release_all_locks.rbs +8 -1
  69. data/sig/redis_queued_locks/acquirer/release_lock.rbs +6 -3
  70. data/sig/redis_queued_locks/acquirer/release_locks_of.rbs +20 -1
  71. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  72. data/sig/redis_queued_locks/client.rbs +21 -2
  73. data/sig/redis_queued_locks/errors.rbs +5 -2
  74. data/sig/redis_queued_locks/instrument/sampler.rbs +0 -2
  75. data/sig/redis_queued_locks/logging/sampler.rbs +0 -2
  76. data/sig/redis_queued_locks/resource.rbs +7 -1
  77. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +51 -1
  78. data/sig/redis_queued_locks/swarm/zombie_info.rbs +58 -7
  79. data/sig/vendor/redis_client.rbs +2 -0
  80. metadata +6 -1
@@ -6,7 +6,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::WithAcqTimeout
6
6
  # @param redis [RedisClient]
7
7
  # Redis connection manager required for additional data extraction for error message.
8
8
  # @param timeout [NilClass,Integer]
9
- # Time period after which the logic will fail with timeout error.
9
+ # Time period after which the logic will fail with timeout error (`nil` and `0`: no timeout).
10
10
  # @param lock_key [String]
11
11
  # Lock name in RQL notation (rql:lock:some-lock-name).
12
12
  # @param lock_name [String]
@@ -26,7 +26,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::WithAcqTimeout
26
26
  #
27
27
  # @api private
28
28
  # @since 1.0.0
29
- # @version 1.11.0
29
+ # @version 1.21.0
30
30
  def with_acq_timeout(
31
31
  redis,
32
32
  timeout,
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.16.0
5
+ # @version 1.18.0
5
6
  module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire::LogVisitor
6
7
  class << self
7
8
  # @param logger [::Logger,#debug]
@@ -19,7 +19,9 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
19
19
  # @param logger [::Logger,#debug] Logger object.
20
20
  # @param lock_key [String] Obtained lock key that should be expired.
21
21
  # @param lock_readers_key [String] Registry of read lock holders.
22
- # @param read_lock_key [String] Read lock data of the current acquirer.
22
+ # @param read_lock_key [String,NilClass]
23
+ # Read lock data of the current acquirer (`nil` for write lock requests: they never obtain
24
+ # read locks).
23
25
  # @param rw_mode [Symbol]
24
26
  # - The mode of the obtained lock (`:read` or `:write`);
25
27
  # - `:read` => only the read lock of the current acquirer is released/decreased;
@@ -28,7 +30,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
28
30
  # @param access_strategy [Symbol] Lock obtaining strategy.
29
31
  # @param timed [Boolean] Should the lock be wrapped by Timeout with with lock's ttl
30
32
  # @param ttl_shift [Numeric] Lock's TTL shifting. Should affect block's ttl. In millisecodns.
31
- # @param ttl [Integer,NilClass] Lock's time to live (in ms). Nil means "without timeout".
33
+ # @param ttl [Integer] Lock's time to live (in ms). A positive Integer.
32
34
  # @param queue_ttl [Integer] Lock request lifetime.
33
35
  # @param meta [NilClass,Hash<String|Symbol,Any>] Custom metadata wich is passed to the lock data;
34
36
  # @param log_sampled [Boolean] Should the logic be logged or not.
@@ -43,7 +45,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
43
45
  #
44
46
  # @api private
45
47
  # @since 1.3.0
46
- # @version 1.18.0
48
+ # @version 1.21.0
47
49
  # rubocop:disable Metrics/MethodLength
48
50
  def yield_expire(
49
51
  redis,
@@ -69,7 +71,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
69
71
  initial_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
70
72
 
71
73
  if block_given?
72
- if timed && ttl != nil
74
+ if timed
73
75
  # NOTE:
74
76
  # - steep is ignored cuz steep can not recognize `::Integer - ::Integer|::Float`
75
77
  # operation here for some mystical reason (it tryes to find overloadd `-` method for
@@ -148,7 +150,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
148
150
 
149
151
  # @param timeout [Float]
150
152
  # @param lock_key [String]
151
- # @param lock_ttl [Integer,NilClass]
153
+ # @param lock_ttl [Integer]
152
154
  # @param acquirer_id [String]
153
155
  # @param host_id [String]
154
156
  # @param meta [NilClass,Hash<Symbol|String,Any>]
@@ -159,7 +161,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
159
161
  #
160
162
  # @api private
161
163
  # @since 1.3.0
162
- # @version 1.12.0
164
+ # @version 1.21.0
163
165
  def yield_with_timeout(timeout, lock_key, lock_ttl, acquirer_id, host_id, meta, &block)
164
166
  ::Timeout.timeout(timeout, RedisQueuedLocks::TimedLockIntermediateTimeoutError, &block)
165
167
  rescue RedisQueuedLocks::TimedLockIntermediateTimeoutError
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
- # @version 1.18.0
5
+ # @version 1.20.0
6
6
  # rubocop:disable Metrics/ModuleLength
7
7
  # rubocop:disable Metrics/MethodLength
8
8
  # rubocop:disable Metrics/ClassLength
@@ -42,12 +42,23 @@ module RedisQueuedLocks::Acquirer::AcquireLock
42
42
  # A current fiber that want to acquire a lock.
43
43
  # @option ractor_id [Integer,String]
44
44
  # The current ractor that want to acquire a lock.
45
- # @option ttl [Integer,NilClass]
46
- # Lock's time to live (in milliseconds). Nil means "without timeout".
45
+ # @option ttl [Integer]
46
+ # Lock's time to live (in milliseconds). A positive Integer (both modes).
47
47
  # @option queue_ttl [Integer]
48
48
  # Lifetime of the acuier's lock request. In seconds.
49
+ # @option lock_index_enabled [Boolean]
50
+ # - Maintain the lock index (`rql:index:locks`): the lock request and the obtained lock are
51
+ # indexed, the lock request queue gets TTL, expired lock names are dropped from the index;
52
+ # - `config['lock_index_enabled']` (the client-wide mode, not a per-call option);
53
+ # @option lock_index_grace_period [Integer]
54
+ # - The lock index score margin over the lock key lifetime (in milliseconds): the lock name
55
+ # stays in the lock index for this period after the expiration of the lock keys;
56
+ # - tolerance of the client clock skew (between hosts) and of the network latency;
57
+ # - pre-configured in `config['lock_index_grace_period']`;
58
+ # - used only with the enabled lock index (`lock_index_enabled`);
49
59
  # @option timeout [Integer,NilClass]
50
- # Time period whe should try to acquire the lock (in seconds).
60
+ # Time period whe should try to acquire the lock (in seconds). Nil means "without timeout"
61
+ # (`0` is the same as `nil`: `Timeout.timeout(0)` sets no timeout).
51
62
  # @option timed [Boolean]
52
63
  # Limit the invocation time period of the passed block of code by the lock's TTL.
53
64
  # @option retry_count [Integer,NilClass]
@@ -121,7 +132,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
121
132
  # - works in tandem with <config['log_sampling_percent'] and <config['log_sampler']>;
122
133
  # @option log_sampling_percent [Integer]
123
134
  # - the percent of cases that should be logged;
124
- # - take an effect when <config['log_sampling_enalbed']> is true;
135
+ # - take an effect when <config['log_sampling_enabled']> is true;
125
136
  # - works in tandem with <config['log_sampling_enabled']> and <config['log_sampler']> configs;
126
137
  # @option log_sampler [#sampling_happened?,Module<RedisQueuedLocks::Logging::Sampler>]
127
138
  # - percent-based log sampler that decides should be RQL case logged or not;
@@ -142,7 +153,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
142
153
  # - works in tandem with <config['instr_sampling_percent']> and <config['instr_sampler']>;
143
154
  # @option instr_sampling_percent [Integer]
144
155
  # - the percent of cases that should be instrumented;
145
- # - take an effect when <config['instr_sampling_enalbed']> is true;
156
+ # - take an effect when <config['instr_sampling_enabled']> is true;
146
157
  # - works in tandem with <config['instr_sampling_enabled']>
147
158
  # and <config['instr_sampler']> configs;
148
159
  # @option instr_sampler [#sampling_happened?,Module<RedisQueuedLocks::Instrument::Sampler>]
@@ -166,7 +177,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
166
177
  #
167
178
  # @api private
168
179
  # @since 1.0.0
169
- # @version 1.18.0
180
+ # @version 1.21.0
170
181
  def acquire_lock(
171
182
  redis,
172
183
  lock_name,
@@ -176,6 +187,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock
176
187
  ractor_id:,
177
188
  ttl:,
178
189
  queue_ttl:,
190
+ lock_index_enabled:,
191
+ lock_index_grace_period:,
179
192
  timeout:,
180
193
  timed:,
181
194
  retry_count:,
@@ -223,29 +236,79 @@ module RedisQueuedLocks::Acquirer::AcquireLock
223
236
  )
224
237
  end
225
238
 
226
- # Step 0.1 (RW): prevent read lock incompatabilities
227
- # - read lock expiration time is calculated from the lock ttl;
228
- if read_write_mode == :read && (!ttl.is_a?(::Integer) || ttl <= 0)
239
+ # Step 0.1: prevent :ttl incompatibilities (both modes)
240
+ # - write lock: `PEXPIRE` with a non-positive ttl deletes the just obtained lock key in the
241
+ # obtaining transaction (the lock is "obtained" without the mutual exclusion);
242
+ # - read lock: the read lock expiration time is calculated from the lock ttl;
243
+ if !ttl.is_a?(::Integer) || ttl <= 0
229
244
  raise(
230
245
  RedisQueuedLocks::ArgumentError,
231
- "`:ttl` argument should be a positive Integer for read locks, got #{ttl.inspect}."
246
+ "`:ttl` argument should be a positive Integer, got #{ttl.inspect}."
247
+ )
248
+ end
249
+
250
+ # Step 0.1: prevent :queue_ttl, :timeout, :retry_* and :lock_index_grace_period
251
+ # incompatibilities (the same ranges as their config keys):
252
+ # - a non-positive queue ttl drops the own lock request in the queueing pipeline (the lock
253
+ # request is always dead);
254
+ # - a negative timeout/retry delay/retry jitter fails `Timeout.timeout`/`sleep` in the
255
+ # middle of the acquirement (`0` timeout is the same as `nil`: no timeout);
256
+ if !queue_ttl.is_a?(::Integer) || queue_ttl <= 0
257
+ raise(
258
+ RedisQueuedLocks::ArgumentError,
259
+ "`:queue_ttl` argument should be a positive Integer, got #{queue_ttl.inspect}."
260
+ )
261
+ end
262
+ if timeout != nil && (!timeout.is_a?(::Integer) || timeout < 0)
263
+ raise(
264
+ RedisQueuedLocks::ArgumentError,
265
+ "`:timeout` argument should be nil or a non-negative Integer, got #{timeout.inspect}."
266
+ )
267
+ end
268
+ if retry_count != nil && (!retry_count.is_a?(::Integer) || retry_count < 0)
269
+ raise(
270
+ RedisQueuedLocks::ArgumentError,
271
+ "`:retry_count` argument should be nil or a non-negative Integer, " \
272
+ "got #{retry_count.inspect}."
273
+ )
274
+ end
275
+ if !retry_delay.is_a?(::Integer) || retry_delay < 0
276
+ raise(
277
+ RedisQueuedLocks::ArgumentError,
278
+ "`:retry_delay` argument should be a non-negative Integer, got #{retry_delay.inspect}."
279
+ )
280
+ end
281
+ if !retry_jitter.is_a?(::Integer) || retry_jitter < 0
282
+ raise(
283
+ RedisQueuedLocks::ArgumentError,
284
+ "`:retry_jitter` argument should be a non-negative Integer, got #{retry_jitter.inspect}."
285
+ )
286
+ end
287
+ if !lock_index_grace_period.is_a?(::Integer) || lock_index_grace_period < 0
288
+ raise(
289
+ RedisQueuedLocks::ArgumentError,
290
+ "`:lock_index_grace_period` argument should be a non-negative Integer, " \
291
+ "got #{lock_index_grace_period.inspect}."
232
292
  )
233
293
  end
234
294
 
235
295
  # Step 0.2: prevent :meta incompatabiltiies (structure)
296
+ # - Symbol keys are written to the lock data as Strings (`Symbol#name`, as redis-client
297
+ # does it) and would overwrite the reserved lock data fields too;
236
298
  if meta.is_a?(::Hash) && (meta.any? do |key, _value|
237
- key == 'acq_id' ||
238
- key == 'hst_id' ||
239
- key == 'ts' ||
240
- key == 'ini_ttl' ||
241
- key == 'lock_key' ||
242
- key == 'rem_ttl' ||
243
- key == 'spc_ext_ttl' ||
244
- key == 'spc_cnt' ||
245
- key == 'l_spc_ext_ini_ttl' ||
246
- key == 'l_spc_ext_ts' ||
247
- key == 'l_spc_ts' ||
248
- key == 'rw_mode'
299
+ meta_key = key.is_a?(::Symbol) ? key.name : key
300
+ meta_key == 'acq_id' ||
301
+ meta_key == 'hst_id' ||
302
+ meta_key == 'ts' ||
303
+ meta_key == 'ini_ttl' ||
304
+ meta_key == 'lock_key' ||
305
+ meta_key == 'rem_ttl' ||
306
+ meta_key == 'spc_ext_ttl' ||
307
+ meta_key == 'spc_cnt' ||
308
+ meta_key == 'l_spc_ext_ini_ttl' ||
309
+ meta_key == 'l_spc_ext_ts' ||
310
+ meta_key == 'l_spc_ts' ||
311
+ meta_key == 'rw_mode'
249
312
  end)
250
313
  raise(
251
314
  RedisQueuedLocks::ArgumentError,
@@ -276,7 +339,12 @@ module RedisQueuedLocks::Acquirer::AcquireLock
276
339
  # NOTE: (RW) write lock requests use the classic lock queue (lock_key_queue)
277
340
  read_lock_key_queue = RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
278
341
  lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
279
- read_lock_key = RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
342
+ # NOTE: (RW) read lock data key is required by read lock requests only (write lock requests
343
+ # never obtain read locks: read-to-write lock upgrade is not supported);
344
+ read_lock_key =
345
+ if read_write_mode == :read
346
+ RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
347
+ end
280
348
 
281
349
  acquirer_position = RedisQueuedLocks::Resource.calc_initial_acquirer_position
282
350
 
@@ -329,6 +397,9 @@ module RedisQueuedLocks::Acquirer::AcquireLock
329
397
  )
330
398
 
331
399
  # Step 2: try to lock with timeout
400
+ # NOTE: the acquirement start time is defined out of the timeout block: the failed
401
+ # acquirement reports the acquirement time too (Steps 3.b and 3.c);
402
+ acq_start_time = clock_gettime
332
403
  with_acq_timeout(
333
404
  redis,
334
405
  timeout,
@@ -338,8 +409,6 @@ module RedisQueuedLocks::Acquirer::AcquireLock
338
409
  detailed_acq_timeout_error,
339
410
  on_timeout: acq_dequeue
340
411
  ) do
341
- acq_start_time = clock_gettime
342
-
343
412
  # Step 2.1: cyclically try to obtain the lock
344
413
  while acq_process[:should_try]
345
414
 
@@ -371,15 +440,19 @@ module RedisQueuedLocks::Acquirer::AcquireLock
371
440
  read_lock_key_queue,
372
441
  lock_readers_key,
373
442
  read_lock_key,
443
+ lock_name,
374
444
  acquirer_id,
375
445
  host_id,
376
446
  acquirer_position,
377
447
  lock_ttl,
378
448
  queue_ttl,
449
+ lock_index_enabled,
450
+ lock_index_grace_period,
379
451
  fail_fast,
380
452
  conflict_strategy,
381
453
  access_strategy,
382
454
  meta,
455
+ acq_process[:tries] > 0, # NOTE: retry attempt (see "pre-check" in `try_to_lock`)
383
456
  log_sampled,
384
457
  instr_sampled
385
458
  ) => { ok:, result: }
@@ -629,8 +702,43 @@ module RedisQueuedLocks::Acquirer::AcquireLock
629
702
  acq_process[:result] = :timeout_reached
630
703
  end
631
704
  # Step 3.b: lock is not acquired (acquirer is dequeued by timeout callback)
705
+ acq_time = ((clock_gettime - acq_start_time) / 1_000.0).ceil(2)
706
+ LogVisitor.lock_obtaining_failed(
707
+ logger, log_sampled, lock_key, queue_ttl, acquirer_id, host_id,
708
+ acq_time, access_strategy, read_write_mode, acq_process[:result]
709
+ )
710
+ InstrVisitor.lock_obtaining_failed(
711
+ instrumenter, instr_sampled, lock_key, read_write_mode, lock_ttl, acquirer_id, host_id,
712
+ Time.now.to_f, acq_time, acq_process[:result], instrument
713
+ )
632
714
  { ok: false, result: acq_process[:result] } #: { ok: bool, result: Symbol }
633
715
  end
716
+ rescue RedisQueuedLocks::LockAlreadyObtainedError,
717
+ RedisQueuedLocks::ConflictLockObtainError,
718
+ RedisQueuedLocks::LockAcquirementRetryLimitError,
719
+ RedisQueuedLocks::LockAcquirementTimeoutError => error
720
+ # Step 3.c: (raise_errors) the failed acquirement is reported before the error is raised
721
+ # (like Step 3.b); errors of the block of code under the obtained lock are not reported;
722
+ # NOTE: (steep) the locals of the method body are nilable in the method-level `rescue` for
723
+ # Steep, but these errors are raised by the acquirement only (after Step 1: the locals
724
+ # are assigned);
725
+ # steep:ignore:start
726
+ unless acq_process[:acquired]
727
+ if error.is_a?(RedisQueuedLocks::LockAcquirementTimeoutError)
728
+ acq_process[:result] = :timeout_reached
729
+ end
730
+ acq_time = ((clock_gettime - acq_start_time) / 1_000.0).ceil(2)
731
+ LogVisitor.lock_obtaining_failed(
732
+ logger, log_sampled, lock_key, queue_ttl, acquirer_id, host_id,
733
+ acq_time, access_strategy, read_write_mode, acq_process[:result]
734
+ )
735
+ InstrVisitor.lock_obtaining_failed(
736
+ instrumenter, instr_sampled, lock_key, read_write_mode, lock_ttl, acquirer_id, host_id,
737
+ Time.now.to_f, acq_time, acq_process[:result], instrument
738
+ )
739
+ end
740
+ # steep:ignore:end
741
+ raise
634
742
  end
635
743
  end
636
744
  end
@@ -2,11 +2,32 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
+ # @version 1.21.0
6
+ # rubocop:disable Metrics/ModuleLength
5
7
  module RedisQueuedLocks::Acquirer::ClearDeadRequests
8
+ # @since 1.21.0
9
+ extend RedisQueuedLocks::Utilities
10
+
11
+ # rubocop:disable Metrics/ClassLength
6
12
  class << self
13
+ # Drops dead lock requests (requests older than `dead_ttl`) from the lock queues (write and
14
+ # read):
15
+ # - the disabled lock index: the lock queues found in one SCAN pass, one round trip per batch
16
+ # of `scan_size` lock queues (batches are collected across SCAN pages);
17
+ # - the enabled lock index: the lock queues of the locks of the lock index: one ZSCAN pass
18
+ # over the lock names (the keyspace is never scanned), one round trip per batch of
19
+ # `scan_size` lock names (both lock queues of each lock name); expired lock names are
20
+ # dropped from the lock index first;
21
+ # - the operation is reported with one log and one instrumentation event
22
+ # (`redis_queued_locks.clear_dead_requests`) with the time of the whole operation;
23
+ #
7
24
  # @param redis_client [RedisClient]
8
25
  # @param scan_size [Integer]
26
+ # SCAN count and the number of lock queues in one batch (the disabled lock index) or ZSCAN
27
+ # count and the number of lock names in one batch (the enabled lock index).
9
28
  # @param dead_ttl [Integer] In milliseconds
29
+ # @param lock_index_enabled [Boolean]
30
+ # Find lock queues via the lock index instead of the keyspace SCAN.
10
31
  # @param logger [::Logger,#debug]
11
32
  # @param instrumenter [#notify]
12
33
  # @param instrument [NilClass,Any]
@@ -22,11 +43,13 @@ module RedisQueuedLocks::Acquirer::ClearDeadRequests
22
43
  #
23
44
  # @api private
24
45
  # @since 1.0.0
25
- # @version 1.18.0
46
+ # @version 1.21.0
47
+ # rubocop:disable Metrics/MethodLength
26
48
  def clear_dead_requests(
27
49
  redis_client,
28
50
  scan_size,
29
51
  dead_ttl,
52
+ lock_index_enabled,
30
53
  logger,
31
54
  instrumenter,
32
55
  instrument,
@@ -39,48 +62,230 @@ module RedisQueuedLocks::Acquirer::ClearDeadRequests
39
62
  instr_sampler,
40
63
  instr_sample_this
41
64
  )
65
+ # NOTE: (arguments) the same ranges as the config keys: zero/negative sizes fail SCAN/ZSCAN
66
+ # (`COUNT 0`) and the batching, a non-positive dead ttl drops all lock requests;
67
+ if !scan_size.is_a?(::Integer) || scan_size <= 0
68
+ raise(
69
+ RedisQueuedLocks::ArgumentError,
70
+ "`:scan_size` argument should be a positive Integer, got #{scan_size.inspect}."
71
+ )
72
+ end
73
+ if !dead_ttl.is_a?(::Integer) || dead_ttl <= 0
74
+ raise(
75
+ RedisQueuedLocks::ArgumentError,
76
+ "`:dead_ttl` argument should be a positive Integer, got #{dead_ttl.inspect}."
77
+ )
78
+ end
79
+
80
+ rel_start_time = clock_gettime
81
+ processed_queues = fully_clear_dead_requests(
82
+ redis_client, scan_size, dead_ttl, lock_index_enabled
83
+ )
84
+ time_at = Time.now.to_f
85
+ rel_end_time = clock_gettime
86
+ rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)
87
+
88
+ log_sampled = RedisQueuedLocks::Logging.should_log?(
89
+ log_sampling_enabled,
90
+ log_sample_this,
91
+ log_sampling_percent,
92
+ log_sampler
93
+ )
94
+ instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(
95
+ instr_sampling_enabled,
96
+ instr_sample_this,
97
+ instr_sampling_percent,
98
+ instr_sampler
99
+ )
100
+
101
+ run_non_critical do
102
+ logger.debug do
103
+ "[redis_queued_locks.clear_dead_requests] " \
104
+ "dead_ttl => #{dead_ttl} " \
105
+ "prc_queue_cnt => #{processed_queues.size} " \
106
+ "rel_time => #{rel_time} " \
107
+ "at => #{time_at}"
108
+ end
109
+ end if log_sampled
110
+
111
+ run_non_critical do
112
+ instrumenter.notify('redis_queued_locks.clear_dead_requests', {
113
+ dead_ttl: dead_ttl,
114
+ prc_queue_cnt: processed_queues.size,
115
+ rel_time: rel_time,
116
+ at: time_at,
117
+ instrument: instrument
118
+ })
119
+ end if instr_sampled
120
+
121
+ { ok: true, result: { processed_queues: } }
122
+ end
123
+ # rubocop:enable Metrics/MethodLength
124
+
125
+ private
126
+
127
+ # Drops dead lock requests from the lock queues (see `clear_dead_requests`).
128
+ #
129
+ # @param redis_client [RedisClient]
130
+ # @param scan_size [Integer]
131
+ # @param dead_ttl [Integer] In milliseconds
132
+ # @param lock_index_enabled [Boolean]
133
+ # @return [Set<String>] The processed lock queues (write and read lock request queues).
134
+ #
135
+ # @api private
136
+ # @since 1.21.0
137
+ def fully_clear_dead_requests(redis_client, scan_size, dead_ttl, lock_index_enabled)
42
138
  dead_score = RedisQueuedLocks::Resource.acquirer_dead_score(dead_ttl / 1_000.0)
43
139
 
44
- # @type var result: Set[String]
45
- result = Set.new.tap do |processed_queues|
140
+ Set.new.tap do |processed_queues|
46
141
  # @type var processed_queues: Set[String]
47
142
  redis_client.with do |rconn|
48
- each_lock_queue(rconn, scan_size) do |lock_queue|
49
- rconn.call('ZREMRANGEBYSCORE', lock_queue, '-inf', dead_score)
50
- processed_queues << lock_queue
143
+ if lock_index_enabled
144
+ # Step 1 (lock index): drop the expired lock names from the lock index
145
+ # (all their keys are expired);
146
+ rconn.call(
147
+ 'ZREMRANGEBYSCORE', RedisQueuedLocks::Resource::LOCK_INDEX_KEY,
148
+ '-inf', RedisQueuedLocks::Resource.lock_index_time - 1
149
+ )
150
+
151
+ # Step 2 (lock index): drop dead requests from the lock queues of the lock index
152
+ each_lock_name_batch(rconn, scan_size) do |lock_names|
153
+ drop_dead_requests_of_locks(rconn, lock_names, dead_score, processed_queues)
154
+ end
155
+ else
156
+ # Step 1 (keyspace): drop dead requests from the lock queues found in one SCAN pass
157
+ # NOTE: dead requests are dropped with one pipeline per batch of `scan_size` queues
158
+ # (batches are collected across SCAN pages: lock queues can be rare among other keys);
159
+ # @type var lock_queues: Array[String]
160
+ lock_queues = []
161
+
162
+ each_lock_queue(rconn, scan_size) do |lock_queue|
163
+ processed_queues << lock_queue
164
+ lock_queues << lock_queue
165
+ next if lock_queues.size < scan_size
166
+
167
+ drop_dead_requests(rconn, lock_queues, dead_score)
168
+ lock_queues.clear
169
+ end
170
+
171
+ drop_dead_requests(rconn, lock_queues, dead_score) unless lock_queues.empty?
51
172
  end
52
173
  end
53
174
  end
54
-
55
- { ok: true, result: { processed_queues: result } }
56
175
  end
57
176
 
58
- private
59
-
177
+ # Write and read lock queues in one SCAN pass (the disabled lock index).
178
+ #
60
179
  # @param redis_client [RedisClient]
61
180
  # @param scan_size [Integer]
62
181
  # @yield [lock_queue]
63
182
  # @yieldparam lock_queue [String]
64
183
  # @yieldreturn [void]
65
- # @return [Enumerator]
184
+ # @return [void]
66
185
  #
67
186
  # @api private
68
187
  # @since 1.0.0
69
- # @version 1.18.0
70
- def each_lock_queue(redis_client, scan_size, &block)
71
- redis_client.scan(
72
- 'MATCH',
73
- RedisQueuedLocks::Resource::LOCK_QUEUE_PATTERN,
74
- count: scan_size,
75
- &block
76
- )
77
- # NOTE: (RW) read lock queues
188
+ # @version 1.20.0
189
+ def each_lock_queue(redis_client, scan_size)
78
190
  redis_client.scan(
79
191
  'MATCH',
80
- RedisQueuedLocks::Resource::READ_LOCK_QUEUE_PATTERN,
81
- count: scan_size,
82
- &block
83
- )
192
+ RedisQueuedLocks::Resource::ALL_LOCK_QUEUES_PATTERN,
193
+ count: scan_size
194
+ ) do |key|
195
+ # NOTE: the pattern matches other keys too (lock names with `queue:`): lock queues only
196
+ family = RedisQueuedLocks::Resource.lock_key_family(key)
197
+ yield(key) if family == :lock_queue || family == :read_lock_queue
198
+ end
199
+ end
200
+
201
+ # Drops dead requests of the given lock queues in one round trip (the disabled lock index).
202
+ #
203
+ # @param redis_client [RedisClient]
204
+ # @param lock_queues [Array<String>]
205
+ # @param dead_score [Float]
206
+ # @return [void]
207
+ #
208
+ # @api private
209
+ # @since 1.20.0
210
+ def drop_dead_requests(redis_client, lock_queues, dead_score)
211
+ redis_client.pipelined do |pipeline|
212
+ lock_queues.each do |lock_queue|
213
+ pipeline.call('ZREMRANGEBYSCORE', lock_queue, '-inf', dead_score)
214
+ end
215
+ end
216
+ end
217
+
218
+ # Drops dead requests of both lock queues of the given lock names in one round trip
219
+ # (the enabled lock index).
220
+ #
221
+ # @param redis_client [RedisClient]
222
+ # @param lock_names [Array<String>]
223
+ # @param dead_score [Float]
224
+ # @param processed_queues [Set<String>] Collects the existing (processed) lock queues.
225
+ # @return [void]
226
+ #
227
+ # @api private
228
+ # @since 1.20.0
229
+ def drop_dead_requests_of_locks(redis_client, lock_names, dead_score, processed_queues)
230
+ # NOTE: (pipeline layout) two replies per lock queue (two lock queues per lock name) in
231
+ # the batch order: EXISTS (the queue is processed when it exists), ZREMRANGEBYSCORE;
232
+ # @type var replies: Array[Integer]
233
+ replies = redis_client.pipelined do |pipeline|
234
+ lock_names.each do |lock_name|
235
+ lock_queue = RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
236
+ read_lock_queue = RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
237
+ pipeline.call('EXISTS', lock_queue)
238
+ pipeline.call('ZREMRANGEBYSCORE', lock_queue, '-inf', dead_score)
239
+ pipeline.call('EXISTS', read_lock_queue)
240
+ pipeline.call('ZREMRANGEBYSCORE', read_lock_queue, '-inf', dead_score)
241
+ end
242
+ end
243
+
244
+ lock_names.each_with_index do |lock_name, index|
245
+ if replies[index * 4] == 1
246
+ processed_queues << RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
247
+ end
248
+ if replies[(index * 4) + 2] == 1
249
+ processed_queues << RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
250
+ end
251
+ end
252
+ end
253
+
254
+ # Iterates over the lock names of the lock index (one ZSCAN pass) in batches of `scan_size`
255
+ # lock names (batches are collected across ZSCAN pages). Expired lock names (not dropped from
256
+ # the index yet) are skipped: all their keys are expired.
257
+ #
258
+ # NOTE: each operation module iterates over the lock index by itself (duplicated on purpose:
259
+ # operation modules are independent of each other, see `Locks.each_lock_name_batch`);
260
+ #
261
+ # @param redis_client [RedisClient]
262
+ # @param scan_size [Integer] ZSCAN count and the number of lock names in one batch.
263
+ # @yield [lock_names]
264
+ # @yieldparam lock_names [Array<String>] The batch of lock names (reused between batches).
265
+ # @return [void]
266
+ #
267
+ # @api private
268
+ # @since 1.20.0
269
+ def each_lock_name_batch(redis_client, scan_size)
270
+ lock_index_time = RedisQueuedLocks::Resource.lock_index_time
271
+ # @type var lock_names: Array[String]
272
+ lock_names = []
273
+
274
+ redis_client.zscan(
275
+ RedisQueuedLocks::Resource::LOCK_INDEX_KEY, count: scan_size
276
+ ) do |lock_name, lock_index_score|
277
+ next if lock_index_score.to_f < lock_index_time
278
+
279
+ lock_names << lock_name
280
+ next if lock_names.size < scan_size
281
+
282
+ yield(lock_names)
283
+ lock_names.clear
284
+ end
285
+
286
+ yield(lock_names) unless lock_names.empty?
84
287
  end
85
288
  end
289
+ # rubocop:enable Metrics/ClassLength
86
290
  end
291
+ # rubocop:enable Metrics/ModuleLength