redis_queued_locks 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +96 -23
  3. data/.claude/rules/acquirer.md +39 -20
  4. data/.claude/rules/arguments.md +3 -2
  5. data/.claude/rules/git.md +3 -2
  6. data/.claude/rules/logic.md +32 -8
  7. data/.claude/rules/swarm.md +14 -9
  8. data/.claude/rules/tests.md +12 -3
  9. data/.claude/rules/type-checking.md +2 -1
  10. data/.claude/rules/visitors.md +10 -11
  11. data/.claude/skills/rql-code-review/SKILL.md +386 -0
  12. data/.claude/skills/rql-code-review/templates/report.en.md +31 -0
  13. data/.claude/skills/rql-code-review/templates/report.ru.md +31 -0
  14. data/CHANGELOG.md +194 -0
  15. data/CLAUDE.md +6 -3
  16. data/README.md +502 -92
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +1 -0
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +42 -0
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +45 -0
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +1 -0
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +695 -128
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/with_acq_timeout.rb +2 -2
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +1 -0
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +8 -6
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +134 -26
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +229 -24
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +189 -18
  28. data/lib/redis_queued_locks/acquirer/is_locked.rb +0 -1
  29. data/lib/redis_queued_locks/acquirer/is_queued.rb +0 -1
  30. data/lib/redis_queued_locks/acquirer/keys.rb +151 -7
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +39 -41
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +3 -2
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +5 -4
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +79 -20
  35. data/lib/redis_queued_locks/acquirer/locks.rb +212 -83
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +11 -18
  37. data/lib/redis_queued_locks/acquirer/queues.rb +123 -50
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +253 -0
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +177 -61
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +84 -25
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +274 -70
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/acquirer.rb +1 -0
  44. data/lib/redis_queued_locks/client.rb +164 -36
  45. data/lib/redis_queued_locks/config.rb +16 -13
  46. data/lib/redis_queued_locks/errors.rb +34 -4
  47. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  48. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  49. data/lib/redis_queued_locks/resource.rb +113 -6
  50. data/lib/redis_queued_locks/swarm/flush_zombies.rb +333 -61
  51. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  52. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +3 -2
  53. data/lib/redis_queued_locks/swarm/zombie_info.rb +203 -53
  54. data/lib/redis_queued_locks/swarm.rb +97 -5
  55. data/lib/redis_queued_locks/utilities.rb +1 -1
  56. data/lib/redis_queued_locks/version.rb +2 -2
  57. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +14 -0
  58. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +13 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +107 -1
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -3
  61. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +3 -1
  62. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +19 -1
  63. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +20 -4
  64. data/sig/redis_queued_locks/acquirer/keys.rbs +11 -1
  65. data/sig/redis_queued_locks/acquirer/locks.rbs +15 -6
  66. data/sig/redis_queued_locks/acquirer/queues.rbs +9 -2
  67. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +52 -0
  68. data/sig/redis_queued_locks/acquirer/release_all_locks.rbs +8 -1
  69. data/sig/redis_queued_locks/acquirer/release_lock.rbs +6 -3
  70. data/sig/redis_queued_locks/acquirer/release_locks_of.rbs +20 -1
  71. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  72. data/sig/redis_queued_locks/client.rbs +21 -2
  73. data/sig/redis_queued_locks/errors.rbs +5 -2
  74. data/sig/redis_queued_locks/instrument/sampler.rbs +0 -2
  75. data/sig/redis_queued_locks/logging/sampler.rbs +0 -2
  76. data/sig/redis_queued_locks/resource.rbs +7 -1
  77. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +51 -1
  78. data/sig/redis_queued_locks/swarm/zombie_info.rbs +58 -7
  79. data/sig/vendor/redis_client.rbs +2 -0
  80. metadata +6 -1
@@ -4,17 +4,18 @@
4
4
  # rubocop:disable all
5
5
  # @api private
6
6
  # @since 1.16.0
7
+ # @version 1.20.0
7
8
  module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
8
9
  require_relative 'lock_series_poc/log_visitor'
9
10
  require_relative 'lock_series_poc/instr_visitor'
10
11
 
11
- # @sine 1.16.0
12
+ # @since 1.16.0
12
13
  extend RedisQueuedLocks::Acquirer::AcquireLock::YieldExpire
13
14
 
14
15
  class << self
15
16
  # @api private
16
17
  # @since 1.16.0
