redis_queued_locks 1.19.0 → 1.20.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +62 -11
  3. data/.claude/rules/acquirer.md +29 -11
  4. data/.claude/rules/arguments.md +3 -2
  5. data/.claude/rules/git.md +3 -2
  6. data/.claude/rules/logic.md +4 -3
  7. data/.claude/rules/swarm.md +12 -7
  8. data/.claude/rules/tests.md +6 -0
  9. data/.claude/rules/type-checking.md +2 -1
  10. data/.claude/rules/visitors.md +1 -1
  11. data/.claude/skills/rql-code-review/SKILL.md +372 -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 +68 -0
  15. data/CLAUDE.md +4 -2
  16. data/README.md +257 -22
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +684 -128
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +3 -1
  19. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +24 -3
  20. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +145 -18
  21. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +71 -10
  22. data/lib/redis_queued_locks/acquirer/keys.rb +142 -7
  23. data/lib/redis_queued_locks/acquirer/lock_info.rb +10 -4
  24. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +26 -7
  25. data/lib/redis_queued_locks/acquirer/locks.rb +195 -83
  26. data/lib/redis_queued_locks/acquirer/queue_info.rb +12 -18
  27. data/lib/redis_queued_locks/acquirer/queues.rb +114 -50
  28. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +140 -0
  29. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +147 -58
  30. data/lib/redis_queued_locks/acquirer/release_lock.rb +52 -19
  31. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +235 -66
  32. data/lib/redis_queued_locks/acquirer.rb +1 -0
  33. data/lib/redis_queued_locks/client.rb +97 -11
  34. data/lib/redis_queued_locks/config.rb +5 -1
  35. data/lib/redis_queued_locks/resource.rb +99 -2
  36. data/lib/redis_queued_locks/swarm/flush_zombies.rb +332 -61
  37. data/lib/redis_queued_locks/swarm/zombie_info.rb +203 -53
  38. data/lib/redis_queued_locks/swarm.rb +15 -4
  39. data/lib/redis_queued_locks/version.rb +2 -2
  40. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +107 -1
  41. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +1 -1
  42. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +2 -0
  43. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +10 -1
  44. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +5 -2
  45. data/sig/redis_queued_locks/acquirer/keys.rbs +11 -1
  46. data/sig/redis_queued_locks/acquirer/locks.rbs +15 -6
  47. data/sig/redis_queued_locks/acquirer/queues.rbs +9 -2
  48. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +31 -0
  49. data/sig/redis_queued_locks/acquirer/release_all_locks.rbs +8 -1
  50. data/sig/redis_queued_locks/acquirer/release_lock.rbs +3 -1
  51. data/sig/redis_queued_locks/acquirer/release_locks_of.rbs +20 -1
  52. data/sig/redis_queued_locks/client.rbs +8 -0
  53. data/sig/redis_queued_locks/resource.rbs +6 -0
  54. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +51 -1
  55. data/sig/redis_queued_locks/swarm/zombie_info.rbs +58 -7
  56. data/sig/vendor/redis_client.rbs +1 -0
  57. metadata +6 -1
@@ -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;
@@ -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
@@ -46,6 +46,16 @@ module RedisQueuedLocks::Acquirer::AcquireLock
46
46
  # Lock's time to live (in milliseconds). Nil means "without timeout".
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
60
  # Time period whe should try to acquire the lock (in seconds).
51
61
  # @option timed [Boolean]
@@ -166,7 +176,7 @@ module RedisQueuedLocks::Acquirer::AcquireLock
166
176
  #
167
177
  # @api private
168
178
  # @since 1.0.0
