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
@@ -50,12 +50,12 @@ This is a deliberate framework-level decision, **not** something to refactor int
50
50
  2. New public option: add it as an explicit keyword to **every** affected `Client` method (`x` and `x!`, e.g. `lock` and `lock!`), with the default taken from `config['...']` (add the `setting` + `validate` first) or a literal for per-call-only flags (`raise_errors: false`, `meta: nil`).
51
51
  3. Forward it explicitly by name through every layer using shorthand (`new_option:`); never forward via `**kwargs`, `...`, or `method(__method__).parameters` tricks.
52
52
  4. Keep defaults only at the public boundary (`Client`); internal functions take required keywords/positionals with no defaults so a missed forward fails loudly.
53
- 5. Internal positional lists: append new params in the established order (subject → domain data → observability tail `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`) and update every call site and the RBS signature in the same change. Lock key params keep this order: `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, ...` (`lock_name` is the lock index member; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the batch sizes / `dead_ttl`, before the observability tail; swarm functions: last).
53
+ 5. Internal positional lists: append new params in the established order (subject → domain data → observability tail `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`) and update every call site and the RBS signature in the same change. Lock key params keep this order: `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, acquirer_position, priority, ...` (`lock_name` is the lock index member; `acquirer_position` is the priority-shifted queue score, `priority` its band; queueing helpers take `acquirer_position, priority` right after the queue keys; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the domain data (batch sizes, `dead_ttl`, `by_priority`), before the observability tail; swarm functions: last).
54
54
  6. Document each new option with its own `@option`/`@param` YARD line (type, meaning, default source) and add it to the RBS method signature as a named param/keyword.
55
55
  7. Keep hot-path code allocation-light: no per-call wrapper objects, no hash merging, no splats to build args, no `tap`/closures purely for argument plumbing; prefer passing existing locals.
56
56
  8. Silence length cops locally (`# rubocop:disable Metrics/MethodLength`) rather than shortening signatures; `Metrics/ParameterLists` is disabled project-wide on purpose.
57
57
  9. Client-wide modes are not per-call options: `config['lock_index_enabled']` is read inside the `Client` (and `Swarm`) method bodies and forwarded by name to the acquirers (`lock_index_enabled: config['lock_index_enabled']` / positional `config['lock_index_enabled']`), never added as a keyword of `lock`/`locks`/`clear_locks`/...: one call in another mode would break the lock index invariant (all lock acquirements of a client must maintain the index when its operations read it).
58
- 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`).
58
+ 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`, `priority`). Client-wide registries are read from `config` inside these methods and forwarded as internal keywords, never public keywords (`lock_priorities: config['lock_priorities']`, the priority names: `lock` and `lock_series` pass it, `lock!`/`lock_series!` delegate to them).
59
59
 
60
60
  ## Recommendations (proposed, not yet project policy)
61
61
  1. For new internal functions with many same-typed params (e.g. several Integers/Booleans in a row), consider required keywords instead of positionals to prevent misordering; keep the list flat and explicit.
