redis_queued_locks 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +87 -10
  3. data/.claude/rules/acquirer.md +31 -11
  4. data/.claude/rules/arguments.md +2 -1
  5. data/.claude/rules/git.md +46 -0
  6. data/.claude/rules/logic.md +9 -3
  7. data/.claude/rules/swarm.md +4 -3
  8. data/.claude/rules/tests.md +9 -3
  9. data/.claude/rules/type-checking.md +10 -2
  10. data/.claude/rules/visitors.md +12 -7
  11. data/CHANGELOG.md +68 -0
  12. data/CLAUDE.md +7 -2
  13. data/README.md +427 -79
  14. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +6 -3
  15. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +9 -7
  16. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +20 -10
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +34 -16
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +240 -28
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +398 -129
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +12 -6
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +32 -8
  22. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +73 -20
  23. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +9 -1
  24. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +182 -3
  25. data/lib/redis_queued_locks/acquirer/is_locked.rb +19 -2
  26. data/lib/redis_queued_locks/acquirer/is_queued.rb +6 -2
  27. data/lib/redis_queued_locks/acquirer/lock_info.rb +96 -6
  28. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +8 -2
  29. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -6
  30. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +126 -27
  31. data/lib/redis_queued_locks/acquirer/locks.rb +103 -6
  32. data/lib/redis_queued_locks/acquirer/queue_info.rb +38 -9
  33. data/lib/redis_queued_locks/acquirer/queues.rb +28 -4
  34. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +35 -1
  35. data/lib/redis_queued_locks/acquirer/release_lock.rb +40 -9
  36. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +43 -3
  37. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +143 -0
  38. data/lib/redis_queued_locks/acquirer.rb +1 -0
  39. data/lib/redis_queued_locks/client.rb +143 -14
  40. data/lib/redis_queued_locks/resource.rb +117 -9
  41. data/lib/redis_queued_locks/swarm/flush_zombies.rb +38 -0
  42. data/lib/redis_queued_locks/swarm/zombie_info.rb +42 -0
  43. data/lib/redis_queued_locks/version.rb +2 -2
  44. data/rbs_collection.lock.yaml +10 -2
  45. data/rbs_collection.yaml +0 -2
  46. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -1
  47. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +0 -1
  48. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +5 -0
  49. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +12 -6
  50. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +74 -7
  51. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +4 -2
  52. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -0
  53. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_with_expire/log_visitor.rbs +4 -2
  54. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +19 -1
  55. data/sig/redis_queued_locks/acquirer/lock_info.rbs +9 -1
  56. data/sig/redis_queued_locks/acquirer/locks.rbs +10 -1
  57. data/sig/redis_queued_locks/acquirer/queue_info.rbs +2 -4
  58. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  59. data/sig/redis_queued_locks/acquirer/release_lock.rbs +8 -1
  60. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +44 -0
  61. data/sig/redis_queued_locks/client.rbs +23 -2
  62. data/sig/redis_queued_locks/resource.rbs +9 -2
  63. data/sig/redis_queued_locks/swarm/zombie_info.rbs +5 -0
  64. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de70554e8788c12aaa830c8eca99f7b31f936836a3f664498299d213ba2b6036
4
- data.tar.gz: fa8bc015bb930d9ccef934ab6884dd1ef930cf2b540a98ab221f78b1a06afac8
3
+ metadata.gz: e33f744b3798258657c7b699b078ff4017f0d21341a1103437734a97cc5a46e8
4
+ data.tar.gz: dacf22eae42a663ddf9984da0cda311f3b44f06b4a9e22d015eedc941b77ddbb
5
5
  SHA512:
6
- metadata.gz: e7ca083df52b3e53878512da62a8a515fe0bdf94f58d14e4d62db6facf1684b481597d5618f3d46294051774f0a2f076d7d518c0b848eedaefef0fca22158bd9
7
- data.tar.gz: 1d3e8c5660334ef5925ad951408d262922a4786dce98d539ad9318edce240b9ffa47093d1a1fffa2fb8dc431f7be8d2ed6d0fe565d53743e5f5177b5750c42b6
6
+ metadata.gz: f7b7c4b1b38e633f7ee1fd3dbccd8846da716fa6756cf5a8279488cc75ebb93244adbccbd315844ad6a8590250450beb1487fd7b4b7d3c3441833a0f6d6b6d0f
7
+ data.tar.gz: 34189139e943bdae9e15d8642a8f2d504984547dd08a62efd92d27d786043e74808122c72dc24f25ccacc385862fe65e87249071f6e07e2925a72166ac3b2845
@@ -35,14 +35,22 @@ Client (client.rb) public API facade
35
35
  Every other public method is a thin wrapper: it fills option defaults from `config[...]` and calls
36
36
  an `Acquirer::*` module function with `redis_client`.
37
37
 
38
+ Operation modules are independent of each other (all `Acquirer::*` except the `AcquireLock` core
39
+ and PoC modules such as `LockSeriesPoC`, which may reuse other modules and `AcquireLock`): no
40
+ cross-calls and no shared RBS types; common logic lives in `Resource`/`Utilities`, similar logic is
41
+ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatting, `Queues` the
42
+ `QueueInfo` request formatting). Known debt: `AcquireLock::WithAcqTimeout` calls
43
+ `LockInfo`/`QueueInfo` for detailed timeout errors.
44
+
38
45
  ### Client → implementation map
39
46
 
40
47
  | Client method | Implementation |
41
48
  |---|---|
42
49
  | `lock` / `lock!` | `acquirer/acquire_lock.rb` + `acquirer/acquire_lock/*` |
43
- | `lock_series` / `lock_series!` | `acquirer/lock_series_poc.rb` (proof of concept) |
50
+ | `lock_series` / `lock_series!` | `acquirer/lock_series_poc.rb` (proof of concept; one `read_write_mode` for the whole series) |
44
51
  | `unlock` | `acquirer/release_lock.rb` |
45
- | `extend_lock_ttl` | `acquirer/extend_lock_ttl.rb` |
52
+ | `unlock_read` / `release_read_lock` | `acquirer/release_read_lock.rb` (own read lock or `acquirer_id:` read lock only) |
53
+ | `extend_lock_ttl` | `acquirer/extend_lock_ttl.rb` (`read_write_mode: :read` extends own read lock, `+ all_read_locks: true` all live read locks) |
46
54
  | `locked?` / `queued?` | `acquirer/is_locked.rb` / `acquirer/is_queued.rb` |
47
55
  | `lock_info` / `queue_info` | `acquirer/lock_info.rb` / `acquirer/queue_info.rb` |