169
- # @version 1.18.0
179
+ # @version 1.20.0
170
180
  def acquire_lock(
171
181
  redis,
172
182
  lock_name,
@@ -176,6 +186,8 @@ module RedisQueuedLocks::Acquirer::AcquireLock
176
186
  ractor_id:,
177
187
  ttl:,
178
188
  queue_ttl:,
189
+ lock_index_enabled:,
190
+ lock_index_grace_period:,
179
191
  timeout:,
180
192
  timed:,
181
193
  retry_count:,
@@ -276,7 +288,12 @@ module RedisQueuedLocks::Acquirer::AcquireLock
276
288
  # NOTE: (RW) write lock requests use the classic lock queue (lock_key_queue)
277
289
  read_lock_key_queue = RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
278
290
  lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
279
- read_lock_key = RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
291
+ # NOTE: (RW) read lock data key is required by read lock requests only (write lock requests
292
+ # never obtain read locks: read-to-write lock upgrade is not supported);
293
+ read_lock_key =
294
+ if read_write_mode == :read
295
+ RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
296
+ end
280
297
 
281
298
  acquirer_position = RedisQueuedLocks::Resource.calc_initial_acquirer_position
282
299
 
@@ -371,15 +388,19 @@ module RedisQueuedLocks::Acquirer::AcquireLock
371
388
  read_lock_key_queue,
372
389
  lock_readers_key,
373
390
  read_lock_key,
391
+ lock_name,
374
392
  acquirer_id,
375
393
  host_id,
376
394
  acquirer_position,
377
395
  lock_ttl,
378
396
  queue_ttl,
397
+ lock_index_enabled,
398
+ lock_index_grace_period,
379
399
  fail_fast,
380
400
  conflict_strategy,
381
401
  access_strategy,
382
402
  meta,
403
+ acq_process[:tries] > 0, # NOTE: retry attempt (see "pre-check" in `try_to_lock`)
383
404
  log_sampled,
384
405
  instr_sampled
385
406
  ) => { ok:, result: }
@@ -2,11 +2,25 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
+ # @version 1.20.0
5
6
  module RedisQueuedLocks::Acquirer::ClearDeadRequests
6
7
  class << self
8
+ # Drops dead lock requests (requests older than `dead_ttl`) from the lock queues (write and
9
+ # read):
10
+ # - the disabled lock index: the lock queues found in one SCAN pass, one round trip per batch
11
+ # of `scan_size` lock queues (batches are collected across SCAN pages);
12
+ # - the enabled lock index: the lock queues of the locks of the lock index: one ZSCAN pass
13
+ # over the lock names (the keyspace is never scanned), one round trip per batch of
14
+ # `scan_size` lock names (both lock queues of each lock name); expired lock names are
15
+ # dropped from the lock index first;
16
+ #
7
17
  # @param redis_client [RedisClient]
8
18
  # @param scan_size [Integer]
19
+ # SCAN count and the number of lock queues in one batch (the disabled lock index) or ZSCAN
20
+ # count and the number of lock names in one batch (the enabled lock index).
9
21
  # @param dead_ttl [Integer] In milliseconds
22
+ # @param lock_index_enabled [Boolean]
23
+ # Find lock queues via the lock index instead of the keyspace SCAN.
10
24
  # @param logger [::Logger,#debug]
11
25
  # @param instrumenter [#notify]
12
26
  # @param instrument [NilClass,Any]
@@ -22,11 +36,12 @@ module RedisQueuedLocks::Acquirer::ClearDeadRequests
22
36
  #
23
37
  # @api private
24
38
  # @since 1.0.0
