redis_queued_locks 1.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +249 -36
  3. data/.claude/rules/acquirer.md +16 -14
  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 +34 -13
  8. data/.claude/rules/swarm.md +4 -5
  9. data/.claude/rules/tests.md +9 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +13 -14
  12. data/.claude/skills/rql-code-review/SKILL.md +134 -43
  13. data/CHANGELOG.md +105 -125
  14. data/CLAUDE.md +11 -9
  15. data/README.md +501 -136
  16. data/Rakefile +11 -0
  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 +68 -10
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +82 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -14
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +441 -63
  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 +5 -5
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +176 -39
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +322 -49
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +125 -13
  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 +16 -3
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +31 -39
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +11 -5
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +20 -10
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +105 -36
  35. data/lib/redis_queued_locks/acquirer/locks.rb +32 -11
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -8
  37. data/lib/redis_queued_locks/acquirer/queues.rb +26 -6
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +117 -4
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +37 -6
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +33 -7
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +45 -6
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/client.rb +140 -56
  44. data/lib/redis_queued_locks/config.rb +105 -14
  45. data/lib/redis_queued_locks/errors.rb +34 -4
  46. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  47. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  48. data/lib/redis_queued_locks/resource.rb +137 -12
  49. data/lib/redis_queued_locks/swarm/flush_zombies.rb +1 -0
  50. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  51. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +8 -6
  52. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  53. data/lib/redis_queued_locks/swarm.rb +86 -5
  54. data/lib/redis_queued_locks/utilities.rb +1 -1
  55. data/lib/redis_queued_locks/version.rb +2 -2
  56. data/rbs_collection.lock.yaml +4 -0
  57. data/rbs_collection.yaml +2 -1
  58. data/sig/manifest.yml +1 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  61. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +25 -5
  62. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +32 -12
  63. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  64. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  65. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  66. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +4 -4
  67. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +6 -4
  68. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +23 -1
  69. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +16 -3
  70. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  71. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +22 -1
  72. data/sig/redis_queued_locks/acquirer/release_lock.rbs +3 -2
  73. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  74. data/sig/redis_queued_locks/client.rbs +24 -10
  75. data/sig/redis_queued_locks/config.rbs +54 -1
  76. data/sig/redis_queued_locks/errors.rbs +5 -2
  77. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -3
  78. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  79. data/sig/redis_queued_locks/logging/sampler.rbs +3 -3
  80. data/sig/redis_queued_locks/resource.rbs +23 -13
  81. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  82. data/sig/redis_queued_locks/utilities.rbs +2 -1
  83. data/sig/redis_queued_locks.rbs +7 -0
  84. data/sig/vendor/redis_client.rbs +1 -0
  85. metadata +4 -3
  86. /data/sig/redis_queued_locks/{acquier.rbs → acquirer.rbs} +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.13.0
5
- # @version 1.20.0
5
+ # @version 1.22.0
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class RedisQueuedLocks::Config
8
8
  require_relative 'config/dsl'
@@ -32,6 +32,8 @@ class RedisQueuedLocks::Config
32
32
  setting('is_timed_by_default', false)
33
33
  setting('default_conflict_strategy', :wait_for_lock)
34
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)
35
37
  setting('log_sampling_enabled', false)
36
38
  setting('log_sampling_percent', 15)
37
39
  setting('log_sampler', RedisQueuedLocks::Logging::Sampler)
@@ -57,36 +59,36 @@ class RedisQueuedLocks::Config
57
59
  setting('swarm.flush_zombies.redis_config.pool_config', {})
58
60
 
59
61
  validate('swarm.auto_swarm') { |val| val == true || val == false }
60
- validate('swarm.supervisor.liveness_probing_period') { |val| val.is_a?(Integer) }
62
+ validate('swarm.supervisor.liveness_probing_period') { |val| val.is_a?(Integer) && val > 0 }
61
63
 
62
64
  validate('swarm.probe_hosts.enabled_for_swarm') { |val| val == true || val == false }
