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
@@ -32,31 +32,31 @@ Related rule files (loaded for narrower paths):
32
32
  - Redis keys come only from `RedisQueuedLocks::Resource.prepare_*` helpers and its `*_PATTERN` / `SWARM_KEY` constants. Key families: write lock `rql:lock:` (`LOCK_PATTERN`), write requests `rql:lock_queue:` (`LOCK_QUEUE_PATTERN`), readers registry `rql:lock_readers:` (`LOCK_READERS_PATTERN`), read requests `rql:lock_read_queue:` (`READ_LOCK_QUEUE_PATTERN`), reader data `rql:lock_reader:<name>:<acq_id>` (`READ_LOCK_PATTERN`); all of them match `KEY_PATTERN` (`rql:lock*`); `Resource.lock_key_family(key)` classifies a key by its prefix (`:lock`, `:lock_queue`, `:lock_readers`, `:read_lock_queue`, `:read_lock`, `nil`).
33
33
  - New key families use a distinct **prefix** (`rql:lock_<kind>:<name>`), never a suffix of the lock name: the lock name is an arbitrary string, so `rql:lock_queue:<name>:read` would collide with the queue of a lock named `<name>:read`.
34
34
  - Lock index `rql:index:locks` (`Resource::LOCK_INDEX_KEY`), opt-in via the client-wide `config['lock_index_enabled']` (`false` by default: lock-iterating operations scan the keyspace, no lock index commands, queues without TTL): a global ZSET of lock names (score = upper bound of the lifetime of all lock keys, client ms + ttl + `lock_index_grace_period`); not matched by `KEY_PATTERN`. With the enabled lock index every key-creating command is followed by the index `ZADD GT` (queueing pipeline, obtaining `MULTI`), every extension by `ZADD XX INCR`, names are removed only with all their keys (`unlock`, `clear_locks`), lock request queues have TTL; all lock-iterating operations use it (`ZSCAN`), only `reindex_locks` scans the keyspace. Every lock-iterating module keeps both lookups (keyspace and lock index) side by side with the shared processing in one copy (details in `acquirer.md`, "Iteration" and "Lock index").
35
- - Time: lock expirations that affect safety use Redis time (`PEXPIRE`, or `TIME` via `Resource.redis_time_ms` for reader scores); client `Time.now.to_f` is used only for queue positions, `ts` fields and zombie probes; lock index scores use client ms (`Resource.lock_index_time`) with the `lock_index_grace_period` margin (clock skew tolerance; the index never affects mutual exclusion).
36
- - Config: `setting('key', default)` + `validate('key') { |val| ... }` in `config.rb`; dotted keys for nested groups (`swarm.flush_zombies.zombie_ttl`); units in a trailing `# NOTE: in milliseconds` comment.
35
+ - Time: lock expirations that affect safety use Redis time (`PEXPIRE`, or `TIME` via `Resource.redis_time_ms` for reader scores); client `Time.now.to_f` is used only for queue positions (shifted by the priority bands: `Resource.calc_initial_acquirer_position(priority)`), `ts` fields and zombie probes; lock index scores use client ms (`Resource.lock_index_time`) with the `lock_index_grace_period` margin (clock skew tolerance; the index never affects mutual exclusion).
36
+ - Config: `setting('key', default)` + `validate('key') { |val| ... }` in `config.rb`; dotted keys for nested groups (`swarm.flush_zombies.zombie_ttl`); units in a trailing `# NOTE: in milliseconds` comment. A validator sees one value: checks across keys live in `Config` itself (the only one: a priority name of `default_lock_priority` must be a key of `lock_priorities`, `prevent_default_priority__unknown_name`): once after a `configure` block (`Client.new { }`, `Client#configure`; any key order, the pair is restored on a failure) and before a direct `[]=` of either key is applied.
37
37
  - Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass. Each error inherits from the global base error of its branch: `Error < ::StandardError` (the main hierarchy), `ArgumentError < ::ArgumentError` (`SwarmArgumentError`), `Exception < ::Exception` (the internal timeouts `LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`); inside `module RedisQueuedLocks` the bare `Error`/`ArgumentError`/`Exception` are these bases.
38
38
  - Comments use `# NOTE:`, `# TODO:` and `# @type var x: T` / `#: T` for inline type hints.
39
- - Rubocop is suppressed locally with `# rubocop:disable Metrics/MethodLength` etc. (paired `enable`), mostly for large algorithm methods.
39
+ - Rubocop is suppressed locally with `# rubocop:disable Metrics/MethodLength` etc. (paired `enable`), mostly for large algorithm methods. The inherited armitage config enforces `yield_self` over `then` (`Style/ObjectThen`).
40
40
  - Shared mutable state is guarded by `RedisQueuedLocks::Utilities::Lock#synchronize`; Ractor code must not capture non-shareable objects (build a fresh Redis client via `Swarm::RedisClientBuilder`).
41
41
 
42
42
  ## Claude rules
43
43
  1. Start new files with `# frozen_string_literal: true` and use compact constant paths.
44
44
  2. Add a full YARD block to every new public/private method; new code gets `@since <next version>`. `@version <next version>`:
