redis_queued_locks 1.19.0 → 1.21.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +96 -23
  3. data/.claude/rules/acquirer.md +39 -20
  4. data/.claude/rules/arguments.md +3 -2
  5. data/.claude/rules/git.md +3 -2
  6. data/.claude/rules/logic.md +32 -8
  7. data/.claude/rules/swarm.md +14 -9
  8. data/.claude/rules/tests.md +12 -3
  9. data/.claude/rules/type-checking.md +2 -1
  10. data/.claude/rules/visitors.md +10 -11
  11. data/.claude/skills/rql-code-review/SKILL.md +386 -0
  12. data/.claude/skills/rql-code-review/templates/report.en.md +31 -0
  13. data/.claude/skills/rql-code-review/templates/report.ru.md +31 -0
  14. data/CHANGELOG.md +194 -0
  15. data/CLAUDE.md +6 -3
  16. data/README.md +502 -92
  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 +42 -0
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +45 -0
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +1 -0
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +695 -128
  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 +8 -6
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +134 -26
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +229 -24
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +189 -18
  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 +151 -7
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +39 -41
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +3 -2
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +5 -4
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +79 -20
  35. data/lib/redis_queued_locks/acquirer/locks.rb +212 -83
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +11 -18
  37. data/lib/redis_queued_locks/acquirer/queues.rb +123 -50
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +253 -0
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +177 -61
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +84 -25
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +274 -70
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/acquirer.rb +1 -0
  44. data/lib/redis_queued_locks/client.rb +164 -36
  45. data/lib/redis_queued_locks/config.rb +16 -13
  46. data/lib/redis_queued_locks/errors.rb +34 -4
  47. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  48. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  49. data/lib/redis_queued_locks/resource.rb +113 -6
  50. data/lib/redis_queued_locks/swarm/flush_zombies.rb +333 -61
  51. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  52. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +3 -2
  53. data/lib/redis_queued_locks/swarm/zombie_info.rb +203 -53
  54. data/lib/redis_queued_locks/swarm.rb +97 -5
  55. data/lib/redis_queued_locks/utilities.rb +1 -1
  56. data/lib/redis_queued_locks/version.rb +2 -2
  57. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +14 -0
  58. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +13 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +107 -1
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -3
  61. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +3 -1
  62. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +19 -1
  63. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +20 -4
  64. data/sig/redis_queued_locks/acquirer/keys.rbs +11 -1
  65. data/sig/redis_queued_locks/acquirer/locks.rbs +15 -6
  66. data/sig/redis_queued_locks/acquirer/queues.rbs +9 -2
  67. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +52 -0
  68. data/sig/redis_queued_locks/acquirer/release_all_locks.rbs +8 -1
  69. data/sig/redis_queued_locks/acquirer/release_lock.rbs +6 -3
  70. data/sig/redis_queued_locks/acquirer/release_locks_of.rbs +20 -1
  71. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  72. data/sig/redis_queued_locks/client.rbs +21 -2
  73. data/sig/redis_queued_locks/errors.rbs +5 -2
  74. data/sig/redis_queued_locks/instrument/sampler.rbs +0 -2
  75. data/sig/redis_queued_locks/logging/sampler.rbs +0 -2
  76. data/sig/redis_queued_locks/resource.rbs +7 -1
  77. data/sig/redis_queued_locks/swarm/flush_zombies.rbs +51 -1
  78. data/sig/redis_queued_locks/swarm/zombie_info.rbs +58 -7
  79. data/sig/vendor/redis_client.rbs +2 -0
  80. metadata +6 -1
@@ -13,7 +13,11 @@ The swarm removes **zombie locks**: locks and queue entries left by dead workers
13
13
  - **Host**: a `process/thread/ractor/identity` worker, with the id `rql:hst:<pid>/<thread_id>/<ractor_id>/<identity>` (`Resource.host_identifier`). Fibers are not included because `ObjectSpace` can't see Fibers or Threads once a Ractor exists. Hosts are enumerated with `Thread.list` (`Resource.possible_host_identifiers`).