63
65
  validate('swarm.probe_hosts.redis_config.sentinel') { |val| val == true || val == false }
64
66
  validate('swarm.probe_hosts.redis_config.pooled') { |val| val == true || val == false }
65
67
  validate('swarm.probe_hosts.redis_config.config') { |val| val.is_a?(Hash) }
66
68
  validate('swarm.probe_hosts.redis_config.pool_config') { |val| val.is_a?(Hash) }
67
- validate('swarm.probe_hosts.probe_period') { |val| val.is_a?(Integer) }
69
+ validate('swarm.probe_hosts.probe_period') { |val| val.is_a?(Integer) && val > 0 }
68
70
 
69
71
  validate('swarm.flush_zombies.enabled_for_swarm') { |val| val == true || val == false }
70
72
  validate('swarm.flush_zombies.redis_config.sentinel') { |val| val == true || val == false }
71
73
  validate('swarm.flush_zombies.redis_config.pooled') { |val| val == true || val == false }
72
74
  validate('swarm.flush_zombies.redis_config.config') { |val| val.is_a?(Hash) }
73
75
  validate('swarm.flush_zombies.redis_config.pool_config') { |val| val.is_a?(Hash) }
74
- validate('swarm.flush_zombies.zombie_ttl') { |val| val.is_a?(Integer) }
75
- validate('swarm.flush_zombies.zombie_lock_scan_size') { |val| val.is_a?(Integer) }
76
- validate('swarm.flush_zombies.zombie_queue_scan_size') { |val| val.is_a?(Integer) }
77
- validate('swarm.flush_zombies.zombie_flush_period') { |val| val.is_a?(Integer) }
76
+ validate('swarm.flush_zombies.zombie_ttl') { |val| val.is_a?(Integer) && val > 0 }
77
+ validate('swarm.flush_zombies.zombie_lock_scan_size') { |val| val.is_a?(Integer) && val > 0 }
78
+ validate('swarm.flush_zombies.zombie_queue_scan_size') { |val| val.is_a?(Integer) && val > 0 }
79
+ validate('swarm.flush_zombies.zombie_flush_period') { |val| val.is_a?(Integer) && val > 0 }
78
80
 
79
81
  validate('retry_count') { |val| val == nil || (val.is_a?(Integer) && val >= 0) }
80
82
  validate('retry_delay') { |val| val.is_a?(Integer) && val >= 0 }
81
83
  validate('retry_jitter') { |val| val.is_a?(Integer) && val >= 0 }
82
84
  validate('try_to_lock_timeout') { |val| val == nil || (val.is_a?(Integer) && val >= 0) }
83
- validate('default_lock_ttl') { |val| val.is_a?(Integer) }
84
- validate('default_queue_ttl') { |val| val.is_a?(Integer) }
85
+ validate('default_lock_ttl') { |val| val.is_a?(Integer) && val > 0 }
86
+ validate('default_queue_ttl') { |val| val.is_a?(Integer) && val > 0 }
85
87
  validate('detailed_acq_timeout_error') { |val| val == true || val == false }
86
- validate('lock_release_batch_size') { |val| val.is_a?(Integer) }
87
- validate('clear_locks_of__lock_scan_size') { |val| val.is_a?(Integer) }
88
- validate('clear_locks_of__queue_scan_size') { |val| val.is_a?(Integer) }
89
- validate('key_extraction_batch_size') { |val| val.is_a?(Integer) }
88
+ validate('lock_release_batch_size') { |val| val.is_a?(Integer) && val > 0 }
89
+ validate('clear_locks_of__lock_scan_size') { |val| val.is_a?(Integer) && val > 0 }
90
+ validate('clear_locks_of__queue_scan_size') { |val| val.is_a?(Integer) && val > 0 }
91
+ validate('key_extraction_batch_size') { |val| val.is_a?(Integer) && val > 0 }
90
92
  validate('instrumenter') { |val| RedisQueuedLocks::Instrument.valid_interface?(val) }