@@ -0,0 +1,27 @@
1
+ ---
2
+ paths:
3
+ - "CHANGELOG.md"
4
+ ---
5
+
6
+ # CHANGELOG rules (`CHANGELOG.md`)
7
+
8
+ ## Observed conventions
9
+ - Keep a Changelog layout: `## [Unreleased]` on top, released versions as `## [X.Y.Z] - YYYY-MM-DD`; sections `### Added`, `### Changed`, `### Fixed` (`### Removed` once); entries are `- ` bullets, mostly ending with `;`.
10
+ - Markers: `[**Breaking**]` for incompatible changes, `*Brand New Feature*` for a fundamentally new feature (rule 8; older spellings: `Brand new Feature`, `Brand New Extremely Major Feature` of the swarm mode).
11
+ - Code identifiers in backticks: methods `#lock`/`#lock!`, options `priority:`, config keys `config['key']`, errors `RedisQueuedLocks::ArgumentError`, events `redis_queued_locks.<event>`.
12
+ - Anti-pattern: the 1.18.0-1.21.0 and lock priorities sections grew into implementation reports (algorithm steps, Redis commands, round trips, score formulas, full lists of affected events, multi-line sub-bullets) before they were compacted by this rule (`git show 28adc76:CHANGELOG.md` keeps the full text).
13
+
14
+ ## Claude rules
15
+ 1. Compact entries: one bullet per subject-level change, 1-2 lines (3 at most). A subject-level change is what a gem user gets, loses or has to do: a new method/option/config key/event/log key/error, a changed default/result shape/behavior, a fixed wrong behavior, a breaking change or an upgrade requirement.
16
+ 2. No deep details: no algorithm steps, Redis commands, round trips/pipelines/transactions, internal modules/methods/constants, key layouts, score formulas, costs, implementation edge cases, reasoning or alternatives. They live in README (usage), YARD, `.claude/project-overview.md` and the rules; an entry may point to its README section instead.
17
+ 3. Internal-only changes (refactoring, specs, CI, internal RBS, `.claude` docs, perf work without a visible effect) get no entry; a visible effect of such work (a typing change for Steep users, a notable speedup) gets one short line.
18
+ 4. Sub-bullets only to list separate user-visible items of one feature (its new options, config keys, events), one short line each; one nesting level at most.
19
+ 5. Collapse lists that share one change: "lock acquirement logs and instrumentation events carry the Integer `priority`", not the 20 event names (the exact lists live in README `## Logging` / `### Instrumentation Events`).
20
+ 6. A fix: the wrong behavior and the scope in one sentence (`` `#zombies_info`: the default `lock_scan_size` was read from the wrong config key ``), not the mechanism of the bug.
21
+ 7. Breaking changes and upgrade requirements: the `[**Breaking**]` marker or one sentence of what to do (`upgrade all processes before using positive priorities`), without the reasoning.
22
+ 8. A fundamentally new feature gets its own entry marked `*Brand New Feature*`: a new capability of the library as a whole (Swarm Mode, Lock series, Read/Write locks, Lock Index, Lock Request Prioritization) or globally new support (a Redis-compatible server such as Garnet, a new runtime or platform); not an extension of the existing functionality (a new option, method variant, result field, event or log of an existing feature). Format: `- *Brand New Feature*: **<Feature Name>**: <one-sentence summary> (see README "<section>"):` first in `### Added`, the feature's methods/options/config keys/events and upgrade requirements as its sub-bullets (rule 4).
23
+ 9. One feature = one entry: a later step of the same feature updates its entry instead of adding a bullet per step. Released sections are not rewritten unless asked.
24
+
25
+ ## Example
26
+ - Bad: `` - every lock request drops dead lock requests of its own priority in both lock queues (in the same queueing pipeline: no new commands and round trips); dead lock requests of other priorities that block the lock request ... are dropped ... in one more round trip of the same attempt (up to 5 such round trips per attempt); ``
27
+ - Good: `` - *Brand New Feature*: **Lock Request Prioritization**: `priority:` of `#lock`/`#lock_series` (`-100..100` or a priority name, `0` by default) orders waiting lock requests (strict priority, FIFO inside a priority), see README "Lock Request Prioritization"; ``
data/.claude/rules/git.md CHANGED
@@ -28,7 +28,7 @@ Applies to every commit Claude makes in this repository (no `paths:` scope: alwa
28
28
 
29
29
  **When to commit**
30
30
  7. Never commit on your own. When a step with changes reaches completion, ask the user for permission to commit and wait for the answer:
31
- - completion = the feature/functionality required by the prompt is fully implemented (code, RBS, specs, README/CHANGELOG, `.claude/project-overview.md` and rules kept in sync) and checks are green: the touched examples and the full suite (`bundle exec rake rspec`), `bundle exec rake rubocop`, `bundle exec rake steep:check` (no `ERROR`/`FATAL` lines); changes without code (docs, `.claude` rules) need no test run;
31
+ - completion = the feature/functionality required by the prompt is fully implemented (code, RBS, specs, README/CHANGELOG, `.claude/project-overview.md` and rules kept in sync) and checks are green: the touched examples and the full suite (`bundle exec rake rspec:parallel` by default, the serial `bundle exec rake rspec` when the change needs a check in one process: `tests.md#1`), `bundle exec rake rubocop`, `bundle exec rake steep:check` (no `ERROR`/`FATAL` lines); changes without code (docs, `.claude` rules) need no test run;
32
32
  - pre-commit review: once the step is complete, and only then, run the `rql-code-review` skill on the step's changes (scope `uncommitted`) before the question; never during the implementation or for incomplete/red states. Blocker/Major findings in the step's own changes mean the step is not complete yet: fix them, re-run the affected checks and review the fixes; findings that need the user's decision are reported instead of the question (rule 9);
33
33
  - the question contains: the proposed commit subject (following the format rules above), a short recap of what was done in the changes (features/fixes, touched areas, check results), the review verdict with the remaining Minor/Nit findings and the list of files that will be committed;
34
34
  - commit only after an explicit "yes" (or with the subject the user corrects); on "no" leave the changes uncommitted (staging also only on request).
@@ -24,7 +24,7 @@ Related rule files (loaded for narrower paths):
24
24
  - Constants are defined compactly: `class RedisQueuedLocks::Acquirer::IsLocked` / `module ...`, never nested `module A; module B`.
25
25
  - Namespace files (`acquirer.rb`, `swarm.rb`, `logging.rb`, `swarm/swarm_element.rb`) only `require_relative` their children; the root `lib/redis_queued_locks.rb` requires the namespaces in dependency order.
26
26
  - YARD doc block on every class, module, constant, attr and method:
27
- `@param name [Type]`, `@option`, `@return [Type]`, blank `#` line, then `@api public|private`, `@since X.Y.Z`, optional `@version X.Y.Z` (latest behavior change).
27
+ `@param name [Type]`, `@option`, `@return [Type]`, blank `#` line, then `@api public|private`, `@since X.Y.Z`, optional `@version X.Y.Z` (methods: the latest behavior/signature change; classes/modules: the latest interface change, see rule 2).
28
28
  - Operations are stateless modules with `class << self` functions; dependencies (`redis_client`, logger, instrumenter, sampling options) are passed as explicit args, never read from globals.
29
29
  - Operation modules (except PoC modules such as `LockSeriesPoC`) are independent of each other: no cross-calls and no shared RBS types between them; common logic lives in `Resource`/`Utilities`, similar logic is duplicated on purpose (see `acquirer.md`, "Independence of operation modules").
30
30
  - Public API results are hashes `{ ok: Boolean, result: Symbol|Hash }` (`Client`, `Acquirer::*`, `Swarm` facade actions); `Client` `!` methods raise `RedisQueuedLocks::*Error`. Internal swarm element APIs return bare scalars/primitives (see `swarm.md`).
@@ -32,33 +32,54 @@ 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.
37
- - Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass.
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
+ - 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
- 2. Add a full YARD block to every new public/private method; new code gets `@since <next version>`; when changing existing behavior, add/bump `@version`.
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; 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
+ - a class/module header: only when its interface changes:
47
+ - a method signature changes: its parameters (their kinds and names); the default values and the return values are behavior;
48
+ - a method is added or removed (public or private, `attr_*` and aliases too);
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` 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
+ - 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).
45
52
  3. New operation = new `Acquirer::*` module + thin `Client` method delegating to it (+ `!` variant only if it must raise); details in `acquirer.md`.
46
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.
47
54
  5. Never hardcode `rql:` strings; add a `Resource.prepare_*` helper or constant instead.
48
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.
49
- 7. New config option: `setting` with default + `validate` with a type check, both in `config.rb`; read it in `Client` as `config['key']`.
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." ``).
50
57
  8. Logs and instrumentation events go through visitor modules (see `visitors.md`); event names follow `redis_queued_locks.<snake_case>`.
51
58
  9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
52
- 10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods.
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).
53
60
  11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
54
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.
55
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).
56
63
  14. Never derive safety from queues or client clocks: mutual exclusion is decided by the lock state checked under WATCH (writers WATCH the write key + readers registry, readers WATCH the write key only), queues and positions only order requests.
64
+ 15. Error hierarchy (`errors.rb`, mirrored in `sig/redis_queued_locks/errors.rbs`):
65
+ - every `RedisQueuedLocks` error inherits from the global base error of its branch, never directly from a Ruby class; the main base is `Error < ::StandardError`;
66
+ - a branch that can not be a part of the main hierarchy (a Ruby core error that our error must stay, e.g. `ArgumentError < ::ArgumentError`) gets its own base error named after the Ruby class (`class TypeError < ::TypeError` for a new one), listed in the `errors.rb` header and in the overview "Errors";
67
+ - errors are built per case on the `StandardError` hierarchy or on the Ruby core errors (through the base of their branch), not on `::Exception`;
68
+ - `Exception < ::Exception` is for super exceptional cases only (internal interrupts that the interrupted user/observability code must not rescue: `rescue => e`, `rescue StandardError`, `rescue nil`); each error of this branch states in its comment why it is not a `StandardError` and where the library rescues it (it never reaches the public API: it is converted to a public error/result); only the base disables `Lint/InheritException` (subclasses use the relative `< Exception`);
69
+ - new public errors are `@api public` and documented in README/CHANGELOG; the `Exception` branch is `@api private`.
70
+ 16. Observability of the logic (logs and instrumentation):
71
+ - every logic that creates, changes or deletes Redis keys has both logs and instrumentation; logic that only reads the lock data (`locked?`, `queued?`, `lock_info`, `queue_info`, `locks`, `queues`, `keys`, `*_info`, zombie queries) has neither;
72
+ - lock acquirement (`AcquireLock`, `LockSeriesPoC`): each meaningful step/outcome is logged and instrumented through the visitors (`visitors.md`): obtained (classic and reentrant), held and released, not obtained (`lock_obtaining_failed`, also before a raised acquirement error); the per-attempt `try_lock.*` steps are logs only (gated by `log_lock_try`), never instrumented (no `notify` per attempt);
73
+ - lock mutation operations (release, extension, cleanup, reindexing): exactly one log line and one instrumentation event per operation with the time of the whole work of the root operation module called by the public `Client` method (the two-layer pattern of `acquirer.md`: the timer wraps the `fully_*` helper), never of its internal parts; the log line and the event have the same name (`redis_queued_locks.<event>`) and the same data in the same order (the log has no `instrument`), the payload ends with the user's `instrument`; they are written inline in the module (`run_non_critical`, gated by `Logging.should_log?` / `Instrument.should_instrument?`);
74
+ - every such public `Client` method takes `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling keywords (defaults from `config`) and forwards them by name to the operation module;
75
+ - known debt: the swarm elements and the manual swarm actions (`ProbeHosts`, `FlushZombies`: `#probe_hosts`, `#flush_zombies`) are neither logged nor instrumented yet (the flusher Ractor can not get the user's logger/instrumenter).
76
+ 17. Debug methods of `Resource`: `Resource` may keep methods that the library does not use when they are justified for debugging: they let you check a subtle part of the logic by hand at runtime (in a console), e.g. `lock_key_from_queue` maps a lock request queue of any mode to its lock. Each of them:
77
+ - has a `# NOTE: (debug)` comment in its YARD block: that it is not used by the library and what it checks (with a console example);
78
+ - is correct for every key family it can get (write and read keys), has the full YARD block, the RBS signature and a spec;
79
+ - is not a dead code finding; an unused method without the `(debug)` note is dead code (remove it); a debug method that the library starts to use loses the note.
57
80
 
58
81
  ## Recommendations (proposed, not yet project policy)
59
82
  Apply to new or touched code; don't refactor existing code for these unless asked.
60
- 1. Avoid `# rubocop:disable all` (used in `client.rb` lock_series and `lock_series_poc.rb`); disable only the specific cops.
61
- 2. Document every option fully in YARD.
62
- 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.
63
- 4. Include context in raised errors (lock name, acquirer id, timeout) so failures are diagnosable.
64
- 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 |
@@ -29,7 +29,7 @@ The swarm removes **zombie locks**: locks and queue entries left by dead workers
29
29
  | `ProbeHosts` | `swarm/probe_hosts.rb` | `< Threaded` | periodic host liveness probes |
30
30
  | `FlushZombies` | `swarm/flush_zombies.rb` | `< Isolated` | periodic zombie flushing |
31
31
  | `Acquirers`, `ZombieInfo` | `swarm/acquirers.rb`, `swarm/zombie_info.rb` | stateless `class << self` modules | read-only queries (`HGETALL` swarm hash, keyspace `SCAN`s or lock index `ZSCAN`s) |
32
- | `RedisClientBuilder` | `swarm/redis_client_builder.rb` | stateless module | `build(pooled:, sentinel:, config:, pool_config:)` returns a fresh `RedisClient` or `RedisClient::Pooled` |
32
+ | `RedisClientBuilder` | `swarm/redis_client_builder.rb` | stateless module | `build(pooled:, sentinel:, config:, pool_config:)` returns a fresh `RedisClient` or `RedisClient::Pooled`; never mutates the passed hashes (client config values: can be frozen or shared with the flusher Ractor) |
33
33
 
34
34
  `Client` methods `swarmize!`, `deswarmize!`, `swarm_status`/`swarm_state`, `swarm_info`, `probe_hosts`, `flush_zombies`, `zombie_locks`, `zombie_acquirers`, `zombie_hosts`, `zombies_info`/`zombies` only fill defaults from `config['swarm.*']` and delegate to `Swarm`. `Client#initialize` calls `swarm.swarm!` when `swarm.auto_swarm` is set.
35
35
 
@@ -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
- - Typos: `swarm_element__termiante`, `@since 19.0.0` on `Threaded#reswarm_if_dead!`, `@api ppublic` on `Swarm#zombie_acquirers`, "lopp"/"teh" in comments.
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,9 +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` (~3.4k lines), under `RSpec.describe RedisQueuedLocks`; groups: `describe 'Lock Series PoC'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks. The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
11
- - Known flaky example: `all in + notifications` (timing-dependent `sleep(1)`; passes in isolation).
12
- - `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`), then `rspec/retry`, `pry`, the gem.
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
+ - 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; 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
+ - `(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`; 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).
13
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`.
14
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.
15
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).
@@ -28,15 +31,15 @@ paths:
28
31
  ## Claude rules
29
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).
30
33
 
31
- 1. Run specs with a local Redis available: `bundle exec rake rspec` (single example: `bundle exec rspec spec/redis_queued_locks_spec.rb:<line>`).
32
- 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/`.
33
36
  3. Use only `expect` syntax; no `should`, no monkey-patched DSL, no `focus` left behind.
