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
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'],
@@ -1160,9 +1203,8 @@ rql.lock_info("your_lock_name")
1160
1203
 
1161
1204
  Returns an information about the required lock queue by the lock name. The result
1162
1205
  represnts the ordered lock request queue that is ordered by score (Redis Sets) and shows
1163
- lock acquirers and their position in queue. Async nature with redis communcation can lead
1164
- the situation when the queue becomes empty during the queue data extraction. So sometimes
1165
- 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.
1166
1208
 
1167
1209
  - get the lock queue information;
1168
1210
  - queue represents the ordered set of lock key reqests:
@@ -1232,6 +1274,7 @@ rql.queued?("your_lock_name") # => true/false
1232
1274
 
1233
1275
  - release the concrete lock with lock request queue;
1234
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;
1235
1278
  - has an alias: `#release_lock`;
1236
1279
  - accepts:
1237
1280
  - `lock_name` - (required) `[String]` - the lock name that should be released.
@@ -1364,11 +1407,17 @@ rql.unlock_read('report', acquirer_id: reader['acq_id'])
1364
1407
  <sup>\[[back to top](#usage)\]</sup>
1365
1408
 
1366
1409
  - release all obtained locks and related lock request queues;
1367
- - 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));
1368
1415
  - has an alias: `#release_locks`;
1369
1416
  - accepts:
1370
1417
  - `:batch_size` - (optional) `[Integer]`
1371
- - 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);
1372
1421
  - pre-configured in `config['lock_release_batch_size']`;
1373
1422
  - `:logger` - (optional) `[::Logger,#debug]`
1374
1423
  - custom logger object;
@@ -1445,10 +1494,13 @@ rql.clear_locks
1445
1494
  - `:acquirer_id` - (required) `[String]`
1446
1495
  - acquirer indentifier whose locks we want to release and which we want to remove from all locks queues;
1447
1496
  - `:lock_scan_size` - (optional) `[Integer]`
1448
- - 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);
1449
1500
  - pre-configuerd value in `config['clear_locks_of__lock_scan_size']`;
1450
1501
  - `:queue_scan_size` - (optional) `[Integer]`
1451
- - 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);
1452
1504
  - pre-configuerd value in `config['clear_locks_of__queue_scan_size']`;
1453
1505
  - `:logger` - (optional) `[::Logger,#debug]`
1454
1506
  - custom logger object;
@@ -1550,10 +1602,13 @@ rql.clear_current_locks
1550
1602
  ```
1551
1603
  - produces `"redis_queued_locks.release_locks_of"` instrumentation event;
1552
1604
  - `:lock_scan_size` - (optional) `[Integer]`
1553
- - 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);
1554
1608
  - pre-configuerd value in `config['clear_locks_of__lock_scan_size']`;
1555
1609
  - `:queue_scan_size` - (optional) `[Integer]`
1556
- - 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);
1557
1612
  - pre-configuerd value in `config['clear_locks_of__queue_scan_size']`;
1558
1613
  - `:logger` - (optional) `[::Logger,#debug]`
1559
1614
  - custom logger object;
@@ -1622,6 +1677,8 @@ rql.release_locks_of(host_id: rql.current_host_id, acquirer_id: rql.current_acqu
1622
1677
 
1623
1678
  - extends lock ttl by the required number of milliseconds;
1624
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));
1625
1682
  - accepts:
1626
1683
  - `lock_name` - (required) `[String]`
1627
1684
  - the lock name which ttl should be extended;
@@ -1717,8 +1774,11 @@ rql.extend_lock_ttl("my_lock", 5_000) # NOTE: add 5_000 milliseconds
1717
1774
 
1718
1775
  - get list of obtained locks;
1719
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));
1720
1779
  - accepts:
1721
- - `: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);
1722
1782
  - `:with_info` - `Boolean` - `false` by default (for details see [#locks_info](#locks_info---get-list-of-locks-with-their-info));
1723
1783
  - returns:
1724
1784
  - `Set<String>` (for `with_info: false`);
@@ -1750,8 +1810,11 @@ rql.locks # or rql.locks(scan_size: 123)
1750
1810
 
1751
1811
  - get list of lock request queues;
1752
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));
1753
1815
  - accepts
1754
- - `: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);
1755
1818
  - `:with_info` - `Boolean` - `false` by default (for details see [#queues_info](#queues_info---get-list-of-queues-with-their-info));
1756
1819
  - returns:
1757
1820
  - `Set<String>` (for `with_info: false`);
@@ -1781,10 +1844,15 @@ rql.queues # or rql.queues(scan_size: 123)
1781
1844
 
1782
1845
  <sup>\[[back to top](#usage)\]</sup>
1783
1846
 
1784
- - get list of taken locks and queues;
1785
- - 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));
1786
1853
  - accepts:
1787
- `: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);
1788
1856
  - returns: `Set<String>`
1789
1857
 
1790
1858
  ```ruby
@@ -1816,7 +1884,8 @@ rql.keys # or rql.keys(scan_size: 123)
1816
1884
  <sup>\[[back to top](#usage)\]</sup>
1817
1885
 
1818
1886
  - get list of locks with their info;
1819
- - 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);
1820
1889
  - accepts `scan_size:`/`Integer` option (`config['key_extraction_batch_size']` by default);
1821
1890
  - returns `Set<Hash<Symbol,Any>>` (see [#lock_info](#lock_info) and examples below for details).
1822
1891
  - contained data: `{ lock: String, status: Symbol, info: Hash<String,Any> }`;
@@ -1854,7 +1923,8 @@ rql.locks_info # or rql.locks_info(scan_size: 123)
1854
1923
  <sup>\[[back to top](#usage)\]</sup>
1855
1924
 
1856
1925
  - get list of queues with their info;
1857
- - 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);
1858
1928
  - accepts `scan_size:`/`Integer` option (`config['key_extraction_batch_size']` by default);
1859
1929
  - returns `Set<Hash<Symbol,Any>>` (see [#queue_info](#queue_info) and examples below for details).
1860
1930
  - contained data: `{ queue: String, requests: Array<Hash<String,Any>> }`
@@ -1889,8 +1959,14 @@ before the lock acquire moment occurs and when no any other process does not nee
1889
1959
  For this case your lock reuquest will be cleared only when any process will try
1890
1960
  to acquire this lock again (cuz lock acquirement triggers the removement of expired requests).
1891
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
+
1892
1966
  In order to help with these dead requests you may periodically call `#clear_dead_requests`
1893
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).
1894
1970
 
1895
1971
  `:dead_ttl` option is required because of it is no any **fast** and **resource-free** way to understand which request
1896
1972
  is dead now and is it really dead cuz each request queue can host their requests with
@@ -1902,6 +1978,8 @@ Accepts:
1902
1978
  - has a preconfigured value in `config['dead_request_ttl']` (1 day by default);
1903
1979
  - `:scan_size` - (optional) `[Integer]`
1904
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;
1905
1983
  - has a preconfigured valie in `config['lock_release_batch_size']`;
1906
1984
  - `:logger` - (optional) `[::Logger,#debug]`
1907
1985
  - custom logger object;
@@ -1958,6 +2036,42 @@ rql.clear_dead_requests(dead_ttl: 60 * 60 * 1000) # 1 hour in milliseconds
1958
2036
 
1959
2037
  ---
1960
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
+
1961
2075
  #### #current_acquirer_id
1962
2076
 
1963
2077
  <sup>\[[back to top](#usage)\]</sup>
@@ -2084,6 +2198,12 @@ rql.possible_host_ids
2084
2198
 
2085
2199
  **This documentation section is in progress!** (see the changelog and the usage preview for details at this moment)
2086
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
+
2087
2207
  [(work and usage preview (temporary example-based docs))](#work-and-usage-preview-temporary-example-based-docs)
2088
2208
 
2089
2209
  - [How to Swarm](#how-to-swarm)
@@ -2131,8 +2251,8 @@ rql.possible_host_ids
2131
2251
  config['swarm.flush_zombies.enabled_for_swarm'] = true # NOTE: run zombie flushing or not
2132
2252
  config['swarm.flush_zombies.zombie_flush_period'] = 10 # NOTE: (in seconds) period of time when the zombie flusher is triggered
2133
2253
  config['swarm.flush_zombies.zombie_ttl'] = 15_000 # NOTE: (in milliseconds) when the lock/host/acquirer is considered a zombie
2134
- config['swarm.flush_zombies.zombie_lock_scan_size'] = 500 # NOTE: scan sizec during zombie flushing
2135
- 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)
2136
2256
  # (flush_zombies) individual redis config
2137
2257
  config['swarm.flush_zombies.redis_config.sentinel'] = false # NOTE: individual redis config
2138
2258
  config['swarm.flush_zombies.redis_config.pooled'] = false # NOTE: individual redis config
@@ -2267,6 +2387,120 @@ rql.possible_host_ids
2267
2387
  ```
2268
2388
  </details>
2269
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
+
2270
2504
  ---
2271
2505
 
2272
2506
  ## Lock Access Strategies
@@ -2314,7 +2548,8 @@ rql.lock('report') { rebuild_report } # (write mode is the default mode)
2314
2548
  - `ttl` should be a positive `Integer` (`RedisQueuedLocks::ArgumentError`);
2315
2549
  - each read lock has its own data with the same structure as the write lock data: acquirer, host, timestamp,
2316
2550
  initial ttl, `:meta` (with the same reserved keys) and reentrant lock data (`spc_cnt`, `spc_ext_ttl`, ...);
2317
- 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);
2318
2553
  - `#unlock` releases the lock entirely: the write lock, all read locks (with their data) and all lock requests;
2319
2554
  - `#unlock_read` (aka `#release_read_lock`) releases the read lock of the current acquirer
2320
2555
  (suitable for read locks obtained without a block), or the read lock of the acquirer passed via `acquirer_id:`;
@@ -2363,7 +2598,7 @@ rql.lock('report') { rebuild_report } # (write mode is the default mode)
2363
2598
  - `rql:lock_queue:<lock_name>` - write lock requests (`ZSET`, the classic RQL lock queue);
2364
2599
  - `rql:lock_readers:<lock_name>` - read locks (`ZSET`: acquirer id => read lock expiration time in Redis time, milliseconds);
2365
2600
  - `rql:lock_read_queue:<lock_name>` - read lock requests (`ZSET`);
2366
- - `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);
2367
2602
  - implementation notes:
2368
2603
  - optimistic transactions only (`WATCH`/`MULTI`): readers watch the write lock key, writers watch the write lock key and the read locks key,
2369
2604
  so readers never invalidate transactions of each other;