14
14
  - **Liveness**: `ProbeHosts` runs `HSET rql:swarm:hsts <host_id> <Time.now.to_f>` for every possible host of the client's ractor (`Resource::SWARM_KEY`).
15
15
  - **Zombie**: a host whose last probe score is `< Resource.calc_zombie_score(zombie_ttl / 1_000.0)` (`now - ttl`). `zombie_ttl` is in milliseconds. Zombie locks are `rql:lock:*` whose `hst_id` field is a zombie host, plus zombie read locks: readers registry members (`rql:lock_readers:*`) whose host (derived from the acquirer id by `Resource.host_identifier_from_acquirer`, a pure function usable inside the Ractor) is a zombie host; a zombie read lock is reported as its registry key. Zombie acquirers are their `acq_id`s.
16
- - **Flush** (`FlushZombies.flush_zombies`) runs these steps: `HGETALL` hosts → zombie hosts (return early if none) → `SCAN MATCH rql:lock:*` + `HMGET acq_id hst_id` → `DEL` zombie locks → `SCAN MATCH rql:lock_readers:*` + `ZRANGE` → `ZREM` zombie readers + `DEL` their reader data (`rql:lock_reader:<name>:<acq_id>`) → `SCAN MATCH rql:lock_queue:*` and `rql:lock_read_queue:*` + `ZREM` zombie acquirers → `HDEL` zombie hosts. `ZombieInfo` mirrors the read-lock part (`each_zombie_read_lock`). It is best-effort and non-transactional, with full keyspace scans (`TODO: indexing`).
16
+ - **Flush** (`FlushZombies.flush_zombies(redis_client, zombie_ttl, lock_scan_size, queue_scan_size, lock_index_enabled)`) runs these steps: `HGETALL` hosts → zombie hosts (return early if none) → one pass over the locks in one of two lookup modes (`config['lock_index_enabled']`, passed by the `Swarm` facade and copied into the flusher Ractor at spawn):
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
+ - 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
+ - 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`.
17
21
 
18
22
  ## Components
19
23
  | Object | File | Kind | Role |
@@ -24,14 +28,14 @@ The swarm removes **zombie locks**: locks and queue entries left by dead workers
24
28
  | `SwarmElement::Isolated` | `swarm/swarm_element/isolated.rb` | abstract base | control `Ractor` plus a main-loop `Thread` inside it, driven through a per-element pair of `Ractor::Port`s |
25
29
  | `ProbeHosts` | `swarm/probe_hosts.rb` | `< Threaded` | periodic host liveness probes |
26
30
  | `FlushZombies` | `swarm/flush_zombies.rb` | `< Isolated` | periodic zombie flushing |
27
- | `Acquirers`, `ZombieInfo` | `swarm/acquirers.rb`, `swarm/zombie_info.rb` | stateless `class << self` modules | read-only queries (`HGETALL` swarm hash, lock `SCAN`s) |
28
- | `RedisClientBuilder` | `swarm/redis_client_builder.rb` | stateless module | `build(pooled:, sentinel:, config:, pool_config:)` returns a fresh `RedisClient` or `RedisClient::Pooled` |
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`; never mutates the passed hashes (client config values: can be frozen or shared with the flusher Ractor) |
29
33
 
30
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.
31
35
 
32
36
  ## Why Threaded vs Isolated
33
37
  - `ProbeHosts` **must** be Threaded. Host ids come from `Thread.list` and `Ractor.current` of the client's ractor, so a probe from another Ractor would announce the wrong hosts. It may read `rql_client` (same ractor).
34
- - `FlushZombies` is Isolated. The heavy `SCAN`/`DEL` work runs in its own Ractor with its own Redis connection, isolated from the app's ractor and objects. Everything it needs is passed into `Ractor.new(...)` as copied plain values (`config.slice('swarm.flush_zombies.redis_config')`, which `dup`s values, plus Integers). It never touches `rql_client` inside the ractor.
38
+ - `FlushZombies` is Isolated. The heavy `SCAN`/`ZSCAN`/`DEL` work runs in its own Ractor with its own Redis connection, isolated from the app's ractor and objects. Everything it needs is passed into `Ractor.new(...)` as copied plain values (`config.slice('swarm.flush_zombies.redis_config')`, which `dup`s values, plus Integers and the `lock_index_enabled` boolean). It never touches `rql_client` inside the ractor (a changed `config['lock_index_enabled']` takes effect after the next `swarm!`).
35
39
 