25
- # @version 1.18.0
39
+ # @version 1.20.0
26
40
  def clear_dead_requests(
27
41
  redis_client,
28
42
  scan_size,
29
43
  dead_ttl,
44
+ lock_index_enabled,
30
45
  logger,
31
46
  instrumenter,
32
47
  instrument,
@@ -45,9 +60,35 @@ module RedisQueuedLocks::Acquirer::ClearDeadRequests
45
60
  result = Set.new.tap do |processed_queues|
46
61
  # @type var processed_queues: Set[String]
47
62
  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
63
+ if lock_index_enabled
64
+ # Step 1 (lock index): drop the expired lock names from the lock index
65
+ # (all their keys are expired);
66
+ rconn.call(
67
+ 'ZREMRANGEBYSCORE', RedisQueuedLocks::Resource::LOCK_INDEX_KEY,
68
+ '-inf', RedisQueuedLocks::Resource.lock_index_time - 1
69
+ )
70
+
71
+ # Step 2 (lock index): drop dead requests from the lock queues of the lock index
72
+ each_lock_name_batch(rconn, scan_size) do |lock_names|
73
+ drop_dead_requests_of_locks(rconn, lock_names, dead_score, processed_queues)
74
+ end
75
+ else
76
+ # Step 1 (keyspace): drop dead requests from the lock queues found in one SCAN pass
77
+ # NOTE: dead requests are dropped with one pipeline per batch of `scan_size` queues
78
+ # (batches are collected across SCAN pages: lock queues can be rare among other keys);
79
+ # @type var lock_queues: Array[String]
80
+ lock_queues = []
81
+
82
+ each_lock_queue(rconn, scan_size) do |lock_queue|
83
+ processed_queues << lock_queue
84
+ lock_queues << lock_queue
85
+ next if lock_queues.size < scan_size
86
+
87
+ drop_dead_requests(rconn, lock_queues, dead_score)
88
+ lock_queues.clear
89
+ end
90
+
91
+ drop_dead_requests(rconn, lock_queues, dead_score) unless lock_queues.empty?
51
92
  end
52
93
  end
53
94
  end
@@ -57,30 +98,116 @@ module RedisQueuedLocks::Acquirer::ClearDeadRequests
57
98
 
58
99
  private
59
100
 
101
+ # Write and read lock queues in one SCAN pass (the disabled lock index).
102
+ #
60
103
  # @param redis_client [RedisClient]
61
104
  # @param scan_size [Integer]
62
105
  # @yield [lock_queue]
63
106
  # @yieldparam lock_queue [String]
64
107
  # @yieldreturn [void]
65
- # @return [Enumerator]
108
+ # @return [void]
66
109
  #
67
110
  # @api private
68
111
  # @since 1.0.0
69
- # @version 1.18.0
70
- def each_lock_queue(redis_client, scan_size, &block)
112
+ # @version 1.20.0
113
+ def each_lock_queue(redis_client, scan_size)
71
114
  redis_client.scan(
72
115
  'MATCH',
73
- RedisQueuedLocks::Resource::LOCK_QUEUE_PATTERN,
74
- count: scan_size,
75
- &block
76
- )
77
- # NOTE: (RW) read lock queues
78
- redis_client.scan(
79
- 'MATCH',
80
- RedisQueuedLocks::Resource::READ_LOCK_QUEUE_PATTERN,
81
- count: scan_size,
82
- &block
83
- )
116
+ RedisQueuedLocks::Resource::ALL_LOCK_QUEUES_PATTERN,
117
+ count: scan_size
118
+ ) do |key|
119
+ # NOTE: the pattern matches other keys too (lock names with `queue:`): lock queues only
120
+ family = RedisQueuedLocks::Resource.lock_key_family(key)
121
+ yield(key) if family == :lock_queue || family == :read_lock_queue
122
+ end
123
+ end
124
+
125
+ # Drops dead requests of the given lock queues in one round trip (the disabled lock index).
126
+ #
127
+ # @param redis_client [RedisClient]
128
+ # @param lock_queues [Array<String>]
129
+ # @param dead_score [Float]
130
+ # @return [void]
131
+ #
132
+ # @api private
133
+ # @since 1.20.0
134
+ def drop_dead_requests(redis_client, lock_queues, dead_score)
135
+ redis_client.pipelined do |pipeline|
136
+ lock_queues.each do |lock_queue|
137
+ pipeline.call('ZREMRANGEBYSCORE', lock_queue, '-inf', dead_score)
138
+ end
139
+ end
140
+ end
141
+
142
+ # Drops dead requests of both lock queues of the given lock names in one round trip
143
+ # (the enabled lock index).
144
+ #
145
+ # @param redis_client [RedisClient]
146
+ # @param lock_names [Array<String>]
147
+ # @param dead_score [Float]
148
+ # @param processed_queues [Set<String>] Collects the existing (processed) lock queues.
149
+ # @return [void]
150
+ #
151
+ # @api private
152
+ # @since 1.20.0
153
+ def drop_dead_requests_of_locks(redis_client, lock_names, dead_score, processed_queues)
154
+ # NOTE: (pipeline layout) two replies per lock queue (two lock queues per lock name) in
155
+ # the batch order: EXISTS (the queue is processed when it exists), ZREMRANGEBYSCORE;
156
+ # @type var replies: Array[Integer]
157
+ replies = redis_client.pipelined do |pipeline|
158
+ lock_names.each do |lock_name|
159
+ lock_queue = RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
160
+ read_lock_queue = RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
161
+ pipeline.call('EXISTS', lock_queue)
162
+ pipeline.call('ZREMRANGEBYSCORE', lock_queue, '-inf', dead_score)
163
+ pipeline.call('EXISTS', read_lock_queue)
164
+ pipeline.call('ZREMRANGEBYSCORE', read_lock_queue, '-inf', dead_score)
165
+ end
166
+ end
167
+
168
+ lock_names.each_with_index do |lock_name, index|
169
+ if replies[index * 4] == 1
170
+ processed_queues << RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
171
+ end
172
+ if replies[(index * 4) + 2] == 1
173
+ processed_queues << RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
174
+ end
175
+ end
176
+ end
177
+
178
+ # Iterates over the lock names of the lock index (one ZSCAN pass) in batches of `scan_size`
179
+ # lock names (batches are collected across ZSCAN pages). Expired lock names (not dropped from
180
+ # the index yet) are skipped: all their keys are expired.
181
+ #
182
+ # NOTE: each operation module iterates over the lock index by itself (duplicated on purpose:
183
+ # operation modules are independent of each other, see `Locks.each_lock_name_batch`);
184
+ #
185
+ # @param redis_client [RedisClient]
186
+ # @param scan_size [Integer] ZSCAN count and the number of lock names in one batch.
187
+ # @yield [lock_names]
188
+ # @yieldparam lock_names [Array<String>] The batch of lock names (reused between batches).
189
+ # @return [void]
190
+ #
191
+ # @api private
192
+ # @since 1.20.0
193
+ def each_lock_name_batch(redis_client, scan_size)
194
+ lock_index_time = RedisQueuedLocks::Resource.lock_index_time
195
+ # @type var lock_names: Array[String]
196
+ lock_names = []
197
+
198
+ redis_client.zscan(
199
+ RedisQueuedLocks::Resource::LOCK_INDEX_KEY, count: scan_size
200
+ ) do |lock_name, lock_index_score|
201
+ next if lock_index_score.to_f < lock_index_time
202
+
203
+ lock_names << lock_name
204
+ next if lock_names.size < scan_size
205
+
206
+ yield(lock_names)
207
+ lock_names.clear
208
+ end
209
+
210
+ yield(lock_names) unless lock_names.empty?
84
211
  end
