redis_queued_locks 1.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +249 -36
  3. data/.claude/rules/acquirer.md +16 -14
  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 +34 -13
  8. data/.claude/rules/swarm.md +4 -5
  9. data/.claude/rules/tests.md +9 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +13 -14
  12. data/.claude/skills/rql-code-review/SKILL.md +134 -43
  13. data/CHANGELOG.md +105 -125
  14. data/CLAUDE.md +11 -9
  15. data/README.md +501 -136
  16. data/Rakefile +11 -0
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +1 -0
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +68 -10
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +82 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -14
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +441 -63
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/with_acq_timeout.rb +2 -2
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +1 -0
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +5 -5
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +176 -39
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +322 -49
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +125 -13
  28. data/lib/redis_queued_locks/acquirer/is_locked.rb +0 -1
  29. data/lib/redis_queued_locks/acquirer/is_queued.rb +0 -1
  30. data/lib/redis_queued_locks/acquirer/keys.rb +16 -3
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +31 -39
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +11 -5
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +20 -10
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +105 -36
  35. data/lib/redis_queued_locks/acquirer/locks.rb +32 -11
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -8
  37. data/lib/redis_queued_locks/acquirer/queues.rb +26 -6
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +117 -4
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +37 -6
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +33 -7
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +45 -6
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/client.rb +140 -56
  44. data/lib/redis_queued_locks/config.rb +105 -14
  45. data/lib/redis_queued_locks/errors.rb +34 -4
  46. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  47. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  48. data/lib/redis_queued_locks/resource.rb +137 -12
  49. data/lib/redis_queued_locks/swarm/flush_zombies.rb +1 -0
  50. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  51. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +8 -6
  52. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  53. data/lib/redis_queued_locks/swarm.rb +86 -5
  54. data/lib/redis_queued_locks/utilities.rb +1 -1
  55. data/lib/redis_queued_locks/version.rb +2 -2
  56. data/rbs_collection.lock.yaml +4 -0
  57. data/rbs_collection.yaml +2 -1
  58. data/sig/manifest.yml +1 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  61. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +25 -5
  62. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +32 -12
  63. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  64. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  65. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  66. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +4 -4
  67. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +6 -4
  68. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +23 -1
  69. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +16 -3
  70. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  71. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +22 -1
  72. data/sig/redis_queued_locks/acquirer/release_lock.rbs +3 -2
  73. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  74. data/sig/redis_queued_locks/client.rbs +24 -10
  75. data/sig/redis_queued_locks/config.rbs +54 -1
  76. data/sig/redis_queued_locks/errors.rbs +5 -2
  77. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -3
  78. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  79. data/sig/redis_queued_locks/logging/sampler.rbs +3 -3
  80. data/sig/redis_queued_locks/resource.rbs +23 -13
  81. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  82. data/sig/redis_queued_locks/utilities.rbs +2 -1
  83. data/sig/redis_queued_locks.rbs +7 -0
  84. data/sig/vendor/redis_client.rbs +1 -0
  85. metadata +4 -3
  86. /data/sig/redis_queued_locks/{acquier.rbs → acquirer.rbs} +0 -0
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: rql-code-review
3
- description: Code review of redis_queued_locks changes against the project's own invariants and conventions (WATCH/MULTI lock safety, read/write key families, the lock index superset invariant in both lookup modes, one-pass iteration, operation-module independence, long explicit argument lists, log/instrumentation visitors, RBS/Steep, docs sync). Use when the user asks to review / code-review (ревью, код-ревью, проверь изменения) a branch, diff, commit range, PR, staged changes or files of this repository. Also run it automatically on the uncommitted changes when a task in this repository is done and ready to commit, right before asking about the commit (never earlier). Spec files (*_spec.rb) are exempt from style/principle checks until the suite rework; only the coverage of the changed library behavior is checked.
3
+ description: Code review of redis_queued_locks changes against the project's own invariants and conventions (WATCH/MULTI lock safety, read/write key families and the order between modes, lock request priority bands and their head-driven dead request cleanup, the lock index superset invariant in both lookup modes, one-pass iteration, operation-module independence, long explicit argument lists, log/instrumentation visitors, RBS/Steep, docs sync). Use when the user asks to review / code-review (ревью, код-ревью, проверь изменения) a branch, diff, commit range, PR, staged changes or files of this repository. Also run it automatically on the uncommitted changes when a task in this repository is done and ready to commit, right before asking about the commit (never earlier). Spec files (*_spec.rb) are exempt from style/principle checks until the suite rework; only the coverage of the changed library behavior is checked.
4
4
  argument-hint: "[uncommitted | base..head | <commit> | pr <N> | staged | <paths...>]"
5
5
  ---
6
6
 
7
7
  # redis_queued_locks code review
8
8
 
9
9
  Review a change of this gem the way its maintainer does: lock safety first, then the project invariants
10
- (read/write locks, lock index, iteration), then architecture, types, docs and style. The rules live in
10
+ (read/write locks, lock request priorities, lock index, iteration), then architecture, types, docs and style. The rules live in
11
11
  `CLAUDE.md`, `.claude/project-overview.md` and `.claude/rules/*.md`; this skill says what to check, where the
12
12
  details are, and what must not be reported.
13
13
 
@@ -28,7 +28,7 @@ details are, and what must not be reported.
28
28
  - Scope `uncommitted`: the changes of the step (earlier steps are reviewed before their own commits).
29
29
  - The completion checks have just run: report their results, don't re-run them. A step without code changes (docs,
30
30
  `.claude` files) gets a docs review only: accuracy against the code, the overview and the rules, cross-file
31
- consistency (§4 J).
31
+ consistency (§4 K).
32
32
  - Blocker/Major findings in the step's own changes mean the step is not complete: fix them as part of the task (the
33
33
  review itself stays read-only), re-run the affected checks and review the fixed hunks once more; findings that
34
34
  need the user's decision, or remain after that pass, are reported instead of the commit question (`git.md#9`).
@@ -56,7 +56,8 @@ details are, and what must not be reported.
56
56
  ## 2. Context to load before judging