45
- - a method: when its behavior or its signature changes;
45
+ - a method: when its behavior or its signature changes; a typo fix in the method name or in a keyword argument name is a signature change too (callers change): the method gets `@version <next version>` (and the class/module header, see below); a typo fix in a positional parameter name is not a signature change (callers don't change) and bumps nothing;
46
46
  - a class/module header: only when its interface changes:
47
47
  - a method signature changes: its parameters (their kinds and names); the default values and the return values are behavior;
48
48
  - a method is added or removed (public or private, `attr_*` and aliases too);
49
49
  - the ancestors change: `extend`/`include`/`prepend` of a module or a changed superclass (e.g. the internal timeout errors in 1.21.0);
50
- - behavior changes inside the methods, constants, config settings (`setting`/`validate`: `Config` has no interface changes since 1.13.0) and comments bump only the method tags, never the class/module header;
50
+ - behavior changes inside the methods, constants, config settings (`setting`/`validate` are not methods of `Config`: its header changed in 1.22.0 only for `prevent_default_priority__unknown_name`) and comments bump only the method tags, never the class/module header;
51
51
  - the header keeps the version of the latest interface change; no `@version` when the interface never changed since `@since` (the headers of the versions >= 1.17.0 are aligned with this rule).
52
52
  3. New operation = new `Acquirer::*` module + thin `Client` method delegating to it (+ `!` variant only if it must raise); details in `acquirer.md`.
53
53
  4. Return `{ ok:, result: }` hashes from public API operations (`Client`, `Acquirer::*`, `Swarm` facade actions); raise only in `!` methods or on invalid arguments (`RedisQueuedLocks::ArgumentError`). Internal APIs, and swarm element internals in particular, use bare scalars/primitives (`true`/`false`, String, Symbol, Integer, `nil`, a flat Hash of scalars) without the wrapper.
54
54
  5. Never hardcode `rql:` strings; add a `Resource.prepare_*` helper or constant instead.
55
55
  6. Use WATCH/MULTI or a Lua constant for any read-modify-write on lock/queue keys; never do check-then-set in Ruby without a transaction.
56
- 7. New config option: `setting` with default + `validate` with a type check and the valid range (TTLs, periods, batch/scan sizes are positive Integers: `val.is_a?(Integer) && val > 0`), both in `config.rb`; read it in `Client` as `config['key']`. A per-call numeric option (a `Client` keyword with a `config` default) is validated with the same type and range at the start of the operation module (the `Swarm` facade for the swarm actions), before any Redis command: `RedisQueuedLocks::ArgumentError` (`SwarmArgumentError` in `Swarm`) with the option name and the value (`` "`:scan_size` argument should be a positive Integer, got 0." ``).
56
+ 7. New config option: `setting` with default + `validate` with a type check and the valid range (TTLs, periods, batch/scan sizes are positive Integers: `val.is_a?(Integer) && val > 0`), both in `config.rb`, plus the key's overload line in `sig/redis_queued_locks/config.rbs` (`Config#[]` is typed per key; `spec/redis_queued_locks/config_spec.rb` fails on a missing/extra line); read it in `Client` as `config['key']`. A per-call numeric option (a `Client` keyword with a `config` default) is validated with the same type and range at the start of the operation module (the `Swarm` facade for the swarm actions), before any Redis command: `RedisQueuedLocks::ArgumentError` (`SwarmArgumentError` in `Swarm`) with the option name and the value (`` "`:scan_size` argument should be a positive Integer, got 0." ``).
57
57
  8. Logs and instrumentation events go through visitor modules (see `visitors.md`); event names follow `redis_queued_locks.<snake_case>`.
58
58
  9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
59
- 10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods (the only exception: the line-level `Lint/InheritException` of the base `RedisQueuedLocks::Exception < ::Exception` in `errors.rb`, see rule 15).
59
+ 10. Follow the rubocop rules of the project (`.rubocop.yml` inheriting the armitage config, `.rubocop.rbs.yml` for RBS): `bundle exec rake rubocop` runs two passes (Ruby sources, then RBS) and both must be clean; read the whole output, not only its tail. Never change `.rubocop.yml` to make an offense pass: rewrite the code (e.g. `yield_self`, not `then`); a config change is a separate decision of the user. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods; never `# rubocop:disable all` (it hid real offenses until 1.21.1: an unpaired one in `client.rb` switched the linter off till the end of the file). A PoC file disables its `Metrics/*` cops by name at the top with the paired `enable` at the bottom (the only exception: the line-level `Lint/InheritException` of the base `RedisQueuedLocks::Exception < ::Exception` in `errors.rb`, see rule 15).
60
60
  11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
61
61
  12. Keep `.claude/project-overview.md` and the affected `.claude/rules/*.md` in sync in the same change (keys, options, results, events, algorithm steps, limitations); read the relevant overview sections before implementing and follow their invariants.
62
62
  13. Read/write awareness: every new or changed operation over locks/queues (release, cleanup, info, zombies, series) must handle the read key family too (see the key families above) and keep the result shape for write-only usage unchanged (add keys/branches only when read data exists).
@@ -80,8 +80,6 @@ Related rule files (loaded for narrower paths):
80
80
 
81
81
  ## Recommendations (proposed, not yet project policy)
82
82
  Apply to new or touched code; don't refactor existing code for these unless asked.
83
- 1. Avoid `# rubocop:disable all` (used in `client.rb` lock_series and `lock_series_poc.rb`); disable only the specific cops.
84
- 2. Document every option fully in YARD.
85
- 3. Spell new identifiers correctly and don't copy existing typos (`swarm_element__termiante` method, `Acquier` in comments and the `acquier.rbs` filename); fix them only in a dedicated change.
86
- 4. Include context in raised errors (lock name, acquirer id, timeout) so failures are diagnosable.
87
- 5. Prefer `then` (the modern alias) over `yield_self` in new code.
83
+ 1. Document every option fully in YARD.
84
+ 2. Spell new identifiers correctly; fix a found typo only in a dedicated change.
85
+ 3. Include context in raised errors (lock name, acquirer id, timeout) so failures are diagnosable.
@@ -17,7 +17,7 @@ The swarm removes **zombie locks**: locks and queue entries left by dead workers
17
17
  - keyspace (`flush_keyspace_zombies`, the disabled lock index, default): one `SCAN` pass over `KEY_PATTERN` (`COUNT` = `lock_scan_size`), keys classified by `Resource.lock_key_family`: write locks and readers registries in batches of `lock_scan_size` keys, lock queues collected during the pass and processed after it in batches of `queue_scan_size`;
18
18
  - lock index (`flush_lock_index_zombies`): drop expired lock names from the lock index (`ZREMRANGEBYSCORE rql:index:locks -inf <now - 1>`) → one `ZSCAN` pass over the lock index (`Resource::LOCK_INDEX_KEY`, lock names; `COUNT` = `lock_scan_size`), lock keys are derived from the names (the keyspace is never scanned): write locks (`prepare_lock_key`) and readers registries (`prepare_lock_readers`) in batches of `lock_scan_size` locks, both lock queues of each name in batches of `queue_scan_size` queues during the same pass (`batch_zombie_requests`);
19
19
  - shared batch helpers: pipelined `HMGET acq_id hst_id` + one `DEL` of the zombie locks; pipelined `ZRANGE` + pipelined `ZREM` zombie readers + `DEL` their reader data (`rql:lock_reader:<name>:<acq_id>`); pipelined `ZRANGE` + pipelined `ZREM` of the requests whose host (derived from the request's acquirer id by `Resource.host_identifier_from_acquirer`) is a zombie host, their acquirers are added to the zombie acquirers → `HDEL` zombie hosts.
20
- `ZombieInfo` mirrors the lock part (`each_zombie_lock` → `each_keyspace_zombie_lock` / `each_lock_index_zombie_lock`: the same passes and batches, names with an expired index score are skipped; nothing is scanned when there are no zombie hosts). It is best-effort and non-transactional. With the enabled lock index locks without a lock index entry (older RQL versions, the disabled lock index, raw writes) are invisible until `client.reindex_locks`.
20
+ `ZombieInfo` mirrors the lock part (`each_zombie_lock` → `each_keyspace_zombie_lock` / `each_lock_index_zombie_lock`: the same passes and batches, names with an expired index score are skipped (`Resource.lock_index_score_expired?`: `+inf` never expires); nothing is scanned when there are no zombie hosts). It is best-effort and non-transactional. With the enabled lock index locks without a lock index entry (older RQL versions, the disabled lock index, raw writes) are invisible until `client.reindex_locks`.
21
21
 
22
22
  ## Components
23
23
  | Object | File | Kind | Role |
@@ -42,7 +42,7 @@ Each element has two layers, with different code for each base:
42
42
  1. **Control unit** (`swarm_element`). Threaded: a `Thread` reading `swarm_element_commands` (`Thread::SizedQueue.new(1)`) and replying on `swarm_element_results` (`SizedQueue(1)`). Isolated: a `Ractor` running `self.swarm_loop(swarm_element_results_port)` that reads `swarm_element_commands_port` and replies on `swarm_element_results_port`, both `Ractor::Port`s (see "Isolated ports" below).
43
43
  2. **Main loop**: a `Thread` that does the actual periodic work (`loop { op(redis_client, ...); sleep(period) }`), with a Redis client built inside the thread by `RedisClientBuilder` from `swarm.<element>.redis_config.*`.
44
44
 
45
- Phases: **init** `swarm!` (create control unit, loop not started) → **start** `swarm_loop__start` (kill old main loop, spawn new) → **stop** `swarm_loop__stop` kills only the main loop → **kill** `swarm_element__termiante` (Threaded: kill both threads, close and clear queues, nil them) / `swarm_loop__kill` (Isolated: `:kill` makes the ractor kill and join all its threads, ack, and leave its loop; the host then `join`s the ractor).
45
+ Phases: **init** `swarm!` (create control unit, loop not started) → **start** `swarm_loop__start` (kill old main loop, spawn new) → **stop** `swarm_loop__stop` kills only the main loop → **kill** `swarm_element__terminate` (Threaded: kill both threads, close and clear queues, nil them) / `swarm_loop__kill` (Isolated: `:kill` makes the ractor kill and join all its threads, ack, and leave its loop; the host then `join`s the ractor).
46
46
 
47
47
  Command protocol: the same bare-value replies in both bases (see "Internal API: scalars and primitives" below).
48
48
  | Command | Reply |
@@ -114,11 +114,10 @@ Public element API (called by `Swarm`/`Supervisor` only):
114
114
  16. Swarm specs (in `describe 'swarm'`) deal with real timing: set short periods via config, kill elements with `try_kill!`, wait longer than `liveness_probing_period`, and match states loosely (`eq('running').or(eq('blocking'))`, `'sleep'`/`'run'`).
115
115
 
116
116
  ## Known quirks (don't copy; fix only in a dedicated change)
117
- - Typo in a method name: `swarm_element__termiante` (rename it only in a dedicated change).
118
117
  - `swarm_loop__stop` is not used by the swarm itself, only by specs.
119
118
  - Killing an element while its main loop is still connecting to Redis makes Ruby's `Socket.tcp` helper threads print `terminated with exception ... IO#write` reports. This is harmless noise from the stdlib.
120
119
  - `sleep(0.1)` "give a timespot" waits in `swarm!`/`deswarm!`/`observe!` instead of real readiness signalling.
121
- - RBS runtime type checking (`rbs/test/setup`, the `typecheck-runtime` CI job) can't run inside Ractors. Its hooks on methods called in the element Ractor (`.swarm_loop`, `.flush_zombies`) read `RBS.logger` and raise `Ractor::IsolationError`, so Isolated elements die at startup and the swarm specs fail only under that job.
120
+ - RBS runtime type checking (`rbs/test/setup`, the `typecheck-runtime` CI job) can't run inside Ractors: its hooks read `RBS.logger` and raise `Ractor::IsolationError`. The Ractor-side classes (`FlushZombies`, `SwarmElement::Isolated`, `RedisClientBuilder`) are excluded with `RBS_TEST_SKIP` and the shared helpers they call carry `%a{rbs:test:skip}` (`type-checking.md`); a new function called inside the element Ractor needs the same.
122
121
  - Swarm has no logging or instrumentation, and supervisor errors are silently dropped (`TODO: (CHECK)`).
123
122
  - Every process with a swarm runs its own zombie flusher: N processes make N lookup passes per flush period (no leader election yet); with the default keyspace lookup each pass is a full keyspace `SCAN` (the reason for the lock index).
124
123
  - Zombie write locks are checked (`HMGET`) and deleted (`DEL`) in two round trips without `WATCH`: a lock that expires and is re-acquired by a live acquirer in between can be deleted (kept by the user's decision).
@@ -7,12 +7,12 @@ paths:
7
7
  # Test rules (`spec/`)
8
8
 
9
9
  ## Observed conventions
10
- - All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~5.6k lines), under `RSpec.describe RedisQueuedLocks`; groups: `describe 'Lock Series PoC'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks (all of them in the shared examples), and `describe 'lock index'` (outside the shared examples, see below). The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
10
+ - All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~5.6k lines), under `RSpec.describe RedisQueuedLocks`; the only other spec is the Redis-free `spec/redis_queued_locks/config_spec.rb` (the typed `Config#[]` overload table of `config.rbs` == the `setting` registry); groups: `describe 'Lock Series PoC'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks (all of them in the shared examples), and `describe 'lock index'` (outside the shared examples, see below). The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
11
11
  - Known flaky examples (timing-dependent: they pass in isolation, rspec-retry usually hides them; check them with `RSPEC_RETRY_RETRY_COUNT=1` before blaming a change):
12
- - `all in + notifications`: `sleep(1)` before the lock/queue info checks; in full runs it can fail all retries in a row in the keyspace lookup mode (`locks_info.size` is 1 instead of 2): order-dependent (reproducible with `--seed 6170`, also without retried swarm examples), the example that leaks the state is not found yet (`rspec --bisect` of the suite is too slow);
13
- - `:queued access strategy`: the 4th attempt of `trd2` (~9 s, `retry_delay: 3_000`) races the expiration of the `trd1` lock (obtained at ~7 s with ttl 2 s), and the default `try_to_lock_timeout` (10 s) leaves no later attempt;
12
+ - `all in + notifications`: `sleep(1)` before the lock/queue info checks; it was order-dependent (wrong `locks_info.size` in full runs, e.g. `--seed 6170`, `--seed 28462`): `detailed lock acquirement timeout error` left an unbounded waiter thread that obtained its lock in the next example (now bounded and joined); check new failures without retries before blaming a change;
13
+ - `:queued access strategy` and `:queued access strategy (manually defined)` (the same scenario): the 4th attempt of `trd2` (~9 s, `retry_delay: 3_000`) races the expiration of the `trd1` lock (obtained at ~7 s with ttl 2 s), and the default `try_to_lock_timeout` (10 s) leaves no later attempt;
14
14
  - `(auto-swarming!): zombie locks (with hosts and acquirers)` and `manual zombie flushing (with statuses)`: swarm timings (`sleep`-based waits for the probes and the zombie flusher).
15
- - `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`), then `rspec/retry`, `pry`, the gem.
15
+ - `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`; not started for the `rake rspec:parallel` listing, `RQL_SPEC_LISTING`; a parallel worker, `RQL_SPEC_WORKER`, only stores its result in `coverage/parallel/worker-<n>`: the task collates the report), then `rspec/retry`, `pry`, the gem, then the parallel workers' database offset (`RQL_SPEC_DB_OFFSET`: prepended to `RedisClient::Config::Common#initialize`, which gets the resolved database (`db:` or the database of `url:`), `0` by default).
16
16
  - RSpec config: random order (`Kernel.srand config.seed`), `disable_monkey_patching!`, `expect` syntax only, `filter_run_when_matching :focus`, `Thread.abort_on_exception = true`.
