redis_queued_locks 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +216 -25
  3. data/.claude/rules/acquirer.md +6 -5
  4. data/.claude/rules/arguments.md +2 -2
  5. data/.claude/rules/changelog.md +27 -0
  6. data/.claude/rules/git.md +1 -1
  7. data/.claude/rules/logic.md +10 -12
  8. data/.claude/rules/swarm.md +3 -4
  9. data/.claude/rules/tests.md +6 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +5 -5
  12. data/.claude/skills/rql-code-review/SKILL.md +112 -35
  13. data/CHANGELOG.md +98 -244
  14. data/CLAUDE.md +9 -8
  15. data/README.md +275 -85
  16. data/Rakefile +11 -0
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +28 -12
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +38 -16
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +430 -63
  21. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +72 -22
  22. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +240 -45
  23. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +3 -1
  24. data/lib/redis_queued_locks/acquirer/keys.rb +6 -2
  25. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +10 -5
  26. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -9
  27. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +52 -23
  28. data/lib/redis_queued_locks/acquirer/locks.rb +6 -2
  29. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -7
  30. data/lib/redis_queued_locks/acquirer/queues.rb +17 -6
  31. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +6 -2
  32. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +6 -2
  33. data/lib/redis_queued_locks/client.rb +66 -24
  34. data/lib/redis_queued_locks/config.rb +93 -1
  35. data/lib/redis_queued_locks/resource.rb +124 -9
  36. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +6 -5
  37. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  38. data/lib/redis_queued_locks/version.rb +2 -2
  39. data/rbs_collection.lock.yaml +4 -0
  40. data/rbs_collection.yaml +2 -1
  41. data/sig/manifest.yml +1 -0
  42. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  43. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  44. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +12 -6
  45. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +21 -14
  46. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  47. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  48. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  49. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +2 -2
  50. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +5 -3
  51. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +14 -1
  52. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  53. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  54. data/sig/redis_queued_locks/client.rbs +10 -7
  55. data/sig/redis_queued_locks/config.rbs +54 -1
  56. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -1
  57. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  58. data/sig/redis_queued_locks/logging/sampler.rbs +3 -1
  59. data/sig/redis_queued_locks/resource.rbs +22 -12
  60. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  61. data/sig/redis_queued_locks/utilities.rbs +2 -1
  62. data/sig/redis_queued_locks.rbs +7 -0
  63. metadata +4 -3
  64. /data/sig/redis_queued_locks/{acquier.rbs → acquirer.rbs} +0 -0
data/CHANGELOG.md CHANGED
@@ -1,266 +1,120 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.22.0] - 2026-10-27
4
+ ### Added
5
+ - *Brand New Feature*: **Lock Request Prioritization**: the `priority:` option of `#lock`/`#lock!`/`#lock_series`/`#lock_series!`
6
+ (`-100..100`, `0` by default) orders the waiting `:queued` lock requests of both modes: strict priority, FIFO inside
7
+ a priority; the obtained lock is never taken away (see README "Lock Request Prioritization"):
8
+ - `config['default_lock_priority']`: the default priority of the client;
9
+ - priority names: `config['lock_priorities']` (`:lowest`, `:low`, `:normal`, `:high`, `:highest` by default, custom names
10
+ allowed), accepted by `priority:` and `config['default_lock_priority']`;
11
+ - `#clear_dead_requests`: drops the dead lock requests of all priorities; the new `by_priority:` option reads only the first
12
+ lock request of each priority (long lock queues with few priorities);
13
+ - the new `log_lock_try` log `[redis_queued_locks.try_lock.dead_request_removed]`;
14
+ - upgrade all processes that share locks before using positive priorities: older versions drop such lock requests as dead
15
+ ones (the order breaks, the mutual exclusion is not affected);
16
+ ### Changed
17
+ - `#queue_info`/`#queues_info`: each lock request has the `"priority"` field;
18
+ - the lock acquirement logs and instrumentation events carry the Integer `priority`, the try-lock logs that name another lock
19
+ request carry its priority (see README `## Logging` / `### Instrumentation Events`);
20
+ - RBS: `RedisQueuedLocks::Config#[]` is typed per config key (unknown keys are type errors, dynamic `String` keys no longer
21
+ type-check);
22
+ - RBS: `read_write_mode`, `access_strategy` and `conflict_strategy` of `Client` are typed as literal unions;
23
+ ### Fixed
24
+ - a lock request requeued after waiting longer than its `queue_ttl` could lose its next attempt (a spent retry);
25
+ - the enabled lock index: locks without TTL adopted by `#reindex_locks` were skipped by all lock-iterating operations;
26
+
3
27
  ## [1.21.0] - 2026-10-13
4
28
  ### Added