57
57
 
58
58
  Read the rule files of the touched paths and the overview sections of the touched topics (lock flow, "Read/Write
59
- locks", key layout, "Lock index", lock lookup modes, events, errors, conventions):
59
+ locks in the architecture" + "Read/Write locks", "Lock request prioritization in the architecture" + "Lock request
60
+ priorities", key layout, "Lock index", lock lookup modes, events, errors, conventions):
60
61
 
61
62
  | Changed path | Rule files |
62
63
  |---|---|
@@ -67,6 +68,7 @@ locks", key layout, "Lock index", lock lookup modes, events, errors, conventions
67
68
  | `logging/**`, `instrument/**` | + `visitors.md` |
68
69
  | `sig/**`, `Steepfile`, `rbs_collection.yaml` | `type-checking.md` |
69
70
  | `spec/**` | `tests.md` (coverage expectations only, see §3) |
71
+ | `CHANGELOG.md` | `changelog.md` |
70
72
  | commits / messages | `git.md` |
71
73
 
72
74
  - Read every changed method in full, its callers, its RBS signature and every copy of duplicated logic. Copies
@@ -80,16 +82,24 @@ locks", key layout, "Lock index", lock lookup modes, events, errors, conventions
80
82
  - `EXTEND_LOCK_PTTL` in `TryToLock` and `ExtendLockTTL`;
81
83
  - lock state reply reading in `try_to_lock` Step 0.1 and `precheck_lock` (shared `pipeline_lock_state`,
82
84
  position constants, `lock_obstacle`, `log_lock_obstacle`);
83
- - reserved `meta` keys validation in `AcquireLock` and `LockSeriesPoC`;
85
+ - reserved `meta` keys validation in `AcquireLock` and `LockSeriesPoC`; the `priority` resolution and range check
86
+ in `AcquireLock` Step 0.1 and `LockSeriesPoC` (the same message);
87
+ - the band liveness rule (a request is dead when its score is `<= Resource.lock_priority_band_dead_score(dead_score,
88
+ band)`): `TryToLock#dead_request_priority` / `pipeline_dead_requests_removal` and `ClearDeadRequests.drop_dead_requests` /
89
+ `drop_dead_requests_by_priority`;
90
+ - the `'priority'` field of a request (`Resource.lock_request_priority`) in `QueueInfo.queue_info` and
91
+ `Queues.extract_queues_info`;
84
92
  - lock-family options in `Client#lock`, `#lock!`, `#lock_series`, `#lock_series!` and their forwarding in
85
93
  `LockSeriesPoC.lock_series_poc`.
86
- - Mirror path exceptions: `acquirer.rb` → `sig/redis_queued_locks/acquier.rbs`;
87
- `acquire_lock/yield_expire/log_visitor.rb` → `sig/.../acquire_lock/yield_with_expire/log_visitor.rbs`;
88
- `lock_series_poc*` has no RBS.
94
+ - Mirror path exception: `lock_series_poc*` has no RBS.
89
95
  - Next version for `@since`/`@version`: the version the unreleased work already uses (`git diff <base> -- lib |
90
96
  grep -oE '^\+.*@(since|version) [0-9.]+'`, the CHANGELOG `[Unreleased]` code), otherwise the next minor of
91
- `RedisQueuedLocks::VERSION` (`version.rb`). Don't take the maximum over `lib/`: the typo `@since 19.0.0` wins it.
92
- A value older than `VERSION` on new code, or a behavior change without a `@version` bump, is a Minor finding.
97
+ `RedisQueuedLocks::VERSION` (`version.rb`); don't take the maximum over `lib/` (old tags can be wrong).
98
+ A value older than `VERSION` on new code, a method behavior/signature change (a typo fix in the method or keyword
99
+ argument name included; a renamed positional parameter is not a signature change) without the method `@version` bump, or a
100
+ class/module header `@version` that does not match its latest interface change (`logic.md#2`: signatures,
101
+ added/removed methods, `extend`/`include`/`prepend`, the superclass; behavior changes never bump the header) is a
102
+ Minor finding.
93
103
 
94
104
  ## 3. Spec files are exempt (until the suite rework)
95
105
 
@@ -140,15 +150,31 @@ Still in scope (the exemption covers how specs are written, not whether the libr
140
150
  the reentrant write extension check `result.size == (lock_index_enabled ? 5 : 4)` follows its `MULTI`.
141
151
  - Round trips of the hot path must not regress silently (overview "Lock acquisition flow"): `lock {}` = 4, a
142
152
  failed attempt = 3, a failed retry = 1 (pre-check), `fail_fast` on an obtained lock = 1, `unlock` without
143
- readers = 1. Independent reads share one `pipelined` round trip (also inside `multi(watch:)`).
153
+ readers = 1; the only extra round trips are the dead blocking request removals of Step 2.2 (one per removal, none
154
+ while nothing dead blocks). Independent reads share one `pipelined` round trip (also inside `multi(watch:)`).
144
155
  - New destructive paths (delete/shorten a lock) check the owner, unless the change documents the trade-off as
145
156
  accepted by the user (like the known limitations in §5).
146
157
  - Shared mutable state behind `Utilities::Lock#synchronize` (`logic.md#9`).
147
158
 
148
- ### B. Read/write awareness (`logic.md#13`, `acquirer.md#12`)
159
+ ### B. Read/write awareness (`logic.md#13`, `acquirer.md#12`, overview "Read/Write locks in the architecture")
149
160
  - Every operation over locks, queues, zombies or lock info covers `rql:lock:*`, `rql:lock_queue:*`,
150
- `rql:lock_readers:*`, `rql:lock_read_queue:*` and `rql:lock_reader:<name>:<acq_id>`; every attempt prunes dead
151
- requests in both queues; dequeue/cleanup removes the request from the queue of its mode.
161
+ `rql:lock_readers:*`, `rql:lock_read_queue:*` and `rql:lock_reader:<name>:<acq_id>`; every attempt prunes the dead
162
+ requests of its priority band in both queues (and the bands of the dead requests that block it, §4 C);
163
+ dequeue/cleanup removes the request from the queue of its mode.
164
+ - Order between modes (`:queued`, `find_opposite_request_ahead`/`lock_obstacle`, shared by the attempt and the
165
+ pre-check): a write request must be the head of the write queue and have no earlier read request, a read request
166
+ must have no earlier write request ("earlier" = the `(score, acq_id)` comparison on the shifted scores); the
167
+ order never decides mutual exclusion.
168
+ - The held mode decides the release: `acquire_lock` keeps the try result `rw_mode` as `held_rw_mode` for
169
+ `yield_expire` (a read request that works through the own write lock holds the write lock); a reader is released
170
+ owner-safe (`ZREM` of the own member + `DEL` of the own reader data), `unlock` releases everything; the internal
171
+ `rw_mode` never appears in the public `lock` result.
172
+ - Read lock extension (`extend_lock_ttl(..., read_write_mode: :read)`): `multi(watch: [lock_key])`, only a live read
173
+ lock and only while no write lock exists, never revives an expired reader; `all_read_locks: true` extends every
174
+ live reader (`ZADD XX INCR`), the registry/reader data TTLs keep the max (`PEXPIRE NX` + `GT`).
175
+ - `fail_fast`: write fails on a write holder or live readers, read fails on a write holder (or the own live read);
176
+ lock series: one mode for the whole series, `release_lock_series` releases only the locks the series obtained and
177
+ only the current acquirer's (write: compare-and-delete in one transaction, read: `ZREM` + `DEL`).
152
178
  - Write-only result shapes stay unchanged; read keys/fields appear only when read data exists.
153
179
  - `read_lock_key` is `nil` for write requests; reader data is a data carrier only (the registry decides existence),
154
180
  without `acq_id`/`hst_id`; don't store derivable values; a new lock field updates every formatting copy (§2,
@@ -157,7 +183,41 @@ Still in scope (the exemption covers how specs are written, not whether the libr
157
183
  entries) is still read correctly; a key/data format change states the rolling-deploy requirement in README and
158
184
  CHANGELOG.
159
185
 
160
- ### C. Lock index, both lookup modes (`acquirer.md#16`, overview "Lock index")
186
+ ### C. Lock request priorities (`acquirer.md` "Lock request priorities", overview "Lock request prioritization in the architecture")
187
+ - Queue scores only through `Resource` (the single source of the layout): `calc_initial_acquirer_position(priority)`
188
+ (enqueue time - `priority * LOCK_PRIORITY_BAND`), the requeue check `dead_score_reached?(position, priority, ttl)`
189
+ (a requeued request goes to the end of its own band), decoding with `lock_request_priority(score)`; no raw
190
+ `Time.now.to_f` positions, no own band arithmetic outside `Resource`.
191
+ - Dead request pruning per band only: `ZREMRANGEBYSCORE <queue> <lock_priority_band_floor(p)>
192
+ <lock_priority_band_dead_score(dead_score, p)>`; a new `-inf .. now - ttl` range on a request queue is a Blocker
193
+ (it drops alive higher-priority requests and keeps dead lower-priority ones); full clears (`-inf .. +inf`) and
194
+ member removals (`ZREM`) are band-independent; `-inf .. -inf` drops the requests at `-inf` only (manual writes:
195
+ always dead, out of all bands; `+inf` is never dead), and decoding an infinite score must never raise
196
+ (`lock_request_priority` maps `-inf`/`+inf` to the range bounds).
197
+ - Head-driven cleanup, cost independent of the priority range: the queueing prunes the own band in both queues (in
198
+ its pipeline, before `ZADD NX`: a requeued request must not lose its new position to the removal of its old one),
199
+ Step 2.2 (`drop_dead_requests_ahead`, attempt and pre-check, `:queued` only) drops the bands of the
200
+ dead blocking requests (the write queue head for writers, the earlier opposite-mode request) with the queue heads
201
+ re-read in the same round trip, bounded by `MAX_DEAD_REQUEST_REMOVALS`; `ClearDeadRequests` prunes only the bands
202
+ whose first request is dead. Never enumerate the priority range per attempt or per queue; any band is decoded
203
+ (also out of the range: newer versions).
204
+ - Ordering uses the shifted scores as is (queue heads, the cross-mode `(score, acq_id)` comparison): strict priority
205
+ between bands, FIFO inside a band, the same in both modes; the priority never takes part in mutual exclusion.
206
+ - The option: `priority:` in all four lock `Client` methods (default `config['default_lock_priority']`), an Integer
207
+ `MIN_LOCK_PRIORITY..MAX_LOCK_PRIORITY` or a name of `config['lock_priorities']` (the client-wide registry is
208
+ forwarded as the internal keyword `lock_priorities:`, never a public keyword); the name is resolved and the range
209
+ checked in `AcquireLock` Step 0.1 and before a lock series (`LockSeriesPoC`): code below sees Integers only (RBS
210
+ `RQL::lockPriority` at the boundary, `Integer` below); config validators for both keys, and a name of
211
+ `default_lock_priority` checked against `lock_priorities` by `Config` (after a `configure` block, before a direct `[]=`).
212
+ - Observability: every lock acquirement log line and event (classic, reentrant: the requested priority, lock series:
213
+ the series priority, `lock_obtaining_failed`) carries the Integer `priority` right after `rw_mode`; try-lock logs
214
+ that name another request carry its decoded priority (`first_priority_in_queue`, `ahead_priority`, `dead_priority`)
215
+ computed at the call site only when `log_sampled && log_lock_try` (one helper: `TryToLock#logged_request_priority`); info results carry `'priority'` (every copy).
216
+ - Compatibility: priority `0` keeps the raw timestamp scores of older versions/data; a change of the score layout or
217
+ of the pruning states the rolling-deploy effect in README and CHANGELOG (older versions prune positive-priority
218
+ requests as dead ones).
219
+
220
+ ### D. Lock index, both lookup modes (`acquirer.md#16`, overview "Lock index")
161
221
  - Disabled mode (default): no lock index command at all (`LOCK_INDEX_KEY` only under `if lock_index_enabled` /
162
222
  after `return unless lock_index_enabled`), no `ZSCAN`, queues without TTL. Enabled mode: no keyspace `SCAN`
163
223
  except `ReindexLocks`.
@@ -174,9 +234,9 @@ Still in scope (the exemption covers how specs are written, not whether the libr
174
234
  `lock`, `lock!`, `lock_series`, `lock_series!`, `reindex_locks`. Pruning (`ZREMRANGEBYSCORE <index> -inf
175
235
  <now - 1>`) stays in the queueing pipeline, `flush_zombies`, `clear_locks`, `clear_dead_requests`.
176
236
 
177
- ### D. Iteration and performance (`acquirer.md#7`, `arguments.md#7`)
237
+ ### E. Iteration and performance (`acquirer.md#7`, `arguments.md#7`)
178
238
  - One pass per operation: one `SCAN` over `KEY_PATTERN`/`ALL_LOCK_QUEUES_PATTERN` classified by
179
- `Resource.lock_key_family`, or one `ZSCAN` via `each_lock_name_batch` (expired scores skipped). Never `KEYS`,
239
+ `Resource.lock_key_family`, or one `ZSCAN` via `each_lock_name_batch` (expired scores skipped by `Resource.lock_index_score_expired?`: the `"inf"` score never expires). Never `KEYS`,
180
240
  never one round trip per key; per-key commands in pipelined batches collected across pages, plus the
181
241
  remainder batch.
182
242
  - A new lock-iterating operation implements both lookups side by side, chooses the lookup in one place
@@ -188,7 +248,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
188
248
  `NO_LOCK_DATA`); keys computed once in `acquire_lock`; log-only Redis reads gated at the call site
189
249
  (`acquirer.md#13`); no `Set` splatted in a loop.
190
250
 
191
- ### E. Architecture and API design
251
+ ### F. Architecture and API design
192
252
  - `Client` only fills defaults from `config['...']` and delegates (`logic.md#3`); an `x!` variant exists only when it
193
253
  must raise and repeats the full keyword list.
194
254
  - No parameter objects, `Struct`/`Data.define`/`OpenStruct`, builders, `**opts`, `options = {}`, `...` forwarding
@@ -214,14 +274,18 @@ Still in scope (the exemption covers how specs are written, not whether the libr
214
274
  key / acquirer id in the message.
215
275
  - Keys only via `Resource.prepare_*`, `*_PATTERN`, `SWARM_KEY`, `LOCK_INDEX_KEY` (`logic.md#5`): flag every new
216
276
  `'rql:...'` literal outside `resource.rb`.
217
- - Config: `setting` + `validate` in `config.rb` (booleans `val == true || val == false`, Integers with ranges), dotted
218
- keys for groups, unit in a trailing `# NOTE:` comment, read as `config['key']` (`logic.md#7`). Errors:
219
- `class XError < Error; end` in `errors.rb`. Runtime dependencies: `redis-client` only; dev gems in `Gemfile`.
277
+ - Config: `setting` + `validate` in `config.rb` (booleans `val == true || val == false`, Integers with ranges; per-call
278
+ numeric options validated with the same ranges in the operation module, `logic.md#7`), dotted
279
+ keys for groups, unit in a trailing `# NOTE:` comment, read as `config['key']` (`logic.md#7`), one typed
280
+ `Config#[]` overload line per key in `config.rbs` (`type-checking.md#12`). Errors:
281
+ `class XError < Error; end` in `errors.rb`, each under the global base of its branch (`Error`, `ArgumentError`,
282
+ `Exception`: super exceptional cases only, with the reason in the comment; `logic.md#15`). Runtime dependencies:
283
+ `redis-client` only; dev gems in `Gemfile`.
220
284
  - `AcquireLock`: new behavior as a new step or mixin with `# Step N.x` numbering and consistent `acq_process` keys
221
285
  (`acquirer.md#10`); `lock_series_poc.rb` is experimental: don't accept new features built on it without the
222
286
  user's request.
223
287
 
224
- ### F. Swarm, Ractors, threads (`swarm.md`)
288
+ ### G. Swarm, Ractors, threads (`swarm.md`)
225
289
  - Redis work in stateless class functions shared by the main loop and the manual API (`#1`); main loops build
226
290
  their own client with `RedisClientBuilder` inside the loop thread (`#4`).
227
291
  - Into `Ractor.new` only copied plain values (`config.slice(...)`, scalars, `lock_index_enabled`): no `rql_client`,
@@ -235,9 +299,14 @@ Still in scope (the exemption covers how specs are written, not whether the libr
235
299
  - Lifecycle names and semantics identical in both bases (`#7`); a new element is registered everywhere (`#3`);
236
300
  status shapes `{ running:, state: }` with `'non_initialized'` and their RBS (`#10`).
237
301
 
238
- ### G. Logging and instrumentation (`visitors.md`)
239
- - Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`); release
240
- modules call `instrumenter.notify` inline inside `run_non_critical`, gated by `instr_sampled` (`acquirer.md#9`).
302
+ ### H. Logging and instrumentation (`visitors.md`)
303
+ - Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`); mutation
304
+ modules write one log line and one event inline inside `run_non_critical`, gated by `log_sampled`/`instr_sampled`
305
+ (`acquirer.md#9`).
306
+ - Observability coverage (`logic.md#16`): a new or changed logic that creates/changes/deletes keys without logs and
307
+ instrumentation, a mutation event/log that times an internal part instead of the whole root operation, a mutation
308
+ payload without `instrument`, a `Client` mutation method without the observability keywords, or instrumented lock
309
+ info queries are findings; the swarm elements are known debt.
241
310
  - Visitor shape: `class << self`, method name = event name (`__` between phase and detail), same name in the
242
311
  Log and Instr visitors, first line `return unless log_sampled` (`&& log_lock_try` for `TryToLock` steps), one
243
312
  `logger.debug do ... end rescue nil` with `"[redis_queued_locks.<event>] "` and `key => 'value'` pairs, or
@@ -250,7 +319,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
250
319
  the default write success path (`#13`); new or changed log keys/payload keys go to README `## Logging` /
251
320
  `### Instrumentation Events` and CHANGELOG (`#14`).
252
321
 
253
- ### H. Types (`type-checking.md`)
322
+ ### I. Types (`type-checking.md`)
254
323
  - Every changed method, param, return shape, constant or ivar is mirrored in `sig/` (`#1`): nested module blocks,
255
324
  `use RedisQueuedLocks as RQL` / `use RedisClient as RC`, shared types (`RC::client`, `RQL::loggerObj`,
256
325
  `RQL::instrObj`, `samplerObj`), `type xxxResult = { ok: bool, result: ... }` for public results, literal types for
@@ -260,12 +329,13 @@ Still in scope (the exemption covers how specs are written, not whether the libr
260
329
  annotation carries `NOTE: (steep)`; never shape runtime code around Steep (`#5`). `Steepfile` is never loosened (`#6`).
261
330
  - RBS must match runtime values (runtime type checks run the suite): e.g. `Integer|Float` where a Float can arrive.
262
331
 
263
- ### I. Ruby style of the codebase
332
+ ### J. Ruby style of the codebase
264
333
  - `# frozen_string_literal: true`; compact constant paths (`module RedisQueuedLocks::Acquirer::Keys`); namespace
265
334
  files only `require_relative` (`logic.md#1`).
266
335
  - YARD on every class, module, constant, attr and method: `@param`/`@option`/`@return` (`Format: { ... }` for hash
267
- results), blank `#`, `@api public|private`, `@since <next>`; behavior changes bump `@version <next>` on the
268
- method and on the module/class header (`logic.md#2`).
336
+ results), blank `#`, `@api public|private`, `@since <next>`; behavior/signature changes bump `@version <next>` on
337
+ the method; the module/class header `@version` changes only with the interface (signatures, added/removed methods,
338
+ `extend`/`include`/`prepend`, the superclass), never for behavior changes inside the methods (`logic.md#2`).
269
339
  - Comments: `# NOTE:` with `# - item;` lists, `# Step N.x` in algorithms, `(RW)`/`(lock index)`/`(keyspace)`
270
340
  tags, `NOTE: (pipeline layout)`/`(transaction layout)` for positional replies.
271
341
  - Redis: uppercase String commands, String numeric args (`'0'`, `'-1'`, `'-inf'`, `'+inf'`), `redis.with do |rconn|`
@@ -281,12 +351,16 @@ Still in scope (the exemption covers how specs are written, not whether the libr
281
351
  - Rubocop: only specific `Metrics`/`Layout` cops disabled locally with a paired `enable`, never
282
352
  `# rubocop:disable all` in new code (`logic.md#10`); long signatures are not "fixed" (`arguments.md#8`).
283
353
  - Suggestions only (`logic.md` recommendations, not violations): correctly spelled new identifiers (don't copy the
284
- known typos), `then` instead of `yield_self` in new code.
354
+ known typos).
285
355
 
286
- ### J. Docs and process
356
+ ### K. Docs and process
287
357
  - The same change updates `.claude/project-overview.md` and the affected `.claude/rules/*.md` for every new/changed
288
358
  key, option, result shape, event, algorithm step or limitation (`logic.md#12`), README and CHANGELOG
289
359
  `[Unreleased]` for user-visible behavior, and the YARD of the touched `Client` methods.
360
+ - CHANGELOG entries are compact (`changelog.md`): one 1-2 line bullet per subject-level change, no implementation
361
+ details (algorithm steps, Redis commands, round trips, internals, full event lists), no entries for internal-only
362
+ changes; one entry per feature, released sections untouched; a fundamentally new feature or globally new support
363
+ (Read/Write locks, Lock Index, Garnet support) is a separate `*Brand New Feature*` entry first in `### Added`.
290
364
  - Commits under review (`git.md`): subject only `[<scope>] <summary>`, lowercase, imperative, no trailing period,
291
365
  no body except the attribution trailer, never on `master`.
292
366
 
@@ -297,13 +371,24 @@ Still in scope (the exemption covers how specs are written, not whether the libr
297
371
  read→write upgrade as an error unless `:wait_for_lock`; nested reads through `conflict_strategy`; no fairness
298
372
  between modes with `:random`; best-effort cross-host FIFO; no Lua in the RW path; one mode per lock series;
299
373
  `lock_index_enabled` as a config-only mode; the lock index defaulting to `false`.
374
+ - Settled design of the lock request priorities (overview "Lock request priorities", limitations kept by the user):
375
+ strict priority (lower priorities can starve within their timeout/retry limits), no preemption of an obtained lock,
376
+ the polled handoff (`retry_delay`), dead requests pruned with the pruner's `queue_ttl`, dead requests of lower bands
377
+ lingering until an attempt of their band/`clear_dead_requests`/the queue TTL/the swarm (they never block), the
378
+ library-constant range `-100..100` and its ~61 µs FIFO precision inside a band, `MAX_DEAD_REQUEST_REMOVALS` per
379
+ attempt, `:random` requests ignoring the order and the priorities of other requests (while their own requests are queued
380
+ at their priority: `:queued` requests wait for them), `clear_dead_requests` reading the batch queues (2 round trips per batch; `by_priority: true`: the first
381
+ request of each band only, the most bands of a batch queue + 1 round trips),
382
+ the order degradation with older versions (no safety impact), the queue score as the only order and liveness of a request
383
+ (a separate liveness registry of request deadlines was rejected by the user: don't propose it).
384
+ - Unused `Resource` methods marked `# NOTE: (debug)` (manual runtime checks, `logic.md#17`); report a debug method
385
+ only when it is wrong for a key family it can get.
300
386
  - Known limitations kept by the user: `YieldExpire` write release (`EXPIRE 0`) and ttl decrease without an owner
301
387
  check; `HMGET`→`DEL` without `WATCH` in `ReleaseLocksOf`/`FlushZombies`; `unlock` dropping the data of a reader
302
388
  re-acquired between its two round trips; `zombie_acquirers` vs `flush_zombies` acquirer sets.
303
- - Known debt: `WithAcqTimeout` → `LockInfo`/`QueueInfo`; rspec-retry, disabled coverage minimum, non-blocking runtime
304
- type checks; `acquier.rbs` and the `yield_with_expire` RBS path; typos `swarm_element__termiante`,
305
- `@api ppublic`/`pulic`/`pub;ic`, `@since 19.0.0`; `sleep(0.1)` readiness waits and missing logging in the swarm;
306
- one flusher per process; `EVAL` without `SCRIPT LOAD`; `# rubocop:disable all` around lock series.
389
+ - Known debt: `WithAcqTimeout` → `LockInfo`/`QueueInfo`; rspec-retry, disabled coverage minimum;
390
+ `sleep(0.1)` readiness waits and missing logging in the swarm;
391
+ one flusher per process; `EVAL` without `SCRIPT LOAD`.
307
392
  - "Recommendations (proposed, not yet project policy)" in the rule files: never violations; mention one only as an
308
393
  optional suggestion for touched code.
309
394
  - Pre-existing problems outside the diff: out of scope; mention a severe one briefly in the template's
@@ -314,7 +399,9 @@ Still in scope (the exemption covers how specs are written, not whether the libr
314
399
 
315
400
  - Confirm it in the code (call sites, copies, RBS, specs), not by pattern match: concurrency claims name the
316
401
  interleaving (acquirer A at step X, B at step Y) and why `WATCH` doesn't catch it; layout claims count the
317
- commands and positions; mode claims check both `lock_index_enabled` values and both `read_write_mode` values.
402
+ commands and positions; mode claims check both `lock_index_enabled` values and both `read_write_mode` values;
403
+ priority claims check a positive, the default (`0`) and a negative band, a request of another band ahead (alive and
404
+ dead) and requests in both queues.
318
405
  - Drop findings contradicted by §5, by a design `NOTE` in the code or by the overview. Mark the rest
319
406
  `CONFIRMED` (traced end to end) or `PLAUSIBLE` (depends on an assumption: name it).
320
407
  - Helpful greps:
@@ -328,8 +415,10 @@ Still in scope (the exemption covers how specs are written, not whether the libr
328
415
  - Checks (read-only): a review on request of local changes runs `bundle exec rake rubocop` and
329
416
  `bundle exec rbs collection install && bundle exec rake steep:check` (grep the output for `ERROR`/`FATAL`, restore a
330
417
  rewritten `rbs_collection.lock.yaml` with `git checkout -- rbs_collection.lock.yaml`); the automatic pre-commit run
331
- reports the completion checks instead of re-running them (see "When to run"). Don't run `rake rspec` without
332
- asking: it needs a local Redis, `FLUSHDB`s db 0 and db 1 and takes ~6 minutes.
418
+ reports the completion checks instead of re-running them (see "When to run"). Don't run the specs without
419
+ asking: they need a local Redis; when asked, run them in parallel processes (`rake rspec:parallel`: ~1 minute,
420
+ flushes db `0..2n-1` for `n` workers; the serial `rake rspec`, ~6 minutes and db 0/1, only for the one-process checks
421
+ of `tests.md#1`).
333
422
 
334
423
  ## 7. Report
335
424
 
@@ -361,11 +450,13 @@ Both templates have the same structure: change them together. A new language = a
361
450
  same structure plus a row in the table above.
362
451
 
363
452
  ### Severity and order
364
- - Blocker: breaks mutual exclusion or the lock index superset, loses or leaks locks/data, crashes, breaks the public
365
- API (method, result shape, event, config key) or turns CI red.
366
- - Major: a rule violation with a real consequence: a missed read key family or lookup mode, per-key round trips, a
367
- hot-path regression, broken module independence, a missing RBS/docs update for a changed contract, new
368
- behavior without specs.
453
+ - Blocker: breaks mutual exclusion or the lock index superset, loses or leaks locks/data (e.g. a queue pruning that
454
+ drops alive requests of another priority band), crashes, breaks the public API (method, result shape, event,
455
+ config key) or turns CI red.
456
+ - Major: a rule violation with a real consequence: a missed read key family or lookup mode, a broken order between
457
+ modes or priorities, a release of the wrong (requested instead of held) mode, a dead request that can block a
458
+ queue, a cost that grows with the priority range, per-key round trips, a hot-path regression, broken module
459
+ independence, a missing RBS/docs update for a changed contract, new behavior without specs.
369
460
  - Minor: convention deviations with little impact (YARD tags, comment format, naming, idioms).
370
461
  - Nit: optional polish.
371
462
  - Order by severity, then by file. Empty sections are omitted; with no findings the verdict says so and the sync and
data/CHANGELOG.md CHANGED
@@ -1,140 +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
+
27
+ ## [1.21.0] - 2026-10-13
28
+ ### Added
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;
36
+ - `#reindex_locks`: `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling options;
37
+ ### Changed
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;
43
+ - `:meta`: reserved lock data keys are rejected as Symbol keys too;
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);
46
+ ### Fixed
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`);
63
+
3
64
  ## [1.20.0] - 2026-10-12
4
65
  ### Added
5
- - **Lock index** (opt-in: `config['lock_index_enabled']`, `false` by default: lock-iterating operations scan the keyspace as before):
6
- `rql:index:locks` (`ZSET`: lock name => the upper bound of the lifetime of all lock keys in client time, ms); with the enabled
7
- lock index all lock-iterating operations find locks without the full keyspace `SCAN` (see the "Lock Index" README section):
8
- - the client-wide mode (not a per-call option): the lock acquirement maintains the lock index and lock-iterating operations
9
- read it only when it is enabled; use the same mode on all processes that share the Redis database (enable it everywhere,
10
- then invoke `#reindex_locks` once); the swarm reads the mode when it is started;
11
- - maintained by the lock acquirement itself without new Redis round trips: each lock request adds the lock name (`ZADD GT`
12
- with the queue ttl), sets the request queue TTL and drops the expired lock names (`ZREMRANGEBYSCORE`) in the queueing pipeline
13
- (4 more commands per attempt), each lock obtaining (write and read) adds the lock name with the lock ttl in the same
14
- transaction (1 more command), each extension (`#extend_lock_ttl`, `:extendable_work_through` reentrant locks) increases
15
- the score by the extension (`ZADD XX INCR`; `#extend_lock_ttl` of write locks sends `EVAL` and the index update in one
16
- transaction);
17
- - `#unlock` and `#clear_locks` drop the lock names from the index in the same transaction with the lock keys;
18
- - lock request queues have TTL: the queue lives as long as its latest lock request (`PEXPIRE NX` + `GT` with the queue ttl on
19
- each lock request), queues of dead acquirers expire by themselves;
20
- - lock-iterating operations find locks and lock queues via one `ZSCAN` pass over the lock names (lock keys are derived from
21
- the lock names): the cost depends on the number of locks only, keys of other applications are never visited; locks without
22
- the lock index entry (older RQL versions, the disabled lock index) are not found until `#reindex_locks`:
23
- - swarm (`#flush_zombies`, the zombie flusher, `#zombie_locks`/`#zombie_acquirers`/`#zombies_info`): `zombie_lock_scan_size` is
24
- the `ZSCAN` count and the batch size of locks, `zombie_queue_scan_size` - the batch size of lock queues (both processed during
25
- the same pass); `#flush_zombies` drops the expired lock names from the index;
26
- - `#locks`/`#locks_info`, `#queues`/`#queues_info`, `#keys`: `scan_size` is the `ZSCAN` count and the batch size of locks;
27
- `#keys` returns the existing keys of the lock key families of the indexed locks (other `rql:lock*` keys are not listed);
28
- - `#clear_locks`: the keys of each batch of locks are deleted with one `DEL` in one transaction with the removal of their lock
29
- names from the lock index; `batch_size` is the number of locks;
30
- - `#clear_locks_of`/`#clear_current_locks`: `clear_locks_of__lock_scan_size` is the `ZSCAN` count and the batch size of locks,
31
- `clear_locks_of__queue_scan_size` - the number of lock queues processed in one round trip;
32
- - `#clear_dead_requests`: `scan_size` is the `ZSCAN` count and the number of locks (both lock queues of each lock) processed in
33
- one round trip; the expired lock names are dropped from the index;
34
- - `config['lock_index_grace_period']` (`60_000` ms by default) and `lock_index_grace_period:` option of `#lock`/`#lock!`/
35
- `#lock_series`/`#lock_series!`: the lock name stays in the index for this period after the expiration of all its keys
36
- (the client clock skew and network latency tolerance);
37
- - `#reindex_locks`: adds the lock keys without the lock index entry (keys of older RQL versions and keys created with the disabled
38
- lock index) to the lock index (one `SCAN` pass, lock queues without TTL get the queue ttl); **invoke it once after the lock index
39
- is enabled on all your processes**;
40
- ### Changed
41
- - `#flush_zombies` (and the zombie flusher of the swarm): zombie lock requests are dropped with one `ZREM` per lock queue
42
- (previously one `ZREM` per zombie acquirer and lock queue);
43
- - `#queue_info` / `#queues_info`: lock queues are read without `EXISTS` checks (Redis does not keep empty sorted sets);
44
- - Lock acquirement attempt: read lock requests do not fetch the longest read lock (it is used by write lock requests only),
45
- 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;
46
- - Lock acquirement attempt: the lock request queueing (`ZADD NX`, expired requests pruning, queue heads) is one pipelined
47
- round trip instead of three; with `:wait_for_lock`/`:dead_locking` conflict strategies (without `fail_fast`) it is sent
48
- 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;
49
- - SCAN-based operations (`#clear_locks`, `#clear_locks_of`/`#clear_current_locks`, `#clear_dead_requests`, `#locks`/`#locks_info`,
50
- `#queues`/`#queues_info`, `#flush_zombies`, `#zombie_locks`/`#zombie_acquirers`/`#zombies_info`) make one `SCAN` pass instead
51
- of 2-5 passes (one per key family) and send per-key commands in pipelined batches instead of one round trip per key:
52
- - `#clear_locks` deletes the found keys during the scan with one `DEL` per batch (previously all keys were expired at the end,
53
- lock queues first);
54
- - `#clear_locks_of`/`#clear_current_locks` (`clear_locks_of__queue_scan_size`) and `#flush_zombies` (`zombie_queue_scan_size`):
55
- the queue scan size is the number of lock queues processed in one round trip (queues are found by the same pass as locks);
56
- - `#zombie_locks`/`#zombie_acquirers`/`#zombies_info` do not scan the keyspace when there are no zombie hosts;
57
- - Read lock data (`rql:lock_reader:<lock_name>:<acquirer_id>`) does not store `acq_id`/`hst_id` (both are derived from the acquirer id):
58
- ~40% less memory per read lock (`MEMORY USAGE` 280 -> 168 bytes without `:meta`); `#lock_info`/`#locks_info` report them as before
59
- (the first fields of each reader; read lock data of older versions is supported);
60
- - `#unlock`: one Redis round trip when there are no read locks (the readers registry is read in the release transaction;
61
- the read lock data is dropped with one more `DEL` only when read locks existed);
62
- - `#lock_series` (PoC): write locks of the series are released (compare-and-delete) in one transaction for the whole series
63
- (three round trips per series instead of three round trips per lock);
64
- - `#flush_zombies` (and the zombie flusher of the swarm): requests of zombie hosts are dropped from the lock queues by the host
65
- of the request (derived from its acquirer id): requests of dead hosts that hold no locks are dropped too (previously they stayed
66
- until the queue ttl) and their acquirers are reported in `deleted_zombie_acquirers`;
67
- - Lock acquirement: retries and `fail_fast` attempts pre-check the lock state without `WATCH` (one round trip): a retry that can not
68
- 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
69
- 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`;
70
84
 
71
85
  ## [1.19.0] - 2026-10-11
72
86
  ### Added
73
- - `#unlock_read` (aka `#release_read_lock`): an ability to unlock the read lock of any acquirer
74
- - `acquirer_id:` option (`nil` by default: the current acquirer) to release the read lock of a different acquirer
75
- (`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);
76
89
 
77
90
  ## [1.18.0] - 2026-10-11
78
91
  ### Added
79
- - Brand new feature - **Read/Write locks**: `read_write_mode:` attribute of `#lock`/`#lock!`/`#lock_series`/`#lock_series!` (`:write` by default):
80
- - `:write` - exclusive lock (the classic RQL lock): waits for the write lock and for all read locks;
81
- - `:read` - shared lock: waits for the write lock only (read locks of different acquirers work in parallel);
82
- - `:queued` access strategy orders read and write lock requests in FIFO between modes (writers are not starved by readers);
83
- - `:random` access strategy ignores lock queues (a continuous flow of readers can starve writers);
84
- - read locks are stored in `rql:lock_readers:<lock_name>` (`ZSET`: acquirer id => expiration time in Redis time),
85
- read lock requests - in `rql:lock_read_queue:<lock_name>`, read lock data - in `rql:lock_reader:<lock_name>:<acquirer_id>`
86
- (the same structure as the write lock: acquirer, host, timestamp, ttl, `:meta`, reentrant lock data);
87
- write locks and write lock requests use the classic keys;
88
- - mutual exclusion is based on optimistic transactions (`WATCH`/`MULTI`, no Lua): readers watch the write lock key,
89
- writers watch the write lock key and the read locks key (readers never invalidate transactions of each other);
90
- - reentrant locks: read lock request under the obtained lock works as a reentrant lock of the obtained lock;
91
- read-to-write lock upgrade fails with `RedisQueuedLocks::ConflictLockObtainError` (`:conflict_lock_upgrade`)
92
- for `:work_through`/`:extendable_work_through` conflict strategies;
93
- - `#unlock`, `#clear_locks`, `#clear_locks_of`, `#clear_current_locks`, `#clear_dead_requests`, `#flush_zombies`,
94
- `#zombie_locks`, `#zombie_acquirers`, `#zombies_info`, `#locked?`, `#queued?`, `#lock_info`, `#queue_info`,
95
- `#locks`, `#locks_info`, `#queues`, `#queues_info` support read locks and read lock requests;
96
- - logs: `rw_mode` in the lock logs and in the lock series logs (`start_lock_series_obtaining`, `lock_series_obtained`,
97
- `expire_lock_series`: the mode of the series); new try-lock log events: `exit__write_request_ahead`, `exit__read_request_ahead`,
98
- `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`;
99
- - instrumentation: `rw_mode` in `lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
100
- `lock_hold_and_release`, `reentrant_lock_hold_completes` payloads and in the lock series payloads
101
- (`lock_series_obtained`, `lock_series_hold_and_release`: the mode of the series);
102
- - `:meta` for read locks (stored in the read lock data of each reader, returned by `#lock_info`/`#locks_info`);
103
- - `#lock_series` with `read_write_mode: :read` (PoC): a series of read locks; only read locks obtained by the series
104
- are released (reentrant read locks of the outer logic are kept);
105
- - `#unlock_read` (aka `#release_read_lock`): releases the read lock of the current acquirer only
106
- (new instrumentation event: `redis_queued_locks.explicit_read_lock_release`);
107
- - `#extend_lock_ttl`: `read_write_mode:` (`:write` by default) and `identity:` options; `:read` mode extends the read lock
108
- of the current acquirer only (an expired read lock or a read lock replaced by a writer is not extended);
109
- - `#extend_lock_ttl`: `all_read_locks:` option (`false` by default, `:read` mode only, ignored in `:write` mode):
110
- 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;
111
103
  - RBS: `read_write_mode` is typed as `:read | :write`;
112
- - `#queue_info` / `#queues_info`: each lock request has `"rw_mode"` (`"write"` / `"read"`);
113
- - `#lock_info` / `#locks_info`: write lock info has `"rw_mode" => "write"` (read locks info - `"rw_mode" => "read"`);
114
- - `:meta`: `"rw_mode"` is a reserved lock data key (`RedisQueuedLocks::ArgumentError`), for write and read locks and lock series;
115
- - **all processes should use the RQL version with read/write locks** before read locks are used
116
- (write locks of older RQL versions do not check read locks);
117
- ### Changed
118
- - `lock_series_obtained` instrumentation event (`#lock_series`, PoC): `:ts` is an epoch timestamp (`Float`)
119
- like in all other instrumentation events (previously a `Time#to_s` string);
120
- - **Breaking**: `#extend_lock_ttl` success result is `{ ok: true, result: { extended_locks_count: Integer } }`
121
- instead of `{ ok: true, result: :ttl_extended }` (the number of locks whose TTL is extended: `1` for the write lock
122
- and for the own read lock, the number of extended read locks for `all_read_locks: true`);
123
- the failure result is unchanged (`{ ok: false, result: :async_expire_or_no_lock }`);
124
- - Lock acquirement attempt: the lock state is fetched in one pipelined round trip; lock data for
125
- `exit__no_first`/`exit__lock_still_obtained` try-lock logs is extracted only when these logs are enabled
126
- (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;
127
111
  ### Fixed
128
- - `dequeue_from_lock_queue` log: unclosed quote in the `acs_strat` value;
129
- - `extendable_reentrant_lock_obtained` log: the host identifier is logged as `hst_id` (was `host_id`), like in all other logs;
130
- - `start_lock_series_obtaining` log: missing space between the `lock_keys` and `queue_ttl` values;
131
- - `lock_series` PoC:
132
- - locks obtained by the failed series are released when the series fails without an exception
133
- (`raise_errors: false`); previously they lived till their TTL;
134
- - reentrant locks of the series (already obtained by the outer logic of the same acquirer) are not released
135
- by the series anymore (previously the outer logic continued to work without its lock);
136
- - the series releases only write locks that are still owned by the current acquirer (compare-and-delete):
137
- 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;
138
118
 
139
119
  ## [1.17.0] - 2026-10-04
140
120
  ### Changed