redis_queued_locks 1.21.0 → 1.22.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +216 -25
  3. data/.claude/rules/acquirer.md +6 -5
  4. data/.claude/rules/arguments.md +2 -2
  5. data/.claude/rules/changelog.md +27 -0
  6. data/.claude/rules/git.md +1 -1
  7. data/.claude/rules/logic.md +10 -12
  8. data/.claude/rules/swarm.md +3 -4
  9. data/.claude/rules/tests.md +6 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +5 -5
  12. data/.claude/skills/rql-code-review/SKILL.md +112 -35
  13. data/CHANGELOG.md +98 -244
  14. data/CLAUDE.md +9 -8
  15. data/README.md +275 -85
  16. data/Rakefile +11 -0
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +28 -12
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +38 -16
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +430 -63
  21. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +72 -22
  22. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +240 -45
  23. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +3 -1
  24. data/lib/redis_queued_locks/acquirer/keys.rb +6 -2
  25. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +10 -5
  26. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -9
  27. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +52 -23
  28. data/lib/redis_queued_locks/acquirer/locks.rb +6 -2
  29. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -7
  30. data/lib/redis_queued_locks/acquirer/queues.rb +17 -6
  31. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +6 -2
  32. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +6 -2
  33. data/lib/redis_queued_locks/client.rb +66 -24
  34. data/lib/redis_queued_locks/config.rb +93 -1
  35. data/lib/redis_queued_locks/resource.rb +124 -9
  36. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +6 -5
  37. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  38. data/lib/redis_queued_locks/version.rb +2 -2
  39. data/rbs_collection.lock.yaml +4 -0
  40. data/rbs_collection.yaml +2 -1
  41. data/sig/manifest.yml +1 -0
  42. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  43. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  44. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +12 -6
  45. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +21 -14
  46. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  47. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  48. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  49. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +2 -2
  50. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +5 -3
  51. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +14 -1
  52. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  53. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  54. data/sig/redis_queued_locks/client.rbs +10 -7
  55. data/sig/redis_queued_locks/config.rbs +54 -1
  56. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -1
  57. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  58. data/sig/redis_queued_locks/logging/sampler.rbs +3 -1
  59. data/sig/redis_queued_locks/resource.rbs +22 -12
  60. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  61. data/sig/redis_queued_locks/utilities.rbs +2 -1
  62. data/sig/redis_queued_locks.rbs +7 -0
  63. metadata +4 -3
  64. /data/sig/redis_queued_locks/{acquier.rbs → acquirer.rbs} +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api public
4
4
  # @since 1.0.0
5
- # @version 1.21.0
5
+ # @version 1.22.0
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class RedisQueuedLocks::Client
8
8
  # @return [RedisClient]
@@ -43,7 +43,7 @@ class RedisQueuedLocks::Client
43
43
  # @version 1.13.0
44
44
  def initialize(redis_client, &configuration)
45
45
  @config = RedisQueuedLocks::Config.new(&configuration)
46
- @uniq_identity = config['uniq_identifier'].call #: String
46
+ @uniq_identity = config['uniq_identifier'].call
47
47
  @redis_client = redis_client
48
48
  @swarm = RedisQueuedLocks::Swarm.new(self)
49
49
  @swarm.swarm! if config['swarm.auto_swarm']
@@ -232,8 +232,9 @@ class RedisQueuedLocks::Client
232
232
  # - `:queued` access strategy orders read and write lock requests in FIFO between modes
233
233
  # (read lock request waits for earlier write lock requests, write lock request waits for
234
234
  # earlier read and write lock requests) so writers are not starved by readers;
235
- # - `:random` access strategy ignores lock queues (a continuous flow of readers can starve
236
- # writers);
235
+ # - `:random` access strategy: the lock request does not wait for earlier requests (no order
236
+ # between modes: a continuous flow of readers can starve writers), but it is queued like any
237
+ # lock request (`:queued` lock requests wait for it);
237
238
  # - read lock specifics:
238
239
  # - read lock is released (with a block of code) or expired (by `ttl`) independently
239
240
  # of other read locks; `#unlock` releases all read locks and the write lock;
@@ -255,6 +256,24 @@ class RedisQueuedLocks::Client
255
256
  # (but with checking the limist, retries, timeouts and so on). if lock is
256
257
  # free to obtain - it will be obtained;
257
258
  # - pre-configured in `config['default_access_strategy']`;