17
- # @version 1.18.0
18
+ # @version 1.21.0
18
19
  def lock_series_poc( # steep:ignore
19
20
  redis,
20
21
  lock_names,
@@ -25,6 +26,8 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
25
26
  ractor_id:,
26
27
  ttl:,
27
28
  queue_ttl:,
29
+ lock_index_enabled:,
30
+ lock_index_grace_period:,
28
31
  timeout:,
29
32
  timed:,
30
33
  retry_count:,
@@ -64,19 +67,22 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
64
67
  )
65
68
  end
66
69
 
70
+ # NOTE: Symbol keys are written to the lock data as Strings (the same check as in
71
+ # RedisQueuedLocks::Acquirer::AcquireLock);
67
72
  if meta.is_a?(::Hash) && (meta.any? do |key, _value|
68
- key == 'acq_id' ||
69
- key == 'hst_id' ||
70
- key == 'ts' ||
71
- key == 'ini_ttl' ||
72
- key == 'lock_key' ||
73
- key == 'rem_ttl' ||
74
- key == 'spc_ext_ttl' ||
75
- key == 'spc_cnt' ||
76
- key == 'l_spc_ext_ini_ttl' ||
77
- key == 'l_spc_ext_ts' ||
78
- key == 'l_spc_ts' ||
79
- key == 'rw_mode'
73
+ meta_key = key.is_a?(::Symbol) ? key.name : key
74
+ meta_key == 'acq_id' ||
75
+ meta_key == 'hst_id' ||
76
+ meta_key == 'ts' ||
77
+ meta_key == 'ini_ttl' ||
78
+ meta_key == 'lock_key' ||
79
+ meta_key == 'rem_ttl' ||
80
+ meta_key == 'spc_ext_ttl' ||
81
+ meta_key == 'spc_cnt' ||
82
+ meta_key == 'l_spc_ext_ini_ttl' ||
83
+ meta_key == 'l_spc_ext_ts' ||
84
+ meta_key == 'l_spc_ts' ||
85
+ meta_key == 'rw_mode'
80
86
  end)
81
87
  raise(
82
88
  RedisQueuedLocks::ArgumentError,
@@ -86,6 +92,34 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
86
92
  )
87
93
  end
88
94
 
95
+ # NOTE: the series ttl is validated before the lock ttl calculation: each lock ttl of
96
+ # the series includes the retry delay and jitter (a non-positive series ttl can produce
97
+ # a positive lock ttl), the series ttl limits the `timed` block (the same check as in
98
+ # RedisQueuedLocks::Acquirer::AcquireLock);
99
+ if !ttl.is_a?(::Integer) || ttl <= 0
100
+ raise(
101
+ RedisQueuedLocks::ArgumentError,
102
+ "`:ttl` argument should be a positive Integer, got #{ttl.inspect}."
103
+ )
104
+ end
105
+
106
+ # NOTE: the retry delay and jitter are parts of the lock ttls of the series (validated before
107
+ # the lock ttl calculation; other options are validated by
108
+ # RedisQueuedLocks::Acquirer::AcquireLock on the first lock of the series, before any lock
109
+ # is obtained);
110
+ if !retry_delay.is_a?(::Integer) || retry_delay < 0
111
+ raise(
112
+ RedisQueuedLocks::ArgumentError,
113
+ "`:retry_delay` argument should be a non-negative Integer, got #{retry_delay.inspect}."
114
+ )
115
+ end
116
+ if !retry_jitter.is_a?(::Integer) || retry_jitter < 0
117
+ raise(
118
+ RedisQueuedLocks::ArgumentError,
119
+ "`:retry_jitter` argument should be a non-negative Integer, got #{retry_jitter.inspect}."
120
+ )
121
+ end
122
+
89
123
  locks_count = lock_names.size
90
124
 
91
125
  lock_acquirement_operations = lock_names.each_with_index.map do |lock_name, position|
@@ -155,6 +189,8 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
155
189
  ractor_id:,
156
190
  ttl: lock_operation_options[:ttl],
157
191
  queue_ttl:,
192
+ lock_index_enabled:,
193
+ lock_index_grace_period:,
158
194
  timeout:,
159
195
  timed:,
160
196
  retry_count:,
@@ -357,7 +393,11 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
357
393
  end
358
394
 
359
395
  # Releases write locks of the given acquirer (compare-and-delete: the lock can be expired
360
- # and obtained by another acquirer when the series logic exceeds the lock ttl).
396
+ # and obtained by another acquirer when the series logic exceeds the lock ttl) in one
397
+ # transaction for the whole series (three round trips: WATCH, lock owners, MULTI/EXEC):
398
+ # - an aborted transaction (one of the watched locks is changed) is retried with the locks
399
+ # that were still owned by the acquirer at the last check: the locks of other acquirers
400
+ # are neither deleted nor watched, so the retries converge;
361
401
  #