48
56
  | `locks`, `locks_info`, `queues`, `queues_info`, `keys` | `acquirer/locks.rb`, `queues.rb`, `keys.rb` (SCAN-based) |
@@ -58,23 +66,84 @@ an `Acquirer::*` module function with `redis_client`.
58
66
 
59
67
  | Mixin | Role |
60
68
  |---|---|
61
- | `TryToLock` (`try_to_lock.rb`) | One attempt: `ZADD NX` acquirer into the lock queue (timestamp score), then `multi(watch: [lock_key])` and take the lock if allowed (head of queue for `:queued`, any position for `:random`). Handles reentrant conflicts; TTL extension via inline Lua (`PTTL` + `PEXPIRE`). |
69
+ | `TryToLock` (`try_to_lock.rb`) | One attempt: `multi(watch:)` (write: lock key + readers registry, read: lock key only), one pipelined read of the lock state (`HGET acq_id`, `TIME`, own reader score, longest reader), `ZADD NX` into the queue of the requested mode (timestamp score), prune both queues, take the lock if allowed (see Read/Write locks). Handles reentrant conflicts (incl. read/write ones); write TTL extension via inline Lua (`PTTL` + `PEXPIRE`). |
62
70
  | `WithAcqTimeout` | Global acquisition timeout (`timeout`). |
63
71
  | `DelayExecution` | Retry delay + jitter between attempts. |
64
- | `DequeueFromLockQueue` | Removes the acquirer from the queue on timeout/failure. |
65
- | `YieldExpire` | Runs the user block (optionally `timed`), then releases/expires the lock. |
72
+ | `DequeueFromLockQueue` | Removes the acquirer from the queue of the requested mode on timeout/failure. |
73
+ | `YieldExpire` | Runs the user block (optionally `timed`), then releases/expires the lock of the held mode (write: `EXPIRE 0`; read: `ZREM` own reader + `DEL` own read lock data; extendable reentrant: decrease). |
66
74
  | `LogVisitor` / `InstrVisitor` | One method per lifecycle event for logs and instrumentation. |
67
75
 
68
76
  ### Main `lock` options (defaults from config)
69
77
 
70
78
  `ttl`, `queue_ttl`, `timeout`, `timed`, `retry_count`, `retry_delay`, `retry_jitter`,
71
79
  `raise_errors`, `fail_fast`, `conflict_strategy`, `access_strategy`, `read_write_mode`
72
- (default `:write`; its doc is unfinished), `identity`, `meta`, `logger`, `log_lock_try`,
80
+ (`:write` (default, exclusive) | `:read` (shared)), `identity`, `meta`, `logger`, `log_lock_try`,
73
81
  `instrumenter`, `instrument`, log/instr sampling options, `log_sample_this`, `instr_sample_this`.
74
82
 
75
83
  - `access_strategy`: `:queued` (FIFO, default) or `:random`.
76
84
  - `conflict_strategy` (same process re-acquires its own lock): `:wait_for_lock` (default),
77
85
  `:work_through`, `:extendable_work_through`, `:dead_locking`.