17
17
  - Tests hit a real Redis (db 0) via `let(:redis) { RedisClient.config(db: 0).new_pool(timeout: 5, size: 50, ...) }` with long timeouts (RBS runtime-check runs are slow); `all in + notifications` also flushes db 1. Check the local Redis dbs before running the suite on a machine with other data.
18
18
  - Read/write lock specs use: a monotonic `timeline` hash + `mark` lambda (guarded by a `Mutex`) to assert ordering (`reader_in < other_reader_out`, `writer_in >= reader_out`); an invariant spec with shared reader/writer counters checked inside lock blocks; helper threads that take locks without a block (each thread is a separate acquirer).
@@ -31,8 +31,8 @@ paths:
31
31
  ## Claude rules
32
32
  The suite is pending a total rework (overview "Conventions"): these rules guide new examples, but their violations in `*_spec.rb` are not code review findings until the rework; the review checks only that the changed library behavior is covered and that changed expectations are correct (`.claude/skills/rql-code-review`, §3).
33
33
 
34
- 1. Run specs with a local Redis available: `bundle exec rake rspec` (single example: `bundle exec rspec spec/redis_queued_locks_spec.rb:<line>`).
35
- 2. Add new examples to `spec/redis_queued_locks_spec.rb` with `specify '<feature>'`, inside an existing `describe` when one fits; don't create new spec files unless asked (the suite is pending a rework).
34
+ 1. Run specs with a local Redis available. Run the suite in parallel processes by default: `bundle exec rake rspec:parallel` (~1 min instead of ~6 of the serial `bundle exec rake rspec`; the RBS runtime checks: the same environment with `rake rspec:parallel` (locally; the CI `typecheck-runtime` job stays serial: parallel workers under the RBS hooks starve the timing-sensitive examples on its 2-core runner); `WORKERS=<n>` flushes db `0..2n-1`, see the `RQL_SPEC_DB_OFFSET` note in `spec_helper.rb`; failures are listed with their rerun command, worker logs in `tmp/rspec_parallel/`). Use the serial run only when something has to be checked in one process: an order-dependent failure or a whole-suite `--seed` (each parallel worker has its own seed and order), state leaking between examples (threads, Redis keys of a finished example), changes of the parallel infrastructure (`spec_helper.rb`, `spec/support/parallel_runner.rb`: check both runs), the serial duration itself. Targeted runs of a few examples (development, the flakiness checks of rule 10) use `bundle exec rspec spec/redis_queued_locks_spec.rb:<line>` / `-e '<group>'` directly. Examples must stay independent of other processes: no server-wide Redis commands (`FLUSHALL`, `CONFIG SET`, `SWAPDB`, `SELECT`), Redis clients only through `RedisClient.config` (the database offset of the parallel workers is applied there).
35
+ 2. Add new examples to `spec/redis_queued_locks_spec.rb` with `specify '<feature>'`, inside an existing `describe` when one fits; don't create new spec files unless asked (the suite is pending a rework); Redis-free consistency checks (e.g. the `config_spec.rb` table sync) are the exception: one small file per subject under `spec/redis_queued_locks/`.
36
36
  3. Use only `expect` syntax; no `should`, no monkey-patched DSL, no `focus` left behind.