34
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.
35
38
  5. Build fakes as anonymous classes implementing the duck-typed interface (`debug`, `notify`, `sampling_happened?`), not with doubles of real loggers.
36
39
  6. Clean up everything an example starts: release locks, `deswarmize!` swarm clients, join/kill threads.
37
40
  7. Keep `sleep`-based waits minimal and comment why (`# give a timespot to ...`); prefer polling with a bounded timeout when adding new async checks.
38
41
  8. Do not add new rspec-retry reliance or lower retry settings; do not enable `minimum_coverage` without being asked.
39
- 9. Specs are also run under RBS runtime checks, so pass correctly typed arguments to public API calls (type violations are logged in CI); e.g. test invalid read lock ttl with `ttl: 0`, not `ttl: nil` (`Client#lock` types `ttl` as `Integer`).
42
+ 9. Specs are also run under RBS runtime checks, so pass correctly typed arguments to public API calls (type violations are logged in CI); e.g. test an invalid lock ttl with `ttl: 0`, not `ttl: nil` (`Client#lock` types `ttl` as `Integer`).
40
43
  10. Check new or changed examples for flakiness without retries: `RSPEC_RETRY_RETRY_COUNT=1 bundle exec rspec spec/redis_queued_locks_spec.rb -e '<group>'`, several runs in a row; also watch for `RSpec::Retry: 2nd try` lines in normal runs.