86
+ - `read_write_mode`: `:write` (default) | `:read`, see "Read/Write locks" below.
87
+
88
+ ### Read/Write locks (`read_write_mode`)
89
+
90
+ - Semantics: `:write` is exclusive (waits for the write lock and all live read locks); `:read` is
91
+ shared (waits for the write lock only). Default `:write` keeps the classic behavior and keys.
92
+ - Safety (no Lua, WATCH/MULTI only): readers WATCH the write lock key; writers WATCH the write lock
93
+ key + readers registry. Readers never invalidate each other; a new reader aborts a concurrent
94
+ writer's EXEC and vice versa. Queues never participate in safety.
95
+ - Reader liveness: registry score = expiration in Redis server time (`TIME`, ms); expired members are
96
+ ignored (`score > now`) and pruned on reader acquisition; registry TTL = max reader TTL (`PEXPIRE NX`
97
+ + `PEXPIRE GT` in the same MULTI).
98
+ - Ordering (`:queued`): FIFO between modes by `(score, acq_id)`: read waits for earlier write requests;
99
+ write must be the head of the write queue and waits for earlier read requests. Every attempt prunes
100
+ dead requests in both queues. `:random` ignores queues (writers can starve). Ordering is best effort
101
+ (client clocks, `unlock` clears queues); it never affects mutual exclusion.
102
+ - Read lock data: `rql:lock_reader:<name>:<acq_id>` HASH with the write-lock format (`acq_id`, `hst_id`,
103
+ `ts`, `ini_ttl`, `meta`, `spc_*` reentrant counters), TTL = read lock TTL; data carrier only (the
104
+ registry decides existence). Recreated (DEL + HSET) on each read acquisition.
105
+ - Reentrancy (same acquirer): read under own write/read lock -> `conflict_strategy` as usual
106
+ (`held_rw_mode` decides which lock is extended/decreased/released); read->write upgrade ->
107
+ `:conflict_lock_upgrade` (`ConflictLockObtainError`) for `:work_through`/`:extendable_work_through`,
108
+ `:conflict_dead_lock` for `:dead_locking`, waits for own read expiration for `:wait_for_lock`.
109
+ - `fail_fast`: write fails on write holder or live readers; read fails on write holder (or own live read).
110
+ - Try results (internal symbols): `:write_request_is_ahead`, `:read_request_is_ahead`,
111
+ `:read_lock_is_still_acquired`, `:conflict_lock_upgrade`, `:read_lock_is_expired_during_extension`;
112
+ try success result carries internal `rw_mode` (held mode), not exposed in the public `lock` result.
113
+ - Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; read `ttl` must be a positive
114
+ Integer (`RedisQueuedLocks::ArgumentError`).
115
+ - RW-aware operations: `unlock` (write lock + readers + reader data + both queues, same result shape),
116
+ `clear_locks`, `clear_locks_of`/`clear_current_locks` (host derived from acq id via
117
+ `Resource.host_identifier_from_acquirer`), `clear_dead_requests`, `flush_zombies`/`zombie_*`
118
+ (zombie readers reported as their registry key), `locked?`, `queued?`, `lock_info` (read info:
119
+ `'rw_mode' => 'read'`, `'rem_ttl'`, `'readers' => [reader data + rem_ttl]` when no write lock),
120
+ `queue_info` (`'read_lock_queue'`/`'read_queue'` when present), `locks`/`locks_info`, `queues`/`queues_info`;
121
+ write lock info in `lock_info`/`locks_info` has `'rw_mode' => 'write'` (computed on formatting, not stored),
122
+ so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
123
+ every request in `queue_info`/`queues_info` is `{ 'acq_id', 'score', 'rw_mode' => 'write'|'read' }`
124
+ (`queues_info` derives the mode from the queue key via `Resource.lock_queue_rw_mode`).
125
+ - `lock_series` (both modes): one mode per series; `release_lock_series` releases only locks the series
126
+ obtained itself (`process == :lock_obtaining`, reentrant ones are kept) and only the current acquirer's:
127
+ write = compare-and-delete (`WATCH` + `HGET acq_id` + `DEL`), read = `ZREM` + `DEL` reader data. It runs
128
+ after the block and on any failure (exception or `raise_errors: false`), once per failed series.
129
+ - Own read lock operations (current acquirer via `current_acquirer_id(identity:)`): `unlock_read` /
130
+ `release_read_lock` (`ZREM` + `DEL` reader data, event `explicit_read_lock_release`, result
131
+ `{ rel_time:, rel_key:, rel_acq_id:, lock_res: }`; `acquirer_id:` (default `nil` => current acquirer;
132
+ when passed, `identity:` is ignored: the acquirer id already contains its identity) releases the read lock of a different acquirer) and `extend_lock_ttl(..., read_write_mode: :read)`
133
+ (`multi(watch: [lock_key])`: extends only a live read lock while no write lock exists, never revives
134
+ an expired one; registry and reader data TTLs via `PEXPIRE NX`+`GT`). `unlock` releases everything.
135
+ - `extend_lock_ttl(..., read_write_mode: :read, all_read_locks: true)` (`false` by default, ignored for
136
+ `:write`; non-boolean in `:read` => `ArgumentError`): same `multi(watch: [lock_key])` (registry not
137
+ watched, so concurrent readers don't abort it), `ZRANGE WITHSCORES` snapshot, every live reader gets
138
+ `ZADD XX INCR` + reader data `PEXPIRE NX`+`GT`, registry TTL = longest extended reader;
139
+ `extended_locks_count` = the number of non-nil `ZADD` results (0 => `:async_expire_or_no_lock`).
140
+ `extend_lock_ttl` success result (all modes): `{ ok: true, result: { extended_locks_count: Integer } }`
141
+ (write / own read = 1); failure stays `{ ok: false, result: :async_expire_or_no_lock }`. Internal param order: `read_write_mode, all_read_locks, acquirer_id`.
142
+ - Design decisions (release semantics, not limitations to "fix"): read->write upgrade is an error unless
143
+ `:wait_for_lock`; nested reads of one acquirer go through `conflict_strategy` (no hold counting);
144
+ `:random` gives no fairness between modes; cross-host FIFO is best effort; no Lua in the RW path.
145
+ - RBS: `Client` types `read_write_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
146
+ before read locks are used.
78
147
 
79
148
  ### Redis data layout (`resource.rb`)
80
149
 
@@ -82,7 +151,9 @@ an `Acquirer::*` module function with `redis_client`.
82
151
  |---|---|---|
83
152
  | `rql:lock:<name>` | HASH | lock owner + metadata (e.g. `l_spc_ts`, `l_spc_ext_ts` for reentrant cases) |
84
153
  | `rql:lock_queue:<name>` | ZSET | acquirer queue scored by enqueue time |
85
- | `rql:lock_queue:<name>:read` / `:write` | ZSET | read/write mode queues |
154
+ | `rql:lock_readers:<name>` | ZSET | read locks: acquirer id => expiration (Redis `TIME`, ms) |
155
+ | `rql:lock_read_queue:<name>` | ZSET | read lock requests (write requests use `rql:lock_queue:<name>`) |
156
+ | `rql:lock_reader:<name>:<acq_id>` | HASH | read lock data (same fields as `rql:lock:<name>` incl. meta; data carrier only) |
86
157
  | `rql:swarm:hsts` | HASH | swarm host heartbeats |
87
158
 
88
159
  - Acquirer ID: `rql:acq:<pid>/<thread>/<fiber>/<ractor>/<identity>`
@@ -93,7 +164,12 @@ an `Acquirer::*` module function with `redis_client`.
93
164
  `redis_queued_locks.` + `lock_obtained`, `reentrant_lock_obtained`,
94
165
  `extendable_reentrant_lock_obtained`, `lock_hold_and_release`, `reentrant_lock_hold_completes`,
95
166
  `lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
96
- `explicit_all_locks_release`, `release_locks_of`.
167
+ `explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`.
168
+ Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
169
+ `lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
170
+ lock series events (`lock_series_obtained`, `lock_series_hold_and_release`) carry the mode of the series;
171
+ lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock log events: `exit__write_request_ahead`,
172
+ `exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`.
97
173
 
98
174
  ### Errors (`errors.rb`)
99
175
 
@@ -111,7 +187,7 @@ inherit from `Timeout::Error`.
111
187
  |---|---|
112
188
  | `Client` | Facade; constructor injection (caller-supplied `RedisClient`); `x` returns a result hash, `x!` raises |
113
189
  | `Acquirer::*` | One module per command; stateless `class << self` functions; uniform `{ ok:, result: }` returns (public API contract; swarm element internals use bare scalars/primitives) |
114
- | `AcquireLock` | Composition via `extend` mixins; optimistic concurrency (WATCH/MULTI) + Lua for atomic updates; strategy options (`access_strategy`, `conflict_strategy`) |
190
+ | `AcquireLock` | Composition via `extend` mixins; optimistic concurrency (WATCH/MULTI; asymmetric WATCH for read/write locks) + Lua only for write TTL extension/decrease; strategy options (`access_strategy`, `conflict_strategy`, `read_write_mode`) |
115
191
  | Log/Instr visitors | Visitor-style event hooks keep observability out of the algorithm; percent sampling via `sampling_happened?(percent)` |
116
192
  | `Logging` / `Instrument` | Null Object defaults (`VoidLogger`, `VoidNotifier`); Adapter (`instrument/active_support.rb`); duck typing (`::Logger` API, `#notify(event, payload)`) |
117
193
  | `Config` | Declarative DSL: `setting(key, default)` and `validate(key) { }` registries; access as `config['a.b']`; runtime `Client#configure` |
@@ -151,7 +227,7 @@ lib/redis_queued_locks/
151
227
  resource.rb keys and identities
152
228
  data.rb, errors.rb, utilities.rb, utilities/lock.rb, debugger/, version.rb
153
229
  sig/ RBS mirror of lib/ + sig/vendor stubs (redis_client, active_support, semantic_logger)
154
- spec/ redis_queued_locks_spec.rb (~2.6k lines, integration), spec_helper.rb, setup_simplecov.rb
230
+ spec/ redis_queued_locks_spec.rb (~3.4k lines, integration), spec_helper.rb, setup_simplecov.rb
155
231
  .github/workflows/ tests, lint, typecheck-static, typecheck-runtime
156
232
  bin/console, bin/setup dev scripts
157
233
  Rakefile, Steepfile, rbs_collection.yaml
@@ -202,4 +278,5 @@ Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acqu
202
278
  - New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
203
279
  - New config option: `setting` (+ `validate`) in `config.rb`, read via `config['key']`, document defaults.
204
280
  - Development gems go in `Gemfile` (`Gemspec/DevelopmentDependencies: Gemfile`), not the gemspec.
281
+ - This overview and `.claude/rules/*.md` are updated in the same change as the code (keys, options, results, events, algorithm steps, limitations); README and CHANGELOG `[Unreleased]` for user-visible behavior (log keys and instrumentation payloads: README `## Logging` / `### Instrumentation Events`).
205
282
  - Temporary debt: rspec-retry, disabled coverage minimum, runtime type-check job that can't fail.
@@ -9,33 +9,45 @@ paths:
9
9
  ## Observed style and patterns
10
10
  - **Module shape**: `module RedisQueuedLocks::Acquirer::<CamelName>` in `acquirer/<snake_name>.rb`, `# @api private`, a single public entry function inside `class << self` named after the file (`ReleaseLock.release_lock`, `IsLocked.locked?`), helpers under `private` in the same `class << self`.
11
11
  - **Utilities**: modules that time or instrument `extend RedisQueuedLocks::Utilities` (gives `clock_gettime`, `run_non_critical`).
12
+ - **Independence of operation modules** (core principle): the modules behind `Client` public methods that don't acquire locks (`ClearDeadRequests`, `ExtendLockTTL`, `IsLocked`, `IsQueued`, `Keys`, `LockInfo`, `Locks`, `QueueInfo`, `Queues`, `ReleaseAllLocks`, `ReleaseLock`, `ReleaseLocksOf`, `ReleaseReadLock`) never call each other and never reference each other's RBS types. They share logic only through `Resource`, `Utilities` and other non-operation helpers; similar logic is duplicated on purpose (independence wins over DRY):
13
+ - `Locks.extract_locks_info` / `Locks.read_lock_info` duplicate the write/read lock formatting of `LockInfo.lock_info` / `LockInfo.read_lock_info`;
14
+ - `Queues.extract_queues_info` duplicates the request formatting of `QueueInfo.queue_info`;
15
+ - each module declares its own RBS aliases (`Locks::lockInfo` / `Locks::readerInfo` mirror `LockInfo::lockInfo` / `LockInfo::readerInfo`); only `Client` signatures reference them.
16
+ - `AcquireLock` is the lock acquisition core, not an operation module of this list.
17
+ - PoC modules (`*PoC`, e.g. `LockSeriesPoC`) are not covered by this principle: they may reuse operation modules and the `AcquireLock` core (`LockSeriesPoC` uses `AcquireLock.acquire_lock` and its `YieldExpire` mixin).
18
+ - Known debt (don't copy): `AcquireLock::WithAcqTimeout` calls `LockInfo.lock_info` / `QueueInfo.queue_info` for detailed timeout errors (its TODO asks to make `AcquireLock` independent of them).
12
19
  - **Signatures** (see `arguments.md` for the design rationale):
13
20
  - Read-only queries: few positional args `(redis_client, lock_name)`; collection queries use keywords `(redis_client, scan_size:, with_info:)`.
14
21
  - Mutating operations: long positional lists ending with the fixed observability tail
15
22
  `logger, instrumenter, instrument, log_sampling_enabled, log_sampling_percent, log_sampler, log_sample_this, instr_sampling_enabled, instr_sampling_percent, instr_sampler, instr_sample_this`
16
23
  (order of `logger`/`instrumenter` varies between modules; check the existing signature).
17
- - Only `AcquireLock.acquire_lock` uses keyword args (`process_id:`, `thread_id:`, ...).
18
- - **Two-layer mutating operations** (`release_lock`, `release_all_locks`, `release_locks_of`):
24
+ - Only `AcquireLock.acquire_lock` and `LockSeriesPoC.lock_series_poc` use keyword args (`process_id:`, `thread_id:`, ...).
25
+ - **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`):
19
26
  1. `rel_start_time = clock_gettime`
20
27
  2. call a private `fully_*` helper returning `{ ok:, result: }` and destructure it: `fully_x(...) => { ok:, result: }`
21
28
  3. `time_at = Time.now.to_f`; `rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)` (microseconds → ms)
22
29
  4. `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
23
30
  5. `run_non_critical { instrumenter.notify('redis_queued_locks.<event>', { at:, rel_time:, ... }) } if instr_sampled`
24
31
  6. return `{ ok: true, result: { ..., rel_time: } }`
25
- - **Results**: always `{ ok: Boolean, result: ... }` for operations; `result` is a Symbol status (`:ttl_extended`, `:async_expire_or_no_lock`, `:released`, `:nothing_to_release`) or a Symbol-keyed Hash with abbreviated keys (`rel_key_cnt`, `tch_queue_cnt`, `rel_time`). Info queries return a String-keyed Hash / Set or `nil` when absent.
32
+ - **Results**: always `{ ok: Boolean, result: ... }` for operations; `result` is a Symbol status (`:async_expire_or_no_lock`, `:released`, `:nothing_to_release`) or a Symbol-keyed Hash (`rel_key_cnt`, `tch_queue_cnt`, `rel_time`, `extended_locks_count`). Info queries return a String-keyed Hash / Set or `nil` when absent.
26
33
  - **Redis access**:
27
- - Keys only via `Resource.prepare_lock_key` / `prepare_lock_queue` and `Resource::*_PATTERN`.
34
+ - Keys only via `Resource.prepare_lock_key` / `prepare_lock_queue` / `prepare_read_lock_queue` / `prepare_lock_readers` / `prepare_read_lock_key(lock_name, acquirer_id)` and `Resource::*_PATTERN` (`LOCK_PATTERN`, `LOCK_QUEUE_PATTERN`, `READ_LOCK_QUEUE_PATTERN`, `LOCK_READERS_PATTERN`, `READ_LOCK_PATTERN`); `Resource.lock_name_from_readers` / `lock_key_from_readers` map registry keys back.
35
+ - Several independent reads inside one attempt/operation go into one `pipelined` round trip (it works inside `multi(watch:)` too: reads are sent after WATCH).
28
36
  - Raw commands: `redis.call('CMD', ...)` with uppercase string command names and string args (`'0'`, `'-inf'`, `'+inf'`).
29
37
  - Pooled connection: wrap multi-command work in `redis.with do |rconn| ... end`.
30
38
  - Atomic writes: `rconn.multi do |transact| ... end` (or `multi(watch: [lock_key])` in `TryToLock`); batch reads: `pipelined do |pipeline| ... end` then index `result[0]`, `result[1]` into named vars (`hget_cmd_res`, `pttl_cmd_res`).
31
39
  - Iteration: `scan('MATCH', PATTERN, count: scan_size) { |key| ... }`, collecting into `Set.new.tap { |set| ... }`; deletes are batched by scan size.
32
40
  - Lua: frozen heredoc constant (`<<~LUA_SCRIPT.strip.tr("\n", '').freeze`) + `call('EVAL', SCRIPT, 1, key, arg)`.
33
- - Release = `EXPIRE key 0` / `ZREMRANGEBYSCORE queue -inf +inf`; Redis TTL sentinels handled explicitly (`PTTL` `-2` = missing, `-1` = no expiry → `Float::INFINITY`).
41
+ - Release = `EXPIRE key 0` / `ZREMRANGEBYSCORE queue -inf +inf`; a read lock is released with `ZREM <readers> <acq_id>` + `DEL <reader data>` (owner-safe, other readers untouched); Redis TTL sentinels handled explicitly (`PTTL` `-2` = missing, `-1` = no expiry → `Float::INFINITY`).
42
+ - Readers registry members are live only while `score > Redis TIME (ms)` (`Resource.redis_time_ms`); expired members can stay till cleanup, so info/checks must filter by score, never by `ZCARD`/`EXISTS` alone.
34
43
  - **Data normalization**: Redis hash strings are converted with `Float(...)` / `Integer(...)` inside `hget_cmd_res.tap do |lock_data| ... end`, optional fields guarded with `if lock_data['x']`.
35
44
  - **AcquireLock**:
36
45
  - Main module `require_relative`s its parts, then `extend`s the mixins (`TryToLock`, `DelayExecution`, `YieldExpire`, `WithAcqTimeout`, `DequeueFromLockQueue`); mixins are plain modules with instance methods (`def try_to_lock(...)`), visitors are `class << self` modules called explicitly (see `visitors.md`).
37
46
  - The algorithm is a numbered step script (`# Step 0`, `# Step 2.1`, `# Step 2.2.a`) driven by a mutable `acq_process` hash (`:should_try`, `:tries`, `:acquired`, `:result`, `:lock_info`, timings).
38
- - Failure modes are Symbols (`:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`, ...); exceptions are raised only when `raise_errors` is true, with a message naming the lock key / acquirer id.
47
+ - Failure modes are Symbols (`:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`, `:conflict_lock_upgrade`, ...); exceptions are raised only when `raise_errors` is true, with a message naming the lock key / acquirer id.
48
+ - `TryToLock` attempt (both modes): `multi(watch:)` (write: `[lock_key, lock_readers_key]`, read: `[lock_key]`) → one pipelined state read (`HGET acq_id`, `TIME`, own reader score, longest reader) → same-process conflict detection (`sp_conflict_rw_mode` = held `:write`/`:read`) → `ZADD NX` into the queue of the requested mode → prune dead requests in **both** queues → queue heads of both queues (`:queued`: write must be first in its queue; any mode waits for an earlier `(score, acq_id)` request of the opposite mode) → lock state (write held → `:lock_is_still_acquired`; live readers for write / own live read for read → `:read_lock_is_still_acquired`) → MULTI (write: `ZREM` + `HSET` + `PEXPIRE`; read: `ZREM` + prune registry + `ZADD expiry` + registry `PEXPIRE NX`+`GT` + reader data `DEL`+`HSET`+`PEXPIRE`).
49
+ - The try result carries internal `rw_mode` (the **held** lock mode); `acquire_lock` stores it as `acq_process[:held_rw_mode]` and passes it to `yield_expire`, which releases/decreases the held lock. The public `lock` result shape does not include it.
50
+ - Read lock data (`rql:lock_reader:<name>:<acq_id>`) uses the write-lock field format (`acq_id`, `hst_id`, `ts`, `ini_ttl`, meta, `spc_*`) and the read lock TTL; it is a data carrier only (writers never read it).
39
51
  - Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
40
52
  - **Inline typing**: `# @type var result: Symbol`, `{} #: Hash[String,String|Float|Integer]`, and `# steep:ignore` on pattern-matching destructures and splats (`rconn.call('DEL', *keys) # steep:ignore`).
41
53
 
@@ -48,14 +60,22 @@ paths:
48
60
  6. Use uppercase string Redis commands and string numeric args; handle `PTTL`/`TTL` sentinel values explicitly.
49
61
  7. Iterate keys with `scan('MATCH', Resource::*_PATTERN, count:)`, never `KEYS`; batch deletes by scan size.
50
62
  8. Measure durations with `clock_gettime` and report ms with `/ 1_000.0).ceil(2)`; use `Time.now.to_f` only for event timestamps.
51
- 9. Wrap every `instrumenter.notify` / logger call in `run_non_critical` (or a visitor) and gate it with `Instrument.should_instrument?` / `Logging.should_log?`; observability must never break locking.
63
+ 9. Wrap every `instrumenter.notify` / logger call in `run_non_critical` (or a visitor) and gate it with `Instrument.should_instrument?` / `Logging.should_log?`; observability must never break locking. Payload changes of inline `notify` calls are documented in README `### Instrumentation Events` in the same change (see `visitors.md` rule 14).
52
64
  10. In `AcquireLock`, add behavior as a new step or mixin rather than growing `acquire_lock`; keep the `# Step N.x` comment numbering, update `acq_process` keys consistently, and add a matching `LogVisitor`/`InstrVisitor` method for each new lifecycle event.
53
- 11. When normalizing lock hash fields, follow the `Float()` / `Integer()` conversion pattern; if a new lock field is added, update both `lock_info.rb` and `locks.rb` (they duplicate the formatting).
65
+ 11. When normalizing lock hash fields, follow the `Float()` / `Integer()` conversion pattern; if a new lock field is added, update every copy of the formatting: write locks in `LockInfo.lock_info` and `Locks.extract_locks_info`, readers in `LockInfo.read_lock_info` and `Locks.read_lock_info` (reader data uses the same fields).
66
+ 12. Read/write checklist for any change in this directory:
67
+ - lock state checks: write requires no write lock and no **live** readers; read requires no write lock; same-acquirer reader/writer cases go through `conflict_strategy` (read→write upgrade never "works through");
68
+ - WATCH: writers watch the write key + readers registry, readers watch the write key only (never the registry: readers must not abort each other); don't modify a watched key outside MULTI after WATCH (it aborts your own EXEC);
69
+ - every attempt prunes dead requests in both queues; dequeue/cleanup removes the acquirer from the queue of its mode;
70
+ - every release/cleanup/info/zombie path covers write lock, readers registry, reader data and both queues;
71
+ - registry/reader-data TTLs: `PEXPIRE NX` + `PEXPIRE GT` pair to keep max TTL (`GT` alone never sets TTL on a key without one);
72
+ - keep write-only result shapes unchanged; add read keys/fields only when read data exists.
73
+ 13. Values used only for logs (e.g. `HGETALL` of lock data) are fetched only when the log is enabled (`(log_sampled && log_lock_try) ? ... : {}`): visitor arguments are evaluated before the visitor's guard.
74
+ 14. Keep operation modules independent (see "Independence of operation modules"; PoC modules such as `LockSeriesPoC` are exempt): never call another operation module's function (public or private) or reference its RBS types from an operation module. When similar logic is needed, duplicate it inside the module (same method name and shape as the original copy, with a `NOTE` that it is duplicated on purpose) or move a pure, data-only helper (key names, id parsing, time conversion) to `Resource` / `Utilities`. Helpers used by one module stay `private`. When changing logic that has copies, update every copy in the same change.
54
75
 
55
76
  ## Recommendations (proposed, not yet project policy)
56
77
  Apply to new or touched code; don't refactor existing code for these unless asked.
57
78
  1. Load Lua scripts once (`SCRIPT LOAD` + `EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), as the TODO in `extend_lock_ttl.rb` asks.
58
79
  2. Prefer indexed lookups over full `SCAN` loops for new features (see TODOs in `release_locks_of.rb`, `locks.rb`, `queues.rb`).
59
- 3. Treat `lock_series_poc.rb` as experimental: don't build new features on it without asking.
60
- 4. Extract the duplicated lock-hash normalization in `lock_info.rb` / `locks.rb` and queue formatting in `queue_info.rb` / `queues.rb` into shared helpers (their TODOs ask for this).
61
- 5. Unify the observability tail order (`logger, instrumenter` vs `instrumenter, logger` in `release_lock`) when those signatures are next changed.
80
+ 3. Treat `lock_series_poc.rb` as experimental: don't build new features on it without asking. Its locks are released only through `release_lock_series` (obtained-by-series locks only, owner-checked, on success and on every failure path).
81
+ 4. Unify the observability tail order (`logger, instrumenter` vs `instrumenter, logger` in `release_lock`) when those signatures are next changed.
@@ -50,10 +50,11 @@ 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.
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, acquirer_id, host_id, ...` (`yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); compute keys once in `acquire_lock` and pass them down.
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
+ 9. `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`).
57
58
 
58
59
  ## Recommendations (proposed, not yet project policy)
59
60
  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,46 @@
1
+ # Git commit rules
2
+
3
+ Applies to every commit Claude makes in this repository (no `paths:` scope: always loaded).
4
+
5
+ ## Observed conventions (git history analysis)
6
+ - 653 of 687 non-merge subjects use the `[<scope>] <summary>` form: `[readme] minor updates`, `[gem] bump to 1.17.0`, `[lock_series] correct lock series releasing when blokc of code is failed with an exception`.
7
+ - Scope = the area of the change, lowercase:
8
+ - docs and project files: `readme` (most frequent), `roadmap`, `changelog`, `docs`, `yardoc`, `license`;
9
+ - gem lifecycle: `gem` (`bump to X.Y.Z`, dependency updates), `new-release`;
10
+ - tooling: `rbs`, `types`, `ci`, `rubocop`, `linting`, `specs`, `dev`;
11
+ - features/modules, named like the code or the README section: `swarm`, `lock_series`, `logging`, `instrumentation`, `reentrant-locks`, `release_locks_of/release_current_locks`, `#clear_dead_requests`.
12
+ - Sub-areas are joined with `/` (`ci/typechecking`, `readme/roadmap`, `features/rel_of_acquirer/rel_of_host`); multi-word scopes use `-` or keep the code identifier (`dead-locks-and-reentrant-locks`, `lock_series`).
13
+ - Summary: short, lowercase, no trailing period; imperative and past tense are mixed (`update`, `updated`, `bump`, `fixed typo`); several changes are joined with `+` or commas (`feature + doc updates`, `release_locks_of, release_current_locks`).
14
+ - Several areas in one commit (mostly squash-merged PRs): `[a] + [b] + [c]` (`[claude instructions] + [gem update] + [migration to Ractor 4 API]`).
15
+ - Version bumps: `[gem] bump to X.Y.Z`.
16
+ - Squash merges of PRs end with ` (#N)`; GitHub appends it, it is never typed by hand.
17
+ - Bodies are practically absent: only GitHub squash bullet lists and `Co-Authored-By:` trailers.
18
+ - Anti-patterns found in history (don't repeat): meaningless subjects (`dev`, `cheburek`, `work in progress`, `huuuge updates`), scope typos (`[roadma]`), stash/WIP entries.
19
+
20
+ ## Claude rules
21
+ **Message format**
22
+ 1. Subject only, one line: `[<scope>] <summary>`. Don't write a commit body (no description, overview, bullet lists or explanations). The only allowed extra lines are the attribution trailers the harness requires (`Co-Authored-By: ...`), separated by a blank line.
23
+ 2. Scope: one lowercase area from the list above, or the feature/module name (`read-write-locks`, `swarm`, `lock_series`, `extend_lock_ttl`). Use `/` for a sub-area (`ci/typechecking`). Name the dominant area: specs, RBS, README/CHANGELOG and `.claude` docs that accompany a feature don't get their own scope.
24
+ 3. Summary: a short, meaningful description of what the change does (the feature or the fix), not of the process (`changes`, `updates`, `fixes`, `wip` alone are not allowed). Lowercase start (code identifiers keep their spelling), no trailing period, imperative mood, aim for <= 72 characters in total.
25
+ 4. Several features in one commit: list each feature in at most two words, comma-separated: `[read-write-locks] unlock_read, extend all-readers, rw_mode info`. Don't use `+` chains or sentences for lists.
26
+ 5. Unrelated areas: prefer separate commits per area; if one commit is unavoidable, use the historical form `[a] + [b]` with a 1-2 word summary per scope.
27
+ 6. Releases: `[gem] bump to X.Y.Z`. Never add ` (#N)` by hand.
28
+
29
+ **When to commit**
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;
32
+ - 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) and the list of files that will be committed;
33
+ - commit only after an explicit "yes" (or with the subject the user corrects); on "no" leave the changes uncommitted (staging also only on request).
34
+ 8. An explicit user request to commit ("commit", "закоммить") is the permission itself: commit right away with the given subject (or a subject built by the rules) without asking again.
35
+ 9. Don't propose a commit for incomplete or red states (failing specs, lint/type errors, half-done requirements); report the blocker instead.
36
+ 10. One commit per completed step. Stage only the files of that step (`git status` first); never commit environment artifacts: `rbs_collection.lock.yaml` rewritten by `rbs collection install` (restore it with `git checkout --`), `coverage/`, `.gem_rbs_collection/`, local settings.
37
+ 11. Never commit to `master`: create a feature branch first (named after the feature, e.g. `read-write-locks-realisation`). Never push, amend, rebase, reset or force anything unless the user asks.
38
+ 12. After committing, report the short hash and the subject.
39
+
40
+ **Examples**
41
+ - `[read-write-locks] extend all read locks`
42
+ - `[read-write-locks] unlock_read, rw_mode info, meta reservation`
43
+ - `[extend_lock_ttl] extended locks count result`
44
+ - `[rbs] remove duplicate collection gems`
45
+ - `[ci/typechecking] blocking runtime checks`
46
+ - `[gem] bump to 1.18.0`
@@ -12,7 +12,7 @@ Related rule files (loaded for narrower paths):
12
12
  - `swarm.md`: swarm architecture, element lifecycle, Ractor/Thread coding rules
13
13
 
14
14
  ## Swarm overview (details in `swarm.md`)
15
- - Purpose: zombie-lock elimination. `ProbeHosts` periodically `HSET`s every host id (`rql:hst:<pid>/<thread>/<ractor>/<identity>`) with `Time.now.to_f` into `rql:swarm:hsts`; `FlushZombies` treats hosts older than `zombie_ttl` (ms) as zombies and deletes their locks, their queue entries and the hosts themselves.
15
+ - Purpose: zombie-lock elimination. `ProbeHosts` periodically `HSET`s every host id (`rql:hst:<pid>/<thread>/<ractor>/<identity>`) with `Time.now.to_f` into `rql:swarm:hsts`; `FlushZombies` treats hosts older than `zombie_ttl` (ms) as zombies and deletes their write locks, their read locks (registry members + reader data), their entries in both request queues and the hosts themselves.
16
16
  - `Client#swarm` → `Swarm` facade (one per client) owns a `Supervisor` and the swarm elements; started by `swarmize!` / `swarm.auto_swarm`, stopped by `deswarmize!`.
17
17
  - Elements are independent background units: a control unit (`SwarmElement::Threaded` = Thread + `SizedQueue` command channel; `SwarmElement::Isolated` = Ractor driven via its own pair of `Ractor::Port`s: results port created in the main Ractor, where the Swarm and Supervisor live; command port created inside the element Ractor) that spawns, stops and reports on a main-loop Thread with its own Redis connection (`Swarm::RedisClientBuilder`).
18
18
  - `ProbeHosts` is Threaded (it must see the client's ractor threads); `FlushZombies` is Isolated (copied config values only).
@@ -26,9 +26,12 @@ Related rule files (loaded for narrower paths):
26
26
  - YARD doc block on every class, module, constant, attr and method:
27
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).
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
+ - 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").
29
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`).
30
31
  - `Client` methods only fill defaults from `config['...']` and delegate to `Acquirer::*` / `Swarm`.
31
- - Redis keys come only from `RedisQueuedLocks::Resource.prepare_*` helpers and its `*_PATTERN` / `SWARM_KEY` constants.
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*`).
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
+ - 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.
32
35
  - 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.
33
36
  - Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass.
34
37
  - Comments use `# NOTE:`, `# TODO:` and `# @type var x: T` / `#: T` for inline type hints.
@@ -47,11 +50,14 @@ Related rule files (loaded for narrower paths):
47
50
  9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
48
51
  10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods.
49
52
  11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
53
+ 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.
54
+ 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).
55
+ 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.
50
56
 
51
57
  ## Recommendations (proposed, not yet project policy)
52
58
  Apply to new or touched code; don't refactor existing code for these unless asked.
53
59
  1. Avoid `# rubocop:disable all` (used in `client.rb` lock_series and `lock_series_poc.rb`); disable only the specific cops.
54
- 2. Document every option fully in YARD; `read_write_mode` is currently documented as `?` in `acquire_lock.rb`.
60
+ 2. Document every option fully in YARD.
55
61
  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.
56
62
  4. Include context in raised errors (lock name, acquirer id, timeout) so failures are diagnosable.
57
63
  5. Prefer `then` (the modern alias) over `yield_self` in new code.
@@ -12,8 +12,8 @@ paths:
12
12
  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
- - **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. 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_queue:*` + `ZREM` zombie acquirers → `HDEL` zombie hosts. It is best-effort and non-transactional, with full keyspace scans (`TODO: indexing`).
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`).
17
17
 