37
37
  4. Use the shared `redis` pool and rely on the global `before`/`after` FLUSHDB; never use a different DB or flush in the middle of an example without reason.
38
38
  5. Build fakes as anonymous classes implementing the duck-typed interface (`debug`, `notify`, `sampling_happened?`), not with doubles of real loggers.
@@ -9,17 +9,17 @@ paths:
9
9
  # Type-checking rules (`sig/`, Steep, RBS)
10
10
 
11
11
  ## Observed conventions
12
- - `sig/` mirrors `lib/` one file per file (`lib/redis_queued_locks/acquirer/is_locked.rb` → `sig/redis_queued_locks/acquirer/is_locked.rbs`). Known typo: `sig/redis_queued_locks/acquier.rbs` is the `Acquirer` namespace file.
12
+ - `sig/` mirrors `lib/` one file per file (`lib/redis_queued_locks/acquirer/is_locked.rb` → `sig/redis_queued_locks/acquirer/is_locked.rbs`).
13
13
  - RBS uses fully nested `module RedisQueuedLocks / module Acquirer / ...` blocks (unlike compact Ruby constants).
14
14
  - Common aliases at the top: `use RedisQueuedLocks as RQL`, `use RedisClient as RC`; Redis connections typed as `RC::client`.
15
- - Shared duck types live in `sig/redis_queued_locks.rbs`: `_Loggable`, `_Instrumentable`, `loggerObj`, `instrObj`; samplers as `RQL::Logging::samplerObj` / `RQL::Instrument::samplerObj`.
15
+ - Shared duck types live in `sig/redis_queued_locks.rbs`: `_Loggable`, `_Instrumentable`, `loggerObj`, `instrObj`; samplers as `RQL::Logging::samplerObj` / `RQL::Instrument::samplerObj`; option literal unions there too: `RQL::readWriteMode` (`:read | :write`), `RQL::accessStrategy` (`:queued | :random`), `RQL::conflictStrategy` (the four conflict strategies), plus `RQL::lockPriority` (`Integer | Symbol`: an Integer priority or a priority name; `Client`, `AcquireLock.acquire_lock`, `config['default_lock_priority']`; code after the name resolution types `priority` as `Integer`) type `read_write_mode`/`rw_mode`, `access_strategy` and `conflict_strategy` everywhere (`Client`, `AcquireLock` and its mixins, visitors, `ExtendLockTTL`, the config defaults); never `Symbol` for them. The one exception is the entry point that validates the value at runtime (`AcquireLock.acquire_lock` Step 0.1 (RW), `ExtendLockTTL.extend_lock_ttl`): its `read_write_mode` stays `Symbol` with a `NOTE: (rbs test)` (the RBS runtime checks of the specs must reach the `ArgumentError` of an invalid Symbol), and the validation is a `case read_write_mode when :read, :write then nil else raise(...) end` so Steep narrows the variable to `RQL::readWriteMode` for everything below (an `if x != :read && x != :write` guard leaves `(:read | Symbol)`).
16
16
  - Result shapes are named record aliases inside the module, e.g. `type extendResult = { ok: bool, result: { extended_locks_count: Integer } | Symbol }`.
17
17
  - Each `Acquirer::*` operation module (except PoC modules) declares its own aliases and never references another operation module's aliases (`Locks::lockInfo` / `Locks::readerInfo` mirror `LockInfo::lockInfo` / `LockInfo::readerInfo`); `Client` signatures may reference any of them.
18
18
  - Module functions are declared `def self.name: (...) -> T`; long signatures put one param per line with names.
19
19
  - Instance variables are declared (`@config_setters: configSetters`) and attr readers typed.
20
- - Third-party types: `sig/vendor/*.rbs` hand-written stubs (redis_client, active_support, semantic_logger) plus `rbs collection` gems (redis-client, securerandom, timeout, logger, monitor) installed into `.gem_rbs_collection/`.
20
+ - Third-party types: `sig/vendor/*.rbs` hand-written stubs (redis_client, active_support, semantic_logger) plus `rbs collection` gems (redis-client, securerandom, timeout, logger, monitor, json) installed into `.gem_rbs_collection/`; the `gem_rbs_collection` source is pinned to a commit `revision` in `rbs_collection.yaml` (never `main`), so installs are reproducible and `rbs_collection.lock.yaml` stays stable.
21
21
  - `Steepfile`: target `lib`, signatures `sig`, ignores `spec`, libraries timeout/securerandom/logger/monitor, diagnostics `Steep::Diagnostic::Ruby.strict`.
22
- - In Ruby code: `# @type var x: T` and `x = ... #: T` annotations narrow types; `# steep:ignore` silences spots Steep really reports (nil-narrowed `attr_reader` results, pattern-matching destructures, splats). `Config#[]` returns `untyped`, so `config['...']` defaults in `Client` need no ignore.
22
+ - In Ruby code: `# @type var x: T` and `x = ... #: T` annotations narrow types; `# steep:ignore` silences spots Steep really reports (nil-narrowed `attr_reader` results, pattern-matching destructures, splats). `Config#[]` is typed per key (one overload per `setting` with a string literal key in `config.rbs`), so `config['...']` reads are precisely typed, unknown keys are Steep errors (`Cannot find compatible overloading`) and a dynamic `String` key does not type-check (lookups by variable are not used in `lib/`).
23
23
  - Steep 2.1 pitfalls found in this codebase:
24
24
  - record types with String keys (`{ 'acq_id' => String, ... }`) crash Steep's subtyping (`RuntimeError`/`Unexpected error` logged as `ERROR`/`FATAL`, while the summary still says "No type error detected"): use `Hash[String, ...]` aliases instead (see `QueueInfo::queueInfo`, `LockInfo::lockInfo`);
25
25
  - `x != nil` does not narrow `String?`/`Float?` in `elsif` branches or `&&` chains on locals typed from tuples: use truthiness (`elsif x`) or total conversions (`x.to_f > now`, `ttl.to_i`);
@@ -27,7 +27,7 @@ paths:
27
27
  - `LockSeriesPoC` has no RBS: new calls/definitions there need `# steep:ignore` (like the existing ones).
28
28
  - splatting a `Set` (`call(*set)`) is rejected (`Ruby::ArgumentTypeMismatch`: splat arguments must be arrays), and no honest inline annotation fixes it; at runtime Ruby converts a splatted `Set` with `Set#to_a` on every call.
29
29
  - Steep 2 narrows on `==` with literals (`x == :sym` makes `x` the literal type `:sym`) and rejects `# @type var` annotations that widen a narrowed variable; RBS 4's `Kernel#Array` has a `(nil) -> []` overload that `untyped` arguments resolve to, so `Array(rconn.call(...)).first` needs a `#: T` assertion.
30
- - Runtime checking: CI runs specs with `RUBYOPT=-rrbs/test/setup RBS_TEST_TARGET='RedisQueuedLocks::*'`, so signatures must match real runtime values, not just Steep's view.
30
+ - Runtime checking: CI runs specs with `RUBYOPT='-rbundler/setup -rrbs/test/setup' RBS_TEST_TARGET='RedisQueuedLocks::*'` (the blocking `typecheck-runtime` job, command in the workflow and README; the job stays serial (`bundle exec rspec`): parallel workers under the RBS hooks starve the timing-sensitive examples on the 2-core runner, locally the same environment works with `rake rspec:parallel`; `bundler/setup` goes first so `rbs`/`prism` come from `Gemfile.lock`, not from newer system gems), so signatures must match real runtime values, not just Steep's view. Not hooked (rbs/test hooks read `RBS.logger` and die with `Ractor::IsolationError` inside the flusher Ractor): the Ractor-side classes via `RBS_TEST_SKIP` (`Swarm::FlushZombies`, `Swarm::SwarmElement::Isolated`, `Swarm::RedisClientBuilder`) and the shared helpers they call via `%a{rbs:test:skip}` in RBS (`Resource.prepare_*`, `lock_key_family`, `lock_index_time`, `lock_name_from_readers`, `host_identifier_from_acquirer`, `calc_zombie_score`, `Utilities.thread_state`); `VoidNotifier.notify` and the samplers' `sampling_happened?` are skipped because `Instrument.valid_interface?` / `valid_sampler?` introspect their `#parameters`. `RBS_TEST_SKIP` entries need the absolute form (`::RedisQueuedLocks::...`): rbs/test compares them with absolute type names without normalizing them. Specs that pass type-invalid values on purpose skip those expectations under `RBS_RUNTIME_TEST` (`spec_helper.rb`); value-invalid cases of the right type (`:reed`) stay.
31
31
 
32
32
  ## Claude rules
33
33
  1. Every change to a `lib/` file's public or private API (new method, param, return shape, constant, ivar) must be reflected in the mirrored `.rbs` file in the same change.
@@ -37,15 +37,14 @@ paths:
37
37
  5. Prefer fixing types or adding `# @type var` / `#: T` annotations over `# steep:ignore`; add `# steep:ignore` only where Steep reports a diagnostic. Strict mode fails the build on `Ruby::RedundantIgnoreComment`, so never add a speculative ignore and remove ones that become redundant. Never shape runtime code around Steep limitations (conversions, copies or allocations made only to satisfy Steep): annotate it, or use a targeted `# steep:ignore` with a `NOTE: (steep)` comment when no honest annotation exists. Keep a conversion only when it is cheaper at runtime and say so in its comment (e.g. a `Set` splatted in a loop: build the array once instead of a `Set#to_a` per call).
38
38
  6. Don't loosen `Steep::Diagnostic::Ruby.strict` or add `ignore` entries to the `Steepfile`.
39
39
  7. New third-party gem used in `lib/`: add it to `rbs_collection.yaml` (and `sig/manifest.yml` for stdlib) or write a minimal stub in `sig/vendor/`.
40
- 8. Verify with `bundle exec rbs collection install && bundle exec rake steep:check`; for signature/runtime mismatches run the specs under RBS runtime testing (command in `.github/workflows/typecheck-runtime.yml`).
41
- 9. Don't rename `acquier.rbs` unless asked; it is a known quirk.
40
+ 8. Verify with `bundle exec rbs collection install && bundle exec rake steep:check`; for signature/runtime mismatches run the specs under RBS runtime testing (the environment of `.github/workflows/typecheck-runtime.yml`; in parallel processes by default: the same environment with `bundle exec rake rspec:parallel` instead of `bundle exec rspec`, `tests.md#1`).
41
+ 9. Keep RBS file and directory names identical to their `lib/` counterparts (`acquirer.rbs`, `acquire_lock/yield_expire/`).
42
42
  10. After `steep:check`, also grep its output for `ERROR`/`FATAL` lines: an internal Steep crash skips the file but still reports "No type error detected".
43
- 11. `bundle exec rbs collection install` may rewrite `rbs_collection.lock.yaml` with environment-specific entries; restore it (`git checkout -- rbs_collection.lock.yaml`) unless the change is intended.
43
+ 11. Bump the pinned `gem_rbs_collection` revision only deliberately, in its own change: set the new commit in `rbs_collection.yaml`, run `bundle exec rbs collection update`, commit both files together and keep Steep green.
44
+ 12. New `setting` in `config.rb` = new overload line `| ("<key>") -> <value type>` in `Config#[]` (`config.rbs`), in the `setting` order; the value type is the validator's type (`Integer`, `Integer?` for nullable keys, `bool`, shared aliases such as `RQL::loggerObj`, `RQL::conflictStrategy`).
45
+ 13. Keep the runtime type-check job green: run its command before proposing a commit that touches `sig/` or method signatures; a new method executed inside the flusher Ractor gets `%a{rbs:test:skip}` (with the `(rbs test)` NOTE) or its class goes to `RBS_TEST_SKIP` in the workflow and README; a new spec that passes a type-invalid value wraps that expectation in `unless RBS_RUNTIME_TEST`.
46
+ 14. `bundle exec rbs collection install` may rewrite `rbs_collection.lock.yaml` with environment-specific entries; restore it (`git checkout -- rbs_collection.lock.yaml`) unless the change is intended.
44
47
 
45
48
  ## Recommendations (proposed, not yet project policy)