85
212
  end
86
213
  end
@@ -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
  module RedisQueuedLocks::Acquirer::ExtendLockTTL
8
8
  # @return [String]
@@ -28,6 +28,8 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
28
28
  # (used for `:read` mode only, ignored for `:write` mode).
29
29
  # @param acquirer_id [String]
30
30
  # The read lock acquirer (used for `:read` mode without `all_read_locks` only).
31
+ # @param lock_index_enabled [Boolean]
32
+ # Increase the lock index score of the lock name by the extension (the enabled lock index).
31
33
  # @param logger [::Logger,#debug]
32
34
  # @param instrumenter [#notify]
33
35
  # @param instrument [NilClass,Any]
@@ -43,7 +45,7 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
43
45
  #
44
46
  # @api private
45
47
  # @since 1.0.0
46
- # @version 1.18.0
48
+ # @version 1.20.0
47
49
  # rubocop:disable Metrics/MethodLength
48
50
  def extend_lock_ttl(
49
51
  redis_client,
@@ -52,6 +54,7 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
52
54
  read_write_mode,
53
55
  all_read_locks,
54
56
  acquirer_id,
57
+ lock_index_enabled,
55
58
  logger,
56
59
  instrumenter,
57
60
  instrument,
@@ -84,19 +87,43 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
84
87
 
85
88
  # NOTE: (RW) all live read locks of the lock
86
89
  if all_read_locks
87
- return extend_all_read_locks_ttl(redis_client, lock_name, lock_key, milliseconds)
90
+ return extend_all_read_locks_ttl(
91
+ redis_client, lock_name, lock_key, milliseconds, lock_index_enabled
92
+ )
88
93
  end
89
94
 
90
95
  # NOTE: (RW) read lock of the concrete acquirer
91
- return extend_read_lock_ttl(redis_client, lock_name, lock_key, acquirer_id, milliseconds)
96
+ return extend_read_lock_ttl(
97
+ redis_client, lock_name, lock_key, acquirer_id, milliseconds, lock_index_enabled
98
+ )
92
99
  end
93
100
 
94
- # NOTE: EVAL signature -> <lua script>, (number of keys), *(keys), *(arguments)
95
- result = redis_client.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, milliseconds)
101
+ # NOTE:
102
+ # - EVAL signature -> <lua script>, (number of keys), *(keys), *(arguments);
103
+ # - (lock index) the enabled lock index: the lock index score is increased by the extension
104
+ # in the same transaction (`ZADD XX INCR`: the upper bound of the lock lifetime stays
105
+ # the upper bound, locks without the index entry (of older RQL versions) are not indexed
106
+ # with a wrong score); a failed extension (no lock) increases the score of the lock name
107
+ # too: the name just stays in the index a bit longer;
108
+ # - the disabled lock index: one EVAL command (no transaction);
109
+ # @type var extended: Integer
110
+ extended =
111
+ if lock_index_enabled
112
+ # @type var result: [Integer, Float?]
113
+ result = redis_client.multi do |transact|
114
+ transact.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, milliseconds)
115
+ transact.call(
116
+ 'ZADD', RedisQueuedLocks::Resource::LOCK_INDEX_KEY,
117
+ 'XX', 'INCR', milliseconds, lock_name
118
+ )
119
+ end
120
+ result[0]
121
+ else
122
+ redis_client.call('EVAL', EXTEND_LOCK_PTTL, 1, lock_key, milliseconds)
123
+ end
96
124
  # TODO: upload scripts to the redis