18
18
  ## Components
19
19
  | Object | File | Kind | Role |
@@ -116,4 +116,5 @@ Public element API (called by `Swarm`/`Supervisor` only):
116
116
  - `sleep(0.1)` "give a timespot" waits in `swarm!`/`deswarm!`/`observe!` instead of real readiness signalling.
117
117
  - 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
118
  - Swarm has no logging or instrumentation, and supervisor errors are silently dropped (`TODO: (CHECK)`).
119
- - `FlushZombies` scans the whole keyspace on every run and runs `ZREM` for every zombie acquirer on every queue.
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.
@@ -7,10 +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`, under `RSpec.describe RedisQueuedLocks`. The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
10
+ - All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~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).
11
12
  - `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`), then `rspec/retry`, `pry`, the gem.
12
13
  - RSpec config: random order (`Kernel.srand config.seed`), `disable_monkey_patching!`, `expect` syntax only, `filter_run_when_matching :focus`, `Thread.abort_on_exception = true`.
13
- - 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).
14
+ - 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
+ - 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).
14
16
  - `before`: `FLUSHDB`, `DEL Resource::SWARM_KEY`, `RedisQueuedLocks.enable_debugger!`; `after`: `DEL SWARM_KEY`, `FLUSHDB`.
15
17
  - Examples are written with `specify '<feature>'` (few `it`); grouped with `describe` only for big areas (`'Lock Series PoC'`, `'swarm'`).
16
18
  - Clients are built inline: `RedisQueuedLocks::Client.new(redis) { |config| ... }`.
@@ -28,7 +30,11 @@ paths:
28
30
  6. Clean up everything an example starts: release locks, `deswarmize!` swarm clients, join/kill threads.
29
31
  7. Keep `sleep`-based waits minimal and comment why (`# give a timespot to ...`); prefer polling with a bounded timeout when adding new async checks.