362
402
  # @param redis [RedisClient]
363
403
  # @param lock_names [Array<String>]
@@ -366,18 +406,37 @@ module RedisQueuedLocks::Acquirer::LockSeriesPoC # steep:ignore
366
406
  #
367
407
  # @api private
368
408
  # @since 1.18.0
409
+ # @version 1.21.0
369
410
  def release_write_lock_series(redis, lock_names, acquirer_id) # steep:ignore
370
411
  return if lock_names.empty?
371
412
 
413
+ own_lock_keys = lock_names.map do |lock_name|
414
+ RedisQueuedLocks::Resource.prepare_lock_key(lock_name)
415
+ end
416
+
372
417
  redis.with do |conn|
373
- lock_names.each do |lock_name|
374
- lock_key = RedisQueuedLocks::Resource.prepare_lock_key(lock_name)
375
- conn.multi(watch: [lock_key]) do |transaction|
376
- if conn.call('HGET', lock_key, 'acq_id') == acquirer_id
377
- transaction.call('DEL', lock_key)
418
+ until own_lock_keys.empty?
419
+ released = conn.multi(watch: own_lock_keys) do |transaction|
420
+ # NOTE: (pipeline layout) one reply (lock owner) per lock in the series order
421
+ lock_owners = conn.pipelined do |pipeline|
422
+ own_lock_keys.each { |lock_key| pipeline.call('HGET', lock_key, 'acq_id') }
423
+ end
424
+ own_lock_keys = own_lock_keys.select.with_index do |_lock_key, index|
425
+ lock_owners[index] == acquirer_id
378
426
  end
427
+ own_lock_keys.each { |lock_key| transaction.call('DEL', lock_key) }
379
428
  end
429
+ # NOTE: `nil` => the transaction is aborted (a watched lock is changed) => retry
430
+ break unless released.nil?
380
431
  end
432
+ rescue RedisQueuedLocks::LockAcquirementIntermediateTimeoutError,
433
+ RedisQueuedLocks::TimedLockIntermediateTimeoutError,
434
+ ::Timeout::ExitException
435
+ # NOTE: (timeouts) an interrupted WATCH block (the internal timeouts and the user's
436
+ # `Timeout.timeout` without an exception class): the closed connection drops the WATCH
437
+ # state (redis-client sends `UNWATCH` only on `StandardError`);
438
+ conn.close
439
+ raise
381
440
  end
382
441
  end
383
442
 
@@ -2,27 +2,51 @@
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::Locks
8
+ # The number of replies of one lock in the lock state pipeline of a batch of locks
9
+ # (`extract_locks_info`): HGETALL (write lock), PTTL (write lock), ZRANGE (readers registry).
10
+ #
11
+ # @return [Integer]
12
+ #
13
+ # @api private
14
+ # @since 1.20.0
15
+ LOCK_STATE_REPLIES = 3
16
+
8
17
  # rubocop:disable Metrics/ClassLength
9
18
  class << self
10
19
  # @param redis_client [RedisClient]
11
20
  # @option scan_size [Integer]
12
21
  # @option with_info [Boolean]
22
+ # @option lock_index_enabled [Boolean]
23
+ # Find locks via the lock index (`index_locks`) instead of the keyspace SCAN (`scan_locks`).
13
24
  # @return [Set<String>,Set<Hash<Symbol,Any>>]
14
25
  #
15
26
  # @api private
16
27
  # @since 1.0.0
17
- def locks(redis_client, scan_size:, with_info:)
28
+ # @version 1.21.0
29
+ def locks(redis_client, scan_size:, with_info:, lock_index_enabled:)
30
+ # NOTE: (arguments) the same range as the config key: zero/negative sizes fail SCAN/ZSCAN
31
+ # (`COUNT 0`) and the batching (`each_slice(0)`);
32
+ if !scan_size.is_a?(::Integer) || scan_size <= 0
33
+ raise(
34
+ RedisQueuedLocks::ArgumentError,
35
+ "`:scan_size` argument should be a positive Integer, got #{scan_size.inspect}."
36
+ )
37
+ end
38
+
18
39
  redis_client.with do |rconn|