97
125
 
98
- # @type var result: Integer
99
- if result == 1
126
+ if extended == 1
100
127
  { ok: true, result: { extended_locks_count: 1 } }
101
128
  else
102
129
  { ok: false, result: :async_expire_or_no_lock }
@@ -117,12 +144,21 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
117
144
  # @param lock_key [String]
118
145
  # @param acquirer_id [String]
119
146
  # @param milliseconds [Integer]
147
+ # @param lock_index_enabled [Boolean]
120
148
  # @return [Hash<Symbol,Boolean|Symbol|Hash<Symbol,Integer>>]
121
149
  #
122
150
  # @api private
123
151
  # @since 1.18.0
152
+ # @version 1.20.0
124
153
  # rubocop:disable Metrics/MethodLength
125
- def extend_read_lock_ttl(redis_client, lock_name, lock_key, acquirer_id, milliseconds)
154
+ def extend_read_lock_ttl(
155
+ redis_client,
156
+ lock_name,
157
+ lock_key,
158
+ acquirer_id,
159
+ milliseconds,
160
+ lock_index_enabled
161
+ )
126
162
  lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
127
163
  read_lock_key = RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
128
164
 
@@ -145,6 +181,14 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
145
181
  transact.call('PEXPIRE', lock_readers_key, extended_ttl, 'GT')
146
182
  transact.call('PEXPIRE', read_lock_key, extended_ttl, 'NX')
147
183
  transact.call('PEXPIRE', read_lock_key, extended_ttl, 'GT')