259
+ # @option priority [Integer,Symbol]
260
+ # - The lock request priority: an Integer from `-100` (the lowest) to `100` (the highest),
261
+ # `0` by default (`RedisQueuedLocks::Resource::MIN_LOCK_PRIORITY`..`MAX_LOCK_PRIORITY`),
262
+ # or a priority name of `config['lock_priorities']` (`:lowest` (-100), `:low` (-50),
263
+ # `:normal` (0), `:high` (50), `:highest` (100) by default; the application can define its
264
+ # own names, `merge` keeps the default ones:
265
+ # `config['lock_priorities'] = config['lock_priorities'].merge(payment: 90, email: -10)`);
266
+ # - `:queued` access strategy: the lock request is placed in the lock request queue after all
267
+ # requests of the same or higher priority and before all requests of the lower priority
268
+ # (strict priority, FIFO inside the same priority; read and write lock requests too);
269
+ # - `:random` access strategy: the lock request does not wait for its turn (it ignores the order
270
+ # of the lock request queues and the priorities of other requests), but it is queued at the
271
+ # position of its priority like a `:queued` one: `:queued` lock requests wait for it;
272
+ # - the obtained lock is never taken away: the priority orders the waiting lock requests;
273
+ # - positive priorities need all processes that share the lock on RQL versions with priorities
274
+ # (older versions drop such lock requests as dead ones: the order is broken, the mutual
275
+ # exclusion is not affected); `0` and negative priorities work with older versions too;
276
+ # - pre-configured in `config['default_lock_priority']`;
258
277
  # @option meta [NilClass,Hash<String|Symbol,Any>]
259
278
  # - A custom metadata wich will be passed to the lock data in addition to the existing data;
260
279
  # - Metadata can not contain reserved lock data keys;
@@ -291,8 +310,8 @@ class RedisQueuedLocks::Client
291
310
  # - Custom instrumenter that will be invoked via #notify method with `event` and `payload` data;
292
311
  # - See RedisQueuedLocks::Instrument::ActiveSupport for examples and implementation details;
293
312
  # - See [Instrumentation](#instrumentation) section of docs;
294
- # - pre-configured in `config['isntrumenter']` with void notifier
295
- # (`RedisQueuedLocks::Instrumenter::VoidNotifier`);
313
+ # - pre-configured in `config['instrumenter']` with void notifier
314
+ # (`RedisQueuedLocks::Instrument::VoidNotifier`);
296
315
  # @option instrument [NilClass,Any]
297
316
  # - Custom instrumentation data wich will be passed to the instrumenter's payload
298
317
  # with :instrument key;
@@ -347,7 +366,7 @@ class RedisQueuedLocks::Client
347
366
  #
348
367
  # @api public
349
368
  # @since 1.0.0
350
- # @version 1.21.0
369
+ # @version 1.22.0
351
370
  # rubocop:disable Metrics/MethodLength