5
- - Logging and instrumentation of all operations that change Redis keys (the swarm is not covered yet):
6
- - the lock acquirement: the new `redis_queued_locks.lock_obtaining_failed` instrumentation event and log
7
- (`#lock`/`#lock!` and each lock of `#lock_series`/`#lock_series!`: the lock is not obtained, the failure `result`
8
- (`:timeout_reached`, `:retry_limit_reached`, `:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`,
9
- `:conflict_lock_upgrade`) is in the payload; with `raise_errors: true` it is reported before the error is raised);
10
- - each lock mutation operation is reported with one log line and one instrumentation event with the time of the whole
11
- operation (the same name and data; the payload ends with `instrument`): the new `redis_queued_locks.extend_lock_ttl`
12
- (`#extend_lock_ttl`), `redis_queued_locks.clear_dead_requests` (`#clear_dead_requests`), `redis_queued_locks.reindex_locks`
13
- (`#reindex_locks`) events, and the new log lines of the existing `explicit_lock_release` (`#unlock`),
14
- `explicit_read_lock_release` (`#unlock_read`), `explicit_all_locks_release` (`#clear_locks`), `release_locks_of`
15
- (`#clear_locks_of`/`#clear_current_locks`) events (the `logger:` option of these methods was not used);
29
+ - Logging and instrumentation of all operations that change Redis keys (the swarm is not covered yet; see README `## Logging` /
30
+ `### Instrumentation Events`):
31
+ - the `redis_queued_locks.lock_obtaining_failed` event and log (with the failure `result`);
32
+ - the `redis_queued_locks.extend_lock_ttl`, `redis_queued_locks.clear_dead_requests` and `redis_queued_locks.reindex_locks`
33
+ events and logs;
34
+ - logs of the existing `explicit_lock_release`, `explicit_read_lock_release`, `explicit_all_locks_release` and
35
+ `release_locks_of` events;
16
36
  - `#reindex_locks`: `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling options;
17
37
  ### Changed
18
- - stricter validation (the details are in "Fixed"): invalid values raise `RedisQueuedLocks::ArgumentError`
19
- (`RedisQueuedLocks::SwarmArgumentError` for the swarm methods, `RedisQueuedLocks::ConfigValidationError` for the config)
20
- instead of the previous silent misbehavior:
21
- - `ttl` of `#lock`/`#lock!`/`#lock_series`/`#lock_series!` should be a positive `Integer` in both modes (`ttl: 0` was
22
- accepted in the write mode);
23
- - `#extend_lock_ttl`: `milliseconds` should be a positive `Integer` (`extend_lock_ttl(name, 0)` was a successful no-op);
24
- - the per-call numeric options should be `Integer`s in the ranges of their config keys: `Float` values (e.g.
25
- `timeout: 0.5`, `retry_delay: 10.0`) are rejected now;
26
- - the config: positive TTLs, periods and batch/scan sizes;
38
+ - stricter validation: invalid values raise `RedisQueuedLocks::ArgumentError` (`RedisQueuedLocks::SwarmArgumentError` for
39
+ the swarm methods, `RedisQueuedLocks::ConfigValidationError` for the config) instead of a silent misbehavior:
40
+ - `ttl` of the lock methods and `milliseconds` of `#extend_lock_ttl`: positive `Integer`s;
41
+ - the per-call numeric options: `Integer`s in the ranges of their config keys (`Float`s like `timeout: 0.5` are rejected);
42
+ - the config: positive `Integer` TTLs, periods and batch/scan sizes;
27
43
  - `:meta`: reserved lock data keys are rejected as Symbol keys too;
28
- - the internal timeout errors of `timed: true` and `timeout` inherit from `RedisQueuedLocks::Exception < ::Exception`
29
- (were `Timeout::Error`s): a `rescue Timeout::Error` (or `rescue => e`) inside the block does not catch the lock timeout
30
- anymore; the public errors/results are the same;
31
- - `#locks_info`: the info of write locks has the `"lock_key"` field (like `#lock_info`);
32
- - instrumentation payloads: `explicit_lock_release`, `explicit_all_locks_release` and `release_locks_of` have `instrument`;
44
+ - the internal timeout errors of `timed: true` and `timeout` are not `StandardError`s anymore: `rescue => e` /
45
+ `rescue Timeout::Error` inside the block does not catch them (the public errors and results are the same);
33
46
  ### Fixed