184
+ # NOTE: (lock index) the lock index score is increased by the extension
185
+ # (the enabled lock index only; the last command: its reply is not read);
186
+ if lock_index_enabled
187
+ transact.call(
188
+ 'ZADD', RedisQueuedLocks::Resource::LOCK_INDEX_KEY,
189
+ 'XX', 'INCR', milliseconds, lock_name
190
+ )
191
+ end
148
192
  end
149
193
  end
150
194
  end
@@ -174,12 +218,20 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
174
218
  # @param lock_name [String]
175
219
  # @param lock_key [String]
176
220
  # @param milliseconds [Integer]
221
+ # @param lock_index_enabled [Boolean]
177
222
  # @return [Hash<Symbol,Boolean|Symbol|Hash<Symbol,Integer>>]
178
223
  #
179
224
  # @api private
180
225
  # @since 1.18.0
226
+ # @version 1.20.0
181
227
  # rubocop:disable Metrics/MethodLength
182
- def extend_all_read_locks_ttl(redis_client, lock_name, lock_key, milliseconds)
228
+ def extend_all_read_locks_ttl(
229
+ redis_client,
230
+ lock_name,
231
+ lock_key,
232
+ milliseconds,
233
+ lock_index_enabled
234
+ )
183
235
  lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
184
236
  extended_read_locks_count = 0
185
237
 
@@ -214,6 +266,15 @@ module RedisQueuedLocks::Acquirer::ExtendLockTTL
214
266
  if extended_read_locks_count > 0
215
267
  transact.call('PEXPIRE', lock_readers_key, registry_ttl, 'NX')
216
268
  transact.call('PEXPIRE', lock_readers_key, registry_ttl, 'GT')
269
+ # NOTE: (lock index) the lock index score is increased by the extension
270
+ # (each read lock is extended by the same milliseconds; the enabled lock index
271
+ # only; the last command: its reply is not read);
272
+ if lock_index_enabled
273
+ transact.call(
274
+ 'ZADD', RedisQueuedLocks::Resource::LOCK_INDEX_KEY,
275
+ 'XX', 'INCR', milliseconds, lock_name
276
+ )
277
+ end
217
278
  end
218
279
  end
219
280
  end
@@ -2,25 +2,160 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
+ # @version 1.20.0
5
6
  module RedisQueuedLocks::Acquirer::Keys
7
+ # The number of replies of one lock name in the key state pipeline of a batch of lock names
8
+ # (`collect_lock_keys`): EXISTS (write lock), EXISTS (write lock queue), EXISTS (read lock
9
+ # queue), ZRANGE (readers registry).
10
+ #
11
+ # @return [Integer]
12
+ #
13
+ # @api private
14
+ # @since 1.20.0
15
+ KEY_STATE_REPLIES = 4
16
+
6
17
  class << self
18
+ # RQL keys of the locks:
19
+ # - the disabled lock index: all the RQL keys (`RedisQueuedLocks::Resource::KEY_PATTERN`,
20
+ # `rql:lock*`) found in one SCAN pass over the keyspace;
21
+ # - the enabled lock index: all the existing keys of the locks of the lock index (write locks,
22
+ # lock queues, readers registries, read lock queues, read lock data): one ZSCAN pass over
23
+ # the lock names (`RedisQueuedLocks::Resource::LOCK_INDEX_KEY`, the keyspace is never
24
+ # scanned), lock keys are derived from the lock names;
25
+ #
7
26
  # @param redis_client [RedisClient]
8
27
  # @option scan_size [Integer]
28
+ # SCAN count (the disabled lock index) or ZSCAN count and the number of lock names in one
29
+ # batch (the enabled lock index).
30
+ # @option lock_index_enabled [Boolean]
9
31
  # @return [Set<String>]
10
32
  #
11
33
  # @api private
12
34
  # @since 1.0.0
13
- def keys(redis_client, scan_size:)
35
+ # @version 1.20.0
36
+ def keys(redis_client, scan_size:, lock_index_enabled:)
14
37
  Set.new.tap do |keyset|