91
93
  validate('uniq_identifier') { |val| val.is_a?(Proc) }
92
94
  validate('logger') { |val| RedisQueuedLocks::Logging.valid_interface?(val) }
@@ -115,6 +117,27 @@ class RedisQueuedLocks::Config
115
117
  val == :random
116
118
  # rubocop:enable Layout/MultilineOperationIndentation
117
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
118
141
 
119
142
  # @return [Hash<Symbol,Any>]
120
143
  #
@@ -124,10 +147,14 @@ class RedisQueuedLocks::Config
124
147
 
125
148
  # @return [void]
126
149
  #
150
+ # @raise [RedisQueuedLocks::ConfigValidationError]
151
+ #
127
152
  # @api private
128
153
  # @since 1.13.0
154
+ # @version 1.22.0
129
155
  def initialize(&configuration)
130
156
  @config_state = {}
157
+ @configuring = false
131
158
  config_setters.each_value { |setter| setter.call(@config_state) }
132
159
  configure(&configuration)
133
160
  end
@@ -137,10 +164,40 @@ class RedisQueuedLocks::Config
137
164
  # @yieldparam [?]
138
165
  # @return [void]
139
166
  #
167
+ # @raise [RedisQueuedLocks::ConfigValidationError]
168
+ #
140
169
  # @api private
141
170
  # @since 1.13.0
171
+ # @version 1.22.0
142
172
  def configure(&configuration)
143
- 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
144
201
  end
145
202
 
146
203
  # @param config_key [String]
@@ -164,9 +221,21 @@ class RedisQueuedLocks::Config
164
221
  #
165
222
  # @api public
166
223
  # @since 1.13.0
224
+ # @version 1.22.0
167
225
  def []=(config_key, config_value)
168
226
  prevent_key__non_existent(config_key)
169
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
170
239
  config_state[config_key] = config_value
171
240
  end
172
241
 
@@ -237,5 +306,27 @@ class RedisQueuedLocks::Config
237
306
  )
238
307
  end
239
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
240
331
  end
241
332
  # rubocop:enable Metrics/ClassLength
@@ -4,6 +4,12 @@
4
4
  # the one-line definition style (like "Error = Class.new(::StandardError)")
5
5
  # is not used cuz that style can not be correctly defined via RBS types: we can not
6
6
  # identify the superclass of our class for a constant;
7
+ # NOTE: (error hierarchy) each error inherits from the global base error of its branch:
8
+ # - `Error` (`::StandardError`) - the main hierarchy;
9
+ # - `ArgumentError` (`::ArgumentError`) - invalid arguments (the Ruby core branch that can not be
10
+ # a part of the main hierarchy);
11
+ # - `Exception` (`::Exception`) - super exceptional cases only: each error of this branch
12
+ # describes why it is not a `StandardError`;
7
13
  module RedisQueuedLocks
8
14
  # @api public
9
15
  # @since 1.0.0
@@ -13,13 +19,29 @@ module RedisQueuedLocks
13
19
  # @since 1.0.0
14
20
  class ArgumentError < ::ArgumentError; end
15
21
 
22
+ # The base of the errors that should not be rescued by the code they interrupt
23
+ # (`rescue => e`, `rescue StandardError`, `rescue nil`): like `Timeout::ExitException`,
24
+ # they are rescued only by `rescue Exception` (and by the library itself).
25
+ # NOTE: inside `module RedisQueuedLocks` a bare `Exception` refers to this class;
26
+ #
27
+ # @api private
28
+ # @since 1.21.0
29
+ class Exception < ::Exception; end # rubocop:disable Lint/InheritException
30
+
16
31
  # @api public
17
32
  # @since 1.0.0
18
33
  class LockAlreadyObtainedError < Error; end
19
34
 