36
40
  ## Element lifecycle (both bases)
37
41
  Each element has two layers, with different code for each base:
@@ -89,7 +93,7 @@ Public element API (called by `Swarm`/`Supervisor` only):
89
93
  - The supervisor block swallows errors (`yield rescue nil`), so a failing element never kills the visor.
90
94
 
91
95
  ## Config (`config.rb`)
92
- `swarm.auto_swarm`, `swarm.supervisor.liveness_probing_period` (s). Per element: `swarm.<el>.enabled_for_swarm`, a period (`probe_hosts.probe_period` s, `flush_zombies.zombie_flush_period` s), and the 4-key `swarm.<el>.redis_config.{sentinel,pooled,config,pool_config}` group. Flush extras: `zombie_ttl` (ms), `zombie_lock_scan_size`, `zombie_queue_scan_size`.
96
+ `swarm.auto_swarm`, `swarm.supervisor.liveness_probing_period` (s). Per element: `swarm.<el>.enabled_for_swarm`, a period (`probe_hosts.probe_period` s, `flush_zombies.zombie_flush_period` s), and the 4-key `swarm.<el>.redis_config.{sentinel,pooled,config,pool_config}` group. Flush extras: `zombie_ttl` (ms), `zombie_lock_scan_size` (`SCAN` count or lock index `ZSCAN` count, and lock batch size), `zombie_queue_scan_size` (lock queue batch size). The lookup mode is the global `lock_index_enabled` (no swarm-specific setting).
93
97
 
94
98
  ## Claude rules
95
99
  1. Swarm operations are stateless class-level functions (`ProbeHosts.probe_hosts`, `FlushZombies.flush_zombies`, `ZombieInfo.*`, `Acquirers.acquirers`) that take `redis_client` plus plain values. The manual public API and the main loop call the same function. Don't put Redis logic in instance methods.
@@ -102,7 +106,7 @@ Public element API (called by `Swarm`/`Supervisor` only):
102
106
  8. Main loop threads must have `abort_on_exception = false` (both control units set this) and let errors kill only the loop. Recovery belongs to the supervisor, so don't add retry loops inside elements.
103
107
  9. Stop the supervisor before killing or re-creating elements, or it will race and resurrect them.
104
108
  10. Report element status as the nested `{ running:, state: }` hashes with `'non_initialized'` for missing parts. Update `swarm_status` RBS types when the shape changes.