34
- - the `instrument:` option of `#unlock`, `#clear_locks`, `#clear_locks_of`/`#clear_current_locks` (README: "passed to
35
- the instrumenter's payload") was not passed to the payload (`#unlock` did not even forward it); the payloads of
36
- `explicit_lock_release`, `explicit_all_locks_release` and `release_locks_of` have `instrument` now; `#extend_lock_ttl` and
37
- `#clear_dead_requests` had no instrumentation events at all (see "Added");
38
- - `#lock`/`#lock!` with a non-positive `ttl` in the write mode: the lock was reported as obtained, but `PEXPIRE` with
39
- a non-positive ttl deleted the lock key in the obtaining transaction, so other acquirers could obtain the same lock
40
- at the same time (no mutual exclusion); `ttl` should be a positive `Integer` in both modes now
41
- (`RedisQueuedLocks::ArgumentError`, previously validated for read locks only); `ttl: nil` was never supported
42
- (the YARD docs described it as "without timeout");
43
- - `#lock_series`/`#lock_series!` (PoC) with a non-positive `ttl` (both modes): the locks of the series were obtained with
44
- the ttl made of the retry delay and jitter only; the series `ttl` should be a positive `Integer` now
45
- (`RedisQueuedLocks::ArgumentError`);
46
- - `config['default_lock_ttl']` should be a positive `Integer` (`RedisQueuedLocks::ConfigValidationError`);
47
- - the config validation accepted zero and negative TTLs, periods and batch/scan sizes: `config['default_queue_ttl'] = 0`
48
- dropped the own lock request in the queueing pipeline (endless `:dead_score_reached`), zero batch/scan sizes failed
49
- `SCAN`/`ZSCAN` (`COUNT 0`: `ERR syntax error`) and `each_slice(0)`, zero/negative swarm periods killed the swarm main loops
50
- and the supervisor (`sleep` with a negative value) or made them busy loops, a non-positive `zombie_ttl` made all hosts
51
- zombies; these keys should be positive `Integer`s now (`RedisQueuedLocks::ConfigValidationError`): `default_queue_ttl`,
52
- `lock_release_batch_size`, `clear_locks_of__lock_scan_size`, `clear_locks_of__queue_scan_size`, `key_extraction_batch_size`,
53
- `swarm.supervisor.liveness_probing_period`, `swarm.probe_hosts.probe_period`, `swarm.flush_zombies.zombie_ttl`,
54
- `swarm.flush_zombies.zombie_flush_period`, `swarm.flush_zombies.zombie_lock_scan_size`,
55
- `swarm.flush_zombies.zombie_queue_scan_size`;
56
- - `#extend_lock_ttl` with a non-positive `milliseconds` decreased the lock TTL (or deleted the lock) and reported a successful
57
- extension: `milliseconds` should be a positive `Integer` now (`RedisQueuedLocks::ArgumentError`);
58
- - `#lock`/`#lock!`/`#lock_series`/`#lock_series!` (both modes): reserved lock data keys were rejected in `:meta` only as
59
- String keys, while Symbol keys (`meta: { acq_id: '...' }`) passed the validation and overwrote the lock data fields
60
- (`acq_id`, `hst_id`, `ts`, `ini_ttl`, `spc_*`, ...: reentrant locks, owner checks of lock releases and lock info were
61
- broken while the lock lived); Symbol keys are validated too now (`RedisQueuedLocks::ArgumentError`);
62
- - `timed: true` and `timeout`: the internal timeout errors inherited from `Timeout::Error` (a `StandardError`), so `rescue => e`,
63
- `rescue StandardError`, `rescue Timeout::Error` and `rescue nil` of the interrupted code swallowed them: a `timed` block with
64
- such a `rescue` continued after the lock TTL (another acquirer could obtain the expired lock, and the write lock release
65
- after the block deleted the lock of that acquirer), and the acquirement timeout that interrupted a logger/instrumenter
66
- call (`rescue nil` of the log/instrumentation events) was lost (with `retry_count: nil` the acquirement waited without
67
- the timeout); the internal timeouts inherit from the new private base `RedisQueuedLocks::Exception < ::Exception` now
68
- (like `Timeout::ExitException`): only `rescue Exception` catches them, `ensure` still runs, the public errors/results
69
- are the same (`RedisQueuedLocks::TimedLockTimeoutError`, `RedisQueuedLocks::LockAcquirementTimeoutError`,
70
- `:timeout_reached`); a connection interrupted inside a `WATCH` block is closed (redis-client sends `UNWATCH` only on
71
- `StandardError`s: the next transaction on the connection would be aborted);
72
- - `#lock_series`/`#lock_series!` (PoC): the log events and the `redis_queued_locks.lock_series_hold_and_release`
73
- instrumentation event raised the errors of the logger/instrumenter: a failing logger broke the series call (after
74
- obtaining all the locks of the series the locks were not released till their ttl), a failing instrumenter replaced
75
- the block result with its error; the logger/instrumenter errors are swallowed now (like in all other events);
76
- - `#locks_info` (and `#locks(with_info: true)`): the info of write locks had no `"lock_key"` field (the info of read locks and
77
- `#lock_info` have it), so the info shape depended on the lock mode; the write lock info has `"lock_key"` now (the same
78
- fields as `#lock_info`);
79
- - `RedisQueuedLocks::Logging::Sampler` and `RedisQueuedLocks::Instrument::Sampler` (the default log/instrumentation samplers)
80
- sampled `sampling_percent` of 101 values (`SecureRandom.rand(0..100)`): `0%` sampled ~1% of the cases (instead of none),
81
- `15%` sampled ~15.8%; they sample exactly `sampling_percent` of 100 values now (`0%` - never, `100%` - always);
82
- the private `SAMPLING_PERCENT_RANGE` constants are removed;
83
- - swarm: the Redis client builder of the swarm elements mutated the `swarm.*.redis_config.config`/`pool_config` hashes of
84
- the client config (`transform_keys!`): with frozen hashes each start of the host probing and zombie flushing main loops
85
- failed with `FrozenError` (the hosts were not probed, zombie locks were not flushed); the hashes are not mutated now;
86
- - RBS: the `RedisQueuedLocks::Client#current_acq_id` alias was declared as `ccurrent_acq_id` (a typo: the public method was
87
- missing in the signatures); `RedisQueuedLocks::Client#queues_info` returned the `queues` union (`queueList | queuesInfo`)
88
- instead of `queuesInfo`;
89
- - `RedisQueuedLocks::Resource.lock_key_from_queue` (a private debug helper for manual checks in a console, not used by
90
- the library) returned a wrong key for the read lock request queues (`rql:lock:ueue:<name>`); it supports the write and
91
- read lock request queues now and returns `nil` for other keys;
92
- - a connection interrupted inside a `WATCH` block by a `Timeout.timeout` of your code without an exception class
93
- (`Timeout::ExitException`, e.g. `Timeout.timeout(5) { rql.lock("x") { ... } }`) returned to the connection pool with
94
- the active `WATCH` (redis-client sends `UNWATCH` only on `StandardError`s): the next transaction on the connection could
95
- be aborted (e.g. a lock release); the connection is closed now (like with the internal timeouts);
96
- - the per-call numeric options were not validated (zero/negative values broke the operations like the same config values
97
- before the config validation fix): they are validated with the same ranges as their config keys now
98
- (`RedisQueuedLocks::ArgumentError`; `RedisQueuedLocks::SwarmArgumentError` for the swarm methods), before any Redis
99
- command; the values should be `Integer`s (like the config values: `Float` values such as `timeout: 0.5` or
100
- `retry_delay: 10.0` are rejected now):
101
- - `#lock`/`#lock!`/`#lock_series`/`#lock_series!`: `queue_ttl` > 0, `timeout` and `retry_count` - `nil` or >= 0,
102
- `retry_delay`, `retry_jitter` and `lock_index_grace_period` >= 0;
103
- - `#locks`, `#locks_info`, `#queues`, `#queues_info`, `#keys`, `#clear_dead_requests`, `#reindex_locks`: `scan_size` > 0;
104
- `#clear_locks`: `batch_size` > 0; `#clear_locks_of`/`#clear_current_locks`: `lock_scan_size`, `queue_scan_size` > 0;
105
- `#clear_dead_requests`: `dead_ttl` > 0; `#reindex_locks`: `queue_ttl` > 0, `lock_index_grace_period` >= 0;
106
- - `#swarm_info`, `#flush_zombies`, `#zombie_locks`, `#zombie_acquirers`, `#zombie_hosts`, `#zombies_info`: `zombie_ttl`
107
- and the scan sizes > 0;
108
- - README/YARD documentation that did not match the code:
109
- - config keys: `config['instr_sampling_enabled']` in the configuration example (was `nstr_sampling_enabled`),
110
- `config['log_sampling_enabled']` (was `log_samplign_enabled`), `*_sampling_enabled` (was `*_sampling_enalbed`);
111
- - `#lock` results: `:retry_limit_reached` (was `:retry_count_reached`), `:conflict_lock_upgrade` is listed, `:unknown`
112
- is not returned, `:dead_locking` is not a `:process` value (it fails with `:conflict_dead_lock`), the result has `hst_id`;
113
- - `#lock_info`: `"ini_ttl"` (was `"init_ttl"`), `"spc_cnt"` (was `"spc_count"`), host ids `rql:hst:...` in the examples;
114
- - `#unlock`: `rel_time` is in milliseconds (was "in seconds");
115
- - `#clear_dead_requests` returns `{ ok: true, result: { processed_queues: Set<String> } }` (write and read request queues);
116
- - `#extend_lock_ttl`: the write lock TTL is extended atomically by one Lua script, without the lock owner check
117
- (was "non-atomic, two commands");
118
- - the log lists of the `### Configuration` section: `rw_mode`, the key order, the read/write and lock series logs
119
- (the same as in the `## Logging` section);
120
- - the `#lock!` link of the Usage list and the `#lock` link of `#lock_info`, the Usage list and the table of contents: `unlock_read`, `clear_locks_of`,
121
- `clear_current_locks`, `reindex_locks`;
122
- - YARD: the `#clear_current_locks` options (`lock_scan_size`, `queue_scan_size` instead of the missing `batch_size`), the tag
123
- typos (`@sine`, `@since ?.?.?`, `@since 19.0.0`, `@api ppublic`/`pulic`/`pub;ic`); the inert `# steep:ingore` comments
124
- are removed;
125
- - YARD: the `@version` tags of the classes/modules (versions >= 1.17.0) follow the interface changes only (method
126
- signatures, added/removed methods, `extend`/`include`/`prepend`, the superclass), not the behavior changes inside
127
- the methods;
47
+ - write locks with a non-positive `ttl` were reported as obtained without the mutual exclusion;
48
+ - non-positive config and per-call values broke the operations (endless `:dead_score_reached`, failing scans, stopped swarm
49
+ loops, all hosts as zombies, `#extend_lock_ttl` shortening the lock, `#lock_series` locks with a near-zero ttl);
50
+ - Symbol keys of `:meta` overwrote the reserved lock data (broken reentrant locks, owner checks and lock info);
51
+ - a `rescue` inside a `timed` block or in a logger/instrumenter swallowed the lock timeouts: the block could run past
52
+ the lock TTL, the acquirement timeout could be lost;
53
+ - a connection interrupted inside `WATCH` (lock timeouts, `Timeout.timeout` of your code) could abort the next transaction
54
+ on it (e.g. a lock release); it is closed now;
55
+ - the `instrument:` option of `#unlock`, `#clear_locks`, `#clear_locks_of`/`#clear_current_locks` was not passed to the payload;
56
+ - `#lock_series` (PoC): logger/instrumenter errors broke the series call (the locks were kept till their ttl);
57
+ - `#locks_info`: the write lock info had no `"lock_key"` field (unlike `#lock_info`);
58
+ - the default log/instrumentation samplers were off by one percent (`0%` sampled ~1% of the cases);
59
+ - swarm: frozen `swarm.*.redis_config` hashes failed the swarm main loops with `FrozenError`;
60
+ - RBS: the missing `Client#current_acq_id` alias and the wrong `Client#queues_info` return type;
61
+ - README/YARD descriptions that did not match the code (config key names, `#lock` statuses, `#lock_info` fields, results of
62
+ `#unlock`, `#clear_dead_requests` and `#extend_lock_ttl`);
128
63
 
129
64
  ## [1.20.0] - 2026-10-12
130
65
  ### Added
131
- - **Lock index** (opt-in: `config['lock_index_enabled']`, `false` by default: lock-iterating operations scan the keyspace as before):
132
- `rql:index:locks` (`ZSET`: lock name => the upper bound of the lifetime of all lock keys in client time, ms); with the enabled
133
- lock index all lock-iterating operations find locks without the full keyspace `SCAN` (see the "Lock Index" README section):
134
- - the client-wide mode (not a per-call option): the lock acquirement maintains the lock index and lock-iterating operations
135
- read it only when it is enabled; use the same mode on all processes that share the Redis database (enable it everywhere,
136
- then invoke `#reindex_locks` once); the swarm reads the mode when it is started;
137
- - maintained by the lock acquirement itself without new Redis round trips: each lock request adds the lock name (`ZADD GT`
138
- with the queue ttl), sets the request queue TTL and drops the expired lock names (`ZREMRANGEBYSCORE`) in the queueing pipeline
139
- (4 more commands per attempt), each lock obtaining (write and read) adds the lock name with the lock ttl in the same
140
- transaction (1 more command), each extension (`#extend_lock_ttl`, `:extendable_work_through` reentrant locks) increases
141
- the score by the extension (`ZADD XX INCR`; `#extend_lock_ttl` of write locks sends `EVAL` and the index update in one
142
- transaction);
143
- - `#unlock` and `#clear_locks` drop the lock names from the index in the same transaction with the lock keys;
144
- - lock request queues have TTL: the queue lives as long as its latest lock request (`PEXPIRE NX` + `GT` with the queue ttl on
145
- each lock request), queues of dead acquirers expire by themselves;
146
- - lock-iterating operations find locks and lock queues via one `ZSCAN` pass over the lock names (lock keys are derived from
147
- the lock names): the cost depends on the number of locks only, keys of other applications are never visited; locks without
148
- the lock index entry (older RQL versions, the disabled lock index) are not found until `#reindex_locks`:
149
- - swarm (`#flush_zombies`, the zombie flusher, `#zombie_locks`/`#zombie_acquirers`/`#zombies_info`): `zombie_lock_scan_size` is
150
- the `ZSCAN` count and the batch size of locks, `zombie_queue_scan_size` - the batch size of lock queues (both processed during
151
- the same pass); `#flush_zombies` drops the expired lock names from the index;
152
- - `#locks`/`#locks_info`, `#queues`/`#queues_info`, `#keys`: `scan_size` is the `ZSCAN` count and the batch size of locks;
153
- `#keys` returns the existing keys of the lock key families of the indexed locks (other `rql:lock*` keys are not listed);
154
- - `#clear_locks`: the keys of each batch of locks are deleted with one `DEL` in one transaction with the removal of their lock
155
- names from the lock index; `batch_size` is the number of locks;
156
- - `#clear_locks_of`/`#clear_current_locks`: `clear_locks_of__lock_scan_size` is the `ZSCAN` count and the batch size of locks,
157
- `clear_locks_of__queue_scan_size` - the number of lock queues processed in one round trip;
158
- - `#clear_dead_requests`: `scan_size` is the `ZSCAN` count and the number of locks (both lock queues of each lock) processed in
159
- one round trip; the expired lock names are dropped from the index;
160
- - `config['lock_index_grace_period']` (`60_000` ms by default) and `lock_index_grace_period:` option of `#lock`/`#lock!`/
161
- `#lock_series`/`#lock_series!`: the lock name stays in the index for this period after the expiration of all its keys
162
- (the client clock skew and network latency tolerance);
163
- - `#reindex_locks`: adds the lock keys without the lock index entry (keys of older RQL versions and keys created with the disabled
164
- lock index) to the lock index (one `SCAN` pass, lock queues without TTL get the queue ttl); **invoke it once after the lock index
165
- is enabled on all your processes**;
166
- ### Changed
167
- - `#flush_zombies` (and the zombie flusher of the swarm): zombie lock requests are dropped with one `ZREM` per lock queue
168
- (previously one `ZREM` per zombie acquirer and lock queue);
169
- - `#queue_info` / `#queues_info`: lock queues are read without `EXISTS` checks (Redis does not keep empty sorted sets);
170
- - Lock acquirement attempt: read lock requests do not fetch the longest read lock (it is used by write lock requests only),
171
- write lock requests do not build the read lock data key, disabled try-lock logs get a shared frozen hash instead of a new one;
172
- - Lock acquirement attempt: the lock request queueing (`ZADD NX`, expired requests pruning, queue heads) is one pipelined
173
- round trip instead of three; with `:wait_for_lock`/`:dead_locking` conflict strategies (without `fail_fast`) it is sent
174
- in the lock state round trip: `#lock` with a block takes 4 Redis round trips instead of 7, a failed attempt - 3 instead of 6;
175
- - SCAN-based operations (`#clear_locks`, `#clear_locks_of`/`#clear_current_locks`, `#clear_dead_requests`, `#locks`/`#locks_info`,
176
- `#queues`/`#queues_info`, `#flush_zombies`, `#zombie_locks`/`#zombie_acquirers`/`#zombies_info`) make one `SCAN` pass instead
177
- of 2-5 passes (one per key family) and send per-key commands in pipelined batches instead of one round trip per key:
178
- - `#clear_locks` deletes the found keys during the scan with one `DEL` per batch (previously all keys were expired at the end,
179
- lock queues first);
180
- - `#clear_locks_of`/`#clear_current_locks` (`clear_locks_of__queue_scan_size`) and `#flush_zombies` (`zombie_queue_scan_size`):
181
- the queue scan size is the number of lock queues processed in one round trip (queues are found by the same pass as locks);
182
- - `#zombie_locks`/`#zombie_acquirers`/`#zombies_info` do not scan the keyspace when there are no zombie hosts;
183
- - Read lock data (`rql:lock_reader:<lock_name>:<acquirer_id>`) does not store `acq_id`/`hst_id` (both are derived from the acquirer id):
184
- ~40% less memory per read lock (`MEMORY USAGE` 280 -> 168 bytes without `:meta`); `#lock_info`/`#locks_info` report them as before
185
- (the first fields of each reader; read lock data of older versions is supported);
186
- - `#unlock`: one Redis round trip when there are no read locks (the readers registry is read in the release transaction;
187
- the read lock data is dropped with one more `DEL` only when read locks existed);
188
- - `#lock_series` (PoC): write locks of the series are released (compare-and-delete) in one transaction for the whole series
189
- (three round trips per series instead of three round trips per lock);
190
- - `#flush_zombies` (and the zombie flusher of the swarm): requests of zombie hosts are dropped from the lock queues by the host
191
- of the request (derived from its acquirer id): requests of dead hosts that hold no locks are dropped too (previously they stayed
192
- until the queue ttl) and their acquirers are reported in `deleted_zombie_acquirers`;
193
- - Lock acquirement: retries and `fail_fast` attempts pre-check the lock state without `WATCH` (one round trip): a retry that can not
194
- obtain the lock takes 1 Redis round trip instead of 3 and a `fail_fast` attempt on an obtained lock - 1 instead of 3 (an attempt
195
- that can obtain the lock continues with `WATCH`/`MULTI`: one more round trip); mutual exclusion still relies on `WATCH`/`MULTI` only;
66
+ - *Brand New Feature*: **Lock Index** (opt-in: `config['lock_index_enabled']`, `false` by default): lock-iterating operations
67
+ (`#locks`, `#queues`, `#keys`, `#clear_*`, the swarm) find locks through the `rql:index:locks` index instead of the keyspace
68
+ `SCAN` (see README "Lock Index"):
69
+ - a client-wide mode: use the same mode on all processes that share the Redis database;
70
+ - `config['lock_index_grace_period']` and the `lock_index_grace_period:` lock option (`60_000` ms by default): the clock
71
+ skew tolerance of the index;
72
+ - lock request queues have TTL in the enabled mode (queues of dead acquirers expire by themselves);
73
+ - `#reindex_locks`: adds the existing lock keys to the lock index (keys of older versions and of the disabled lock index
74
+ are not found until then); **invoke it once after the lock index is enabled on all your processes**;
75
+ ### Changed
76
+ - fewer Redis round trips of the lock acquirement: `#lock` with a block takes 4 instead of 7, a failed attempt - 3 instead
77
+ of 6, a failed retry or a `fail_fast` attempt on an obtained lock - 1 instead of 3;
78
+ - `#unlock` (without read locks) and the `#lock_series` release take fewer round trips;
79
+ - SCAN-based operations make one keyspace pass with pipelined batches instead of a pass per key family and a round trip per key;
80
+ - `clear_locks_of__queue_scan_size` and `zombie_queue_scan_size` are the number of lock queues processed in one round trip;
81
+ - read lock data takes ~40% less memory (read locks of older versions are still supported);
82
+ - `#flush_zombies` (and the zombie flusher): lock requests of zombie hosts without locks are dropped too and reported in
83
+ `deleted_zombie_acquirers`;
196
84
 
197
85
  ## [1.19.0] - 2026-10-11
198
86
  ### Added
199
- - `#unlock_read` (aka `#release_read_lock`): an ability to unlock the read lock of any acquirer
200
- - `acquirer_id:` option (`nil` by default: the current acquirer) to release the read lock of a different acquirer
201
- (`identity:` is ignored when `acquirer_id:` is passed: the acquirer id already contains its own identity);
87
+ - `#unlock_read` (aka `#release_read_lock`): the `acquirer_id:` option releases the read lock of another acquirer
88
+ (`nil` by default: the current acquirer);
202
89
 
203
90
  ## [1.18.0] - 2026-10-11
204
91
  ### Added
205
- - Brand new feature - **Read/Write locks**: `read_write_mode:` attribute of `#lock`/`#lock!`/`#lock_series`/`#lock_series!` (`:write` by default):
206
- - `:write` - exclusive lock (the classic RQL lock): waits for the write lock and for all read locks;
207
- - `:read` - shared lock: waits for the write lock only (read locks of different acquirers work in parallel);
208
- - `:queued` access strategy orders read and write lock requests in FIFO between modes (writers are not starved by readers);
209
- - `:random` access strategy ignores lock queues (a continuous flow of readers can starve writers);
210
- - read locks are stored in `rql:lock_readers:<lock_name>` (`ZSET`: acquirer id => expiration time in Redis time),
211
- read lock requests - in `rql:lock_read_queue:<lock_name>`, read lock data - in `rql:lock_reader:<lock_name>:<acquirer_id>`
212
- (the same structure as the write lock: acquirer, host, timestamp, ttl, `:meta`, reentrant lock data);
213
- write locks and write lock requests use the classic keys;
214
- - mutual exclusion is based on optimistic transactions (`WATCH`/`MULTI`, no Lua): readers watch the write lock key,
215
- writers watch the write lock key and the read locks key (readers never invalidate transactions of each other);
216
- - reentrant locks: read lock request under the obtained lock works as a reentrant lock of the obtained lock;
217
- read-to-write lock upgrade fails with `RedisQueuedLocks::ConflictLockObtainError` (`:conflict_lock_upgrade`)
218
- for `:work_through`/`:extendable_work_through` conflict strategies;
219
- - `#unlock`, `#clear_locks`, `#clear_locks_of`, `#clear_current_locks`, `#clear_dead_requests`, `#flush_zombies`,
220
- `#zombie_locks`, `#zombie_acquirers`, `#zombies_info`, `#locked?`, `#queued?`, `#lock_info`, `#queue_info`,
221
- `#locks`, `#locks_info`, `#queues`, `#queues_info` support read locks and read lock requests;
222
- - logs: `rw_mode` in the lock logs and in the lock series logs (`start_lock_series_obtaining`, `lock_series_obtained`,
223
- `expire_lock_series`: the mode of the series); new try-lock log events: `exit__write_request_ahead`, `exit__read_request_ahead`,
224
- `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`;
225
- - instrumentation: `rw_mode` in `lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
226
- `lock_hold_and_release`, `reentrant_lock_hold_completes` payloads and in the lock series payloads
227
- (`lock_series_obtained`, `lock_series_hold_and_release`: the mode of the series);
228
- - `:meta` for read locks (stored in the read lock data of each reader, returned by `#lock_info`/`#locks_info`);
229
- - `#lock_series` with `read_write_mode: :read` (PoC): a series of read locks; only read locks obtained by the series
230
- are released (reentrant read locks of the outer logic are kept);
231
- - `#unlock_read` (aka `#release_read_lock`): releases the read lock of the current acquirer only
232
- (new instrumentation event: `redis_queued_locks.explicit_read_lock_release`);
233
- - `#extend_lock_ttl`: `read_write_mode:` (`:write` by default) and `identity:` options; `:read` mode extends the read lock
234
- of the current acquirer only (an expired read lock or a read lock replaced by a writer is not extended);
235
- - `#extend_lock_ttl`: `all_read_locks:` option (`false` by default, `:read` mode only, ignored in `:write` mode):
236
- extends all live read locks of the lock (expired read locks are not revived);
92
+ - *Brand New Feature*: **Read/Write locks**: `read_write_mode:` of `#lock`/`#lock!`/`#lock_series`/`#lock_series!`
93
+ (`:write` by default; `:read` is a shared lock that waits for the write lock only; see README "Read/Write Locks"):
94
+ - `:queued` orders read and write lock requests in FIFO between modes (`:random` can starve writers);
95
+ - reentrant read locks; a read-to-write upgrade fails with `:conflict_lock_upgrade` for `:work_through` /
96
+ `:extendable_work_through`;
97
+ - all lock operations (releases, cleanups, zombies, lock info, `#lock_series`) and `:meta` support read locks;
98
+ - `#unlock_read` (aka `#release_read_lock`) with the `redis_queued_locks.explicit_read_lock_release` event;
99
+ - `#extend_lock_ttl`: `read_write_mode:`, `identity:` and `all_read_locks:` options;
100
+ - `rw_mode` in the lock logs and instrumentation payloads, `#lock_info`/`#locks_info` and `#queue_info`/`#queues_info`;
101
+ new try-lock logs (see README `## Logging`);
102
+ - `:meta`: `"rw_mode"` is a reserved lock data key;
237
103
  - RBS: `read_write_mode` is typed as `:read | :write`;
238
- - `#queue_info` / `#queues_info`: each lock request has `"rw_mode"` (`"write"` / `"read"`);
239
- - `#lock_info` / `#locks_info`: write lock info has `"rw_mode" => "write"` (read locks info - `"rw_mode" => "read"`);
240
- - `:meta`: `"rw_mode"` is a reserved lock data key (`RedisQueuedLocks::ArgumentError`), for write and read locks and lock series;
241
- - **all processes should use the RQL version with read/write locks** before read locks are used
242
- (write locks of older RQL versions do not check read locks);
243
- ### Changed
244
- - `lock_series_obtained` instrumentation event (`#lock_series`, PoC): `:ts` is an epoch timestamp (`Float`)
245
- like in all other instrumentation events (previously a `Time#to_s` string);
246
- - **Breaking**: `#extend_lock_ttl` success result is `{ ok: true, result: { extended_locks_count: Integer } }`
247
- instead of `{ ok: true, result: :ttl_extended }` (the number of locks whose TTL is extended: `1` for the write lock
248
- and for the own read lock, the number of extended read locks for `all_read_locks: true`);
249
- the failure result is unchanged (`{ ok: false, result: :async_expire_or_no_lock }`);
250
- - Lock acquirement attempt: the lock state is fetched in one pipelined round trip; lock data for
251
- `exit__no_first`/`exit__lock_still_obtained` try-lock logs is extracted only when these logs are enabled
252
- (previously `HGETALL` was called on every unsuccessful attempt);
104
+ - **all processes should use the RQL version with read/write locks** before read locks are used (write locks of older
105
+ versions do not check read locks);
106
+ ### Changed
107
+ - [**Breaking**] `#extend_lock_ttl` success result is `{ ok: true, result: { extended_locks_count: Integer } }` instead of
108
+ `{ ok: true, result: :ttl_extended }`;
109
+ - `lock_series_obtained` instrumentation event (`#lock_series`, PoC): `:ts` is an epoch `Float` (was a `Time#to_s` string);
110
+ - a lock acquirement attempt makes fewer Redis calls;
253
111
  ### Fixed
254
- - `dequeue_from_lock_queue` log: unclosed quote in the `acs_strat` value;
255
- - `extendable_reentrant_lock_obtained` log: the host identifier is logged as `hst_id` (was `host_id`), like in all other logs;
256
- - `start_lock_series_obtaining` log: missing space between the `lock_keys` and `queue_ttl` values;
257
- - `lock_series` PoC:
258
- - locks obtained by the failed series are released when the series fails without an exception
259
- (`raise_errors: false`); previously they lived till their TTL;
260
- - reentrant locks of the series (already obtained by the outer logic of the same acquirer) are not released
261
- by the series anymore (previously the outer logic continued to work without its lock);
262
- - the series releases only write locks that are still owned by the current acquirer (compare-and-delete):
263
- a lock expired during the block and obtained by another acquirer is not deleted anymore;
112
+ - logs: an unclosed quote in `dequeue_from_lock_queue`, a missing space in `start_lock_series_obtaining`, `host_id` instead of
113
+ `hst_id` in `extendable_reentrant_lock_obtained`;
114
+ - `#lock_series` (PoC):
115
+ - a series failed without an exception (`raise_errors: false`) kept its obtained locks till their TTL;
116
+ - the series released the reentrant locks of the outer logic of the same acquirer;
117
+ - the series could delete a lock that expired during the block and was obtained by another acquirer;
264
118
 
265
119
  ## [1.17.0] - 2026-10-04
266
120
  ### Changed
data/CLAUDE.md CHANGED
@@ -4,8 +4,8 @@ Ruby gem: distributed Redis locks with a per-lock FIFO acquisition queue. Ruby >
4
4
  Full details (config keys, key layout, events, errors, stack, CI): `.claude/project-overview.md`.
5
5
 
6
6
  ## Commands
7
- - Tests (need a running local Redis): `bundle exec rake rspec`
8
- - Lint: `bundle exec rake rubocop`
7
+ - Tests (need a running local Redis): `bundle exec rake rspec:parallel` by default (~1 min; `WORKERS=<n>`, default: Redis `databases` / 2 up to the CPU count; each worker flushes its own pair of databases: db 0..2n-1 in total); the serial `bundle exec rake rspec` (~6 min) only when something must be checked in one process (order-dependent failures, a whole-suite `--seed`, leaks between examples, changes of the parallel runner: `tests.md#1`)
8
+ - Lint: `bundle exec rake rubocop` (two passes, Ruby then RBS: both must be clean; never tune `.rubocop.yml` to pass, fix the code)
9
9
  - Static types: `bundle exec rbs collection install && bundle exec rake steep:check`
10
10
 
11
11
  ## Architecture
@@ -13,27 +13,28 @@ Full details (config keys, key layout, events, errors, stack, CI): `.claude/proj
13
13
  - `acquirer/*.rb`: one stateless module per operation (`class << self`), returns `{ ok:, result: }`; `!` client methods raise.
14
14
  - `acquirer/acquire_lock.rb`: composed via `extend` mixins in `acquire_lock/` (try_to_lock, with_acq_timeout, delay_execution, dequeue_from_lock_queue, yield_expire) + `LogVisitor`/`InstrVisitor` event hooks.
15
15
  - Locking: `ZADD NX` into the request queue, then WATCH/MULTI; Lua only for write TTL extension/decrease.
16
- - Read/Write locks (`read_write_mode: :write|:read`): write lock `rql:lock:<name>` + queue `rql:lock_queue:<name>`; readers registry `rql:lock_readers:<name>` (ZSET acq_id => Redis-time expiry), read queue `rql:lock_read_queue:<name>`, per-reader data `rql:lock_reader:<name>:<acq_id>`. Readers WATCH the write key only, writers WATCH the write key + registry; queues give ordering only. Details: overview "Read/Write locks".
16
+ - Lock request priorities (`priority:` -100..100 or a priority name of `config['lock_priorities']`, resolved to its Integer in `AcquireLock` Step 0.1 and before a lock series; `config['default_lock_priority']`): queue score = enqueue time - priority * 2**32 (`Resource.calc_initial_acquirer_position`), strict priority + FIFO inside a priority; dead requests are pruned per priority band (never one `-inf..now-ttl` range): the own band on each queueing, the bands of the dead requests that block the attempt (the write queue head, the earlier opposite-mode request) in one more round trip (`TryToLock#drop_dead_requests_ahead`, band and liveness decoded from the score); the attempt cost never depends on the priority range; every lock acquirement log/event carries the Integer `priority`. Component map: overview "Lock request prioritization in the architecture"; details: overview "Lock request priorities".
17
+ - Read/Write locks (`read_write_mode: :write|:read`): write lock `rql:lock:<name>` + queue `rql:lock_queue:<name>`; readers registry `rql:lock_readers:<name>` (ZSET acq_id => Redis-time expiry), read queue `rql:lock_read_queue:<name>`, per-reader data `rql:lock_reader:<name>:<acq_id>`. Readers WATCH the write key only, writers WATCH the write key + registry; queues give ordering only. Component map: overview "Read/Write locks in the architecture"; details: overview "Read/Write locks".
17
18
  - Lock lookup modes: `config['lock_index_enabled']` (client-wide, `false` by default) is read by `Client`/`Swarm` and passed to every lock-iterating module (swarm, `locks`, `queues`, `keys`, `clear_*`) and to the hot path; each module keeps the keyspace lookup (one `SCAN` pass, the pre-index code) and the lock index lookup (one `ZSCAN` pass) side by side with the shared processing in one copy.
18
19
  - Lock index `rql:index:locks` (enabled mode only; ZSET lock name => upper bound of the lifetime of all lock keys, client ms + ttl + `lock_index_grace_period`): every key-creating command is followed by `ZADD GT` (queueing pipeline, obtaining `MULTI`), every extension by `ZADD XX INCR`, names are removed only with all their keys (`unlock`, `clear_locks`), queues have TTL, expired names are pruned on queueing; lock index commands are conditional tails of their pipelines/transactions (none in the disabled mode); `reindex_locks` (one keyspace `SCAN`, both modes) adopts keys without entries after enabling. Details: overview "Lock index".
19
20
  - Strategies: `access_strategy` `:queued`|`:random`; `conflict_strategy` `:wait_for_lock`|`:work_through`|`:extendable_work_through`|`:dead_locking`.
20
21
  - `resource.rb`: all key names and acquirer/host IDs. `config.rb`: `setting`/`validate` DSL, read as `config['a.b']`.
21
22
  - `swarm/`: zombie-lock cleanup. `Supervisor` thread revives `ProbeHosts` (`SwarmElement::Threaded`) and `FlushZombies` (`SwarmElement::Isolated`, Ractor talking via `Ractor::Port`s); each element runs a main-loop thread with its own Redis connection.
22
23
  - `logging/`, `instrument/`: Void null-object defaults, percent samplers, ActiveSupport adapter.
23
- - `sig/`: RBS mirror of `lib/`. `spec/redis_queued_locks_spec.rb`: single integration spec.
24
+ - `sig/`: RBS mirror of `lib/`. `spec/redis_queued_locks_spec.rb`: single integration spec (+ Redis-free `spec/redis_queued_locks/config_spec.rb`).
24
25
 
25
26
  ## Rules
26
- Detailed, path-scoped rules in `.claude/rules/`: `logic.md` (lib, general), `acquirer.md` (acquirer modules, Redis access), `visitors.md` (log/instrumentation visitors), `arguments.md` (long keyword lists), `swarm.md` (swarm supervisor/elements, Ractor/Thread rules), `tests.md` (spec), `type-checking.md` (sig/Steep), `git.md` (commit messages and commit flow, always loaded).
27
+ Detailed, path-scoped rules in `.claude/rules/`: `logic.md` (lib, general), `acquirer.md` (acquirer modules, Redis access), `visitors.md` (log/instrumentation visitors), `arguments.md` (long keyword lists), `swarm.md` (swarm supervisor/elements, Ractor/Thread rules), `tests.md` (spec), `type-checking.md` (sig/Steep), `changelog.md` (CHANGELOG entries), `git.md` (commit messages and commit flow, always loaded).
27
28
  - Long explicit keyword/parameter lists are intentional (minimal allocations, signature-as-DSL): never introduce parameter objects, option structs or `**opts`; add new options as explicit keywords to every `Client` variant and forward them by name.
28
29
  - Update the matching `sig/*.rbs` for every `lib/` change; keep Steep green.
29
30
  - Keep `# frozen_string_literal: true` and YARD `@api`/`@since`/`@version` tags (class/module `@version` changes only with the interface: signatures, added/removed methods, `extend`/`include`/`prepend`, the superclass; `logic.md#2`).
30
31
  - `{ ok:, result: }` is for the public API only (`Client`, `Acquirer::*`, `Swarm` facade actions). Internal swarm element APIs (commands, replies, helpers) use bare scalars/primitives (see `swarm.md`).
31
32
  - New operation: `Acquirer::*` module, thin `Client` method (+ `!` variant), RBS, spec.
32
33
  - Operation modules behind `Client` methods (all `Acquirer::*` except the `AcquireLock` acquisition core and PoC modules such as `LockSeriesPoC`, which may reuse other modules) are independent: they never call each other or reference each other's RBS types, share logic only via `Resource`/`Utilities`, and duplicate similar logic on purpose (independence over DRY; e.g. `Locks` duplicates the `LockInfo` formatting). Details: `acquirer.md`.
33
- - New option: `setting` (+ `validate`) in `config.rb`.
34
+ - New option: `setting` (+ `validate`) in `config.rb` + its `Config#[]` overload line in `sig/redis_queued_locks/config.rbs` (typed per key).
34
35
  - Dev gems go in `Gemfile`, not the gemspec.
35
36
  - Commits (`git.md`): subject only `[<scope>] <summary>` (several features: 1-2 words each, comma-separated), no body; when a step is complete (requirement fully implemented, specs/rubocop/steep green), and only then, run the `rql-code-review` skill on the uncommitted changes, then ask before committing, with the proposed subject, a recap of the changes and the review verdict; an explicit "commit" request needs no extra question and no review (a commit authorized in advance for a task still gets the review); never on `master`, never push.
36
- - Before implementing, read the relevant sections of `.claude/project-overview.md` (key layout, lock flow, "Read/Write locks", events) and keep their invariants. In the same change, update the overview and the affected `.claude/rules/*.md` for every new/changed key, option, result shape, event, algorithm step or limitation (and README/CHANGELOG `[Unreleased]` for user-visible behavior). New or changed log keys / instrumentation payload keys (visitors or inline `notify`) also go to README `## Logging` / `### Instrumentation Events` (see `visitors.md`).
37
+ - Before implementing, read the relevant sections of `.claude/project-overview.md` (key layout, lock flow, "Read/Write locks", events) and keep their invariants. In the same change, update the overview and the affected `.claude/rules/*.md` for every new/changed key, option, result shape, event, algorithm step or limitation (and README/CHANGELOG `[Unreleased]` for user-visible behavior; CHANGELOG entries are compact: only the subject-level change in 1-2 lines, no implementation details; a fundamentally new feature or globally new support is a separate `*Brand New Feature*` entry, `changelog.md`). New or changed log keys / instrumentation payload keys (visitors or inline `notify`) also go to README `## Logging` / `### Instrumentation Events` (see `visitors.md`).
37
38
  - Observability (`logic.md#16`): logic that creates/changes/deletes keys is logged and instrumented: the lock acquirement per meaningful outcome (visitors), each mutation operation with one log line + one event of the whole root operation called by `Client` (inline, `instrument` last in the payload); lock info queries are neither; the swarm elements are known debt.
38
39
  - Lock-touching code is read/write-aware and index-aware: any operation over locks, queues, zombies or lock info must cover the read family (`rql:lock_readers:*`, `rql:lock_read_queue:*`, `rql:lock_reader:*`) next to `rql:lock:*`/`rql:lock_queue:*`, and any new key-creating/extending command must keep the lock index a superset of the existing locks (enabled mode); a new lock-iterating operation implements both lookups (keyspace and lock index, `lock_index_enabled`). Mutual exclusion comes only from WATCH/MULTI on lock state (no Lua in the RW acquisition path unless asked); queues never guarantee safety.
39
- - Known debt: rspec-retry, disabled coverage minimum, runtime type-check CI uses `--failure-exit-code=0`.
40
+ - Known debt: rspec-retry, disabled coverage minimum.