352
371
  def lock(
353
372
  lock_name,
@@ -364,6 +383,7 @@ class RedisQueuedLocks::Client
364
383
  conflict_strategy: config['default_conflict_strategy'],
365
384
  read_write_mode: :write,
366
385
  access_strategy: config['default_access_strategy'],
386
+ priority: config['default_lock_priority'],
367
387
  identity: uniq_identity,
368
388
  meta: nil,
369
389
  detailed_acq_timeout_error: config['detailed_acq_timeout_error'],
@@ -404,6 +424,8 @@ class RedisQueuedLocks::Client
404
424
  conflict_strategy:,
405
425
  read_write_mode:,
406
426
  access_strategy:,
427
+ priority:,
428
+ lock_priorities: config['lock_priorities'],
407
429
  meta:,
408
430
  detailed_acq_timeout_error:,
409
431
  logger:,
@@ -423,10 +445,10 @@ class RedisQueuedLocks::Client
423
445
  # rubocop:enable Metrics/MethodLength
424
446
 
425
447
  # NOTE: Lock Series PoC
426
- # rubocop:disable all
448
+ # rubocop:disable Metrics/MethodLength
427
449
  # @api public
428
450
  # @since 1.16.0
429
- # @version 1.21.0
451
+ # @version 1.22.0
430
452
  def lock_series( # steep:ignore
431
453
  *lock_names,
432
454
  detailed_result: false,
@@ -443,6 +465,7 @@ class RedisQueuedLocks::Client
443
465
  conflict_strategy: config['default_conflict_strategy'],
444
466
  read_write_mode: :write,
445
467
  access_strategy: config['default_access_strategy'],
468
+ priority: config['default_lock_priority'],
446
469
  identity: uniq_identity,
447
470
  meta: nil,
448
471
  detailed_acq_timeout_error: config['detailed_acq_timeout_error'],
@@ -484,6 +507,8 @@ class RedisQueuedLocks::Client
484
507
  conflict_strategy:,
485
508
  read_write_mode:,
486
509
  access_strategy:,
510
+ priority:,
511
+ lock_priorities: config['lock_priorities'],
487
512
  meta:,
488
513
  detailed_acq_timeout_error:,
489
514
  logger:,
@@ -500,13 +525,13 @@ class RedisQueuedLocks::Client
500
525
  &block
501
526
  )
502
527
  end
503
- # rubocop:enable all
528
+ # rubocop:enable Metrics/MethodLength
504
529
 
505
530
  # NOTE: Lock Series PoC
506
- # rubocop:disable all
531
+ # rubocop:disable Metrics/MethodLength
507
532
  # @api public
508
533
  # @since 1.16.0
509
- # @version 1.21.0
534
+ # @version 1.22.0
510
535
  def lock_series!( # steep:ignore
511
536
  *lock_names,
512
537
  detailed_result: false,
@@ -522,6 +547,7 @@ class RedisQueuedLocks::Client
522
547
  conflict_strategy: config['default_conflict_strategy'],
523
548
  read_write_mode: :write,
524
549
  access_strategy: config['default_access_strategy'],
550
+ priority: config['default_lock_priority'],
525
551
  identity: uniq_identity,
526
552
  meta: nil,
527
553
  detailed_acq_timeout_error: config['detailed_acq_timeout_error'],
@@ -555,6 +581,7 @@ class RedisQueuedLocks::Client
555
581
  conflict_strategy:,
556
582
  read_write_mode:,
557
583
  access_strategy:,
584
+ priority:,
558
585
  identity:,
559
586
  meta:,
560
587
  detailed_acq_timeout_error:,
@@ -573,12 +600,14 @@ class RedisQueuedLocks::Client
573
600
  &block
574
601
  )
575
602
  end
603
+ # rubocop:enable Metrics/MethodLength
576
604
 
577
605
  # @note See #lock method signature.
578
606
  #
607
+ # rubocop:disable Metrics/MethodLength
579
608
  # @api public
580
609
  # @since 1.0.0
581
- # @version 1.21.0
610
+ # @version 1.22.0
582
611
  def lock!(
583
612
  lock_name,
584
613
  ttl: config['default_lock_ttl'],
@@ -593,6 +622,7 @@ class RedisQueuedLocks::Client
593
622
  conflict_strategy: config['default_conflict_strategy'],
594
623
  read_write_mode: :write,
595
624
  access_strategy: config['default_access_strategy'],
625
+ priority: config['default_lock_priority'],
596
626
  identity: uniq_identity,
597
627
  instrumenter: config['instrumenter'],
598
628
  meta: nil,
@@ -632,6 +662,7 @@ class RedisQueuedLocks::Client
632
662
  conflict_strategy:,
633
663
  read_write_mode:,
634
664
  access_strategy:,
665
+ priority:,
635
666
  log_sampling_enabled:,
636
667
  log_sampling_percent:,
637
668
  log_sampler:,
@@ -714,13 +745,15 @@ class RedisQueuedLocks::Client
714
745
  #
715
746
  # @param lock_name [String] The lock name whose read lock should be released.
716
747
  # @option acquirer_id [String,NilClass]
717
- # - The acquirer whose read lock should be released (`nil` by default);
718
- # - `nil` => the current acquirer is used (calculated from `identity:`, see `#current_acquirer_id`);
719
- # - can be extracted from the lock data (`#lock_info`, `#locks_info`, `#queue_info`, `#queues_info`);
748
+ # - the acquirer whose read lock should be released (`nil` by default);
749
+ # - `nil` => the current acquirer is used (calculated from `identity:`,
750
+ # see `#current_acquirer_id`);
751
+ # - can be extracted from the lock data (`#lock_info`, `#locks_info`, `#queue_info`,
752
+ # `#queues_info`);
720
753
  # @option identity [String]
721
754
  # Unique acquirer identity (the same as for `#lock`) that is used to calculate the current
722
- # acquirer id (see `#current_acquirer_id`). Ignored when `acquirer_id:` is passed: the acquirer id
723
- # already contains its own identity.
755
+ # acquirer id (see `#current_acquirer_id`). Ignored when `acquirer_id:` is passed: the
756
+ # acquirer id already contains its own identity.
724
757
  # @option logger [::Logger,#debug]
725
758
  # @option instrumenter [#notify]
726
759
  # @option instrument [NilClass,Any]
@@ -826,7 +859,7 @@ class RedisQueuedLocks::Client
826
859
  #
827
860
  # @api public
828
861
  # @since 1.0.0
829
- # @version 1.18.0
862
+ # @version 1.22.0
830
863
  def queue_info(lock_name)
831
864
  RedisQueuedLocks::Acquirer::QueueInfo.queue_info(redis_client, lock_name)
832
865
  end
@@ -1260,7 +1293,7 @@ class RedisQueuedLocks::Client
1260
1293
  #
1261
1294
  # @api public
1262
1295
  # @since 1.0.0
1263
- # @version 1.21.0
1296
+ # @version 1.22.0
1264
1297
  def queues(scan_size: config['key_extraction_batch_size'], with_info: false)
1265
1298
  RedisQueuedLocks::Acquirer::Queues.queues(
1266
1299
  redis_client, scan_size:, with_info:, lock_index_enabled: config['lock_index_enabled']
@@ -1274,7 +1307,7 @@ class RedisQueuedLocks::Client
1274
1307
  #
1275
1308
  # @api public
1276
1309
  # @since 1.0.0
1277
- # @version 1.21.0
1310
+ # @version 1.22.0
1278
1311
  def queues_info(scan_size: config['key_extraction_batch_size'])
1279
1312
  queues(scan_size:, with_info: true) #: RedisQueuedLocks::Acquirer::Queues::queuesInfo
1280
1313
  end
@@ -1302,6 +1335,13 @@ class RedisQueuedLocks::Client
1302
1335
  # - the batch of scanned keys for Redis'es SCAN command (or the number of locks in one batch of
1303
1336
  # the lock index ZSCAN pass: the enabled `config['lock_index_enabled']`);
1304
1337
  # - `config['lock_release_batch_size']` is used by default;
1338
+ # @option by_priority [Boolean]
1339
+ # - read only the first lock request of each priority in the lock queues instead of all lock
1340
+ # requests (the dead requests of a priority are found by its first lock request): the
1341
+ # transfer does not depend on the length of the lock queues, a batch of lock queues takes
1342
+ # one round trip per priority of its lock queue with the most priorities + 1 (instead of two
1343
+ # round trips): for long lock queues with few priorities;
1344
+ # - `false` by default (all lock requests are read);
1305
1345
  # @option logger [::Logger,#debug]
1306
1346
  # @option instrumenter [#notify]
1307
1347
  # @option instrument [NilClass,Any]
@@ -1318,10 +1358,11 @@ class RedisQueuedLocks::Client
1318
1358
  #
1319
1359
  # @api public
1320
1360
  # @since 1.0.0
1321
- # @version 1.21.0
1361
+ # @version 1.22.0
1322
1362
  def clear_dead_requests(
1323
1363
  dead_ttl: config['dead_request_ttl'],
1324
1364
  scan_size: config['lock_release_batch_size'],
1365
+ by_priority: false,
1325
1366
  logger: config['logger'],
1326
1367
  instrumenter: config['instrumenter'],
1327
1368
  instrument: nil,
@@ -1338,6 +1379,7 @@ class RedisQueuedLocks::Client
1338
1379
  redis_client,
1339
1380
  scan_size,
1340
1381
  dead_ttl,
1382
+ by_priority,
1341
1383
  config['lock_index_enabled'],
1342
1384
  logger,
1343
1385
  instrumenter,
@@ -1354,8 +1396,8 @@ class RedisQueuedLocks::Client
1354
1396
  end
1355
1397
 
1356
1398
  # Adds the lock keys without the lock index entry (keys of older RQL versions, keys created while
1357
- # the lock index was disabled) to the lock index (`rql:index:locks`). Invoke it once after the lock
1358
- # index is enabled (`config['lock_index_enabled']`) on all your processes: lock-iterating
1399
+ # the lock index was disabled) to the lock index (`rql:index:locks`). Invoke it once after the
1400
+ # lock index is enabled (`config['lock_index_enabled']`) on all your processes: lock-iterating
1359
1401
  # operations (the swarm, `#locks`, `#queues`, `#keys`, `#clear_locks`, ...) find locks via
1360
1402
  # the lock index only.
1361
1403
  # - works regardless of `config['lock_index_enabled']` (the lock index is not used and not
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.13.0
5
+ # @version 1.22.0
5
6
  # rubocop:disable Metrics/ClassLength
6
7
  class RedisQueuedLocks::Config
7
8
  require_relative 'config/dsl'
@@ -31,6 +32,8 @@ class RedisQueuedLocks::Config
31
32
  setting('is_timed_by_default', false)
32
33
  setting('default_conflict_strategy', :wait_for_lock)
33
34
  setting('default_access_strategy', :queued)
35
+ setting('lock_priorities', { lowest: -100, low: -50, normal: 0, high: 50, highest: 100 }.freeze)
36
+ setting('default_lock_priority', 0)
34
37
  setting('log_sampling_enabled', false)
35
38
  setting('log_sampling_percent', 15)
36
39
  setting('log_sampler', RedisQueuedLocks::Logging::Sampler)
@@ -114,6 +117,27 @@ class RedisQueuedLocks::Config
114
117
  val == :random
115
118
  # rubocop:enable Layout/MultilineOperationIndentation
116
119
  end
120
+ validate('lock_priorities') do |val|
121
+ val.is_a?(Hash) && val.all? do |name, priority|
122
+ # rubocop:disable Layout/MultilineOperationIndentation
123
+ name.is_a?(Symbol) &&
124
+ priority.is_a?(Integer) &&
125
+ priority >= RedisQueuedLocks::Resource::MIN_LOCK_PRIORITY &&
126
+ priority <= RedisQueuedLocks::Resource::MAX_LOCK_PRIORITY
127
+ # rubocop:enable Layout/MultilineOperationIndentation
128
+ end
129
+ end
130
+ # NOTE: a Symbol is a priority name of `config['lock_priorities']`: a validator gets one value,
131
+ # so the name is checked by the configuration itself (`prevent_default_priority__unknown_name`:
132
+ # once after a configuration block, the keys can be assigned in any order; before a direct
133
+ # assignment is applied);
134
+ validate('default_lock_priority') do |val|
135
+ val.is_a?(Symbol) || (
136
+ val.is_a?(Integer) &&
137
+ val >= RedisQueuedLocks::Resource::MIN_LOCK_PRIORITY &&
138
+ val <= RedisQueuedLocks::Resource::MAX_LOCK_PRIORITY
139
+ )
140
+ end
117
141
 
118
142
  # @return [Hash<Symbol,Any>]
119
143
  #
@@ -123,10 +147,14 @@ class RedisQueuedLocks::Config
123
147
 
124
148
  # @return [void]
125
149
  #
150
+ # @raise [RedisQueuedLocks::ConfigValidationError]
151
+ #
126
152
  # @api private
127
153
  # @since 1.13.0
154
+ # @version 1.22.0
128
155
  def initialize(&configuration)
129
156
  @config_state = {}
157
+ @configuring = false
130
158
  config_setters.each_value { |setter| setter.call(@config_state) }
131
159
  configure(&configuration)
132
160
  end
@@ -136,10 +164,40 @@ class RedisQueuedLocks::Config
136
164
  # @yieldparam [?]
137
165
  # @return [void]
138
166
  #
167
+ # @raise [RedisQueuedLocks::ConfigValidationError]
168
+ #
139
169
  # @api private
140
170
  # @since 1.13.0
171
+ # @version 1.22.0
141
172
  def configure(&configuration)
142
- yield(self) if block_given?
173
+ return unless block_given?
174
+
175
+ # NOTE: (priorities) the keys of the block can be assigned in any order: the priority name of
176
+ # `default_lock_priority` is checked against `lock_priorities` once, after the block (after
177
+ # the outer block for nested blocks); an inconsistent pair is never applied: both keys keep
178
+ # their values from before the block (also when the block fails: its error is raised);
179
+ lock_priorities = config_state['lock_priorities']
180
+ default_lock_priority = config_state['default_lock_priority']
181
+ nested = @configuring
182
+ completed = false
183
+ @configuring = true
184
+ begin
185
+ yield(self)
186
+ completed = true
187
+ ensure
188
+ @configuring = nested
189
+ unless nested
190
+ begin
191
+ prevent_default_priority__unknown_name(
192
+ config_state['lock_priorities'], config_state['default_lock_priority']
193
+ )
194
+ rescue RedisQueuedLocks::ConfigValidationError
195
+ config_state['lock_priorities'] = lock_priorities
196
+ config_state['default_lock_priority'] = default_lock_priority
197
+ raise if completed
198
+ end
199
+ end
200
+ end
143
201
  end
144
202
 
145
203
  # @param config_key [String]
@@ -163,9 +221,21 @@ class RedisQueuedLocks::Config
163
221
  #
164
222
  # @api public
165
223
  # @since 1.13.0
224
+ # @version 1.22.0
166
225
  def []=(config_key, config_value)
167
226
  prevent_key__non_existent(config_key)
168
227
  prevent_key__invalid_type(config_key, config_value)
228
+ # NOTE: (priorities) a direct assignment (out of a configuration block) keeps the priority name
229
+ # of `default_lock_priority` a key of `lock_priorities` (a configuration block checks it once
230
+ # after the block, see `configure`);
231
+ unless @configuring
232
+ case config_key
233
+ when 'lock_priorities'
234
+ prevent_default_priority__unknown_name(config_value, config_state['default_lock_priority'])
235
+ when 'default_lock_priority'
236
+ prevent_default_priority__unknown_name(config_state['lock_priorities'], config_value)
237
+ end
238
+ end
169
239
  config_state[config_key] = config_value
170
240
  end
171
241
 
@@ -236,5 +306,27 @@ class RedisQueuedLocks::Config
236
306
  )
237
307
  end
238
308
  end
309
+
310
+ # A priority name of the default lock request priority should be a priority name of
311
+ # `lock_priorities` (an unknown name would fail each lock acquirement with the default priority).
312
+ #
313
+ # @param lock_priorities [Hash<Symbol,Integer>] The priority names.
314
+ # @param default_lock_priority [Integer,Symbol] The default lock request priority.
315
+ # @return [void]
316
+ #
317
+ # @raise [RedisQueuedLocks::ConfigValidationError]
318
+ #
319
+ # @api private
320
+ # @since 1.22.0
321
+ def prevent_default_priority__unknown_name(lock_priorities, default_lock_priority)
322
+ if default_lock_priority.is_a?(Symbol) && !lock_priorities.key?(default_lock_priority)
323
+ raise(
324
+ RedisQueuedLocks::ConfigValidationError,
325
+ "The priority name #{default_lock_priority.inspect} of the 'default_lock_priority' " \
326
+ "config key is not a key of the 'lock_priorities' config key " \
327
+ "(#{lock_priorities.keys.map(&:inspect).join(', ')})."
328
+ )
329
+ end
330
+ end
239
331
  end
240
332
  # rubocop:enable Metrics/ClassLength
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
- # @version 1.20.0
5
+ # @version 1.22.0
6
6
  # rubocop:disable Metrics/ModuleLength
7
7
  module RedisQueuedLocks::Resource
8
8
  # @return [String]
@@ -90,6 +90,44 @@ module RedisQueuedLocks::Resource
90
90
  # @since 1.3.0
91
91
  REDIS_TIMESHIFT_ERROR = 2
92
92
 
93
+ # The lowest lock request priority (the `priority` option of `#lock`).
94
+ #
95
+ # @return [Integer]
96
+ #
97
+ # @api private
98
+ # @since 1.22.0
99
+ MIN_LOCK_PRIORITY = -100
100
+
101
+ # The highest lock request priority (the `priority` option of `#lock`).
102
+ #
103
+ # @return [Integer]
104
+ #
105
+ # @api private
106
+ # @since 1.22.0
107
+ MAX_LOCK_PRIORITY = 100
108
+
109
+ # Lock request priority band: the position of the lock request in the lock request queue (the
110
+ # score) is the enqueue time (epoch, seconds) shifted by `priority * LOCK_PRIORITY_BAND` down:
111
+ # - each priority gets its own range of positions (the band): requests of the higher priority
112
+ # are always ahead of the requests of the lower priority, requests of the same priority are
113
+ # ordered by the enqueue time (FIFO);
114
+ # - 2**32 seconds (~136 years) is greater than any enqueue time (epoch) up to the year 2106,
115
+ # so the bands never overlap and the priority is decoded from the position unambiguously;
116
+ # - the default priority (`0`) keeps the position equal to the enqueue time (the positions of
117
+ # the RQL versions without priorities);
118
+ # - the band of the priority `p` is `(-p * LOCK_PRIORITY_BAND, (1 - p) * LOCK_PRIORITY_BAND)`;
119
+ # - the enqueue time is decoded from the position of any priority, so dead lock requests of any
120
+ # band are recognized by the position (the time barrier of the band, see
121
+ # `lock_priority_band_dead_score`) without the knowledge of the priority range;
122
+ # - the precision of the positions (the FIFO precision inside a priority) depends on the
123
+ # shift: ~1 µs for -1..1, ~61 µs for -100..100 (`MIN_LOCK_PRIORITY`..`MAX_LOCK_PRIORITY`):
124
+ # requests of the same priority made within this interval are ordered by acquirer ids;
125
+ # @return [Integer] In seconds.
126
+ #
127
+ # @api private
128
+ # @since 1.22.0
129
+ LOCK_PRIORITY_BAND = 4_294_967_296
130
+
93
131
  # rubocop:disable Metrics/ClassLength
94
132
  class << self
95
133
  # Returns 16-byte unique identifier. It is used for uniquely
@@ -276,6 +314,22 @@ module RedisQueuedLocks::Resource
276
314
  ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
277
315
  end
278
316
 
317
+ # Is the lock name of the lock index expired (all the keys of the lock are expired)?
318
+ #
319
+ # NOTE: the lock index replies of ZSCAN have String scores: `+inf` (a key without TTL indexed
320
+ # by `ReindexLocks`) is `"inf"`, and `String#to_f` turns it into `0.0` (the name would be
321
+ # skipped as an expired one by every lock index lookup): it never expires;
322
+ #
323
+ # @param lock_index_score [String] The score of the lock name (a ZSCAN reply).
324
+ # @param lock_index_time [Integer] The current client time (see `lock_index_time`).
325
+ # @return [Boolean]
326
+ #
327
+ # @api private
328
+ # @since 1.22.0
329
+ def lock_index_score_expired?(lock_index_score, lock_index_time)
330
+ lock_index_score != 'inf' && lock_index_score.to_f < lock_index_time
331
+ end
332
+
279
333
  # @param lock_readers_key [String]
280
334
  # @return [String]
281
335
  #
@@ -327,16 +381,23 @@ module RedisQueuedLocks::Resource
327
381
  (Integer(redis_time[0]) * 1_000) + (Integer(redis_time[1]) / 1_000)
328
382
  end
329
383
 
330
- # @return [Float] Redis's <Set> score that is calculated from the time (epoch) as a float.
384
+ # @param priority [Integer]
385
+ # The lock request priority (from `MIN_LOCK_PRIORITY` to `MAX_LOCK_PRIORITY`).
386
+ # @return [Float]
387
+ # Redis's <Set> score: the time (epoch, as a float) shifted by the priority band (see
388
+ # `LOCK_PRIORITY_BAND`; the default priority `0` keeps the time).
331
389
  #
332
390
  # @api private
333
391
  # @since 1.0.0
334
- def calc_initial_acquirer_position
335
- Time.now.to_f
392
+ # @version 1.22.0
393
+ def calc_initial_acquirer_position(priority)
394
+ Time.now.to_f - (priority * LOCK_PRIORITY_BAND)
336
395
  end
337
396
 
338
397
  # @param queue_ttl [Numeric] In seconds
339
- # @return [Float] Redis's <Set> score barrier for acquirers that should be removed from queue.
398
+ # @return [Float]
399
+ # Redis's <Set> score barrier for acquirers that should be removed from queue (the time
400
+ # barrier: the barrier of the default priority band, see `lock_priority_band_dead_score`).
340
401
  #
341
402
  # @api private
342
403
  # @since 1.0.0
@@ -344,6 +405,57 @@ module RedisQueuedLocks::Resource
344
405
  Time.now.to_f - queue_ttl
345
406
  end
346
407
 
408
+ # The lowest position (score) of the lock requests of the given priority band (see
409
+ # `LOCK_PRIORITY_BAND`): the lower bound of the dead lock request removal (`ZREMRANGEBYSCORE`).
410
+ #
411
+ # @param priority [Integer] Any priority (the bands of all priorities have the same layout).
412
+ # @return [Integer]
413
+ # The lower edge of the band (the positions of the band never reach its edges: an enqueue
414
+ # time is always greater than 0 and less than `LOCK_PRIORITY_BAND`).
415
+ #
416
+ # @api private
417
+ # @since 1.22.0
418
+ def lock_priority_band_floor(priority)
419
+ -priority * LOCK_PRIORITY_BAND
420
+ end
421
+
422
+ # Requests of the given priority band queued at this position or earlier are dead (the upper
423
+ # bound of the dead lock request removal, see `LOCK_PRIORITY_BAND`).
424
+ #
425
+ # @param dead_score [Float] The time barrier (see `acquirer_dead_score`).
426
+ # @param priority [Integer]
427
+ # @return [Float]
428
+ #
429
+ # @api private
430
+ # @since 1.22.0
431
+ def lock_priority_band_dead_score(dead_score, priority)
432
+ dead_score - (priority * LOCK_PRIORITY_BAND)
433
+ end
434
+
435
+ # Priority of the lock request by its position in the lock request queue (the score).
436
+ #
437
+ # NOTE: (infinite positions) RQL never writes infinite positions (manual writes of the lock
438
+ # request queues): they are out of all bands and are decoded to the bounds of the priority
439
+ # range (the floor of an infinite Float raises `FloatDomainError`):
440
+ # - `-inf`: `MAX_LOCK_PRIORITY` (ahead of all lock requests; such a request is dead: its
441
+ # enqueue time is unknown, it is dropped like before priorities);
442
+ # - `+inf`: `MIN_LOCK_PRIORITY` (behind all lock requests; never dead);
443
+ #
444
+ # @param acquirer_position [Numeric] The position (the score) of the lock request.
445
+ # @return [Integer] See `LOCK_PRIORITY_BAND`.
446
+ #
447
+ # @api private
448
+ # @since 1.22.0
449
+ def lock_request_priority(acquirer_position)
450
+ if acquirer_position == -Float::INFINITY
451
+ MAX_LOCK_PRIORITY
452
+ elsif acquirer_position == Float::INFINITY
453
+ MIN_LOCK_PRIORITY
454
+ else
455
+ -(acquirer_position / LOCK_PRIORITY_BAND).floor
456
+ end
457
+ end
458
+
347
459
  # @param zombie_ttl [Float] In seconds with milliseconds.
348
460
  # @return [Float]
349
461
  #
@@ -354,8 +466,10 @@ module RedisQueuedLocks::Resource
354
466
  end
355
467
 
356
468
  # @param acquirer_position [Float]
357
- # A time (epoch, seconds.milliseconds) that represents
358
- # the acquirer position in lock request queue.
469
+ # A time (epoch, seconds.milliseconds) shifted by the priority band that represents
470
+ # the acquirer position in lock request queue (see `calc_initial_acquirer_position`).
471
+ # @param priority [Integer]
472
+ # The lock request priority (the band of the position).
359
473
  # @param queue_ttl [Integer]
360
474
  # In second.
361
475
  # @return [Boolean]
@@ -363,8 +477,9 @@ module RedisQueuedLocks::Resource
363
477
  #
364
478
  # @api private
365
479
  # @since 1.0.0
366
- def dead_score_reached?(acquirer_position, queue_ttl)
367
- (acquirer_position + queue_ttl) < Time.now.to_f
480
+ # @version 1.22.0
481
+ def dead_score_reached?(acquirer_position, priority, queue_ttl)
482
+ (acquirer_position + (priority * LOCK_PRIORITY_BAND) + queue_ttl) < Time.now.to_f
368
483
  end
369
484
 
370
485
  # NOTE: (debug) not used by the library: a manual runtime check (in a console) of the lock that
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.9.0
5
- # @version 1.17.0
5
+ # @version 1.21.1
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class RedisQueuedLocks::Swarm::SwarmElement::Threaded
8
8
  # @since 1.9.0
@@ -70,7 +70,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
70
70
  return unless enabled?
71
71
 
72
72
  sync.synchronize do
73
- swarm_element__termiante
73
+ swarm_element__terminate
74
74
  swarm!
75
75
  swarm_loop__start
76
76
  end
@@ -99,7 +99,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
99
99
  # @since 1.9.0
100
100
  def try_kill!
101
101
  sync.synchronize do
102
- swarm_element__termiante
102
+ swarm_element__terminate
103
103
  end
104
104
  end
105
105
 
@@ -157,7 +157,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
157
157
  # => 1) init (swarm!): create a thread, main loop is not started;
158
158
  # => 2) start (swarm_loop__start): run the main loop inside the created thread;
159
159
  # => 3) stop (swarm_loop__stop): stop the main loop inside the created thread;
160
- # => 4) terminate (swarm_element__termiante): kill the created thread;
160
+ # => 4) terminate (swarm_element__terminate): kill the created thread;
161
161
  # In addition you should implement `#spawn_main_loop!` method that will be the main logic
162
162
  # of the your swarm element.
163
163
  #
@@ -341,7 +341,8 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
341
341
  #
342
342
  # @api private
343
343
  # @since 1.9.0
344
- def swarm_element__termiante
344
+ # @version 1.21.1
345
+ def swarm_element__terminate
345
346
  return if idle? || swarmed__dead?
346
347
  sync.synchronize do
347
348
  main_loop&.kill
@@ -232,7 +232,8 @@ module RedisQueuedLocks::Swarm::ZombieInfo
232
232
  # names, the full keyspace is never scanned): write locks and readers registries are processed
233
233
  # in batches of `lock_scan_size` locks with one round trip per batch and per key family
234
234
  # (batches are collected across ZSCAN pages). Expired lock names of the index are skipped
235
- # (all their keys are expired).
235
+ # (all their keys are expired; `+inf` names, keys without TTL, never expire: see
236
+ # `RedisQueuedLocks::Resource.lock_index_score_expired?`).
236
237
  #
237
238
  # @param rconn [RedisClient] redis connection obtained via `#with` from RedisClient instance;
238
239
  # @param zombie_hosts [Set<String>]
@@ -244,6 +245,7 @@ module RedisQueuedLocks::Swarm::ZombieInfo
244
245
  #
245
246
  # @api private
246
247
  # @since 1.20.0
248
+ # @version 1.22.0
247
249
  def each_lock_index_zombie_lock(rconn, zombie_hosts, lock_scan_size, &block)
248
250
  # @type var lock_keys: Array[String]
249
251
  lock_keys = []
@@ -255,7 +257,9 @@ module RedisQueuedLocks::Swarm::ZombieInfo
255
257
  RedisQueuedLocks::Resource::LOCK_INDEX_KEY, count: lock_scan_size
256
258
  ) do |lock_name, lock_index_score|
257
259
  # NOTE: the expired lock name (not dropped from the index yet): its keys are expired
258
- next if lock_index_score.to_f < lock_index_time
260
+ next if RedisQueuedLocks::Resource.lock_index_score_expired?(
261
+ lock_index_score, lock_index_time
262
+ )
259
263
 
260
264
  lock_keys << RedisQueuedLocks::Resource.prepare_lock_key(lock_name)
261
265
  lock_readers_keys << RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)