19
- lock_keys = scan_locks(rconn, scan_size)
20
- with_info ? extract_locks_info(rconn, lock_keys) : lock_keys
40
+ lock_keys =
41
+ lock_index_enabled ? index_locks(rconn, scan_size) : scan_locks(rconn, scan_size)
42
+ with_info ? extract_locks_info(rconn, lock_keys, scan_size) : lock_keys
21
43
  end
22
44
  end
23
45
 
24
46
  private
25
47
 
48
+ # Write locks and read locks in one SCAN pass (the disabled lock index).
49
+ #
26
50
  # @param redis_client [RedisClient]
27
51
  # @param scan_size [Integer]
28
52
  # @return [Set<String>]
@@ -30,89 +54,201 @@ module RedisQueuedLocks::Acquirer::Locks
30
54
  #
31
55
  # @api private
32
56
  # @since 1.0.0
33
- # @version 1.18.0
57
+ # @version 1.20.0
34
58
  def scan_locks(redis_client, scan_size)
35
59
  Set.new.tap do |lock_keys|
60
+ # @type var lock_keys: Set[String]
36
61
  redis_client.scan(
37
62
  'MATCH',
38
- RedisQueuedLocks::Resource::LOCK_PATTERN,
63
+ RedisQueuedLocks::Resource::KEY_PATTERN,
39
64
  count: scan_size
40
- ) do |lock_key|
41
- # TODO: reduce unnecessary iterations
42
- lock_keys.add(lock_key)
65
+ ) do |key|
66
+ case RedisQueuedLocks::Resource.lock_key_family(key)
67
+ when :lock
68
+ lock_keys.add(key)
69
+ when :lock_readers
70
+ # NOTE: (RW) read locks (readers registries can contain expired read locks only)
71
+ lock_keys.add(RedisQueuedLocks::Resource.lock_key_from_readers(key))
72
+ end
43
73
  end
74
+ end
75
+ end
44
76
 
45
- # NOTE: (RW) read locks (readers registries can contain expired read locks only)
46
- redis_client.scan(
47
- 'MATCH',
48
- RedisQueuedLocks::Resource::LOCK_READERS_PATTERN,
49
- count: scan_size
50
- ) do |lock_readers_key|
51
- # TODO: reduce unnecessary iterations
52
- lock_keys.add(RedisQueuedLocks::Resource.lock_key_from_readers(lock_readers_key))
77
+ # Write locks and read locks of the lock index (the enabled lock index): one ZSCAN pass over
78
+ # the lock names (`RedisQueuedLocks::Resource::LOCK_INDEX_KEY`, the keyspace is never scanned)
79
+ # with one round trip per batch of `scan_size` lock names (`EXISTS` of the write lock and of
80
+ # the readers registry of each lock name).
81
+ #
82
+ # @param redis_client [RedisClient]
83
+ # @param scan_size [Integer] ZSCAN count and the number of lock names in one batch.
84
+ # @return [Set<String>]
85
+ # - (RW) read locks are represented by their lock keys (`rql:lock:<lock_name>`) too
86
+ # (readers registries can contain expired read locks only);
87
+ #
88
+ # @api private
89
+ # @since 1.20.0
90
+ def index_locks(redis_client, scan_size)
91
+ Set.new.tap do |lock_keys|
92
+ # @type var lock_keys: Set[String]
93
+ each_lock_name_batch(redis_client, scan_size) do |lock_names|
94
+ # NOTE: (pipeline layout) one reply per lock name in the batch order: the number of
95
+ # the existing keys of the lock (the write lock and the readers registry);
96
+ # @type var lock_keys_count: Array[Integer]
97
+ lock_keys_count = redis_client.pipelined do |pipeline|
98
+ lock_names.each do |lock_name|
99
+ pipeline.call(
100
+ 'EXISTS',
101
+ RedisQueuedLocks::Resource.prepare_lock_key(lock_name),
102
+ RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
103
+ )
104
+ end
105
+ end
106
+
107
+ lock_names.each_with_index do |lock_name, index|
108
+ next if lock_keys_count[index] == 0
109
+
110
+ lock_keys.add(RedisQueuedLocks::Resource.prepare_lock_key(lock_name))
111
+ end
53
112
  end
54
113
  end
55
114
  end
56
115
 