30
32
  8. Do not add new rspec-retry reliance or lower retry settings; do not enable `minimum_coverage` without being asked.
31
- 9. Specs are also run under RBS runtime checks, so pass correctly typed arguments to public API calls (type violations are logged in CI).
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`).
34
+ 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
+ 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
+ 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.
37
+ 13. New lock features get specs for both modes when relevant (`read_write_mode: :write` and `:read`): ordering, mutual exclusion, reentrancy per conflict strategy, `fail_fast`, timeouts/dequeue, release/cleanup/info/zombie paths, and that no `rql:*` keys are left (`client.keys` is empty) after blocks finish.
32
38
 
33
39
  ## Recommendations (proposed, not yet project policy)
34
40
  Apply to new tests; don't restructure the existing suite unless asked.
@@ -13,12 +13,18 @@ paths:
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
15
  - Shared duck types live in `sig/redis_queued_locks.rbs`: `_Loggable`, `_Instrumentable`, `loggerObj`, `instrObj`; samplers as `RQL::Logging::samplerObj` / `RQL::Instrument::samplerObj`.
16
- - Result shapes are named record aliases inside the module, e.g. `type extendResult = { ok: bool, result: Symbol }`.
16
+ - Result shapes are named record aliases inside the module, e.g. `type extendResult = { ok: bool, result: { extended_locks_count: Integer } | Symbol }`.
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.
17
18
  - Module functions are declared `def self.name: (...) -> T`; long signatures put one param per line with names.
18
19
  - Instance variables are declared (`@config_setters: configSetters`) and attr readers typed.
19
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
21
  - `Steepfile`: target `lib`, signatures `sig`, ignores `spec`, libraries timeout/securerandom/logger/monitor, diagnostics `Steep::Diagnostic::Ruby.strict`.
21
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.
23
+ - Steep 2.1 pitfalls found in this codebase:
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
+ - `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
+ - `pipelined`/`call` return `untyped`: annotate destructured pipeline results with a tuple (`# @type var lock_state: [String?, Array[String], Float?, Array[[String, Float]]]`);
27
+ - `LockSeriesPoC` has no RBS: new calls/definitions there need `# steep:ignore` (like the existing ones).
22
28
  - 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.