105
- 11. Zombie detection compares probe scores (`Time.now.to_f`) with `Resource.calc_zombie_score`. Keep `zombie_ttl` in ms at the API and convert with `/ 1_000.0`. Use `Resource::SWARM_KEY`/`*_PATTERN` and never hardcode keys.
109
+ 11. Zombie detection compares probe scores (`Time.now.to_f`) with `Resource.calc_zombie_score`. Keep `zombie_ttl` in ms at the API and convert with `/ 1_000.0`. Use `Resource::SWARM_KEY`/`LOCK_INDEX_KEY`/`*_PATTERN` and never hardcode keys. Find locks with both lookups (`lock_index_enabled`): one keyspace `SCAN` pass (`KEY_PATTERN` + `Resource.lock_key_family`) or the lock index (`ZSCAN` + `Resource.prepare_*` keys of each name, never a keyspace `SCAN` then); keep the batch helpers shared between them.
106
110
  12. Mirror every change in `sig/redis_queued_locks/swarm/**` and keep the existing `# steep:ignore` on nil-narrowed `Thread?`/`Ractor?` calls (Steep doesn't narrow `attr_reader` results).
107
111
  13. Isolated communication goes only through `Ractor::Port`s: create the results port in the main Ractor (`swarm!`) and the command port inside the element Ractor (`.swarm_loop`), and register `Ractor#monitor` on the results port before waiting on it. Every command gets exactly one reply, sent before the Ractor leaves its loop. Treat Symbol replies as death notices. Don't use `Ractor.receive`, `Ractor#send` or the default port, and never share one port between elements. Naming: port variables, attributes and params end in `_port` (`swarm_element_results_port`, `swarm_element_commands_port`). Block (proc) params use short initial-letter names (`r_res_p` for the results port, `r_com_p` for the commands port), like the other abbreviated Ractor block params (`rc`, `z_ttl`).
108
112
  14. Inside an element Ractor, terminate threads with kill + join (`.terminate_thread`) and keep `:kill` terminating every thread of the Ractor. Otherwise its status lags as `running`.
@@ -110,11 +114,12 @@ Public element API (called by `Swarm`/`Supervisor` only):
110
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'`).
111
115
 
112
116
  ## Known quirks (don't copy; fix only in a dedicated change)
113
- - Typos: `swarm_element__termiante`, `@since 19.0.0` on `Threaded#reswarm_if_dead!`, `@api ppublic` on `Swarm#zombie_acquirers`, "lopp"/"teh" in comments.
117
+ - Typo in a method name: `swarm_element__termiante` (rename it only in a dedicated change).
114
118
  - `swarm_loop__stop` is not used by the swarm itself, only by specs.
115
119
  - 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.
116
120
  - `sleep(0.1)` "give a timespot" waits in `swarm!`/`deswarm!`/`observe!` instead of real readiness signalling.
117
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.
118
122
  - Swarm has no logging or instrumentation, and supervisor errors are silently dropped (`TODO: (CHECK)`).
119
- - `FlushZombies` scans the whole keyspace on every run (four patterns: write locks, readers registries, write and read queues) and runs `ZREM` for every zombie acquirer on every queue.
120
- - Zombie acquirers are collected only from held locks (write locks and read locks): requests of dead hosts that never obtained a lock are removed only by queue TTL pruning.
123
+ - 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
+ - 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).
125
+ - `zombie_acquirers`/`zombies_info` report only the acquirers of zombie locks, while `flush_zombies` also drops the requests of zombie hosts that hold no locks and reports their acquirers in `deleted_zombie_acquirers`.
@@ -7,8 +7,11 @@ 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).
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.
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;
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).
12
15
  - `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`), then `rspec/retry`, `pry`, the gem.
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.
@@ -19,9 +22,15 @@ paths:
19
22
  - Logger/instrumenter fakes are anonymous classes (`Class.new { def debug(...); def notify(event, payload = {}) }`) collecting calls into arrays.
20
23
  - Concurrency is tested with real `Thread.new` and `sleep` to wait for async swarm elements; assertions use `match(...)`, `eq(...).or(eq(...))`, `include`, `raise_error(RedisQueuedLocks::...Error)`.
21
24
  - Every example cleans up its own state (`client.clear_locks`, `deswarmize!`, `redis.close`).
25
+ - Command-layout specs observe the sent commands through a separate pool on db 0 built with a recording `redis-client` middleware (a `Module` with `define_method(:call_pipelined) { |commands, config, &block| ...; super(commands, config, &block) }`: the block must be forwarded explicitly), e.g. `lock state pipeline layout` (it reads the position constants of `TryToLock`). Update their expectations deliberately when a pipeline layout changes.
22
26
  - Coverage: SimpleCov line + branch, HTML only; `minimum_coverage 100` is a TODO.