116
+ # Iterates over the lock names of the lock index (one ZSCAN pass) in batches of `scan_size`
117
+ # lock names (batches are collected across ZSCAN pages). Expired lock names (not dropped from
118
+ # the index yet) are skipped: all their keys are expired.
119
+ #
120
+ # NOTE: each operation module iterates over the lock index by itself (duplicated on purpose:
121
+ # operation modules are independent of each other, see `extract_locks_info`);
122
+ #
123
+ # @param redis_client [RedisClient]
124
+ # @param scan_size [Integer] ZSCAN count and the number of lock names in one batch.
125
+ # @yield [lock_names]
126
+ # @yieldparam lock_names [Array<String>] The batch of lock names (reused between batches).
127
+ # @return [void]
128
+ #
129
+ # @api private
130
+ # @since 1.20.0
131
+ def each_lock_name_batch(redis_client, scan_size)
132
+ lock_index_time = RedisQueuedLocks::Resource.lock_index_time
133
+ # @type var lock_names: Array[String]
134
+ lock_names = []
135
+
136
+ redis_client.zscan(
137
+ RedisQueuedLocks::Resource::LOCK_INDEX_KEY, count: scan_size
138
+ ) do |lock_name, lock_index_score|
139
+ next if lock_index_score.to_f < lock_index_time
140
+
141
+ lock_names << lock_name
142
+ next if lock_names.size < scan_size
143
+
144
+ yield(lock_names)
145
+ lock_names.clear
146
+ end
147
+
148
+ yield(lock_names) unless lock_names.empty?
149
+ end
150
+
57
151
  # NOTE:
58
152
  # - the lock info is extracted by the module itself with the same approach as in `LockInfo`:
59
153
  # public operation modules do not reuse each other (they share logic via `Resource`,
60
154
  # `Utilities`), so the similar logic is duplicated on purpose;
155
+ # - locks are processed in batches of `scan_size` locks: two round trips per batch
156
+ # (the state of the locks, then the data of the live read locks);
61
157
  #
62
158
  # @param redis_client [RedisClient]
63
159
  # @param lock_keys [Set<String>]
160
+ # @param scan_size [Integer] The number of locks in one batch.
64
161
  # @return [Set<Hash<Symbol,Any>>]
65
162
  #
66
163
  # @api private
67
164
  # @since 1.0.0
68
- # @version 1.18.0
165
+ # @version 1.21.0
69
166
  # rubocop:disable Metrics/MethodLength
70
- def extract_locks_info(redis_client, lock_keys)
167
+ def extract_locks_info(redis_client, lock_keys, scan_size)
71
168
  Set.new.tap do |seeded_locks|
72
169
  # rubocop:disable Layout/LineLength
73
170
  # @type var seeded_locks: Set[{ lock: String, status: :released|:alive, info: Hash[String,untyped] }]
74
171
  # rubocop:enable Layout/LineLength
75
172
 
76
- # Step X: iterate each lock and extract their info
77
- lock_keys.each do |lock_key|
78
- # Step 1: extract lock info from redis
79
-
80
- # NOTE: (RW) readers registry of the lock
81
- lock_name = lock_key.delete_prefix('rql:lock:')
82
- lock_readers_key = RedisQueuedLocks::Resource.prepare_lock_readers(lock_name)
173
+ lock_keys.each_slice(scan_size) do |lock_keys_batch|
174
+ # Step 0: lock names of the batch (once per lock: the readers registry keys and the read
175
+ # lock data keys are built from them)
176
+ # @type var lock_names_batch: Array[String]
177
+ lock_names_batch = lock_keys_batch.map do |lock_key|
178
+ RedisQueuedLocks::Resource.lock_name_from_key(lock_key, :lock) #: String
179
+ end
83
180
 
84
- # @type var lock_info: RedisQueuedLocks::Acquirer::Locks::lockInfo
85
- lock_info = redis_client.pipelined do |pipeline|
86
- pipeline.call('HGETALL', lock_key)
87
- pipeline.call('PTTL', lock_key)
181
+ # Step 1: extract the state of the batch of locks in one round trip
182
+ # NOTE: (pipeline layout) [0] TIME (read locks are checked in redis time), then
183
+ # `LOCK_STATE_REPLIES` replies per lock in the batch order (the replies of the lock
184
+ # with the index N start at `1 + N * LOCK_STATE_REPLIES`): HGETALL (write lock),
185
+ # PTTL (write lock), ZRANGE WITHSCORES (readers registry);
186
+ # @type var batch_state: Array[untyped]
187
+ batch_state = redis_client.pipelined do |pipeline|
88
188
  pipeline.call('TIME')