35
+ # Interrupts the lock acquirement after the `timeout` (`Timeout.timeout`); rescued by
36
+ # `WithAcqTimeout` (=> `LockAcquirementTimeoutError` / `:timeout_reached`).
37
+ # NOTE: (why `Exception`) the acquirement calls the logger and the instrumenter: a `StandardError`
38
+ # that interrupts them is swallowed by `rescue nil` of the log/instrumentation events, and the
39
+ # acquirement continues without the timeout (with `retry_count: nil` - without any limit);
40
+ #
20
41
  # @api private
21
42
  # @since 1.11.0
22
- class LockAcquirementIntermediateTimeoutError < ::Timeout::Error; end
43
+ # @version 1.21.0
44
+ class LockAcquirementIntermediateTimeoutError < Exception; end
23
45
 
24
46
  # @api public
25
47
  # @since 1.0.0
@@ -29,11 +51,19 @@ module RedisQueuedLocks
29
51
  # @since 1.0.0
30
52
  class LockAcquirementRetryLimitError < Error; end
31
53
 
54
+ # Interrupts the `timed: true` block after the lock ttl (`Timeout.timeout`); rescued by
55
+ # `YieldExpire` (=> `TimedLockTimeoutError`).
56
+ # NOTE: (why `Exception`) a `StandardError` is swallowed by `rescue => e` / `rescue nil` of
57
+ # the user block: the block continues after the lock ttl (without the mutual exclusion:
58
+ # another acquirer can obtain the expired lock, and the write lock release after the block
59
+ # deletes its lock);
60
+ #
32
61
  # @api private
33
62
  # @since 1.12.0
34
- class TimedLockIntermediateTimeoutError < ::Timeout::Error; end
63
+ # @version 1.21.0
64
+ class TimedLockIntermediateTimeoutError < Exception; end
35
65
 
36
- # @api pulic
66
+ # @api public
37
67
  # @since 1.0.0
38
68
  class TimedLockTimeoutError < Error; end
39
69
 
@@ -57,7 +87,7 @@ module RedisQueuedLocks
57
87
  # @since 1.13.0
58
88
  class ConfigNotFoundError < ConfigError; end
59
89
 
60
- # @api pub;ic
90
+ # @api public
61
91
  # @since 1.13.0
62
92
  class ConfigValidationError < ConfigError; end
63
93
  end
@@ -3,12 +3,6 @@
3
3
  # @api public
4
4
  # @since 1.6.0
5
5
  module RedisQueuedLocks::Instrument::Sampler
6
- # @return [Range]
7
- #
8
- # @api private
9
- # @since 1.6.0
10
- SAMPLING_PERCENT_RANGE = (0..100)
11
-
12
6
  class << self
13
7
  # Super simple probalistic function based on the `weight` of <true>/<false> values.
14
8
  # Requires the <percent> parameter as the required percent of <true> values sampled.
@@ -20,8 +14,11 @@ module RedisQueuedLocks::Instrument::Sampler
20
14
  #
21
15
  # @api public
22
16
  # @since 1.6.0
17
+ # @version 1.21.0
23
18
  def sampling_happened?(sampling_percent)
24
- sampling_percent >= SecureRandom.rand(SAMPLING_PERCENT_RANGE)
19
+ # NOTE: one of 100 equally probable values (0..99): `sampling_percent` of them are lower
20
+ # (0% => never, 100% => always);
21
+ SecureRandom.rand(100) < sampling_percent
25
22
  end
26
23
  end
27
24
  end
@@ -3,12 +3,6 @@
3
3
  # @api public
4
4
  # @since 1.5.0
5
5
  module RedisQueuedLocks::Logging::Sampler
6
- # @return [Range]
7
- #
8
- # @api private
9
- # @since 1.6.0
10
- SAMPLING_PERCENT_RANGE = (0..100)
11
-
12
6
  class << self
13
7
  # Super simple probalistic function based on the `weight` of <true>/<false> values.
14
8
  # Requires the <percent> parameter as the required percent of <true> values sampled.
@@ -20,9 +14,11 @@ module RedisQueuedLocks::Logging::Sampler
20
14
  #
21
15
  # @api public
22
16
  # @since 1.5.0