27
+ - Both lock lookup modes (`config['lock_index_enabled']`): the behavior examples are `RSpec.shared_examples 'redis_queued_locks'` (the `redis` pool and the FLUSHDB hooks are `RSpec.shared_context 'redis_queued_locks redis'`), included by `RSpec.describe RedisQueuedLocks` twice: `context 'with the disabled lock index (keyspace SCAN)', lock_index_enabled: false` and `context 'with the enabled lock index (lock index ZSCAN)', lock_index_enabled: true`; the `around` hook of `spec_helper` switches the default of `config['lock_index_enabled']` for the example by this metadata (`RedisQueuedLocks::Config.setting`, the library default is restored after the example), so `RedisQueuedLocks::Client.new(redis)` inside the shared examples gets the mode of the context (explicit config values win). One `bundle exec rake rspec` runs the shared examples in both modes (~2x time).
28
+ - Mode-dependent expectations inside the shared examples branch on `client.config['lock_index_enabled']` (e.g. `lock state pipeline layout`: 5 queueing commands, 9 with the lock index; `lock-iterating operations: one pass (keyspace or lock index) with small batches`: `#keys` lists other `rql:lock*` keys only with the keyspace lookup); keys written directly with raw commands (`ZADD` queues, `HSET` locks) are found by the keyspace SCAN and are indexed with `client.reindex_locks if client.config['lock_index_enabled']` before lock-iterating assertions.
29
+ - Lock index specifics live in `describe 'lock index'` (outside the shared examples: run once, every client enables the lock index explicitly with `config['lock_index_enabled'] = true`; `let(:index_score_ahead)` = index score minus the current client ms, `let(:upper_bound)` = `be_between(lifetime + grace - 1_000, lifetime + grace)`); `the lock obtaining transaction indexes the lock` drops the index entry right before the obtaining `MULTI` with a middleware (keep it when touching the obtaining transaction); `the disabled lock index (default): no lock index commands, keyspace lookups` records all commands of the default mode (no `rql:index:locks` argument, no `ZSCAN`, queues without TTL); `enabling the lock index: ...` covers the migration (`#reindex_locks` after enabling).
23
30
 
24
31
  ## Claude rules
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
+
25
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>`).
26
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).
27
36
  3. Use only `expect` syntax; no `should`, no monkey-patched DSL, no `focus` left behind.
@@ -30,7 +39,7 @@ paths:
30
39
  6. Clean up everything an example starts: release locks, `deswarmize!` swarm clients, join/kill threads.
31
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.
32
41
  8. Do not add new rspec-retry reliance or lower retry settings; do not enable `minimum_coverage` without being asked.
33
- 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`).
34
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.
35
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).
36
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.
@@ -25,6 +25,7 @@ paths:
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`);
26
26
  - `pipelined`/`call` return `untyped`: annotate destructured pipeline results with a tuple (`# @type var lock_state: [String?, Array[String], Float?, Array[[String, Float]]]`);
27
27
  - `LockSeriesPoC` has no RBS: new calls/definitions there need `# steep:ignore` (like the existing ones).
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.
28
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.
29
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
31
 
@@ -33,7 +34,7 @@ paths:
33
34
  2. Create new `.rbs` files at the mirrored path with nested module blocks and `use RedisQueuedLocks as RQL` / `use RedisClient as RC` aliases when needed.
34
35
  3. Reuse shared types (`RQL::loggerObj`, `RQL::instrObj`, `samplerObj`, `RC::client`) instead of `untyped`; use `untyped` only for truly dynamic values (e.g. user `meta`, `instrument`).
35
36
  4. Name public API result hashes with a `type xxxResult = { ok: bool, result: ... }` alias next to the method. Type internal swarm element replies with plain types (`bool`, `String`, a flat record such as `{ alive: bool, state: String }`, optional `?` for `nil`) instead of `{ ok:, result: }` records.
36
- 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.
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).
37
38
  6. Don't loosen `Steep::Diagnostic::Ruby.strict` or add `ignore` entries to the `Steepfile`.
38
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/`.
39
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`).
@@ -17,14 +17,14 @@ 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` |
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
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` |
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.
@@ -40,12 +40,12 @@ Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks
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
 
@@ -57,13 +57,12 @@ Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks
57
57
  9. Instrumentation: `instrumenter.notify('redis_queued_locks.<event>', { ... }) rescue nil` with shorthand Symbol keys; reuse the standard payload keys (`lock_key`, `rw_mode`, `ttl`, `acq_id`, `hst_id`, `ts`, `acq_time`, `hold_time`, `instrument`). Event names are public API: never rename or remove one without being asked, and document new ones in the README/CHANGELOG.
58
58
  10. Use the same method name in LogVisitor and InstrVisitor when both report the same event.
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
- 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 : {}`).
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. 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.