89
- pipeline.call('ZRANGE', lock_readers_key, '0', '-1', 'WITHSCORES')
90
- end.yield_self do |result| # Step 2: format the result
91
- # Step 2.X: lock is released
92
- if result == nil
93
- {} #: Hash[String,String|Float|Integer]
94
- else
95
- # NOTE: the result of MULTI-command is an array of results of each internal command
96
- # - result[0] (HGETALL) (Hash<String,String>)
97
- # => (will be mutated further with different value types)
98
- # - result[1] (PTTL) (Integer)
99
- # => (without any mutation, integer is atomic)
100
-
101
- # rubocop:disable Layout/LineLength
102
- # @type var result: [Hash[String,String|Float|Integer],Integer,Array[String],Array[[String, Float]]]
103
- # rubocop:enable Layout/LineLength
104
- hget_cmd_res = result[0] # NOTE: HGETALL result (hash)
105
- pttl_cmd_res = result[1] # NOTE: PTTL result (integer)
106
-
107
- # Step 2.Y: write lock is released
108
- if hget_cmd_res == {} || pttl_cmd_res == -2 # NOTE: key does not exist
189
+ lock_keys_batch.each_with_index do |lock_key, index|
190
+ lock_readers_key =
191
+ RedisQueuedLocks::Resource.prepare_lock_readers(lock_names_batch[index])
192
+ pipeline.call('HGETALL', lock_key)
193
+ pipeline.call('PTTL', lock_key)
194
+ pipeline.call('ZRANGE', lock_readers_key, '0', '-1', 'WITHSCORES')
195
+ end
196
+ end
197
+ now = RedisQueuedLocks::Resource.redis_time_ms(batch_state[0])
198
+
199
+ # Step 2: (RW) live read locks of the locks without a write lock
200
+ # (`nil` for the locks with a write lock: their read locks are not reported);
201
+ # @type var live_read_locks: Array[Array[[String, Float]]?]
202
+ live_read_locks = lock_keys_batch.each_with_index.map do |_lock_key, index|
203
+ replies_at = 1 + (index * LOCK_STATE_REPLIES)
204
+ # NOTE: `nil` when the write lock is alive (the key exists)
205
+ next unless batch_state[replies_at].empty? || batch_state[replies_at + 1] == -2
206
+
207
+ # @type var read_locks: Array[[String, Float]]
208
+ read_locks = batch_state[replies_at + 2]
209
+ read_locks.select { |(_acquirer_id, expiration)| expiration > now }
210
+ end
211
+
212
+ # Step 3: (RW) read lock data of the live read locks in one round trip
213
+ # NOTE: (pipeline layout) one reply per live read lock in the lock and reader order;
214
+ # read lock data can be absent if it is dropped during the read lock life;
215
+ # @type var read_locks_data: Array[Hash[String,String]]
216
+ read_locks_data = redis_client.pipelined do |pipeline|
217
+ lock_keys_batch.each_index do |index|
218
+ live_read_locks[index]&.each do |(acquirer_id, _expiration)|
219
+ pipeline.call(
220
+ 'HGETALL',
221
+ RedisQueuedLocks::Resource.prepare_read_lock_key(
222
+ lock_names_batch[index], acquirer_id
223
+ )
224
+ )
225
+ end
226
+ end
227
+ end
228
+
229
+ # Step 4: format the info of each lock and push it to the result store
230
+ read_locks_data_at = 0
231
+ lock_keys_batch.each_with_index do |lock_key, index|
232
+ lock_live_read_locks = live_read_locks[index]
233
+
234
+ # @type var lock_info: RedisQueuedLocks::Acquirer::Locks::lockInfo
235
+ lock_info =
236
+ if lock_live_read_locks
237
+ # Step 4.Y: write lock is released
109
238
  # NOTE: (RW) read locks (or nothing if there are no live read locks)
110
239
  read_lock_info(
111
- redis_client, lock_name, lock_key, result[2], result[3]
240
+ lock_key, now, lock_live_read_locks, read_locks_data, read_locks_data_at
112
241
  ) || {} #: Hash[String,String|Float|Integer]
113
242
  else
114
- # Step 2.Z: lock is alive => format received info + add additional rem_ttl info
243
+ # Step 4.Z: lock is alive => format received info + add additional rem_ttl info
244
+ replies_at = 1 + (index * LOCK_STATE_REPLIES)
245
+ # @type var hget_cmd_res: Hash[String,String|Float|Integer]
246
+ hget_cmd_res = batch_state[replies_at] # NOTE: HGETALL result (hash)
247
+ # @type var pttl_cmd_res: Integer
248
+ pttl_cmd_res = batch_state[replies_at + 1] # NOTE: PTTL result (integer)
249
+
115
250
  hget_cmd_res.tap do |lock_data|