15
38
  # @type var keyset: Set[String]
16
- redis_client.scan(
17
- 'MATCH',
18
- RedisQueuedLocks::Resource::KEY_PATTERN,
19
- count: scan_size
20
- ) do |key|
21
- keyset.add(key)
39
+ if lock_index_enabled
40
+ redis_client.with do |rconn|
41
+ each_lock_name_batch(rconn, scan_size) do |lock_names|
42
+ collect_lock_keys(rconn, lock_names, keyset)
43
+ end
44
+ end
45
+ else
46
+ redis_client.scan(
47
+ 'MATCH',
48
+ RedisQueuedLocks::Resource::KEY_PATTERN,
49
+ count: scan_size
50
+ ) do |key|
51
+ keyset.add(key)
52
+ end
22
53
  end
23
54
  end
24
55
  end
56
+
57
+ private
58
+
59
+ # (lock index) Adds the existing keys of the given batch of lock names to the key set
60
+ # (one round trip for the lock keys, one more for the read lock data when read locks exist).
61
+ #
62
+ # @param redis_client [RedisClient]
63
+ # @param lock_names [Array<String>]
64
+ # @param keyset [Set<String>]
65
+ # @return [void]
66
+ #
67
+ # @api private
68
+ # @since 1.20.0
69
+ # rubocop:disable Metrics/MethodLength
70
+ def collect_lock_keys(redis_client, lock_names, keyset)
71
+ # Step 1: the state of the lock keys of the batch
72
+ # NOTE: (pipeline layout) `KEY_STATE_REPLIES` replies per lock name in the batch order
73
+ # (the replies of the lock name with the index N start at `N * KEY_STATE_REPLIES`):
74
+ # EXISTS (write lock), EXISTS (write lock queue), EXISTS (read lock queue),
75
+ # ZRANGE (readers registry: redis deletes empty sorted sets, so an empty result means
76
+ # the registry does not exist);
77
+ # @type var key_state: Array[untyped]
78
+ key_state = redis_client.pipelined do |pipeline|
79
+ lock_names.each do |lock_name|
80
+ pipeline.call('EXISTS', RedisQueuedLocks::Resource.prepare_lock_key(lock_name))
81
+ pipeline.call('EXISTS', RedisQueuedLocks::Resource.prepare_lock_queue(lock_name))
82
+ pipeline.call('EXISTS', RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name))
83
+ pipeline.call(
84
+ 'ZRANGE', RedisQueuedLocks::Resource.prepare_lock_readers(lock_name), '0', '-1'
85
+ )
86
+ end
87
+ end
88
+
89
+ # @type var read_lock_keys: Array[String]
90
+ read_lock_keys = []
91
+ lock_names.each_with_index do |lock_name, index|
92
+ replies_at = index * KEY_STATE_REPLIES
93
+ if key_state[replies_at] == 1
94
+ keyset.add(RedisQueuedLocks::Resource.prepare_lock_key(lock_name))
95
+ end
96
+ if key_state[replies_at + 1] == 1
97
+ keyset.add(RedisQueuedLocks::Resource.prepare_lock_queue(lock_name))
98
+ end
99
+ if key_state[replies_at + 2] == 1
100
+ keyset.add(RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name))
101
+ end
102
+ # @type var read_lock_acquirers: Array[String]
103
+ read_lock_acquirers = key_state[replies_at + 3]
104
+ next if read_lock_acquirers.empty?
105
+
106
+ keyset.add(RedisQueuedLocks::Resource.prepare_lock_readers(lock_name))
107
+ read_lock_acquirers.each do |acquirer_id|
108
+ read_lock_keys << RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
109
+ end
110
+ end
111
+ return if read_lock_keys.empty?
112
+
113
+ # Step 2: (RW) read lock data of the readers registries (it can be absent: the data of an
114
+ # expired read lock is expired before the registry cleanup)
115
+ # NOTE: (pipeline layout) one EXISTS reply per read lock data key
116
+ # @type var read_lock_keys_existence: Array[Integer]
117
+ read_lock_keys_existence = redis_client.pipelined do |pipeline|
118
+ read_lock_keys.each { |read_lock_key| pipeline.call('EXISTS', read_lock_key) }
119
+ end
120
+ read_lock_keys.each_with_index do |read_lock_key, index|
121
+ keyset.add(read_lock_key) if read_lock_keys_existence[index] == 1
122
+ end
123
+ end
124
+ # rubocop:enable Metrics/MethodLength
125
+
126
+ # Iterates over the lock names of the lock index (one ZSCAN pass) in batches of `scan_size`
127
+ # lock names (batches are collected across ZSCAN pages). Expired lock names (not dropped from
128
+ # the index yet) are skipped: all their keys are expired.
129
+ #
130
+ # NOTE: each operation module iterates over the lock index by itself (duplicated on purpose:
131
+ # operation modules are independent of each other, see `Locks.each_lock_name_batch`);
132
+ #
133
+ # @param redis_client [RedisClient]
134
+ # @param scan_size [Integer] ZSCAN count and the number of lock names in one batch.
135
+ # @yield [lock_names]
136
+ # @yieldparam lock_names [Array<String>] The batch of lock names (reused between batches).
137
+ # @return [void]
138
+ #
139
+ # @api private
140
+ # @since 1.20.0
141
+ def each_lock_name_batch(redis_client, scan_size)
142
+ lock_index_time = RedisQueuedLocks::Resource.lock_index_time
143
+ # @type var lock_names: Array[String]
144
+ lock_names = []
145
+
146
+ redis_client.zscan(
147
+ RedisQueuedLocks::Resource::LOCK_INDEX_KEY, count: scan_size
148
+ ) do |lock_name, lock_index_score|
149
+ next if lock_index_score.to_f < lock_index_time
150
+
151
+ lock_names << lock_name
152
+ next if lock_names.size < scan_size
153
+
154
+ yield(lock_names)
155
+ lock_names.clear
156
+ end
157
+
158
+ yield(lock_names) unless lock_names.empty?
159
+ end
25
160
  end