23
- # @version 1.6.0
17
+ # @version 1.21.0
24
18
  def sampling_happened?(sampling_percent)
25
- sampling_percent >= SecureRandom.rand(SAMPLING_PERCENT_RANGE)
19
+ # NOTE: one of 100 equally probable values (0..99): `sampling_percent` of them are lower
20
+ # (0% => never, 100% => always);
21
+ SecureRandom.rand(100) < sampling_percent
26
22
  end
27
23
  end
28
24
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
+ # @version 1.22.0
5
6
  # rubocop:disable Metrics/ModuleLength
6
7
  module RedisQueuedLocks::Resource
7
8
  # @return [String]
@@ -89,6 +90,44 @@ module RedisQueuedLocks::Resource
89
90
  # @since 1.3.0
90
91
  REDIS_TIMESHIFT_ERROR = 2
91
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
+
92
131
  # rubocop:disable Metrics/ClassLength
93
132
  class << self
94
133
  # Returns 16-byte unique identifier. It is used for uniquely
@@ -275,6 +314,22 @@ module RedisQueuedLocks::Resource
275
314
  ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
276
315
  end
277
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
+
278
333
  # @param lock_readers_key [String]
279
334
  # @return [String]
280
335
  #
@@ -326,16 +381,23 @@ module RedisQueuedLocks::Resource
326
381
  (Integer(redis_time[0]) * 1_000) + (Integer(redis_time[1]) / 1_000)
327
382
  end
328
383
 
329
- # @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).
330
389
  #
331
390
  # @api private
332
391
  # @since 1.0.0
333
- def calc_initial_acquirer_position
334
- 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)
335
395
  end
336
396
 
337
397
  # @param queue_ttl [Numeric] In seconds
338
- # @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`).
339
401
  #
340
402
  # @api private
341
403
  # @since 1.0.0
@@ -343,6 +405,57 @@ module RedisQueuedLocks::Resource
343
405
  Time.now.to_f - queue_ttl
344
406
  end
345
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
+
346
459
  # @param zombie_ttl [Float] In seconds with milliseconds.
347
460
  # @return [Float]
348
461
  #
@@ -353,8 +466,10 @@ module RedisQueuedLocks::Resource
353
466
  end
354
467
 
355
468
  # @param acquirer_position [Float]
356
- # A time (epoch, seconds.milliseconds) that represents
357
- # 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).
358
473
  # @param queue_ttl [Integer]
359
474
  # In second.
360
475
  # @return [Boolean]
@@ -362,18 +477,28 @@ module RedisQueuedLocks::Resource
362
477
  #
363
478
  # @api private
364
479
  # @since 1.0.0