251
+ lock_data['lock_key'] = lock_key
116
252
  lock_data['rw_mode'] = 'write'
117
253
  lock_data['ts'] = Float(lock_data['ts'])
118
254
  lock_data['ini_ttl'] = Integer(lock_data['ini_ttl'])
@@ -127,30 +263,30 @@ module RedisQueuedLocks::Acquirer::Locks
127
263
  Float(lock_data['l_spc_ext_ts']) if lock_data['l_spc_ext_ts']
128
264
  end
129
265
  end
130
- end
131
- end
266
+ read_locks_data_at += lock_live_read_locks.size if lock_live_read_locks
132
267
 
133
- # Step 3: push the lock info to the result store
134
- seeded_locks << {
135
- lock: lock_key,
136
- status: (lock_info.empty? ? :released : :alive),
137
- info: lock_info
138
- }
268
+ seeded_locks << {
269
+ lock: lock_key,
270
+ status: (lock_info.empty? ? :released : :alive),
271
+ info: lock_info
272
+ }
273
+ end
139
274
  end
140
275
  end
141
276
  end
142
277
  # rubocop:enable Metrics/MethodLength
143
278
 
144
- # Formats live read locks of the lock (expired read locks are ignored).
279
+ # Formats live read locks of the lock.
145
280
  #
146
281
  # NOTE: the same approach as in `LockInfo` (duplicated on purpose: operation modules are
147
- # independent of each other, see `extract_locks_info`);
282
+ # independent of each other, see `extract_locks_info`); the read lock data is extracted
283
+ # for the whole batch of locks in advance;
148
284
  #
149
- # @param redis_client [RedisClient]
150
- # @param lock_name [String]
151
285
  # @param lock_key [String]
152
- # @param redis_time [Array<String>] Result of the redis `TIME` command.
153
- # @param read_locks [Array<Array<String,Float>>] Readers registry (acquirer id, expiration).
286
+ # @param now [Integer] Redis time (in milliseconds).
287
+ # @param live_read_locks [Array<Array<String,Float>>] Live read locks (acquirer id, expiration).
288
+ # @param read_locks_data [Array<Hash<String,String>>] Read lock data of the batch of locks.
289
+ # @param read_locks_data_at [Integer] Position of the first read lock data of the lock.
154
290
  # @return [Hash<String,String|Numeric|Array<Hash<String,String|Numeric>>>,NilClass]
155
291
  # - `nil` is returned when there are no live read locks;
156
292
  # - each reader has the same data as the write lock (acq_id, hst_id, ts, ini_ttl, rem_ttl,
@@ -158,32 +294,25 @@ module RedisQueuedLocks::Acquirer::Locks
158
294
  #
159
295
  # @api private
160
296
  # @since 1.18.0
297
+ # @version 1.20.0
161
298
  # rubocop:disable Metrics/MethodLength
162
- def read_lock_info(redis_client, lock_name, lock_key, redis_time, read_locks)
163
- now = RedisQueuedLocks::Resource.redis_time_ms(redis_time)
164
- live_read_locks = read_locks.select { |(_acquirer_id, expiration)| expiration > now }
299
+ def read_lock_info(lock_key, now, live_read_locks, read_locks_data, read_locks_data_at)
165
300
  return nil if live_read_locks.empty?
166
301
 
167
- # NOTE: read lock data (it can be absent if it is dropped during the read lock life)
168
- # @type var read_locks_data: Array[Hash[String,String]]
169
- read_locks_data = redis_client.pipelined do |pipeline|
170
- live_read_locks.each do |(acquirer_id, _expiration)|
171
- pipeline.call(
172
- 'HGETALL',
173
- RedisQueuedLocks::Resource.prepare_read_lock_key(lock_name, acquirer_id)
174
- )
175
- end
176
- end
177
-
178
302
  longest_rem_ttl = 0
179
303
  readers = live_read_locks.each_with_index.map do |(acquirer_id, expiration), index|
180
304
  rem_ttl = (expiration - now).ceil
181
305
  longest_rem_ttl = rem_ttl if rem_ttl > longest_rem_ttl
182
306
 
307
+ # NOTE: acquirer and host ids are not stored in the read lock data (both are derived from
308
+ # the acquirer id): they are the first fields like in the write lock info (read lock data
309
+ # of older versions contains them with the same values);
183
310
  # @type var reader: Hash[String,String|Float|Integer|nil]