23
29
  - 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.
24
30
 
@@ -32,11 +38,13 @@ paths:
32
38
  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/`.
33
39
  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`).
34
40
  9. Don't rename `acquier.rbs` unless asked; it is a known quirk.
41
+ 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".
42
+ 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.
35
43
 
36
44
  ## Recommendations (proposed, not yet project policy)
37
45
  1. Make the runtime type-check CI job blocking (drop `--failure-exit-code=0`) once current violations are fixed.
38
46
  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#[]`).
39
- 3. Remove duplicate entries in `rbs_collection.yaml` (`redis-client` and `securerandom` are listed twice) and pin the `gem_rbs_collection` revision instead of `main` for reproducible checks.
47
+ 3. Pin the `gem_rbs_collection` revision in `rbs_collection.yaml` instead of `main` for reproducible checks.
40
48
  4. Rename `sig/redis_queued_locks/acquier.rbs` to `acquirer.rbs` in a dedicated change.
41
49
  5. Replace remaining `untyped` in signatures with precise unions or interfaces where the value set is known (e.g. strategy symbols as `:queued | :random`).
42
50
  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.
@@ -19,26 +19,28 @@ The project calls these modules "visitors", but they are not GoF Visitor (no `ac
19
19
  |---|---|---|
20
20
  | `AcquireLock::LogVisitor` | `acquire_lock/log_visitor.rb` | 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
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` | 14 step-level events (`start`, `rconn_fetched`, `acq_added_to_queue`, `exit__no_first`, `obtain__free_to_acquire`, ...) |
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
- | `LockSeriesPoC::LogVisitor` / `InstrVisitor` | `lock_series_poc/*_visitor.rb` | lock series events (no RBS, `# steep:ignore`) |
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_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: `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).
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']`).
35
- - InstrVisitor: `(instrumenter, instr_sampled, lock_key, ttl, acq_id, hst_id, ts, acq_time, [hold_time], instrument)`; 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`.
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.
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`.
36
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.
37
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
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.
39
40
  - **Never raise**: every emit ends with `rescue nil` (modifier), so a broken logger/instrumenter can't affect lock correctness.