26
161
  end
@@ -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
  module RedisQueuedLocks::Acquirer::LockInfo
7
7
  class << self
8
8
  # @param redis_client [RedisClient]
@@ -117,6 +117,7 @@ module RedisQueuedLocks::Acquirer::LockInfo
117
117
  #
118
118
  # @api private
119
119
  # @since 1.18.0
120
+ # @version 1.20.0
120
121
  # rubocop:disable Metrics/MethodLength
121
122
  def read_lock_info(redis_client, lock_name, lock_key, redis_time, read_locks)
122
123
  now = RedisQueuedLocks::Resource.redis_time_ms(redis_time)
@@ -139,10 +140,15 @@ module RedisQueuedLocks::Acquirer::LockInfo
139
140
  rem_ttl = (expiration - now).ceil
140
141
  longest_rem_ttl = rem_ttl if rem_ttl > longest_rem_ttl
141
142
 
143
+ # NOTE: acquirer and host ids are not stored in the read lock data (both are derived from
144
+ # the acquirer id): they are the first fields like in the write lock info (read lock data
145
+ # of older versions contains them with the same values);
142
146
  # @type var reader: Hash[String,String|Float|Integer|nil]
143
- reader = read_locks_data[index] || {}
144
- reader['acq_id'] = acquirer_id
145
- reader['hst_id'] ||= RedisQueuedLocks::Resource.host_identifier_from_acquirer(acquirer_id)
147
+ reader = {
148
+ 'acq_id' => acquirer_id,
149
+ 'hst_id' => RedisQueuedLocks::Resource.host_identifier_from_acquirer(acquirer_id)
150
+ }
151
+ read_locks_data[index]&.each { |field, value| reader[field] = value }
146
152
  reader['ts'] = Float(reader['ts']) if reader['ts']
147
153
  reader['ini_ttl'] = Integer(reader['ini_ttl']) if reader['ini_ttl']
148
154
  reader['rem_ttl'] = rem_ttl