184
- reader = read_locks_data[index] || {}
185
- reader['acq_id'] = acquirer_id
186
- reader['hst_id'] ||= RedisQueuedLocks::Resource.host_identifier_from_acquirer(acquirer_id)
311
+ reader = {
312
+ 'acq_id' => acquirer_id,
313
+ 'hst_id' => RedisQueuedLocks::Resource.host_identifier_from_acquirer(acquirer_id)
314
+ }
315
+ read_locks_data[read_locks_data_at + index]&.each { |field, value| reader[field] = value }
187
316
  reader['ts'] = Float(reader['ts']) if reader['ts']
188
317
  reader['ini_ttl'] = Integer(reader['ini_ttl']) if reader['ini_ttl']
189
318
  reader['rem_ttl'] = rem_ttl
@@ -2,14 +2,12 @@
2
2
 
3
3
  # @api private
4
4
  # @since 1.0.0
5
- # @version 1.18.0
6
5
  module RedisQueuedLocks::Acquirer::QueueInfo
7
6
  class << self
8
7
  # Returns an information about the required lock queue by the lock name. The result
9
8
  # represnts the ordered lock request queue that is ordered by score (Redis sets) and shows
10
- # lock acquirers and their position in queue. Async nature with redis communcation can lead
11
- # the sitaution when the queue becomes empty during the queue data extraction. So sometimes
12
- # you can receive the lock queue info with empty queue.
9
+ # lock acquirers and their position in queue. The write lock queue is empty when only
10
+ # the queue of read lock requests exists.
13
11
  #
14
12
  # @param redis_client [RedisClient]
15
13
  # @param lock_name [String]
@@ -33,28 +31,24 @@ module RedisQueuedLocks::Acquirer::QueueInfo
33
31
  #
34
32
  # @api private
35
33
  # @since 1.0.0
36
- # @version 1.18.0
37
- # rubocop:disable Metrics/MethodLength
34
+ # @version 1.20.0
38
35
  def queue_info(redis_client, lock_name)
39
36
  lock_key_queue = RedisQueuedLocks::Resource.prepare_lock_queue(lock_name)
40
37
  read_lock_key_queue = RedisQueuedLocks::Resource.prepare_read_lock_queue(lock_name)
41
38
 
39
+ # NOTE:
40
+ # redis deletes empty sorted sets, so an empty ZRANGE result means that the queue
41
+ # does not exist (no need for EXISTS checks);
42
42
  result = redis_client.pipelined do |pipeline|
43
- pipeline.call('EXISTS', lock_key_queue)
44
43
  pipeline.call('ZRANGE', lock_key_queue, '0', '-1', 'WITHSCORES')
45
- pipeline.call('EXISTS', read_lock_key_queue)
46
44
  pipeline.call('ZRANGE', read_lock_key_queue, '0', '-1', 'WITHSCORES')
47
45
  end
48
46
 
49
- # rubocop:disable Layout/LineLength
50
- # @type var result: [Integer,Array[[String,Integer|Float]],Integer,Array[[String,Integer|Float]]]
51
- # rubocop:enable Layout/LineLength
52
- exists_cmd_res = result[0]
53
- zrange_cmd_res = result[1]
54
- read_exists_cmd_res = result[2]
55
- read_zrange_cmd_res = result[3]
47
+ # @type var result: [Array[[String,Integer|Float]],Array[[String,Integer|Float]]]
48
+ zrange_cmd_res = result[0]
49
+ read_zrange_cmd_res = result[1]
56
50
 
57
- if exists_cmd_res == 1 || read_exists_cmd_res == 1
51
+ if !zrange_cmd_res.empty? || !read_zrange_cmd_res.empty?
58
52
  # NOTE: queue existed during the piepline invocation
59
53
  # @type var queue_info: RedisQueuedLocks::Acquirer::QueueInfo::queueInfo
60
54
  queue_info = {
@@ -64,7 +58,7 @@ module RedisQueuedLocks::Acquirer::QueueInfo
64
58
  end
65
59
  }
66
60
  # NOTE: (RW) the queue of read lock requests
67
- if read_exists_cmd_res == 1
61
+ unless read_zrange_cmd_res.empty?
68
62
  queue_info['read_lock_queue'] = read_lock_key_queue
69
63
  queue_info['read_queue'] = read_zrange_cmd_res.map do |val|
70
64
  { 'acq_id' => val[0], 'score' => val[1], 'rw_mode' => 'read' }
@@ -76,6 +70,5 @@ module RedisQueuedLocks::Acquirer::QueueInfo
76
70
  nil
77
71
  end
78
72
  end
79
- # rubocop:enable Metrics/MethodLength
80
73
  end
81
74
  end