365
- def dead_score_reached?(acquirer_position, queue_ttl)
366
- (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
367
483
  end
368
484
 
369
- # @param lock_queue [String]
370
- # @return [String]
485
+ # NOTE: (debug) not used by the library: a manual runtime check (in a console) of the lock that
486
+ # a queue belongs to (e.g. `Resource.lock_key_from_queue(client.queues.first)`);
487
+ #
488
+ # @param lock_queue [String] `rql:lock_queue:<name>` or `rql:lock_read_queue:<name>`.
489
+ # @return [String,NilClass]
490
+ # - `rql:lock:<name>` for the write lock request queue and for the read lock request queue;
491
+ # - `nil` for any other key;
371
492
  #
372
493
  # @api private
373
494
  # @since 1.0.0
495
+ # @version 1.21.0
374
496
  def lock_key_from_queue(lock_queue)
375
- # NOTE: 15 is a start position of the lock name
376
- "rql:lock:#{lock_queue[15..]}"
497
+ key_family = lock_key_family(lock_queue)
498
+ return unless key_family == :lock_queue || key_family == :read_lock_queue
499
+
500
+ lock_name = lock_name_from_key(lock_queue, key_family) #: String
501
+ prepare_lock_key(lock_name)
377
502
  end
378
503
 
379
504
  # @return [Integer]
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.9.0
5
+ # @version 1.20.0
5
6
  # rubocop:disable Metrics/ClassLength
6
7
  class RedisQueuedLocks::Swarm::FlushZombies < RedisQueuedLocks::Swarm::SwarmElement::Isolated
7
8
  class << self
@@ -7,19 +7,23 @@ module RedisQueuedLocks::Swarm::RedisClientBuilder
7
7
  # @option pooled [Boolean]
8
8
  # @option sentinel [Boolean]
9
9
  # @option config [Hash]
10
+ # @option pool_config [Hash]
10
11
  # @return [RedisClient|RedisClient::Pooled]
11
12
  #
12
13
  # @api private
13
14
  # @since 1.9.0
15
+ # @version 1.21.0
14
16
  # rubocop:disable Style/RedundantAssignment
15
17
  def build(pooled: false, sentinel: false, config: {}, pool_config: {})
16
- config.transform_keys!(&:to_sym)
17
- pool_config.transform_keys!(&:to_sym)
18
+ # NOTE: the passed hashes are not mutated: they are the values of the client config (they
19
+ # can be frozen and are shared with the zombie flusher Ractor when they are shareable);
20
+ redis_options = config.transform_keys(&:to_sym)
21
+ pool_options = pool_config.transform_keys(&:to_sym)
18
22
 
19
23
  redis_config =
20
- sentinel ? sentinel_config(config) : non_sentinel_config(config)
24
+ sentinel ? sentinel_config(redis_options) : non_sentinel_config(redis_options)
21
25
  redis_client =
22
- pooled ? pooled_client(redis_config, pool_config) : non_pooled_client(redis_config)
26
+ pooled ? pooled_client(redis_config, pool_options) : non_pooled_client(redis_config)
23
27
 
24
28
  redis_client
25
29
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.9.0
5
+ # @version 1.21.1
5
6
  # rubocop:disable Metrics/ClassLength
6
7
  class RedisQueuedLocks::Swarm::SwarmElement::Threaded
7
8
  # @since 1.9.0
@@ -69,7 +70,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
69
70
  return unless enabled?
70
71
 
71
72
  sync.synchronize do
72
- swarm_element__termiante
73
+ swarm_element__terminate
73
74
  swarm!
74
75
  swarm_loop__start
75
76
  end
@@ -78,7 +79,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
78
79
  # @return [void]
79
80
  #
80
81
  # @api private
81
- # @since 19.0.0
82
+ # @since 1.9.0
82
83
  def reswarm_if_dead!
83
84
  return unless enabled?
84
85
 
@@ -98,7 +99,7 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
98
99
  # @since 1.9.0
99
100
  def try_kill!
100
101
  sync.synchronize do
101
- swarm_element__termiante
102
+ swarm_element__terminate
102
103
  end
103
104
  end
104
105
 
@@ -154,9 +155,9 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
154
155
 
155
156
  # Swarm element lifecycle have the following flow:
156
157
  # => 1) init (swarm!): create a thread, main loop is not started;
157
- # => 2) start (swarm_loop__start): run the main lopp inside the created thread;
158
+ # => 2) start (swarm_loop__start): run the main loop inside the created thread;
158
159
  # => 3) stop (swarm_loop__stop): stop the main loop inside the created thread;
159
- # => 4) terminate (swarm_element__termiante): kill the created thread;
160
+ # => 4) terminate (swarm_element__terminate): kill the created thread;
160
161
  # In addition you should implement `#spawn_main_loop!` method that will be the main logic
161
162
  # of the your swarm element.
162
163
  #
@@ -340,7 +341,8 @@ class RedisQueuedLocks::Swarm::SwarmElement::Threaded
340
341
  #
341
342
  # @api private
342
343
  # @since 1.9.0
343
- def swarm_element__termiante
344
+ # @version 1.21.1
345
+ def swarm_element__terminate
344
346
  return if idle? || swarmed__dead?
345
347
  sync.synchronize do
346
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)