40
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.
41
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`).
42
44
 
43
45
  ## Claude rules
44
46
  **When to use**
@@ -49,12 +51,15 @@ Not covered by visitors: `release_lock`, `release_all_locks`, `release_locks_of`
49
51
 
50
52
  **How to write one**
51
53
  5. Method: inside `class << self`, named exactly after the event (snake_case, `__` for phase/detail), full YARD with `@return [void]`, `@api private`, `@since <next version>`.
52
- 6. Parameters: port first, sampled flag second, extra gates next, then `lock_key`, then event data; for instrumentation keep the user `instrument` value last. Keep the list explicit (see `arguments.md`).
54
+ 6. Parameters: port first, sampled flag second, extra gates next, then `lock_key`, then event data; lock events take `rw_mode` (after `access_strategy` for logs, after `lock_key` for instrumentation); for instrumentation keep the user `instrument` value last. Keep the list explicit (see `arguments.md`).
53
55
  7. First line: `return unless <sampled>` (plus gate). Don't compute sampling inside the visitor and don't recompute it at the call site per event; reuse the operation's `log_sampled` / `instr_sampled`.
54
56
  8. Logs: `logger.debug do ... end rescue nil` with message `"[redis_queued_locks.<event>] "` (`try_lock.` prefix for TryToLock steps) and `key => value` pairs, quoting strings, using the existing abbreviations (`acq_id`, `hst_id`, `acs_strat`, `queue_ttl`).
55
- 9. Instrumentation: `instrumenter.notify('redis_queued_locks.<event>', { ... }) rescue nil` with shorthand Symbol keys; reuse the standard payload keys (`lock_key`, `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.
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.
56
58
  10. Use the same method name in LogVisitor and InstrVisitor when both report the same event.
57
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 : {}`).
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).
58
63
 
59
64
  ## Recommendations (proposed, not yet project policy)
60
65
  Apply to new or touched code; don't refactor existing code for these unless asked.