41
44
  11. Threads in specs: `Thread.abort_on_exception = true`, so never raise expected errors inside threads (call non-raising `lock` there and assert results in the main thread); keep references to helper threads until the end of the example (acquirer ids contain `Thread#object_id`, which can be reused after GC).
42
45
  12. Timing assertions on TTLs allow the redis time shift error: extendable reentrant locks return the extension minus the time spent in the inner block and `Resource::REDIS_TIMESHIFT_ERROR` (2 ms), so the remaining TTL can slightly exceed the initial one.
@@ -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).
@@ -17,35 +17,35 @@ The project calls these modules "visitors", but they are not GoF Visitor (no `ac
17
17
  ## Where they live
18
18
  | Visitor | File | Events |
19
19
  |---|---|---|
20
- | `AcquireLock::LogVisitor` | `acquire_lock/log_visitor.rb` | 6: `start_lock_obtaining`, `start_try_to_lock_cycle`, `dead_score_reached__reset_acquirer_position`, `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained` |
21
- | `AcquireLock::InstrVisitor` | `acquire_lock/instr_visitor.rb` | 5: `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained`, `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` |
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
+ | `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` | 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) |
26
26
 
27
- Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of` call `instrumenter.notify` inline inside `run_non_critical`, and do not log at all (their `logger` param is unused).
27
+ Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`, `ReleaseReadLock`, `ReleaseAllLocks`, `ReleaseLocksOf`, `ExtendLockTTL`, `ClearDeadRequests`, `ReindexLocks` write one log line (`logger.debug`) and one event (`instrumenter.notify`) inline inside `run_non_critical` after the whole operation: `explicit_lock_release`, `explicit_read_lock_release`, `explicit_all_locks_release`, `release_locks_of`, `extend_lock_ttl`, `clear_dead_requests`, `reindex_locks` (the log line has the event name and the payload data in the same order, without `instrument`; the payload ends with `instrument`). `lock_obtaining_failed` is reported by `acquire_lock` in Step 3.b (the failure result) and in the method-level `rescue` of the raised acquirement errors (Step 3.c: only while the lock is not acquired; errors of the block of code are not reported).
28
28
 
29
29
  ## Observed conventions
30
30
  - **Placement**: a visitor sits in a subdirectory named after the component it reports on (`<component>/log_visitor.rb`, `<component>/instr_visitor.rb`) and is loaded by that component with `require_relative` at the top of the module body.
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.
42
42
  - **Typing**: each visitor (except lock_series_poc) has an RBS file with `def self.<event>: (RQL::loggerObj logger, bool log_sampled, ...) -> void` / `RQL::instrObj instrumenter`.
43
- - **User docs**: README documents every log event with its logged keys (`## Logging`: the default and `log_lock_try` lists, mirrored in the `config['logger']` / `config['log_lock_try']` comments of `### Logging Configuration`) and every instrumentation event with its payload keys, types and semantics (`### Instrumentation Events`).
43
+ - **User docs**: README documents every log event with its logged keys (`## Logging`: the default, `log_lock_try`, lock series and operation logs lists, mirrored in the `config['logger']` / `config['log_lock_try']` comments of `### Logging Configuration` and `### Configuration`) and every instrumentation event with its payload keys, types and semantics (`### Instrumentation Events`).
44
44
 
45
45
  ## Claude rules
46
46
  **When to use**
47
47
  1. Any new log line or instrumentation event inside `AcquireLock` (and its mixins) or `LockSeriesPoC` must go through a visitor method; never call `logger.*` or `instrumenter.notify` directly in algorithm code.
48
- 2. Add a LogVisitor event for each new meaningful step or branch of the algorithm (start, decision, exit, success). Add an InstrVisitor event only for outcomes users would want to measure (obtained, held/released, completed); not for internal steps.
48
+ 2. Add a LogVisitor event for each new meaningful step or branch of the algorithm (start, decision, exit, success). Add an InstrVisitor event for the outcomes users would want to measure (obtained, not obtained, held/released, completed); not for internal per-attempt steps (`logic.md#16`).
49
49
  3. Fine-grained per-attempt logging belongs in `TryToLock::LogVisitor` and must be gated by `log_lock_try`.
50
50
  4. For a new algorithm component (new mixin under `acquire_lock/`), create its own `<component>/log_visitor.rb` (and `instr_visitor.rb` if needed) and `require_relative` it from the component.
51
51
 
@@ -59,11 +59,10 @@ Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks
59
59
  11. Add the method to the mirrored RBS visitor file (`RQL::loggerObj` / `RQL::instrObj`, `bool` sampled flag, `-> void`) and cover the new event in specs via the fake logger/notifier (assert on the `[redis_queued_locks.<event>]` prefix or event name).
60
60
  12. Never pass Redis reads (or other costly computations) as visitor arguments unconditionally: arguments are evaluated before the visitor's `return unless` guard. Gate them at the call site with the same condition (`(log_sampled && log_lock_try) ? rconn.call('HGETALL', ...).to_h : NO_LOCK_DATA`; the fallback is the shared frozen `TryToLock::NO_LOCK_DATA`, not a new `{}`).
61
61
  13. A new branch/exit of the read/write algorithm gets its own TryToLock event (`exit__write_request_ahead`, `exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade` are the existing ones). Don't add events to the default write success path: specs assert its exact log sequence (10 lines with `log_lock_try`).
62
- 14. Any change of observable data is documented in README in the same change: a new log event or a new/renamed/removed logged key (`LogVisitor`s) → `## Logging` (both lists and the matching `config['logger']` / `config['log_lock_try']` comments in `### Logging Configuration`); a new instrumentation event or a new/renamed/removed payload key (`InstrVisitor`s and the inline `notify` of release modules) → `### Instrumentation Events` (event list + payload line with type and semantics, e.g. requested vs held `rw_mode`). Add a CHANGELOG `[Unreleased]` line as well. Before finishing, compare the README lists with the visitor code (event names and key order).
62
+ 14. Any change of observable data is documented in README in the same change: a new log event or a new/renamed/removed logged key (`LogVisitor`s) → `## Logging` (both lists and the matching `config['logger']` / `config['log_lock_try']` comments in `### Logging Configuration`); a new instrumentation event or a new/renamed/removed payload key (`InstrVisitor`s and the inline `notify` of the mutation modules) → `### Instrumentation Events` (event list + payload line with type and semantics, e.g. requested vs held `rw_mode`). Add a CHANGELOG `[Unreleased]` line as well. Before finishing, compare the README lists with the visitor code (event names and key order).
63
63
 
64
64
  ## Recommendations (proposed, not yet project policy)
65
65
  Apply to new or touched code; don't refactor existing code for these unless asked.
66
- 1. Give the release operations (`release_lock`, `release_all_locks`, `release_locks_of`) their own `InstrVisitor`/`LogVisitor` instead of inline `instrumenter.notify`, and either use or drop their currently unused `logger` param.
67
- 2. Use one error-swallowing style consistently (visitors use modifier `rescue nil`, release 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.
68
- 3. 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.
69
- 4. 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.
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
+ 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. Add RBS for the `lock_series_poc` visitors when the PoC stabilizes.