redis_queued_locks 1.18.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 +65 -13
  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 +74 -0
  15. data/CLAUDE.md +4 -2
  16. data/README.md +338 -24
  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 +116 -16
  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 +9 -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
data/README.md CHANGED
@@ -10,7 +10,7 @@ In addition to the classic `queued` (FIFO) strategy RQL supports `random` (RANDO
10
10
 
11
11
  Supports `Read`/`Write` locks and `Reentrant` locks.
12
12
 
13
- Provides flexible invocation flow, parametrized limits (lock request ttl, lock ttl, queue ttl, lock attempts limit, fast failing, etc), **zombie locks elimination**, logging and instrumentation (and much more).
13
+ Provides flexible invocation flow, parametrized limits (lock request ttl, lock ttl, queue ttl, lock attempts limit, fast failing, etc), **zombie locks elimination**, **[lock indexing](#lock-index)**, logging and instrumentation (and much more).
14
14
 
15
15
  ---
16
16
 
@@ -64,6 +64,9 @@ Provides flexible invocation flow, parametrized limits (lock request ttl, lock t
64
64
  - [zombie_locks](#zombie_locks)
65
65
  - [zombie_hosts](#zombie_hosts)
66
66
  - [zombie_acquirers](#zombie_acquirers)
67
+ - [Lock Index](#lock-index)
68
+ - [Lock Index Performance](#lock-index-performance)
69
+ - [Lock Index Size](#lock-index-size)
67
70
  - [Lock Access Strategies](#lock-access-strategies)
68
71
  - [queued](#lock-access-strategies)
69
72
  - [random](#lock-access-strategies)
@@ -196,6 +199,8 @@ client = RedisQueuedLocks::Client.new(redis_client) do |config|
196
199
 
197
200
  # (seconds) (default: 15)
198
201
  # - lock request timeout. after this timeout your lock request in queue will be requeued with new position (at the end of the queue);
202
+ # - (the enabled lock index) the lock request queue lives as long as its latest lock request (the queue TTL is refreshed with
203
+ # the queue ttl on each lock request): queues of dead acquirers expire by themselves;
199
204
  config['default_queue_ttl'] = 15
200
205
 
201
206
  # (boolean) (default: false)
@@ -252,23 +257,30 @@ client = RedisQueuedLocks::Client.new(redis_client) do |config|
252
257
 
253
258
  # (default: 100)
254
259
  # - how many items will be released at a time in #clear_locks and in #clear_dead_requests (uses SCAN);
260
+ # - (the enabled lock index) how many locks (lock names of the lock index) will be processed at a time
261
+ # (the ZSCAN count of the lock index pass);
255
262
  # - affects the performance of your Redis and Ruby Application (configure thoughtfully);
256
263
  config['lock_release_batch_size'] = 100
257
264
 
258
265
  # (default: 300)
259
266
  # - how many items will be released at a time in #clear_locks_of and in #clear_current_locks methods (uses SCAN for batch extraction and DEL for batch deletion);
267
+ # - (the enabled lock index) how many locks (lock names of the lock index) will be processed at a time
268
+ # (the ZSCAN count of the lock index pass, DEL for batch deletion);
260
269
  # - affects the ruby's memory (cuz this batch will be stored in Set object in first, and then will be splatted to the DEL redis method invocation) (configure thoughtfully);
261
270
  # - affects the performance of your Redis (configure thoughtfully);
262
271
  config['clear_locks_of__lock_scan_size'] = 300
263
272
 
264
273
  # (default: 300)
265
- # - how many queues will be SCAN'ned at a time in #clear_locks_of and in #clear_current_locks methods (uses SCAN);
274
+ # - how many lock queues will be processed in one round trip in #clear_locks_of and in #clear_current_locks methods
275
+ # (lock queues are found by the same SCAN pass as locks or derived from the lock names of the same lock index pass);
266
276
  # - affects the performance of your Redis (configure thoughtfully);
267
277
  config['clear_locks_of__queue_scan_size'] = 300
268
278
 
269
279
  # (default: 500)
270
280
  # - how many items should be extracted from redis during the #locks, #queues, #keys
271
281
  # #locks_info, and #queues_info operations (uses SCAN);
282
+ # - (the enabled lock index) how many locks (lock names of the lock index) should be processed at a time
283
+ # (the ZSCAN count of the lock index pass);
272
284
  # - affects the performance of your Redis and Ruby Application (configure thoughtfully);
273
285
  config['key_extraction_batch_size'] = 500
274
286
 
@@ -277,6 +289,22 @@ client = RedisQueuedLocks::Client.new(redis_client) do |config|
277
289
  # - used for `#clear_dead_requests` as default vaule of `:dead_ttl` option;
278
290
  config['dead_request_ttl'] = (1 * 24 * 60 * 60 * 1000) # one day in milliseconds
279
291
 
292
+ # (boolean) (default: false)
293
+ # - find locks via the lock index (`rql:index:locks`) instead of the full Redis keyspace `SCAN` in all lock-iterating operations
294
+ # (the swarm, #locks, #queues, #keys, #clear_locks, #clear_locks_of, #clear_dead_requests, ...);
295
+ # - the lock index is maintained by the lock acquirement itself only when it is enabled;
296
+ # - the client-wide mode (not a per-call option): use the same value on all your processes that share the Redis database;
297
+ # after enabling invoke `#reindex_locks` once (see the "Lock Index" documentation section);
298
+ config['lock_index_enabled'] = false
299
+
300
+ # (milliseconds) (default: 60_000)
301
+ # - the lock index grace period: the lock name stays in the lock index (`rql:index:locks`) for this period
302
+ # after the expiration of all its keys (see the "Lock Index" documentation section);
303
+ # - tolerance of the client clock skew between your hosts and of the network latency: should be greater than
304
+ # the maximal clock skew between your hosts plus the network latency;
305
+ # - used only with the enabled lock index (`config['lock_index_enabled']`);
306
+ config['lock_index_grace_period'] = 60_000
307
+
280
308
  # (default: RedisQueuedLocks::Instrument::VoidNotifier)
281
309
  # - instrumentation layer;
282
310
  # - you can provide your own instrumenter that should realize `#notify(event, payload = {})` interface:
@@ -397,6 +425,7 @@ end
397
425
  - [locks_info](#locks_info---get-list-of-locks-with-their-info)
398
426
  - [queues_info](#queues_info---get-list-of-queues-with-their-info)
399
427
  - [clear_dead_requests](#clear_dead_requests)
428
+ - [reindex_locks](#reindex_locks)
400
429
  - [current_acquirer_id](#current_acquirer_id)
401
430
  - [current_host_id](#current_host_id)
402
431
  - [possible_host_ids](#possible_host_ids)
@@ -411,6 +440,10 @@ end
411
440
  - If block is passed:
412
441
  - the obtained lock will be released after the block execution or the lock's ttl (what will happen first);
413
442
  - if you want to timeout (fail with timeout) the block execution with lock's TTL use `timed: true` option;
443
+ - (limitation) the write lock is released after the block without an owner check: if the block (without `timed: true`)
444
+ runs longer than the lock's ttl, the expired lock can be already obtained by another acquirer and the release
445
+ deletes that lock (the same for the ttl decrease of `:extendable_work_through` reentrant locks); use `timed: true`
446
+ or extend the lock (`#extend_lock_ttl`) for long blocks (read locks are released per acquirer and are not affected);
414
447
  - the block's result will be returned;
415
448
  - If block is not passed:
416
449
  - the obtained lock will be released after lock's ttl;
@@ -421,6 +454,7 @@ def lock(
421
454
  lock_name,
422
455
  ttl: config['default_lock_ttl'],
423
456
  queue_ttl: config['default_queue_ttl'],
457
+ lock_index_grace_period: config['lock_index_grace_period'],
424
458
  timeout: config['try_to_lock_timeout'],
425
459
  timed: config['is_timed_by_default'],
426
460
  retry_count: config['retry_count'],
@@ -457,7 +491,15 @@ def lock(
457
491
  - pre-configured in `config['default_lock_ttl']`;
458
492
  - `queue_ttl` - (optional) `[Integer]`
459
493
  - Lifetime of the acuier's lock request. In seconds.
494
+ - (the enabled lock index) the lock request queue lives as long as its latest lock request (the queue TTL is refreshed
495
+ on each lock request);
460
496
  - pre-configured in `config['default_queue_ttl']`;
497
+ - `lock_index_grace_period` - (optional) `[Integer]`
498
+ - The lock index grace period (in milliseconds): the lock name stays in the lock index for this period after the expiration
499
+ of all its keys (tolerance of the client clock skew between your hosts and of the network latency);
500
+ - used only with the enabled lock index (`config['lock_index_enabled']`, the client-wide mode);
501
+ - see the [Lock Index](#lock-index) documentation section;
502
+ - pre-configured in `config['lock_index_grace_period']`;
461
503
  - `timeout` - (optional) `[Integer,NilClass]`
462
504
  - Time period a client should try to acquire the lock (in seconds). Nil means "without timeout".
463
505
  - pre-configured in `config['try_to_lock_timeout']`;
@@ -842,6 +884,7 @@ def lock!(
842
884
  lock_name,
843
885
  ttl: config['default_lock_ttl'],
844
886
  queue_ttl: config['default_queue_ttl'],
887
+ lock_index_grace_period: config['lock_index_grace_period'],
845
888
  timeout: config['try_to_lock_timeout'],
846
889
  timed: config['is_timed_by_default'],
847
890
  retry_count: config['retry_count'],
@@ -1094,6 +1137,64 @@ rql.lock_info("your_lock_name")
1094
1137
  }
1095
1138
  ```
1096
1139
 
1140
+ ```ruby
1141
+ # <for read/write locks>
1142
+ # (see `read_write_mode:` kwarg attribute of #lock/#lock! methods and "Read/Write Locks" documentation section)
1143
+ # (lock requests are not included: use #queue_info to see the queued readers and writers)
1144
+
1145
+ # -> case 1: the lock is held by readers, writers and readers are waiting in the lock queues;
1146
+
1147
+ # process#1 and process#2 (readers, work in parallel):
1148
+ rql.lock("your_lock_name", read_write_mode: :read, ttl: 5_000, meta: { "kek" => "pek" }) { ... }
1149
+ rql.lock("your_lock_name", read_write_mode: :read, ttl: 3_000) { ... }
1150
+ # process#3 (writer, waits for all readers in the write lock queue):
1151
+ rql.lock("your_lock_name", read_write_mode: :write) { ... }
1152
+ # process#4 (reader, waits for the earlier writer in the read lock queue):
1153
+ rql.lock("your_lock_name", read_write_mode: :read) { ... }
1154
+
1155
+ rql.lock_info("your_lock_name")
1156
+
1157
+ # => (read locks info: process#1 and process#2)
1158
+ {
1159
+ "lock_key" => "rql:lock:your_lock_name",
1160
+ "rw_mode" => "read",
1161
+ "rem_ttl" => 4_985, # remaining ttl of the longest read lock
1162
+ "readers" => [
1163
+ {
1164
+ "acq_id" => "rql:acq:123/456/567/678/374dd74324",
1165
+ "hst_id" => "rql:hst:123/456/678/374dd74324",
1166
+ "ts" => 123456789.12345,
1167
+ "ini_ttl" => 5_000,
1168
+ "rem_ttl" => 4_985,
1169
+ "kek" => "pek" # custom metadata of the read lock
1170
+ },
1171
+ {
1172
+ "acq_id" => "rql:acq:124/457/568/679/a83cb4f9d1",
1173
+ "hst_id" => "rql:hst:124/457/679/a83cb4f9d1",
1174
+ "ts" => 123456789.54321,
1175
+ "ini_ttl" => 3_000,
1176
+ "rem_ttl" => 2_412
1177
+ }
1178
+ ]
1179
+ }
1180
+
1181
+ # -> case 2: (some time later) readers are released, the lock is held by the writer (process#3),
1182
+ # the reader (process#4) is still waiting in the read lock queue;
1183
+
1184
+ rql.lock_info("your_lock_name")
1185
+
1186
+ # => (write lock info: process#3, read lock requests are not included)
1187
+ {
1188
+ "lock_key" => "rql:lock:your_lock_name",
1189
+ "rw_mode" => "write",
1190
+ "acq_id" => "rql:acq:125/458/569/680/c7bfcaf4f9",
1191
+ "hst_id" => "rql:hst:125/458/680/c7bfcaf4f9",
1192
+ "ts" => 123456795.12345,
1193
+ "ini_ttl" => 5_000,
1194
+ "rem_ttl" => 4_870
1195
+ }
1196
+ ```
1197
+
1097
1198
  ---
1098
1199
 
1099
1200
  #### #queue_info
@@ -1102,9 +1203,8 @@ rql.lock_info("your_lock_name")
1102
1203
 
1103
1204
  Returns an information about the required lock queue by the lock name. The result
1104
1205
  represnts the ordered lock request queue that is ordered by score (Redis Sets) and shows
1105
- lock acquirers and their position in queue. Async nature with redis communcation can lead
1106
- the situation when the queue becomes empty during the queue data extraction. So sometimes
1107
- you can receive the lock queue info with empty queue value (an empty array).
1206
+ lock acquirers and their position in queue. The queue of write lock requests is an empty array
1207
+ (`"queue" => []`) when only the queue of read lock requests exists.
1108
1208
 
1109
1209
  - get the lock queue information;
1110
1210
  - queue represents the ordered set of lock key reqests:
@@ -1174,6 +1274,7 @@ rql.queued?("your_lock_name") # => true/false
1174
1274
 
1175
1275
  - release the concrete lock with lock request queue;
1176
1276
  - queue will be relased first;
1277
+ - (the enabled lock index) the lock name is dropped from the [Lock Index](#lock-index) in the same transaction;
1177
1278
  - has an alias: `#release_lock`;
1178
1279
  - accepts:
1179
1280
  - `lock_name` - (required) `[String]` - the lock name that should be released.
@@ -1253,10 +1354,15 @@ rql.unlock("your_lock_name")
1253
1354
  - suitable for read locks obtained without a block of code (`lock(..., read_write_mode: :read)` without a block);
1254
1355
  - the current acquirer is calculated in the same way as for `#lock` (`#current_acquirer_id`: process, thread, fiber, ractor, identity),
1255
1356
  so call it from the same thread/fiber that obtained the read lock;
1357
+ - the read lock of a different acquirer can be released via `:acquirer_id` (for example, extracted from `#lock_info`/`#locks_info`);
1358
+ `:identity` is ignored in this case (the acquirer id already contains its own identity);
1256
1359
  - has an alias: `#release_read_lock`;
1257
1360
  - accepts:
1258
1361
  - `lock_name` - (required) `[String]` - the lock name whose read lock should be released;
1362
+ - `:acquirer_id` - (optional) `[String, NilClass]` - the acquirer whose read lock should be released
1363
+ (`nil` by default: the current acquirer is used);
1259
1364
  - `:identity` - (optional) `[String]` - the same identity as for `#lock` (`uniq_identity` by default);
1365
+ ignored when `:acquirer_id` is passed: the acquirer id already contains its own identity;
1260
1366
  - `:logger`, `:instrumenter`, `:instrument`, `:log_sampling_enabled`, `:log_sampling_percent`, `:log_sampler`, `:log_sample_this`,
1261
1367
  `:instr_sampling_enabled`, `:instr_sampling_percent`, `:instr_sampler`, `:instr_sample_this` - (optional) - the same as for `#unlock`;
1262
1368
  - returns `{ ok: true, result: { rel_time:, rel_key:, rel_acq_id:, lock_res: } }` (`lock_res` - `:released` or `:nothing_to_release`);
@@ -1278,6 +1384,22 @@ rql.unlock_read('report')
1278
1384
  }
1279
1385
  ```
1280
1386
 
1387
+ ```ruby
1388
+ # release the read lock of a different acquirer
1389
+ reader = rql.lock_info('report')['readers'].first
1390
+ rql.unlock_read('report', acquirer_id: reader['acq_id'])
1391
+ # =>
1392
+ {
1393
+ ok: true,
1394
+ result: {
1395
+ rel_time: 0.29, # milliseconds
1396
+ rel_key: 'rql:lock:report',
1397
+ rel_acq_id: 'rql:acq:17580/2260/2380/2280/3f16b93973612580', # reader['acq_id']
1398
+ lock_res: :released # or :nothing_to_release
1399
+ }
1400
+ }
1401
+ ```
1402
+
1281
1403
  ---
1282
1404
 
1283
1405
  #### #clear_locks - release all locks and lock queues
@@ -1285,11 +1407,17 @@ rql.unlock_read('report')
1285
1407
  <sup>\[[back to top](#usage)\]</sup>
1286
1408
 
1287
1409
  - release all obtained locks and related lock request queues;
1288
- - queues will be released first;
1410
+ - all lock keys (locks, read locks and lock queues) are found in one `SCAN` pass and deleted during it with one `DEL` per batch;
1411
+ - (the enabled [Lock Index](#lock-index)) all locks are found in one `ZSCAN` pass over the lock index (the keyspace is never
1412
+ scanned): all the keys of each batch of locks (locks, read locks and lock queues) are deleted with one `DEL` in one transaction
1413
+ with the removal of their lock names from the lock index; keys without the lock index entry (keys of older RQL versions) are not
1414
+ found (see [#reindex_locks](#reindex_locks));
1289
1415
  - has an alias: `#release_locks`;
1290
1416
  - accepts:
1291
1417
  - `:batch_size` - (optional) `[Integer]`
1292
- - the size of batch of locks and lock queus that should be cleared under the one pipelined redis command at once;
1418
+ - the size of batch of locks and lock queus that should be cleared under the one `DEL` redis command at once (and the `SCAN` count);
1419
+ - (the enabled lock index) the number of locks (lock names) whose keys should be cleared under the one `DEL` redis command
1420
+ at once (and the `ZSCAN` count);
1293
1421
  - pre-configured in `config['lock_release_batch_size']`;
1294
1422
  - `:logger` - (optional) `[::Logger,#debug]`
1295
1423
  - custom logger object;
@@ -1366,10 +1494,13 @@ rql.clear_locks
1366
1494
  - `:acquirer_id` - (required) `[String]`
1367
1495
  - acquirer indentifier whose locks we want to release and which we want to remove from all locks queues;
1368
1496
  - `:lock_scan_size` - (optional) `[Integer]`
1369
- - how many items will be released at a time (uses `SCAN `for batch extraction and `DEL` for batch deletion);
1497
+ - how many items will be released at a time (uses one `SCAN` pass for batch extraction and `DEL` for batch deletion);
1498
+ - (the enabled [Lock Index](#lock-index)) how many locks will be processed at a time (one `ZSCAN` pass over the lock index
1499
+ for batch extraction and `DEL` for batch deletion);
1370
1500
  - pre-configuerd value in `config['clear_locks_of__lock_scan_size']`;
1371
1501
  - `:queue_scan_size` - (optional) `[Integer]`
1372
- - how many queues will be SCAN'ned at a time (uses `SCAN)`;
1502
+ - how many lock queues will be processed in one round trip (lock queues are found by the same `SCAN` pass as locks or
1503
+ derived from the lock names of the same lock index pass);
1373
1504
  - pre-configuerd value in `config['clear_locks_of__queue_scan_size']`;
1374
1505
  - `:logger` - (optional) `[::Logger,#debug]`
1375
1506
  - custom logger object;
@@ -1471,10 +1602,13 @@ rql.clear_current_locks
1471
1602
  ```
1472
1603
  - produces `"redis_queued_locks.release_locks_of"` instrumentation event;
1473
1604
  - `:lock_scan_size` - (optional) `[Integer]`
1474
- - how many items will be released at a time (uses `SCAN `for batch extraction and `DEL` for batch deletion);
1605
+ - how many items will be released at a time (uses one `SCAN` pass for batch extraction and `DEL` for batch deletion);
1606
+ - (the enabled [Lock Index](#lock-index)) how many locks will be processed at a time (one `ZSCAN` pass over the lock index
1607
+ for batch extraction and `DEL` for batch deletion);
1475
1608
  - pre-configuerd value in `config['clear_locks_of__lock_scan_size']`;
1476
1609
  - `:queue_scan_size` - (optional) `[Integer]`
1477
- - how many queues will be SCAN'ned at a time (uses `SCAN)`;
1610
+ - how many lock queues will be processed in one round trip (lock queues are found by the same `SCAN` pass as locks or
1611
+ derived from the lock names of the same lock index pass);
1478
1612
  - pre-configuerd value in `config['clear_locks_of__queue_scan_size']`;
1479
1613
  - `:logger` - (optional) `[::Logger,#debug]`
1480
1614
  - custom logger object;
@@ -1543,6 +1677,8 @@ rql.release_locks_of(host_id: rql.current_host_id, acquirer_id: rql.current_acqu
1543
1677
 
1544
1678
  - extends lock ttl by the required number of milliseconds;
1545
1679
  - expects the lock name and the number of milliseconds;
1680
+ - (the enabled lock index) the lock index score of the lock is increased by the same number of milliseconds
1681
+ (see [Lock Index](#lock-index));
1546
1682
  - accepts:
1547
1683
  - `lock_name` - (required) `[String]`
1548
1684
  - the lock name which ttl should be extended;
@@ -1638,8 +1774,11 @@ rql.extend_lock_ttl("my_lock", 5_000) # NOTE: add 5_000 milliseconds
1638
1774
 
1639
1775
  - get list of obtained locks;
1640
1776
  - uses redis `SCAN` under the hood;
1777
+ - (the enabled [Lock Index](#lock-index)) uses the lock index under the hood (one `ZSCAN` pass, the keyspace is never scanned):
1778
+ keys without the lock index entry (keys of older RQL versions) are not found (see [#reindex_locks](#reindex_locks));
1641
1779
  - accepts:
1642
- - `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default);
1780
+ - `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default) - the `SCAN` count (or the `ZSCAN` count and
1781
+ the batch size of locks with the enabled lock index);
1643
1782
  - `:with_info` - `Boolean` - `false` by default (for details see [#locks_info](#locks_info---get-list-of-locks-with-their-info));
1644
1783
  - returns:
1645
1784
  - `Set<String>` (for `with_info: false`);
@@ -1671,8 +1810,11 @@ rql.locks # or rql.locks(scan_size: 123)
1671
1810
 
1672
1811
  - get list of lock request queues;
1673
1812
  - uses redis `SCAN` under the hood;
1813
+ - (the enabled [Lock Index](#lock-index)) uses the lock index under the hood (one `ZSCAN` pass, the keyspace is never scanned):
1814
+ keys without the lock index entry (keys of older RQL versions) are not found (see [#reindex_locks](#reindex_locks));
1674
1815
  - accepts
1675
- - `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default);
1816
+ - `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default) - the `SCAN` count (or the `ZSCAN` count and
1817
+ the batch size of locks with the enabled lock index);
1676
1818
  - `:with_info` - `Boolean` - `false` by default (for details see [#queues_info](#queues_info---get-list-of-queues-with-their-info));
1677
1819
  - returns:
1678
1820
  - `Set<String>` (for `with_info: false`);
@@ -1702,10 +1844,15 @@ rql.queues # or rql.queues(scan_size: 123)
1702
1844
 
1703
1845
  <sup>\[[back to top](#usage)\]</sup>
1704
1846
 
1705
- - get list of taken locks and queues;
1706
- - uses redis `SCAN` under the hood;
1847
+ - get list of taken locks and queues (all the existing keys of the locks: locks, lock queues, read locks registries,
1848
+ read lock queues, read lock data);
1849
+ - uses redis `SCAN` under the hood (all `rql:lock*` keys);
1850
+ - (the enabled [Lock Index](#lock-index)) uses the lock index under the hood (one `ZSCAN` pass, the keyspace is never scanned):
1851
+ keys without the lock index entry (keys of older RQL versions) and other `rql:lock*` keys are not listed
1852
+ (see [#reindex_locks](#reindex_locks));
1707
1853
  - accepts:
1708
- `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default);
1854
+ `:scan_size` - `Integer` - (`config['key_extraction_batch_size']` by default) - the `SCAN` count (or the `ZSCAN` count and
1855
+ the batch size of locks with the enabled lock index);
1709
1856
  - returns: `Set<String>`
1710
1857
 
1711
1858
  ```ruby
@@ -1737,7 +1884,8 @@ rql.keys # or rql.keys(scan_size: 123)
1737
1884
  <sup>\[[back to top](#usage)\]</sup>
1738
1885
 
1739
1886
  - get list of locks with their info;
1740
- - uses redis `SCAN` under the hod;
1887
+ - uses redis `SCAN` under the hod (one pass) or the [Lock Index](#lock-index) (one `ZSCAN` pass, the keyspace is never scanned:
1888
+ the enabled lock index), lock info is extracted in batches of `scan_size` locks (two round trips per batch);
1741
1889
  - accepts `scan_size:`/`Integer` option (`config['key_extraction_batch_size']` by default);
1742
1890
  - returns `Set<Hash<Symbol,Any>>` (see [#lock_info](#lock_info) and examples below for details).
1743
1891
  - contained data: `{ lock: String, status: Symbol, info: Hash<String,Any> }`;
@@ -1775,7 +1923,8 @@ rql.locks_info # or rql.locks_info(scan_size: 123)
1775
1923
  <sup>\[[back to top](#usage)\]</sup>
1776
1924
 
1777
1925
  - get list of queues with their info;
1778
- - uses redis `SCAN` under the hod;
1926
+ - uses redis `SCAN` under the hod (one pass) or the [Lock Index](#lock-index) (one `ZSCAN` pass, the keyspace is never scanned:
1927
+ the enabled lock index), queue info is extracted in batches of `scan_size` queues (one round trip per batch);
1779
1928
  - accepts `scan_size:`/`Integer` option (`config['key_extraction_batch_size']` by default);
1780
1929
  - returns `Set<Hash<Symbol,Any>>` (see [#queue_info](#queue_info) and examples below for details).
1781
1930
  - contained data: `{ queue: String, requests: Array<Hash<String,Any>> }`
@@ -1810,8 +1959,14 @@ before the lock acquire moment occurs and when no any other process does not nee
1810
1959
  For this case your lock reuquest will be cleared only when any process will try
1811
1960
  to acquire this lock again (cuz lock acquirement triggers the removement of expired requests).
1812
1961
 
1962
+ NOTE: with the enabled [Lock Index](#lock-index) the lock request queue lives as long as its latest lock request (the queue TTL
1963
+ is refreshed with the request `queue_ttl` on each lock request), so the queue of dead requests expires by itself (queues of older
1964
+ RQL versions and queues created with the disabled lock index have no TTL: see [#reindex_locks](#reindex_locks)).
1965
+
1813
1966
  In order to help with these dead requests you may periodically call `#clear_dead_requests`
1814
1967
  with corresponding `:dead_ttl` option, that is pre-configured by default via `config['dead_request_ttl']`.
1968
+ Lock queues are found via the redis `SCAN` (one pass) or via the [Lock Index](#lock-index) (one `ZSCAN` pass, the keyspace is
1969
+ never scanned: the enabled lock index).
1815
1970
 
1816
1971
  `:dead_ttl` option is required because of it is no any **fast** and **resource-free** way to understand which request
1817
1972
  is dead now and is it really dead cuz each request queue can host their requests with
@@ -1823,6 +1978,8 @@ Accepts:
1823
1978
  - has a preconfigured value in `config['dead_request_ttl']` (1 day by default);
1824
1979
  - `:scan_size` - (optional) `[Integer]`
1825
1980
  - the batch of scanned keys for Redis'es SCAN command;
1981
+ - (the enabled lock index) the `ZSCAN` count of the [Lock Index](#lock-index) pass and the number of locks (both lock queues
1982
+ of each lock) processed in one round trip;
1826
1983
  - has a preconfigured valie in `config['lock_release_batch_size']`;
1827
1984
  - `:logger` - (optional) `[::Logger,#debug]`
1828
1985
  - custom logger object;
@@ -1879,6 +2036,42 @@ rql.clear_dead_requests(dead_ttl: 60 * 60 * 1000) # 1 hour in milliseconds
1879
2036
 
1880
2037
  ---
1881
2038
 
2039
+ #### #reindex_locks
2040
+
2041
+ <sup>\[[back to top](#usage)\]</sup>
2042
+
2043
+ - adds the lock keys without the lock index entry (keys of older RQL versions and keys created with the disabled lock index)
2044
+ to the lock index (see [Lock Index](#lock-index));
2045
+ - invoke it once after the lock index is enabled (`config['lock_index_enabled'] = true`) on **all** your processes: with the
2046
+ enabled lock index all lock-iterating operations (the swarm, `#locks`, `#queues`, `#keys`, `#clear_locks`, `#clear_locks_of`,
2047
+ `#clear_dead_requests`, ...) find locks via the lock index only;
2048
+ - works regardless of `config['lock_index_enabled']` (it is not needed while the lock index is disabled: the lock index is
2049
+ neither used nor maintained then);
2050
+ - one `SCAN` pass over the RQL keys (`rql:lock*`) with pipelined batches (two round trips per batch);
2051
+ - write locks and read locks registries are indexed with their remaining TTL (+ the grace period), lock queues without TTL
2052
+ (older RQL versions) get the `queue_ttl` TTL first; read lock data is not indexed (it is found via the read locks registry);
2053
+ - idempotent: the lock index scores are never decreased (it can be invoked at any time);
2054
+ - accepts:
2055
+ - `:scan_size` - (optional) `[Integer]`
2056
+ - the batch of scanned keys for Redis'es SCAN command;
2057
+ - pre-configured in `config['lock_release_batch_size']`;
2058
+ - `:queue_ttl` - (optional) `[Integer]`
2059
+ - TTL of the lock queues without TTL (in seconds);
2060
+ - pre-configured in `config['default_queue_ttl']`;
2061
+ - `:lock_index_grace_period` - (optional) `[Integer]`
2062
+ - the lock index grace period (in milliseconds);
2063
+ - pre-configured in `config['lock_index_grace_period']`;
2064
+ - returns `{ ok: true, result: { idx_key_cnt: Integer } }` (the number of indexed keys);
2065
+
2066
+ ```ruby
2067
+ rql.reindex_locks
2068
+
2069
+ # =>
2070
+ { ok: true, result: { idx_key_cnt: 123 } }
2071
+ ```
2072
+
2073
+ ---
2074
+
1882
2075
  #### #current_acquirer_id
1883
2076
 
1884
2077
  <sup>\[[back to top](#usage)\]</sup>
@@ -2005,6 +2198,12 @@ rql.possible_host_ids
2005
2198
 
2006
2199
  **This documentation section is in progress!** (see the changelog and the usage preview for details at this moment)
2007
2200
 
2201
+ - zombie locks and zombie lock requests are found in one `SCAN` pass over the RQL keys (`rql:lock*`) or, with the enabled
2202
+ [Lock Index](#lock-index) (`config['lock_index_enabled']`), in one `ZSCAN` pass over the lock names: the swarm never scans
2203
+ the whole Redis keyspace then (keys of your other applications are never visited);
2204
+ - after enabling the lock index invoke [#reindex_locks](#reindex_locks) once (when all your processes use the lock index):
2205
+ locks created without the lock index are not indexed and are not found by the swarm until then;
2206
+
2008
2207
  [(work and usage preview (temporary example-based docs))](#work-and-usage-preview-temporary-example-based-docs)
2009
2208
 
2010
2209
  - [How to Swarm](#how-to-swarm)
@@ -2052,8 +2251,8 @@ rql.possible_host_ids
2052
2251
  config['swarm.flush_zombies.enabled_for_swarm'] = true # NOTE: run zombie flushing or not
2053
2252
  config['swarm.flush_zombies.zombie_flush_period'] = 10 # NOTE: (in seconds) period of time when the zombie flusher is triggered
2054
2253
  config['swarm.flush_zombies.zombie_ttl'] = 15_000 # NOTE: (in milliseconds) when the lock/host/acquirer is considered a zombie
2055
- config['swarm.flush_zombies.zombie_lock_scan_size'] = 500 # NOTE: scan sizec during zombie flushing
2056
- config['swarm.flush_zombies.zombie_queue_scan_size'] = 500 # NOTE: scan sizec during zombie flushing
2254
+ config['swarm.flush_zombies.zombie_lock_scan_size'] = 500 # NOTE: SCAN count (or ZSCAN count with the enabled lock index) and the batch size of locks during zombie flushing
2255
+ config['swarm.flush_zombies.zombie_queue_scan_size'] = 500 # NOTE: the batch size of lock queues during zombie flushing (one round trip per batch)
2057
2256
  # (flush_zombies) individual redis config
2058
2257
  config['swarm.flush_zombies.redis_config.sentinel'] = false # NOTE: individual redis config
2059
2258
  config['swarm.flush_zombies.redis_config.pooled'] = false # NOTE: individual redis config
@@ -2188,6 +2387,120 @@ rql.possible_host_ids
2188
2387
  ```
2189
2388
  </details>
2190
2389
 
2390
+ <details>
2391
+ <summary>lock index key in Redis (the enabled lock index only)</summary>
2392
+
2393
+ ```ruby
2394
+ "rql:index:locks"
2395
+ ```
2396
+ </details>
2397
+
2398
+ ---
2399
+
2400
+ ## Lock Index
2401
+
2402
+ <sup>\[[back to top](#table-of-contents)\]</sup>
2403
+
2404
+ > Find locks without scanning the whole Redis keyspace (opt-in: `config['lock_index_enabled'] = true`).
2405
+
2406
+ > [!IMPORTANT]
2407
+ > Your locks should work either **always with** the lock index or **always without** it (the same
2408
+ > `config['lock_index_enabled']` value on all your processes that share the Redis database): locks obtained without the lock
2409
+ > index are not indexed and are invisible to the lock-iterating operations of the processes with the enabled lock index
2410
+ > (the swarm, `#locks`, `#queues`, `#keys`, `#clear_locks`, ...). So every time you enable the lock index after working without it
2411
+ > (including the upgrade from older RQL versions) invoke [#reindex_locks](#reindex_locks) once all your processes use the lock index.
2412
+
2413
+ - **disabled by default**: lock-iterating operations scan the Redis keyspace (`SCAN MATCH rql:lock*`, the classic way),
2414
+ no lock index commands are sent and lock request queues have no TTL; enable the lock index via config:
2415
+
2416
+ ```ruby
2417
+ RedisQueuedLocks::Client.new(redis_client) do |config|
2418
+ config['lock_index_enabled'] = true
2419
+ # (milliseconds) (default: 60_000)
2420
+ # - the lock name stays in the lock index for this period after the expiration of all its keys;
2421
+ # - why: the lock index scores use the client clock, so this margin protects the index from the clock skew
2422
+ # between your hosts and from the network latency (otherwise a live lock can drop out of the index and
2423
+ # the swarm/lock-iterating operations will not see it);
2424
+ # - keep it greater than the maximal clock skew between your hosts plus the network latency;
2425
+ config['lock_index_grace_period'] = 60_000
2426
+ end
2427
+ ```
2428
+ - the client-wide mode (not a per-call option): the lock acquirement maintains the lock index and all lock-iterating operations
2429
+ read it only when it is enabled; use the same mode on **all** your processes that share the Redis database (locks of
2430
+ the processes without the lock index are not indexed: lock-iterating operations of the processes with the lock index do not
2431
+ see them); the swarm reads the mode when it is started;
2432
+ - the lock index is a sorted set `rql:index:locks`: the member is the lock name, the score is the upper bound of the lifetime
2433
+ of all lock keys (write lock, read locks registry, lock request queues): `client time (epoch, ms) + key ttl + grace period`;
2434
+ - all lock-iterating operations iterate over the lock index (`ZSCAN`) and derive lock keys from the lock names instead of
2435
+ the full keyspace `SCAN`: their cost depends on the number of your locks only (not on the size of your Redis database):
2436
+ - the swarm: `#flush_zombies`, the zombie flusher, `#zombie_locks`, `#zombie_acquirers`, `#zombies_info`;
2437
+ - `#locks`/`#locks_info`, `#queues`/`#queues_info`, `#keys`, `#clear_locks`, `#clear_locks_of`/`#clear_current_locks`,
2438
+ `#clear_dead_requests`;
2439
+ - only [#reindex_locks](#reindex_locks) scans the keyspace (it adds the keys without the lock index entries to the lock index);
2440
+ - the index is maintained by the lock acquirement itself (no background jobs, no additional Redis round trips):
2441
+ - each lock request adds the lock name to the index (`ZADD GT` with the queue ttl), sets the lock request queue TTL
2442
+ (the queue lives as long as its latest lock request) and drops the expired lock names from the index (`ZREMRANGEBYSCORE`):
2443
+ 4 commands in the existing queueing pipeline;
2444
+ - each lock obtaining (write and read) adds the lock name with the lock ttl in the same transaction (1 command);
2445
+ - each lock extension (`#extend_lock_ttl`, `:extendable_work_through` reentrant locks) increases the score by the extension;
2446
+ - the lock name is dropped from the index when all its keys are expired and the grace period is over, by `#unlock` (in the
2447
+ release transaction) and by `#clear_locks` (in the transaction that deletes the keys of the lock);
2448
+ - `config['lock_index_grace_period']` (`60_000` milliseconds by default) and `lock_index_grace_period:` option of
2449
+ `#lock`/`#lock!`/`#lock_series`/`#lock_series!`: the lock name stays in the index for this period after the expiration of
2450
+ all its keys; it should be greater than the maximal client clock skew between your hosts plus the duration of one lock
2451
+ acquirement attempt (network latency) (otherwise a lock can be dropped from the index before its expiration and the swarm
2452
+ will not see it);
2453
+ - (enabling) locks of older RQL versions and locks created with the disabled lock index have no lock index entries (they are not
2454
+ found by lock-iterating operations till then): enable the lock index on **all** your processes, then invoke
2455
+ [#reindex_locks](#reindex_locks) once;
2456
+ - (disabling) lock-iterating operations scan the keyspace again; the lock index key is neither used nor maintained anymore
2457
+ (you can delete it: `DEL rql:index:locks`); invoke [#reindex_locks](#reindex_locks) again after the next enabling;
2458
+ - (limitation) the lock index is one Redis key written on each lock request (a "hot" key): fine for a standalone Redis
2459
+ (and Sentinel); Redis Cluster is not supported (RQL transactions use several keys of each lock without hash tags);
2460
+
2461
+ #### Lock Index Performance
2462
+
2463
+ <sup>\[[back to top](#lock-index)\]</sup>
2464
+
2465
+ - measured on a local Redis with 1 million keys of other applications in the same database and ~1 200 locks (+100 lock queues
2466
+ for the lock-iterating operations, a zombie host for `#flush_zombies`): wall time and Redis CPU time of one call with
2467
+ the keyspace `SCAN` (`config['lock_index_enabled'] = false`) and with the lock index (`config['lock_index_enabled'] = true`):
2468
+
2469
+ | Operation | Keyspace `SCAN` (wall / Redis CPU) | Lock index (wall / Redis CPU) | Speedup (wall / Redis CPU) |
2470
+ |---|---|---|---|
2471
+ | `#flush_zombies` (the zombie flusher) | 263 ms / 181 ms | 20 ms / 2.4 ms | **x13** / **x75** |
2472
+ | `#locks` | 269 ms / 192 ms | 7.7 ms / 1.4 ms | **x35** / **x137** |
2473
+ | `#locks_info` | 288 ms / 194 ms | 23 ms / 2.3 ms | **x12.5** / **x84** |
2474
+ | `#queues` / `#queues_info` | 260-280 ms / 190 ms | 8 ms / 1-2 ms | **x32-35** / **x95-190** |
2475
+ | `#keys` | 272 ms / 193 ms | 17 ms / 2.6 ms | **x16** / **x74** |
2476
+ | `#clear_dead_requests` | 574 ms / 224 ms | 18 ms / 2.2 ms | **x32** / **x102** |
2477
+ | `#clear_locks_of` / `#clear_current_locks` | 322 ms / 200 ms | 14 ms / 1.9 ms | **x23** / **x105** |
2478
+ | `#clear_locks` | 574 ms / 225 ms | 14 ms / 5.1 ms | **x41** / **x44** |
2479
+
2480
+ - the speedup grows with the number of keys in your Redis database: the keyspace `SCAN` visits all keys of the database (keys
2481
+ of your other applications too), the lock index visits the lock names only (the cost depends on the number of your locks and
2482
+ on the lock index size, see [Lock Index Size](#lock-index-size));
2483
+ - the zombie flusher runs in every process with the swarm (every `zombie_flush_period`): the saved Redis CPU is multiplied by
2484
+ the number of your processes;
2485
+ - the price is paid by the lock acquirement (it maintains the lock index without new round trips: 4 more commands in
2486
+ the queueing pipeline and 1 more command in the obtaining transaction): ~10% fewer locks per second in a single-thread
2487
+ `lock {}` loop on a local Redis, 280 -> 348 allocations per `lock {}`;
2488
+
2489
+ #### Lock Index Size
2490
+
2491
+ <sup>\[[back to top](#lock-index)\]</sup>
2492
+
2493
+ - the lock index size is about `<the number of distinct lock names per second> * (max(queue_ttl, ttl) + grace period)`
2494
+ (`~75` seconds of lock names with the default settings: `config['default_queue_ttl']` (15 seconds) +
2495
+ `config['lock_index_grace_period']` (60 seconds));
2496
+ - with very diverse lock names (for example, `order:#{id}`) lock-iterating operations (the swarm, `#locks`, `#queues`, `#keys`,
2497
+ `#clear_locks`, ...) visit the dead lock names of this window too (lock names whose keys are already expired);
2498
+ - if it becomes noticeable, there are two levers:
2499
+ - reduce `config['lock_index_grace_period']` (keep it greater than the maximal client clock skew between your hosts plus
2500
+ the network latency);
2501
+ - **TODO**: a cheap existence check of the lock name (`EXISTS` of its keys) before reading the lock details in
2502
+ lock-iterating operations;
2503
+
2191
2504
  ---
2192
2505
 
2193
2506
  ## Lock Access Strategies
@@ -2235,10 +2548,11 @@ rql.lock('report') { rebuild_report } # (write mode is the default mode)
2235
2548
  - `ttl` should be a positive `Integer` (`RedisQueuedLocks::ArgumentError`);
2236
2549
  - each read lock has its own data with the same structure as the write lock data: acquirer, host, timestamp,
2237
2550
  initial ttl, `:meta` (with the same reserved keys) and reentrant lock data (`spc_cnt`, `spc_ext_ttl`, ...);
2238
- the read lock data lives as long as the read lock and is returned by `#lock_info`/`#locks_info` for each reader;
2551
+ the read lock data lives as long as the read lock and is returned by `#lock_info`/`#locks_info` for each reader
2552
+ (acquirer and host are not stored: both are derived from the acquirer id);
2239
2553
  - `#unlock` releases the lock entirely: the write lock, all read locks (with their data) and all lock requests;
2240
- - `#unlock_read` (aka `#release_read_lock`) releases the read lock of the current acquirer only
2241
- (suitable for read locks obtained without a block);
2554
+ - `#unlock_read` (aka `#release_read_lock`) releases the read lock of the current acquirer
2555
+ (suitable for read locks obtained without a block), or the read lock of the acquirer passed via `acquirer_id:`;
2242
2556
  - `#extend_lock_ttl(lock_name, milliseconds, read_write_mode: :read)` extends the read lock of the current acquirer only;
2243
2557
  `all_read_locks: true` extends all live read locks of the lock;
2244
2558
  - `#lock_series` with `read_write_mode: :read` obtains a series of read locks (the mode is the same for all locks of the series):
@@ -2284,7 +2598,7 @@ rql.lock('report') { rebuild_report } # (write mode is the default mode)
2284
2598
  - `rql:lock_queue:<lock_name>` - write lock requests (`ZSET`, the classic RQL lock queue);
2285
2599
  - `rql:lock_readers:<lock_name>` - read locks (`ZSET`: acquirer id => read lock expiration time in Redis time, milliseconds);
2286
2600
  - `rql:lock_read_queue:<lock_name>` - read lock requests (`ZSET`);
2287
- - `rql:lock_reader:<lock_name>:<acquirer_id>` - read lock data (`HASH`, the same structure as the write lock, read lock ttl);
2601
+ - `rql:lock_reader:<lock_name>:<acquirer_id>` - read lock data (`HASH`, the same structure as the write lock without acquirer and host ids, read lock ttl);
2288
2602
  - implementation notes:
2289
2603
  - optimistic transactions only (`WATCH`/`MULTI`): readers watch the write lock key, writers watch the write lock key and the read locks key,
2290
2604
  so readers never invalidate transactions of each other;