46
- 1. Make the runtime type-check CI job blocking (drop `--failure-exit-code=0`) once current violations are fixed.
47
- 2. Type `config['...']` lookups (`Config#[]` returns `untyped`, so `Client` keyword defaults are unchecked) with a typed config accessor (e.g. per-key typed readers or an RBS overload table for `Config#[]`).
48
- 3. Pin the `gem_rbs_collection` revision in `rbs_collection.yaml` instead of `main` for reproducible checks.
49
- 4. Rename `sig/redis_queued_locks/acquier.rbs` to `acquirer.rbs` in a dedicated change.
50
- 5. Replace remaining `untyped` in signatures with precise unions or interfaces where the value set is known (e.g. strategy symbols as `:queued | :random`).
51
- 6. Type strategy and mode options as literal unions (`conflict_strategy: :wait_for_lock | :work_through | :extendable_work_through | :dead_locking`) so Steep catches invalid values.
49
+ 1. Type `Config#[]=` per key like `Config#[]` (an overload table of key literal => value type) so Steep checks assignments in user code too.
50
+ 2. Replace remaining `untyped` in signatures with precise unions or interfaces where the value set is known (`client.rbs` results and `meta`, the `void_logger.rbs` and `redis_client_builder.rbs` args).
@@ -19,7 +19,7 @@ The project calls these modules "visitors", but they are not GoF Visitor (no `ac
19
19
  |---|---|---|
20
20
  | `AcquireLock::LogVisitor` | `acquire_lock/log_visitor.rb` | 7: `start_lock_obtaining`, `start_try_to_lock_cycle`, `dead_score_reached__reset_acquirer_position`, `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained`, `lock_obtaining_failed` |
21
21
  | `AcquireLock::InstrVisitor` | `acquire_lock/instr_visitor.rb` | 6: `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained`, `lock_obtaining_failed`, `reentrant_lock_hold_completes`, `lock_hold_and_release` |
22
- | `AcquireLock::TryToLock::LogVisitor` | `acquire_lock/try_to_lock/log_visitor.rb` | 18 step-level events (`start`, `rconn_fetched`, `acq_added_to_queue`, `exit__no_first`, `exit__read_lock_still_obtained`, `obtain__free_to_acquire`, ...); every lock event logs `rw_mode` |
22
+ | `AcquireLock::TryToLock::LogVisitor` | `acquire_lock/try_to_lock/log_visitor.rb` | 19 step-level events (`start`, `rconn_fetched`, `acq_added_to_queue`, `dead_request_removed`, `exit__no_first`, `exit__read_lock_still_obtained`, `obtain__free_to_acquire`, ...); every lock event logs `rw_mode` |
23
23
  | `AcquireLock::YieldExpire::LogVisitor` | `acquire_lock/yield_expire/log_visitor.rb` | `expire_lock`, `decrease_lock` |
24
24
  | `AcquireLock::DequeueFromLockQueue::LogVisitor` | `acquire_lock/dequeue_from_lock_queue/log_visitor.rb` | `dequeue_from_lock_queue` |
25
25
  | `LockSeriesPoC::LogVisitor` / `InstrVisitor` | `lock_series_poc/*_visitor.rb` | lock series events (no RBS, `# steep:ignore`): `start_lock_series_obtaining`, `lock_series_obtained`, `expire_lock_series` / `lock_series_obtained`, `lock_series_hold_and_release`; all of them log/carry `rw_mode` (the mode of the series) |
@@ -31,11 +31,11 @@ Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`
31
31
  - **Shape**: `module <Component>::LogVisitor` / `::InstrVisitor`, `# @api private`, all methods inside `class << self`, full YARD block per method; return `void`.
32
32
  - **Method name = event name**: snake_case; double underscore separates phase and detail (`exit__queue_ttl_reached`, `reentrant_lock__work_through`). The LogVisitor and InstrVisitor use the same method name for the same event.
33
33
  - **Parameter order**: `(port, sampled_flag, [extra gate], lock_key, ...event data..., [instrument])`:
34
- - LogVisitor: `(logger, log_sampled, ...)`; `TryToLock::LogVisitor` adds `log_lock_try` as the third arg (fine-grained step logs are opt-in through `config['log_lock_try']`). Lock lifecycle events (AcquireLock, TryToLock, YieldExpire, DequeueFromLockQueue, LockSeriesPoC) take `rw_mode` right after `access_strategy`, before event-specific data, and log it as `rw_mode => '<mode>'` right after `acs_strat`.
35
- - InstrVisitor: `(instrumenter, instr_sampled, lock_key, rw_mode, ttl, acq_id, hst_id, ts, acq_time, [hold_time], instrument)`; the payload carries `rw_mode:` (requested mode); the user's `instrument` value is always last.
34
+ - LogVisitor: `(logger, log_sampled, ...)`; `TryToLock::LogVisitor` adds `log_lock_try` as the third arg (fine-grained step logs are opt-in through `config['log_lock_try']`). Lock lifecycle events (AcquireLock, TryToLock, YieldExpire, DequeueFromLockQueue, LockSeriesPoC) take `rw_mode` right after `access_strategy`, before event-specific data, and log it as `rw_mode => '<mode>'` right after `acs_strat`; the lock acquirement logs of `AcquireLock::LogVisitor` (all of them), `LockSeriesPoC::LogVisitor` (the priority of the series) and `TryToLock::LogVisitor.acq_added_to_queue` take `priority` right after `rw_mode` and log `priority => N` right after it (the release logs of `YieldExpire` and `DequeueFromLockQueue` don't); try-lock logs that name another request log its decoded priority right after its acquirer (`first_priority_in_queue` after `first_acq_id_in_queue`, `ahead_priority` after `ahead_acq_id`, `dead_priority` after `dead_acq_id`; `Integer?` params decoded at the call site only when `log_sampled && log_lock_try`, by one helper: `TryToLock#logged_request_priority`, see rule 12).
35
+ - InstrVisitor: `(instrumenter, instr_sampled, lock_key, rw_mode, ttl, acq_id, hst_id, ts, acq_time, [hold_time], instrument)`; the payload carries `rw_mode:` (requested mode); the user's `instrument` value is always last. All lock acquirement events (`lock_obtained`, `lock_hold_and_release`, `lock_obtaining_failed`, the reentrant ones: the requested priority, they never queue; the lock series ones: the priority of the series) take `priority` (the lock request priority: always the Integer, a priority name is resolved before) right after `rw_mode` (payload `priority:` after `rw_mode:`).
36
36
  - `rw_mode` in AcquireLock/TryToLock/DequeueFromLockQueue events is the **requested** mode; in YieldExpire events it is the **held** mode (the lock that is released/decreased); in LockSeriesPoC events it is the mode of the series (`read_write_mode`, one for all locks of the series). LockSeriesPoC instrumentation takes `rw_mode` right after `lock_keys`.
37
37
  - **Guard first**: `return unless log_sampled` (`&& log_lock_try` for try-lock steps) / `return unless instr_sampled`. The sampling decision is computed **once per operation** in the caller via `Logging.should_log?` / `Instrument.should_instrument?` and passed down as a boolean.
38
- - **Log format**: one `logger.debug { ... }` block (lazy string), message = `"[redis_queued_locks.<event>] "` (`[redis_queued_locks.try_lock.<event>]` for TryToLock steps) followed by `key => value` pairs; string values in single quotes (`lock_key => '...'`), numbers bare; abbreviated keys `acq_id`, `hst_id`, `acs_strat`. Built with `\` line continuations.
38
+ - **Log format**: one `logger.debug { ... }` block (lazy string), message = `"[redis_queued_locks.<event>] "` (`[redis_queued_locks.try_lock.<event>]` for TryToLock steps) followed by `key => value` pairs; string values in single quotes (`lock_key => '...'`), numbers bare (a missing number of an `Integer?` param is logged as `nil` with `#inspect`, never as an empty value: `first_priority_in_queue => nil`); abbreviated keys `acq_id`, `hst_id`, `acs_strat`. Built with `\` line continuations.
39
39
  - **Instrumentation format**: `instrumenter.notify('redis_queued_locks.<event>', { lock_key:, ttl:, acq_id:, hst_id:, ts:, acq_time:, instrument: })` using shorthand hash syntax and Symbol keys.
40
40
  - **Never raise**: every emit ends with `rescue nil` (modifier), so a broken logger/instrumenter can't affect lock correctness.
41
41
  - **Call sites**: plain module calls with positional args, usually grouped on 2-3 lines (`LogVisitor.lock_obtained(logger, log_sampled, lock_key, ...)`); data comes from local vars or the `result` hash of the step.
@@ -65,4 +65,4 @@ Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`
65
65
  Apply to new or touched code; don't refactor existing code for these unless asked.
66
66
  1. Use one error-swallowing style consistently (visitors use modifier `rescue nil`, mutation modules use `run_non_critical`). Both silently hide bugs such as a `NoMethodError` inside the log block, so consider reporting swallowed errors when the debugger is enabled.
67
67
  2. Keep log and instrumentation event names in one registry constant (e.g. `RedisQueuedLocks::Instrument::EVENTS`) so README, specs and RBS can be checked against it.
68
- 3. Fix the RBS path mismatch: `sig/.../acquire_lock/yield_with_expire/log_visitor.rbs` mirrors `lib/.../acquire_lock/yield_expire/log_visitor.rb`; add RBS for `lock_series_poc` visitors when the PoC stabilizes.
68
+ 3. Add RBS for the `lock_series_poc` visitors when the PoC stabilizes.
@@ -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,21 @@ 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
97
  `RedisQueuedLocks::VERSION` (`version.rb`); don't take the maximum over `lib/` (old tags can be wrong).
92
- A value older than `VERSION` on new code, a method behavior/signature change without the method `@version` bump, or a
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
93
100
  class/module header `@version` that does not match its latest interface change (`logic.md#2`: signatures,
94
101
  added/removed methods, `extend`/`include`/`prepend`, the superclass; behavior changes never bump the header) is a
95
102
  Minor finding.
@@ -143,15 +150,31 @@ Still in scope (the exemption covers how specs are written, not whether the libr
143
150
  the reentrant write extension check `result.size == (lock_index_enabled ? 5 : 4)` follows its `MULTI`.
144
151
  - Round trips of the hot path must not regress silently (overview "Lock acquisition flow"): `lock {}` = 4, a
145
152
  failed attempt = 3, a failed retry = 1 (pre-check), `fail_fast` on an obtained lock = 1, `unlock` without
146
- 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:)`).
147
155
  - New destructive paths (delete/shorten a lock) check the owner, unless the change documents the trade-off as
148
156
  accepted by the user (like the known limitations in §5).
149
157
  - Shared mutable state behind `Utilities::Lock#synchronize` (`logic.md#9`).
150
158
 
151
- ### 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")
152
160
  - Every operation over locks, queues, zombies or lock info covers `rql:lock:*`, `rql:lock_queue:*`,
153
- `rql:lock_readers:*`, `rql:lock_read_queue:*` and `rql:lock_reader:<name>:<acq_id>`; every attempt prunes dead
154
- 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`).
155
178
  - Write-only result shapes stay unchanged; read keys/fields appear only when read data exists.
156
179
  - `read_lock_key` is `nil` for write requests; reader data is a data carrier only (the registry decides existence),
157
180
  without `acq_id`/`hst_id`; don't store derivable values; a new lock field updates every formatting copy (§2,
@@ -160,7 +183,41 @@ Still in scope (the exemption covers how specs are written, not whether the libr
160
183
  entries) is still read correctly; a key/data format change states the rolling-deploy requirement in README and
161
184
  CHANGELOG.
162
185
 
163
- ### 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")
164
221
  - Disabled mode (default): no lock index command at all (`LOCK_INDEX_KEY` only under `if lock_index_enabled` /
165
222
  after `return unless lock_index_enabled`), no `ZSCAN`, queues without TTL. Enabled mode: no keyspace `SCAN`
166
223
  except `ReindexLocks`.
@@ -177,9 +234,9 @@ Still in scope (the exemption covers how specs are written, not whether the libr
177
234
  `lock`, `lock!`, `lock_series`, `lock_series!`, `reindex_locks`. Pruning (`ZREMRANGEBYSCORE <index> -inf
178
235
  <now - 1>`) stays in the queueing pipeline, `flush_zombies`, `clear_locks`, `clear_dead_requests`.
179
236
 
180
- ### D. Iteration and performance (`acquirer.md#7`, `arguments.md#7`)
237
+ ### E. Iteration and performance (`acquirer.md#7`, `arguments.md#7`)
181
238
  - One pass per operation: one `SCAN` over `KEY_PATTERN`/`ALL_LOCK_QUEUES_PATTERN` classified by
182
- `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`,
183
240
  never one round trip per key; per-key commands in pipelined batches collected across pages, plus the
184
241
  remainder batch.
185
242
  - A new lock-iterating operation implements both lookups side by side, chooses the lookup in one place
@@ -191,7 +248,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
191
248
  `NO_LOCK_DATA`); keys computed once in `acquire_lock`; log-only Redis reads gated at the call site
192
249
  (`acquirer.md#13`); no `Set` splatted in a loop.
193
250
 
194
- ### E. Architecture and API design
251
+ ### F. Architecture and API design
195
252
  - `Client` only fills defaults from `config['...']` and delegates (`logic.md#3`); an `x!` variant exists only when it
196
253
  must raise and repeats the full keyword list.
197
254
  - No parameter objects, `Struct`/`Data.define`/`OpenStruct`, builders, `**opts`, `options = {}`, `...` forwarding
@@ -219,7 +276,8 @@ Still in scope (the exemption covers how specs are written, not whether the libr
219
276
  `'rql:...'` literal outside `resource.rb`.
220
277
  - Config: `setting` + `validate` in `config.rb` (booleans `val == true || val == false`, Integers with ranges; per-call
221
278
  numeric options validated with the same ranges in the operation module, `logic.md#7`), dotted
222
- keys for groups, unit in a trailing `# NOTE:` comment, read as `config['key']` (`logic.md#7`). Errors:
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:
223
281
  `class XError < Error; end` in `errors.rb`, each under the global base of its branch (`Error`, `ArgumentError`,
224
282
  `Exception`: super exceptional cases only, with the reason in the comment; `logic.md#15`). Runtime dependencies:
225
283
  `redis-client` only; dev gems in `Gemfile`.
@@ -227,7 +285,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
227
285
  (`acquirer.md#10`); `lock_series_poc.rb` is experimental: don't accept new features built on it without the
228
286
  user's request.
229
287
 
230
- ### F. Swarm, Ractors, threads (`swarm.md`)
288
+ ### G. Swarm, Ractors, threads (`swarm.md`)
231
289
  - Redis work in stateless class functions shared by the main loop and the manual API (`#1`); main loops build
232
290
  their own client with `RedisClientBuilder` inside the loop thread (`#4`).
233
291
  - Into `Ractor.new` only copied plain values (`config.slice(...)`, scalars, `lock_index_enabled`): no `rql_client`,
@@ -241,7 +299,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
241
299
  - Lifecycle names and semantics identical in both bases (`#7`); a new element is registered everywhere (`#3`);
242
300
  status shapes `{ running:, state: }` with `'non_initialized'` and their RBS (`#10`).
243
301
 
244
- ### G. Logging and instrumentation (`visitors.md`)
302
+ ### H. Logging and instrumentation (`visitors.md`)
245
303
  - Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`); mutation
246
304
  modules write one log line and one event inline inside `run_non_critical`, gated by `log_sampled`/`instr_sampled`
247
305
  (`acquirer.md#9`).
@@ -261,7 +319,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
261
319
  the default write success path (`#13`); new or changed log keys/payload keys go to README `## Logging` /
262
320
  `### Instrumentation Events` and CHANGELOG (`#14`).
263
321
 
264
- ### H. Types (`type-checking.md`)
322
+ ### I. Types (`type-checking.md`)
265
323
  - Every changed method, param, return shape, constant or ivar is mirrored in `sig/` (`#1`): nested module blocks,
266
324
  `use RedisQueuedLocks as RQL` / `use RedisClient as RC`, shared types (`RC::client`, `RQL::loggerObj`,
267
325
  `RQL::instrObj`, `samplerObj`), `type xxxResult = { ok: bool, result: ... }` for public results, literal types for
@@ -271,7 +329,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
271
329
  annotation carries `NOTE: (steep)`; never shape runtime code around Steep (`#5`). `Steepfile` is never loosened (`#6`).
272
330
  - RBS must match runtime values (runtime type checks run the suite): e.g. `Integer|Float` where a Float can arrive.
273
331
 
274
- ### I. Ruby style of the codebase
332
+ ### J. Ruby style of the codebase
275
333
  - `# frozen_string_literal: true`; compact constant paths (`module RedisQueuedLocks::Acquirer::Keys`); namespace
276
334
  files only `require_relative` (`logic.md#1`).
277
335
  - YARD on every class, module, constant, attr and method: `@param`/`@option`/`@return` (`Format: { ... }` for hash
@@ -293,12 +351,16 @@ Still in scope (the exemption covers how specs are written, not whether the libr
293
351
  - Rubocop: only specific `Metrics`/`Layout` cops disabled locally with a paired `enable`, never
294
352
  `# rubocop:disable all` in new code (`logic.md#10`); long signatures are not "fixed" (`arguments.md#8`).
295
353
  - Suggestions only (`logic.md` recommendations, not violations): correctly spelled new identifiers (don't copy the
296
- known typos), `then` instead of `yield_self` in new code.
354
+ known typos).
297
355
 
298
- ### J. Docs and process
356
+ ### K. Docs and process
299
357
  - The same change updates `.claude/project-overview.md` and the affected `.claude/rules/*.md` for every new/changed
300
358
  key, option, result shape, event, algorithm step or limitation (`logic.md#12`), README and CHANGELOG
301
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`.
302
364
  - Commits under review (`git.md`): subject only `[<scope>] <summary>`, lowercase, imperative, no trailing period,
303
365
  no body except the attribution trailer, never on `master`.
304
366
 
@@ -309,15 +371,24 @@ Still in scope (the exemption covers how specs are written, not whether the libr
309
371
  read→write upgrade as an error unless `:wait_for_lock`; nested reads through `conflict_strategy`; no fairness
310
372
  between modes with `:random`; best-effort cross-host FIFO; no Lua in the RW path; one mode per lock series;
311
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).
312
384
  - Unused `Resource` methods marked `# NOTE: (debug)` (manual runtime checks, `logic.md#17`); report a debug method
313
385
  only when it is wrong for a key family it can get.
314
386
  - Known limitations kept by the user: `YieldExpire` write release (`EXPIRE 0`) and ttl decrease without an owner
315
387
  check; `HMGET`→`DEL` without `WATCH` in `ReleaseLocksOf`/`FlushZombies`; `unlock` dropping the data of a reader
316
388
  re-acquired between its two round trips; `zombie_acquirers` vs `flush_zombies` acquirer sets.
317
- - Known debt: `WithAcqTimeout` → `LockInfo`/`QueueInfo`; rspec-retry, disabled coverage minimum, non-blocking runtime
318
- type checks; `acquier.rbs` and the `yield_with_expire` RBS path; the `swarm_element__termiante` typo;
389
+ - Known debt: `WithAcqTimeout` → `LockInfo`/`QueueInfo`; rspec-retry, disabled coverage minimum;
319
390
  `sleep(0.1)` readiness waits and missing logging in the swarm;
320
- one flusher per process; `EVAL` without `SCRIPT LOAD`; `# rubocop:disable all` around lock series.
391
+ one flusher per process; `EVAL` without `SCRIPT LOAD`.
321
392
  - "Recommendations (proposed, not yet project policy)" in the rule files: never violations; mention one only as an
322
393
  optional suggestion for touched code.
323
394
  - Pre-existing problems outside the diff: out of scope; mention a severe one briefly in the template's
@@ -328,7 +399,9 @@ Still in scope (the exemption covers how specs are written, not whether the libr
328
399
 
329
400
  - Confirm it in the code (call sites, copies, RBS, specs), not by pattern match: concurrency claims name the
330
401
  interleaving (acquirer A at step X, B at step Y) and why `WATCH` doesn't catch it; layout claims count the
331
- 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.
332
405
  - Drop findings contradicted by §5, by a design `NOTE` in the code or by the overview. Mark the rest
333
406
  `CONFIRMED` (traced end to end) or `PLAUSIBLE` (depends on an assumption: name it).
334
407
  - Helpful greps:
@@ -342,8 +415,10 @@ Still in scope (the exemption covers how specs are written, not whether the libr
342
415
  - Checks (read-only): a review on request of local changes runs `bundle exec rake rubocop` and
343
416
  `bundle exec rbs collection install && bundle exec rake steep:check` (grep the output for `ERROR`/`FATAL`, restore a
344
417
  rewritten `rbs_collection.lock.yaml` with `git checkout -- rbs_collection.lock.yaml`); the automatic pre-commit run
345
- reports the completion checks instead of re-running them (see "When to run"). Don't run `rake rspec` without
346
- 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`).
347
422
 
348
423
  ## 7. Report
349
424
 
@@ -375,11 +450,13 @@ Both templates have the same structure: change them together. A new language = a
375
450
  same structure plus a row in the table above.
376
451
 
377
452
  ### Severity and order
378
- - Blocker: breaks mutual exclusion or the lock index superset, loses or leaks locks/data, crashes, breaks the public
379
- API (method, result shape, event, config key) or turns CI red.
380
- - Major: a rule violation with a real consequence: a missed read key family or lookup mode, per-key round trips, a
381
- hot-path regression, broken module independence, a missing RBS/docs update for a changed contract, new
382
- 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.
383
460
  - Minor: convention deviations with little impact (YARD tags, comment format, naming, idioms).
384
461
  - Nit: optional polish.
385
462
  - Order by severity, then by file. Empty sections are omitted; with no findings the verdict says so and the sync and