redis_queued_locks 1.22.0 → 1.23.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +88 -36
  3. data/.claude/rules/acquirer.md +11 -7
  4. data/.claude/rules/arguments.md +2 -2
  5. data/.claude/rules/logic.md +5 -4
  6. data/.claude/rules/tests.md +4 -4
  7. data/.claude/rules/type-checking.md +2 -3
  8. data/.claude/rules/visitors.md +8 -6
  9. data/.claude/skills/rql-code-review/SKILL.md +25 -20
  10. data/.ruby-version +1 -1
  11. data/CHANGELOG.md +24 -1
  12. data/CLAUDE.md +5 -4
  13. data/README.md +247 -96
  14. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +4 -4
  15. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +582 -276
  16. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +26 -26
  17. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +13 -13
  18. data/lib/redis_queued_locks/acquirer/lock_series/instr_visitor.rb +243 -0
  19. data/lib/redis_queued_locks/acquirer/lock_series/log_visitor.rb +472 -0
  20. data/lib/redis_queued_locks/acquirer/lock_series/release_lock_series.rb +181 -0
  21. data/lib/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rb +1051 -0
  22. data/lib/redis_queued_locks/acquirer/lock_series.rb +948 -0
  23. data/lib/redis_queued_locks/acquirer.rb +1 -1
  24. data/lib/redis_queued_locks/client.rb +55 -25
  25. data/lib/redis_queued_locks/version.rb +2 -2
  26. data/redis_queued_locks.gemspec +21 -14
  27. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +1 -1
  28. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +106 -5
  29. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +1 -1
  30. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  31. data/sig/redis_queued_locks/acquirer/lock_series/instr_visitor.rbs +102 -0
  32. data/sig/redis_queued_locks/acquirer/lock_series/log_visitor.rbs +154 -0
  33. data/sig/redis_queued_locks/acquirer/lock_series/release_lock_series.rbs +37 -0
  34. data/sig/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rbs +198 -0
  35. data/sig/redis_queued_locks/acquirer/lock_series.rbs +106 -0
  36. data/sig/redis_queued_locks/client.rbs +70 -3
  37. metadata +32 -11
  38. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +0 -61
  39. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +0 -104
  40. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +0 -513
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87e74dea52b88c1ac5cbe1b8f9117ae7b7a3e832e3aa6202980fbf6044c9368e
4
- data.tar.gz: a69ae8d1b952516fa303da9c9b61a8a21de818ffca3274ad856b930e961eed87
3
+ metadata.gz: 0c06bae2bc7f0b6164847caa6b7adb5c77a7aab5a85dc5f3ef8c45ac19b8ed80
4
+ data.tar.gz: 6a73267ecd535b74c98f489066ee5cc6f2a8c2ea261eab6d5b5f6b84e4748585
5
5
  SHA512:
6
- metadata.gz: 5bff06b31f13302658871a0a1c2cc30409953c062f94d9dea7be1fab1091a442f5eac2e190a95bbf7eff8ba57069632e745e8453fdb4462efe59a52114b83acd
7
- data.tar.gz: 267ba8c9174f21bff0ed11ed559fc27603df4239acbac7893cbb986d2aef4e0cdfbe22fb6d0bb07967ddfac100f24958251f529adcaf5cd157246a19c0601f3b
6
+ metadata.gz: b3f34d6976337bcdfe4732b3c58034ed455922450f98758a0f4c90e36a885721d847e648c5dfa64357aac9b1b401138787649bda0491693777581669f932a187
7
+ data.tar.gz: 7d4e8769653ddfaf0f483a413c08ba10ea15d4f4d324e57dee1009e27222f7c2473789b0221380fc68a0047a5383083761963ed541179f7ab633876f68b3e891
@@ -37,8 +37,8 @@ Resource (resource.rb) keys, acquirer/host ids, queue s
37
37
  Every other public method is a thin wrapper: it fills option defaults from `config[...]` and calls
38
38
  an `Acquirer::*` module function with `redis_client`.
39
39
 
40
- Operation modules are independent of each other (all `Acquirer::*` except the `AcquireLock` core
41
- and PoC modules such as `LockSeriesPoC`, which may reuse other modules and `AcquireLock`): no
40
+ Operation modules are independent of each other (all `Acquirer::*` except the lock acquisition core:
41
+ `AcquireLock` and `LockSeries`, which reuses the `AcquireLock` mixins): no
42
42
  cross-calls and no shared RBS types; common logic lives in `Resource`/`Utilities`, similar logic is
43
43
  duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatting, `Queues` the
44
44
  `QueueInfo` request formatting). Known debt: `AcquireLock::WithAcqTimeout` calls
@@ -46,7 +46,7 @@ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatt
46
46
 
47
47
  ### Read/Write locks in the architecture
48
48
 
49
- `read_write_mode: :write` (default) is the exclusive classic lock (its keys and data are unchanged); `:read` is a shared
49
+ `rw_mode: :write` (default) is the exclusive classic lock (its keys and data are unchanged); `:read` is a shared
50
50
  lock: readers share the lock with each other, a writer waits for the write lock and for all live readers, a reader waits
51
51
  for the write lock only. Mutual exclusion comes only from WATCH/MULTI on the lock state (no Lua in the RW path); the
52
52
  request queues only order the requests (FIFO between modes by `(score, acq_id)`).
@@ -64,8 +64,8 @@ rql:lock_read_queue:<name> ZSET read lock requests (the same scores)
64
64
  The path of the mode through the components:
65
65
 
66
66
  ```
67
- Client#lock / #lock! (read_write_mode:) #lock_series / #lock_series! (one mode per series),
68
- │ #unlock_read / #release_read_lock, #extend_lock_ttl(read_write_mode:,
67
+ Client#lock / #lock! (rw_mode:) #lock_series / #lock_series! (one mode per series),
68
+ │ #unlock_read / #release_read_lock, #extend_lock_ttl(rw_mode:,
69
69
  │ all_read_locks:)
70
70
  ▼
71
71
  AcquireLock Step 0.1 (RW) validates the mode; the keys are computed once (read_lock_key: readers only)
@@ -113,7 +113,7 @@ Config (config.rb) lock_priorities (name =>
113
113
  │ the default name checked against the names (configure / []=)
114
114
  │
115
115
  ▼
116
- AcquireLock Step 0.1 / LockSeriesPoC name -> Integer, range -100..100 (ArgumentError); Integer only below
116
+ AcquireLock Step 0.1 / LockSeries Step 0.1 name -> Integer, range -100..100 (ArgumentError); Integer only below
117
117
  │
118
118
  ├──▶ Resource the single source of the score layout:
119
119
  │ calc_initial_acquirer_position(priority) score = enqueue time - priority * 2**32 (one band per priority)
@@ -150,10 +150,10 @@ AcquireLock Step 0.1 / LockSeriesPoC name -> Integer, range -
150
150
  | Client method | Implementation |
151
151
  |---|---|
152
152
  | `lock` / `lock!` | `acquirer/acquire_lock.rb` + `acquirer/acquire_lock/*` |
153
- | `lock_series` / `lock_series!` | `acquirer/lock_series_poc.rb` (proof of concept; one `read_write_mode` for the whole series) |
153
+ | `lock_series` / `lock_series!` | `acquirer/lock_series.rb` + `acquirer/lock_series/*` (atomic, see "Lock series"; one `rw_mode`, `priority` and `ttl` for the whole series) |
154
154
  | `unlock` | `acquirer/release_lock.rb` |
155
155
  | `unlock_read` / `release_read_lock` | `acquirer/release_read_lock.rb` (own read lock or `acquirer_id:` read lock only) |
156
- | `extend_lock_ttl` | `acquirer/extend_lock_ttl.rb` (`read_write_mode: :read` extends own read lock, `+ all_read_locks: true` all live read locks) |
156
+ | `extend_lock_ttl` | `acquirer/extend_lock_ttl.rb` (`rw_mode: :read` extends own read lock, `+ all_read_locks: true` all live read locks) |
157
157
  | `locked?` / `queued?` | `acquirer/is_locked.rb` / `acquirer/is_queued.rb` |
158
158
  | `lock_info` / `queue_info` | `acquirer/lock_info.rb` / `acquirer/queue_info.rb` |
159
159
  | `locks`, `locks_info`, `queues`, `queues_info`, `keys` | `acquirer/locks.rb`, `queues.rb`, `keys.rb` (keyspace SCAN or lock index: `lock_index_enabled`) |
@@ -177,21 +177,74 @@ AcquireLock Step 0.1 / LockSeriesPoC name -> Integer, range -
177
177
  | `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). Known limitation (kept by the user's decision, documented in README): the write release and the write ttl decrease have no owner check, so a block longer than the ttl can delete/shorten the lock of another acquirer. |
178
178
  | `LogVisitor` / `InstrVisitor` | One method per lifecycle event for logs and instrumentation. |
179
179
 
180
+ ### Lock series (`acquirer/lock_series.rb`)
181
+
182
+ The second part of the lock acquisition core (not an operation module): `LockSeries` extends `TryToLockSeries` (it includes
183
+ `AcquireLock::TryToLock` and `ReleaseLockSeries`), `AcquireLock::DelayExecution` and `AcquireLock::YieldExpire` (`yield_with_timeout`).
184
+
185
+ - Semantics: all or nothing; all locks are obtained by one transaction with the same `ttl` at the same moment; nothing is held while
186
+ the series waits (no hold-and-wait, no ttl staircase); `timeout`, `retry_count`, `queue_ttl` are limits of the whole series; one
187
+ `rw_mode`, `priority`, `ttl`, `meta` per series; lock names: at least one (`ArgumentError`), `uniq(&:to_s)`.
188
+ - Deadlock freedom: one `acquirer_position` in all request queues of the series, reset for all queues at once when the dead score is
189
+ reached: all requests of a lock are ordered by `(score, acq_id)` in the same way in all queues, a request waits only for earlier
190
+ requests, so a wait cycle is impossible for any order of the lock names (nested holders of the user's logic are out of it).
191
+ - Attempt (`TryToLockSeries#try_to_lock_series`): the single lock attempt for N locks, built from its helpers: `multi(watch: watched_keys)`
192
+ (write: all lock keys + all registries, read: all lock keys) → one pipeline with `pipeline_lock_state` of each lock (`pipeline_series_lock_state`,
193
+ each lock has the same number of replies: `series_lock_state_replies`, read by the `LOCK_STATE_*` constants: `series_lock_states`) →
194
+ conflicts per lock (`same_process_conflict_status`: `:conflict_dead_lock`/`:conflict_lock_upgrade` fail the series, work through locks are
195
+ not queued and not obtained) → fail-fast check → the queueing of the other locks (eager queueing or `queue_series_lock_requests`) →
196
+ `drop_series_dead_requests_ahead` (batch Step 2.2: one round trip for all locks, up to `MAX_DEAD_REQUEST_REMOVALS`, none while nothing dead
197
+ blocks) → `find_series_lock_obstacle` (the first obstacle in the order of the lock names, `lock_obstacle` per lock) → one `MULTI`:
198
+ (transaction layout) read lock extensions first (`READ_LOCK_EXTENSION_COMMANDS` + 1 with the lock index, `ZADD XX INCR` first: its `nil` reply =
199
+ expired during the extension, the transaction is applied and can not be rolled back: the series releases its locks and fails the
200
+ attempt with `:read_lock_is_expired_during_extension`), then the commands of the other locks (`transact_*` helpers of `TryToLock`, shared with the single lock attempt;
201
+ reentrant locks also `ZREM` their request: the pre-check of a retry queues all locks).
202
+ - Pre-check (`precheck_lock_series`): retries and `fail_fast`, the lock state of all locks without `WATCH` in one round trip (with the queueing
203
+ for retries); a same-process conflict of any lock continues the attempt (no decision), an obstacle fails it; never obtains locks.
204
+ - Round trips (any number of locks): obtained series with a block: write = 6 (`WATCH`, state + queueing, `MULTI/EXEC`, release: `WATCH`, owners,
205
+ `MULTI/EXEC`), read = 4; failed attempt = 3; failed retry = 1 (pre-check); `fail_fast` on an obtained lock = 1; the dequeue of a failed series = 1.
206
+ - Failures: `drop_failed_lock_series` runs in `ensure` for every series that is not obtained (a failure, the `timeout`, `Timeout.timeout` of the
207
+ outer logic, Redis errors, `Thread#kill`): best effort (`run_non_critical`, never replaces the original error): the locks of the obtaining
208
+ transaction whose reply is lost (`acq_process[:lock_processes]`, set before `EXEC`: owner-checked release of the `:lock_obtaining` locks) and the
209
+ requests in all queues (one pipeline of `ZREM`, except `:fail_fast_no_try`: nothing is queued). The result is built after it, in one place
210
+ (`lock_series_obtaining_failed` log + event, then `raise_lock_series_error` with `raise_errors` or the failure result). Both releases go through
211
+ `complete_lock_series_release`: an interrupted release is repeated once (`ensure`, `run_non_critical`: an interruption is delivered once, the release is idempotent). All of it is best
212
+ effort: an interruption right at the entry of `ensure`, a second interruption or a dead process leave the locks till their ttl (the safe direction).
213
+ - Release (`ReleaseLockSeries#release_lock_series`): by `lock_processes` and `held_rw_modes`: `:lock_obtaining` write locks: compare-and-delete in
214
+ one transaction (`WATCH` own keys + pipelined `HGET acq_id` + `DEL`, retried on abort with the keys still owned), read locks: `ZREM` + `DEL` of
215
+ the reader data; `:extendable_conflict_work_through`: the ttl is decreased by `ttl - spent time - REDIS_TIMESHIFT_ERROR` (write: `EVAL DECREASE_LOCK_PTTL`
216
+ in the same owner-checked transaction, read: `ZADD XX INCR`); `:conflict_work_through` locks are kept as is.
217
+ - Results: with a block: the block result; without: `{ ok: true, result: { lock_series:, rql_lock_series:, acq_id:, hst_id:, ts:, ttl: } }`;
218
+ `detailed_result: true`: `yield_result`, `locks_release_strategy`, `locks_released_at`, `locks_acq_time`, `locks_hold_time`, `lock_series`, `rql_lock_series`;
219
+ failure: `{ ok: false, result: { error: :failed_to_acquire_lock_series, detailed_errors:, lock_series:, acquired_locks: [], missing_locks:, failed_on_lock: } }`
220
+ (`failed_on_lock`: the first lock that blocked the last attempt with a known lock, `nil` without such an attempt).
221
+ - Observability (`logic.md#16`, `visitors.md`): the `AcquireLock` events are not raised, the series has its own events, with the series context in their names:
222
+ of the series: `lock_series_obtained`, `lock_series_hold_and_release`, `lock_series_obtaining_failed`; of each lock (`lock_key` + `lock_keys`):
223
+ `lock_series_intermediate_lock_obtained` (the lock passed all checks of an attempt: `ready_locks` of the attempt, added by `find_series_lock_obstacle`, reported by
224
+ `lock_series` out of the WATCH block; nothing is held; once per lock, never per attempt: `reported_ready_locks`; in the order of the lock names up to the blocking lock, so a failed
225
+ attempt shows the same picture as the old sequential series; a failed conflict or `fail_fast` check reports nothing: these attempts fail before the checks), `lock_series_lock_obtained` (after the
226
+ transaction is applied, before `lock_series_obtained`, `:lock_obtaining` locks only), `lock_series_lock_obtaining_failed` (the blocking lock, once per failed series, before
227
+ `lock_series_obtaining_failed`; not raised without a known blocking lock). `log_lock_try` logs of each attempt are the `try_lock.*` logs of `TryToLock::LogVisitor`, one per lock
228
+ (per the blocking lock for the exit events); the detailed timeout error reads the raw state of the blocking lock (`blocking_lock_details`: no dependency on `LockInfo`/`QueueInfo`).
229
+ - Design decisions: a waiting series reserves its turn in the queues of its free locks (`:queued`: these locks stay free, new requests wait for the
230
+ series, bounded by its limits); `:random` needs all locks free at the same moment; a retry of a series with a reentrant lock has no one round
231
+ trip failure (the attempt decides conflicts); no Lua (WATCH/MULTI only).
232
+
180
233
  ### Main `lock` options (defaults from config)
181
234
 
182
235
  `ttl`, `queue_ttl`, `lock_index_grace_period` (used with the enabled lock index only; the mode itself, `config['lock_index_enabled']`,
183
236
  is client-wide: `Client` passes it to the acquirers, it is not a `lock` option), `timeout`, `timed`, `retry_count`, `retry_delay`, `retry_jitter`,
184
237
  `raise_errors`, `fail_fast`, `conflict_strategy`, `access_strategy`, `priority` (`-100`..`100` or a priority name of `config['lock_priorities']`, default
185
- `config['default_lock_priority']` = `0`, see "Lock request priorities"), `read_write_mode`
238
+ `config['default_lock_priority']` = `0`, see "Lock request priorities"), `rw_mode`
186
239
  (`:write` (default, exclusive) | `:read` (shared)), `identity`, `meta`, `logger`, `log_lock_try`,
187
240
  `instrumenter`, `instrument`, log/instr sampling options, `log_sample_this`, `instr_sample_this`.
188
241
 
189
242
  - `access_strategy`: `:queued` (FIFO, default) or `:random`.
190
243
  - `conflict_strategy` (same process re-acquires its own lock): `:wait_for_lock` (default),
191
244
  `:work_through`, `:extendable_work_through`, `:dead_locking`.
192
- - `read_write_mode`: `:write` (default) | `:read`, see "Read/Write locks" below.
245
+ - `rw_mode`: `:write` (default) | `:read`, see "Read/Write locks" below.
193
246
 
194
- ### Read/Write locks (`read_write_mode`)
247
+ ### Read/Write locks (`rw_mode`)
195
248
 
196
249
  - Semantics: `:write` is exclusive (waits for the write lock and all live read locks); `:read` is
197
250
  shared (waits for the write lock only). Default `:write` keeps the classic behavior and keys.
@@ -218,18 +271,17 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
218
271
  - Try results (internal symbols): `:write_request_is_ahead`, `:read_request_is_ahead`,
219
272
  `:read_lock_is_still_acquired`, `:conflict_lock_upgrade`, `:read_lock_is_expired_during_extension`;
220
273
  try success result carries internal `rw_mode` (held mode), not exposed in the public `lock` result.
221
- - Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; `ttl` must be a positive
274
+ - Validation (`AcquireLock`): `rw_mode` must be `:read`/`:write`; `ttl` must be a positive
222
275
  Integer in both modes (`RedisQueuedLocks::ArgumentError`; write: `PEXPIRE` with a non-positive ttl would
223
276
  delete the lock key in the obtaining `MULTI`, read: the reader expiration is computed from it);
224
- `LockSeriesPoC` checks the series `ttl` the same way before computing the lock ttls (they include
225
- the retry delay and jitter); `config['default_lock_ttl']` must be positive too; `extend_lock_ttl`
277
+ `LockSeries` checks the same arguments itself (duplicated on purpose); `config['default_lock_ttl']` must be positive too; `extend_lock_ttl`
226
278
  `milliseconds` must be a positive Integer (a non-positive one would decrease/delete the lock);
227
279
  `meta` keys must not be reserved lock data keys, String or Symbol (Symbol keys are written as
228
- Strings: `Symbol#name`; the same check in `AcquireLock` and `LockSeriesPoC`); the other per-call
280
+ Strings: `Symbol#name`; the same check in `AcquireLock` and `LockSeries`); the other per-call
229
281
  numeric options are validated with the ranges of their config keys (`logic.md#7`): `priority` Integer from
230
282
  `Resource::MIN_LOCK_PRIORITY` (-100) to `MAX_LOCK_PRIORITY` (100), `queue_ttl` > 0,
231
283
  `timeout`/`retry_count` nil or >= 0, `retry_delay`/`retry_jitter`/`lock_index_grace_period` >= 0
232
- (`AcquireLock`; `LockSeriesPoC` checks the retry delay/jitter before the lock ttl calculation),
284
+ (`AcquireLock`, `LockSeries`),
233
285
  scan/batch sizes, `dead_ttl`, reindexing `queue_ttl` > 0 (operation modules), `zombie_ttl` and the
234
286
  zombie scan sizes > 0 (`Swarm` facade: `SwarmArgumentError`).
235
287
  - RW-aware operations: `unlock` (write lock + readers + reader data + both queues + the lock index entry (the enabled lock index), same result shape; one release
@@ -241,31 +293,27 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
241
293
  `'rw_mode' => 'read'`, `'rem_ttl'`, `'readers' => [reader data + rem_ttl]` when no write lock),
242
294
  `queue_info` (`'read_lock_queue'`/`'read_queue'` when present), `locks`/`locks_info`, `queues`/`queues_info`;
243
295
  write lock info in `lock_info`/`locks_info` has `'lock_key'` and `'rw_mode' => 'write'` (computed on formatting,
244
- not stored), so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
296
+ not stored), so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeries`);
245
297
  every request in `queue_info`/`queues_info` is `{ 'acq_id', 'score', 'rw_mode' => 'write'|'read' }`
246
298
  (`queues_info` derives the mode from the queue key via `Resource.lock_queue_rw_mode`).
247
- - `lock_series` (both modes): one mode per series; `release_lock_series` releases only locks the series
248
- obtained itself (`process == :lock_obtaining`, reentrant ones are kept) and only the current acquirer's:
249
- write = compare-and-delete in one transaction per series (`WATCH` the own lock keys + pipelined `HGET acq_id` + `DEL`,
250
- retried on abort with the keys still owned at the last check), read = `ZREM` + `DEL` reader data. It runs
251
- after the block and on any failure (exception or `raise_errors: false`), once per failed series.
299
+ - `lock_series` (both modes): one mode per series, see "Lock series".
252
300
  - Own read lock operations (current acquirer via `current_acquirer_id(identity:)`): `unlock_read` /
253
301
  `release_read_lock` (`ZREM` + `DEL` reader data, event `explicit_read_lock_release`, result
254
302
  `{ rel_time:, rel_key:, rel_acq_id:, lock_res: }`; `acquirer_id:` (default `nil` => current acquirer;
255
- 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)`
303
+ when passed, `identity:` is ignored: the acquirer id already contains its identity) releases the read lock of a different acquirer) and `extend_lock_ttl(..., rw_mode: :read)`
256
304
  (`multi(watch: [lock_key])`: extends only a live read lock while no write lock exists, never revives
257
305
  an expired one; registry and reader data TTLs via `PEXPIRE NX`+`GT`). `unlock` releases everything.
258
- - `extend_lock_ttl(..., read_write_mode: :read, all_read_locks: true)` (`false` by default, ignored for
306
+ - `extend_lock_ttl(..., rw_mode: :read, all_read_locks: true)` (`false` by default, ignored for
259
307
  `:write`; non-boolean in `:read` => `ArgumentError`): same `multi(watch: [lock_key])` (registry not
260
308
  watched, so concurrent readers don't abort it), `ZRANGE WITHSCORES` snapshot, every live reader gets
261
309
  `ZADD XX INCR` + reader data `PEXPIRE NX`+`GT`, registry TTL = longest extended reader;
262
310
  `extended_locks_count` = the number of non-nil `ZADD` results (0 => `:async_expire_or_no_lock`).
263
311
  `extend_lock_ttl` success result (all modes): `{ ok: true, result: { extended_locks_count: Integer } }`
264
- (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`.
312
+ (write / own read = 1); failure stays `{ ok: false, result: :async_expire_or_no_lock }`. Internal param order: `rw_mode, all_read_locks, acquirer_id`.
265
313
  - Design decisions (release semantics, not limitations to "fix"): read->write upgrade is an error unless
266
314
  `:wait_for_lock`; nested reads of one acquirer go through `conflict_strategy` (no hold counting);
267
315
  `:random` gives no fairness between modes; cross-host FIFO is best effort; no Lua in the RW path.
268
- - RBS: `Client` types `read_write_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
316
+ - RBS: `Client` types `rw_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
269
317
  before read locks are used.
270
318
 
271
319
  ### Lock request priorities (`priority`)
@@ -273,14 +321,14 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
273
321
  - Option `priority` of `lock`/`lock!`/`lock_series`/`lock_series!` (one value per series): Integer from
274
322
  `Resource::MIN_LOCK_PRIORITY` (-100, the lowest) to `Resource::MAX_LOCK_PRIORITY` (100, the highest) or a priority
275
323
  name (Symbol, RBS `RQL::lockPriority`), default `config['default_lock_priority']` (0, a name allowed); validated in
276
- `AcquireLock` (Step 0.1), in `LockSeriesPoC` (before the series: the same check, duplicated like its ttl checks) and by the
324
+ `AcquireLock` (Step 0.1), in `LockSeries` (Step 0.1: the same check, duplicated like its ttl checks) and by the
277
325
  config validators.
278
326
  - Priority names: `config['lock_priorities']` (Hash Symbol => Integer in the range, default frozen
279
327
  `{ lowest: -100, low: -50, normal: 0, high: 50, highest: 100 }`, validated by the config) is read by `Client#lock` /
280
- `#lock_series` and forwarded as the internal keyword `lock_priorities:` (`AcquireLock`, `LockSeriesPoC`; not a public
328
+ `#lock_series` and forwarded as the internal keyword `lock_priorities:` (`AcquireLock`, `LockSeries`; not a public
281
329
  keyword: a client-wide registry); `AcquireLock` Step 0.1 resolves a Symbol `priority` to its Integer
282
330
  (`lock_priorities.fetch(priority, priority)`, an unknown name stays a Symbol and fails the range check with
283
- `ArgumentError`; `LockSeriesPoC` resolves it the same way before the series events), so everything after Step 0.1 (scores,
331
+ `ArgumentError`; `LockSeries` resolves it the same way), so everything after Step 0.1 (scores,
284
332
  logs, events, `queue_info`) sees Integers only; a name in `default_lock_priority` must be a key of `lock_priorities`
285
333
  (validators see one value: `Config#prevent_default_priority__unknown_name`, `ConfigValidationError`): `Config#configure`
286
334
  checks the pair once after the block (the keys can be assigned in any order; on a failure both keys keep their values from
@@ -334,7 +382,7 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
334
382
  `unlock`/`clear_locks` (`-inf..+inf`), swarm (hosts from acquirer ids), lock index.
335
383
  - Info/observability: `queue_info`/`queues_info` requests have `'priority'` (decoded); every lock acquirement log and event
336
384
  carries the Integer `priority` of the request (classic, reentrant: the requested one, they never queue; lock series: the
337
- priority of the series, resolved and validated by `LockSeriesPoC` before the series events); `log_lock_try` logs that name
385
+ priority of the series); `log_lock_try` logs that name
338
386
  another request carry its decoded priority (`first_priority_in_queue`, `ahead_priority`, `dead_priority`; decoded for logs
339
387
  only: gated by `log_sampled && log_lock_try`, `TryToLock#logged_request_priority`; `first_priority_in_queue => nil` without the first request).
340
388
  - Limitations: the priority range is a library constant that bounds the score shift (float precision of the shifted positions:
@@ -410,7 +458,8 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
410
458
 
411
459
  `redis_queued_locks.` + `lock_obtained`, `reentrant_lock_obtained`,
412
460
  `extendable_reentrant_lock_obtained`, `lock_hold_and_release`, `reentrant_lock_hold_completes`,
413
- `lock_obtaining_failed`, `lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
461
+ `lock_obtaining_failed`, `lock_series_intermediate_lock_obtained`, `lock_series_lock_obtained`, `lock_series_lock_obtaining_failed`, `lock_series_obtained`,
462
+ `lock_series_hold_and_release`, `lock_series_obtaining_failed`, `explicit_lock_release`,
414
463
  `explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`, `extend_lock_ttl`, `clear_dead_requests`,
415
464
  `reindex_locks`. Observability rule (`logic.md#16`): logic that changes keys is logged and instrumented, the lock
416
465
  acquirement per meaningful outcome (visitors; `lock_obtaining_failed` with the failure `result` also before a raised
@@ -418,10 +467,12 @@ acquirement error), each mutation operation with one log line + one event of the
418
467
  `instrument` last in the payload, inline in the module); lock info queries are neither; the swarm is known debt.
419
468
  Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
420
469
  `lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
421
- lock series events (`lock_series_obtained`, `lock_series_hold_and_release`) carry the mode of the series;
470
+ lock series events (`lock_series_*`) carry the mode of the series (`lock_series_obtained` and `lock_series_hold_and_release` also `reentrant_lock_keys`,
471
+ `lock_series_obtaining_failed` `result` and `failed_lock_key`, the events of each lock `lock_key` and `lock_keys`, `lock_series_lock_obtaining_failed` `result`),
472
+ the `AcquireLock` events are not raised for the locks of a series;
422
473
  all lock acquirement events (the lock events above, `lock_obtaining_failed` and the lock series events) carry `priority`
423
474
  after `rw_mode` (the Integer lock request priority: a priority name is resolved before; the series events: the priority of
424
- the series); all lock acquirement log lines of `AcquireLock` and `LockSeriesPoC` (not the release logs of `YieldExpire`)
475
+ the series); all lock acquirement log lines of `AcquireLock` and `LockSeries` (not the release logs of `YieldExpire`)
425
476
  log `priority => N` after `rw_mode`, `try_lock.acq_added_to_queue` too; the try-lock logs that name another request log
426
477
  its priority (`first_priority_in_queue` after `first_acq_id_in_queue`, `ahead_priority` after `ahead_acq_id`);
427
478
  lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock log events: `exit__write_request_ahead`,
@@ -439,7 +490,7 @@ hierarchy: `LockAlreadyObtainedError`, `LockAcquirementTimeoutError`, `LockAcqui
439
490
  `rescue => e`/`rescue StandardError`/`rescue Timeout::Error`/`rescue nil` of the interrupted code (the user
440
491
  block, logger and instrumenter calls) can not swallow them; the library converts them to the public errors/results).
441
492
  redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so every `with` block
442
- around a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC` write release) rescues
493
+ around a `multi(watch:)` (`TryToLock`, `TryToLockSeries`, `ExtendLockTTL` read extensions, `ReleaseLockSeries` write release) rescues
443
494
  both internal timeouts and `Timeout::ExitException` (the user's `Timeout.timeout` without an exception class), closes
444
495
  the connection (drops the WATCH state, reconnected on the next use) and re-raises.
445
496
 
@@ -451,7 +502,7 @@ the connection (drops the WATCH state, reconnected on the next use) and re-raise
451
502
  |---|---|
452
503
  | `Client` | Facade; constructor injection (caller-supplied `RedisClient`); `x` returns a result hash, `x!` raises |
453
504
  | `Acquirer::*` | One module per command; stateless `class << self` functions; uniform `{ ok:, result: }` returns (public API contract; swarm element internals use bare scalars/primitives) |
454
- | `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`) |
505
+ | `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`, `rw_mode`) |
455
506
  | Log/Instr visitors | Visitor-style event hooks keep observability out of the algorithm; percent sampling via `sampling_happened?(percent)` |
456
507
  | `Logging` / `Instrument` | Null Object defaults (`VoidLogger`, `VoidNotifier`); Adapter (`instrument/active_support.rb`); duck typing (`::Logger` API, `#notify(event, payload)`) |
457
508
  | `Config` | Declarative DSL: `setting(key, default)` and `validate(key) { }` registries; access as `config['a.b']`; runtime `Client#configure` |
@@ -487,6 +538,7 @@ lib/redis_queued_locks/
487
538
  config.rb, config/dsl.rb settings DSL + defaults
488
539
  acquirer/*.rb one operation per file
489
540
  acquirer/acquire_lock/* lock algorithm mixins + visitors
541
+ acquirer/lock_series/* lock series attempt and release mixins + visitors
490
542
  swarm.rb, swarm/* supervisor, swarm elements, zombie logic, redis client builder
491
543
  logging/, instrument/ void defaults, samplers, ActiveSupport adapter
492
544
  resource.rb keys and identities
@@ -536,7 +588,7 @@ Rakefile, Steepfile, rbs_collection.yaml
536
588
  ## 6. Conventions
537
589
 
538
590
  - `# frozen_string_literal: true` in every Ruby file.
539
- - YARD tags on every class/method: `@api public|private`, `@since`, `@version` (methods: bump on a behavior/signature change; classes/modules: only on an interface change: signatures, added/removed methods, `extend`/`include`/`prepend` or the superclass; `logic.md#2`).
591
+ - YARD tags on every class/method: `@api public|private`, `@since`, `@version` (methods: bump on a behavior/signature change; classes/modules: only on an interface change: signatures, added/removed methods, `extend`/`include`/`prepend` or the superclass; `logic.md#2`); `# @since` right above each `include`/`extend`/`prepend` line (the version that added the mixin).
540
592
  - Every `lib/` change gets a matching `sig/` RBS update; use `# steep:ignore` only where Steep can't infer types.
541
593
  - New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
542
594
  - New config option: `setting` (+ `validate`) in `config.rb` + the typed `Config#[]` overload line in `sig/redis_queued_locks/config.rbs`, read via `config['key']`, document defaults.
@@ -13,8 +13,7 @@ paths:
13
13
  - `Locks.extract_locks_info` / `Locks.read_lock_info` duplicate the write/read lock formatting of `LockInfo.lock_info` / `LockInfo.read_lock_info` (`Locks` fetches the data of a batch of locks in two round trips and `Locks.read_lock_info` formats the prefetched read lock data);
14
14
  - `Queues.extract_queues_info` duplicates the request formatting of `QueueInfo.queue_info`;
15
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).
16
+ - `AcquireLock` and `LockSeries` are the lock acquisition core, not operation modules of this list: `LockSeries` reuses the `AcquireLock` mixins (`TryToLockSeries` includes `TryToLock`, `LockSeries` extends `DelayExecution` and `YieldExpire`) and never calls `AcquireLock.acquire_lock`.
18
17
  - 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).
19
18
  - Known limitation (kept by the user's decision): `ReleaseLocksOf` (and `FlushZombies`) check the lock owner (`HMGET`) and delete the lock (`DEL`) in two round trips without `WATCH`: a lock re-acquired in between by another acquirer can be deleted.
20
19
  - Known limitation (kept by the user's decision, documented in README `#lock`): `YieldExpire` releases the write lock (`EXPIRE 0`) and decreases its ttl (Lua) without an owner check: a block (without `timed`) longer than the lock ttl can delete/shorten the lock of another acquirer. Read lock release/decrease is per acquirer (owner-safe).
@@ -23,7 +22,7 @@ paths:
23
22
  - Mutating operations: long positional lists ending with the fixed observability tail
24
23
  `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`
25
24
  (the same order in all mutating modules).
26
- - Only `AcquireLock.acquire_lock` and `LockSeriesPoC.lock_series_poc` use keyword args (`process_id:`, `thread_id:`, ...).
25
+ - Only `AcquireLock.acquire_lock` and `LockSeries.lock_series` use keyword args (`process_id:`, `thread_id:`, ...).
27
26
  - **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`, `extend_lock_ttl`, `clear_dead_requests`, `reindex_locks`; observability rule: `logic.md#16`):
28
27
  1. argument validations (`RedisQueuedLocks::ArgumentError`) first, then `rel_start_time = clock_gettime` (`ext_`/`idx_` prefixes for the extension/reindexing)
29
28
  2. call a private `fully_*` helper with the Redis work (returns `{ ok:, result: }`, destructured with `fully_x(...) => { ok:, result: }`, or the raw data: a `Set`/`Integer`)
@@ -39,7 +38,7 @@ paths:
39
38
  - Raw commands: `redis.call('CMD', ...)` with uppercase string command names and string args (`'0'`, `'-inf'`, `'+inf'`).
40
39
  - Pooled connection: wrap multi-command work in `redis.with do |rconn| ... end`.
41
40
  - 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`).
42
- - Internal timeouts (`LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`) inherit from `RedisQueuedLocks::Exception` (`::Exception`, `logic.md#15`): redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so a connection interrupted inside a WATCH block would return to the pool with the active WATCH (the next `EXEC` on it is aborted when a watched key changes). Every `redis.with do |rconn| ... end` block with a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC.release_write_lock_series`) ends with `rescue <both internal timeouts>, ::Timeout::ExitException` (the user's `Timeout.timeout` without an exception class interrupts with it the same way) → `rconn.close` → `raise`.
41
+ - Internal timeouts (`LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`) inherit from `RedisQueuedLocks::Exception` (`::Exception`, `logic.md#15`): redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so a connection interrupted inside a WATCH block would return to the pool with the active WATCH (the next `EXEC` on it is aborted when a watched key changes). Every `redis.with do |rconn| ... end` block with a `multi(watch:)` (`TryToLock`, `TryToLockSeries`, `ExtendLockTTL` read extensions, `ReleaseLockSeries#release_write_lock_series`) ends with `rescue <both internal timeouts>, ::Timeout::ExitException` (the user's `Timeout.timeout` without an exception class interrupts with it the same way) → `rconn.close` → `raise`.
43
42
  - Iteration: two lock lookup modes, switched by `config['lock_index_enabled']` (client-wide, `false` by default): `Client` (and the `Swarm` facade, the zombie flusher Ractor via a copied value) passes `lock_index_enabled` to every lock-iterating module (`Locks`, `Queues`, `Keys`, `ReleaseAllLocks`, `ReleaseLocksOf`, `ClearDeadRequests`, `FlushZombies`, `ZombieInfo`). Each module keeps both lookups side by side and shares the processing (lock info formatting, flush/release helpers, instrumentation) in one copy; the lookup is chosen in one place (`lock_index_enabled ? index_x : scan_x` or one `if lock_index_enabled ... else ... end` with `(lock index)` / `(keyspace)` step comments):
44
43
  - keyspace (the disabled lock index, the pre-index code): one SCAN pass over `KEY_PATTERN` (or `ALL_LOCK_QUEUES_PATTERN` for queues) per operation, keys classified by `Resource.lock_key_family`, per-key commands in pipelined batches collected across SCAN pages (`scan_locks`, `scan_queues`, `each_lock_queue`, `flush_keyspace_zombies`, `each_keyspace_zombie_lock`); no lock index command is sent;
45
44
  - lock index (enabled): one `ZSCAN` pass over the lock index per operation (`each_lock_name_batch(rconn, scan_size) { |lock_names| ... }`: `zscan(Resource::LOCK_INDEX_KEY, count: scan_size)`, names with an expired score are skipped (`Resource.lock_index_score_expired?`: ZSCAN scores are Strings, the `+inf` score of a key without TTL is `"inf"` and never expires; never `to_f` alone: it gives `0.0`), batches of `scan_size` names collected across ZSCAN pages, the rest yielded after the pass); keys are derived from the names (`Resource.prepare_*`, reader data via registry members) and per-key commands are sent in one pipeline (or one variadic command / `MULTI`) per batch; info queries check existence (`EXISTS`, empty `ZRANGE` = missing registry/queue); the keyspace is never scanned (only `ReindexLocks` scans `KEY_PATTERN` with `Resource.lock_key_family` to adopt keys without index entries, in both modes). Each operation module has its own `each_lock_name_batch` copy (same name and shape, duplicated on purpose; the swarm modules iterate inline: `flush_lock_index_zombies`, `each_lock_index_zombie_lock`).
@@ -55,7 +54,7 @@ paths:
55
54
  - a command that **extends** a lock key is followed by `ZADD <index> XX INCR <ms> <lock_name>` in the same transaction (`XX`: names without an entry are not indexed with a wrong score); ttl decreases and releases never touch the index (the bound stays an upper bound);
56
55
  - lock request queues get `PEXPIRE NX` + `PEXPIRE GT` with the queue ttl (ms, `(queue_ttl * 1_000).ceil`) on each queueing: every indexed key has a bounded lifetime;
57
56
  - pruning is `ZREMRANGEBYSCORE <index> -inf <lock_index_time - 1>` (each queueing, `flush_zombies`, `clear_locks`, `clear_dead_requests`); keys written without the index (older versions, raw writes) are adopted by `ReindexLocks` (SCAN pass, `PEXPIRE NX` for queues, `PTTL` => `ZADD GT`).
58
- - **Lock request priorities**: queue scores are the enqueue time shifted by the priority band (`Resource.calc_initial_acquirer_position(priority)` = `ts - priority * Resource::LOCK_PRIORITY_BAND`, 2**32 s; priority `Resource::MIN_LOCK_PRIORITY`..`MAX_LOCK_PRIORITY`, -100..100, or a priority name of `config['lock_priorities']` resolved to its Integer in `AcquireLock` Step 0.1 and by `LockSeriesPoC` before the series: code after the resolution never sees Symbols). Never prune a lock queue with one `-inf .. now - ttl` range (it drops alive high-priority requests and keeps dead low-priority ones): prune a band with `Resource.lock_priority_band_floor(p)` .. `Resource.lock_priority_band_dead_score(dead_score, p)` (`TryToLock#pipeline_dead_requests_removal`, `ClearDeadRequests.drop_dead_requests` / `drop_dead_requests_by_priority`); full clears (`-inf .. +inf`) and member-based removals (`ZREM`) are band-independent. Never enumerate the priority range per attempt or per queue (the cost must not depend on it): prune the bands found in the data (decode the band and the liveness of a score with `Resource.lock_request_priority(score)` and `lock_priority_band_dead_score`): the attempt prunes its own band and the bands of the dead blocking requests (`TryToLock#drop_dead_requests_ahead`), `ClearDeadRequests` the bands whose first request is dead (all requests of the batch queues are read; `by_priority: true`: a walk over the bands reads only the first request of each band, `ZRANGE <queue> <bound> +inf BYSCORE LIMIT 0 1`, one round trip per band of the batch queue with the most bands + 1, the dead bands are pruned by the next round trip after its reads, `drop_dead_requests_by_priority`; every walk step must make progress: an exclusive bound when the next band edge rounds to the position). Decode priorities with `Resource.lock_request_priority(score)` (info results); the cross-mode `(score, acq_id)` comparison works on shifted scores as is. Infinite positions (manual writes, never written by RQL) are out of all bands: the decoding maps `-inf`/`+inf` to `MAX_LOCK_PRIORITY`/`MIN_LOCK_PRIORITY` (never `Float#floor` of an infinity: `FloatDomainError`); a request at `-inf` is always dead and no band range covers it: it is dropped by its position (`ZREMRANGEBYSCORE <queue> -inf -inf`: `drop_dead_requests_ahead` for a blocking one, `ClearDeadRequests` for the first ones of a queue); a request at `+inf` is never dead.
57
+ - **Lock request priorities**: queue scores are the enqueue time shifted by the priority band (`Resource.calc_initial_acquirer_position(priority)` = `ts - priority * Resource::LOCK_PRIORITY_BAND`, 2**32 s; priority `Resource::MIN_LOCK_PRIORITY`..`MAX_LOCK_PRIORITY`, -100..100, or a priority name of `config['lock_priorities']` resolved to its Integer in `AcquireLock` Step 0.1 and `LockSeries` Step 0.1: code after the resolution never sees Symbols). Never prune a lock queue with one `-inf .. now - ttl` range (it drops alive high-priority requests and keeps dead low-priority ones): prune a band with `Resource.lock_priority_band_floor(p)` .. `Resource.lock_priority_band_dead_score(dead_score, p)` (`TryToLock#pipeline_dead_requests_removal`, `ClearDeadRequests.drop_dead_requests` / `drop_dead_requests_by_priority`); full clears (`-inf .. +inf`) and member-based removals (`ZREM`) are band-independent. Never enumerate the priority range per attempt or per queue (the cost must not depend on it): prune the bands found in the data (decode the band and the liveness of a score with `Resource.lock_request_priority(score)` and `lock_priority_band_dead_score`): the attempt prunes its own band and the bands of the dead blocking requests (`TryToLock#drop_dead_requests_ahead`; a lock series: `TryToLockSeries#drop_series_dead_requests_ahead`, one round trip for all its locks), `ClearDeadRequests` the bands whose first request is dead (all requests of the batch queues are read; `by_priority: true`: a walk over the bands reads only the first request of each band, `ZRANGE <queue> <bound> +inf BYSCORE LIMIT 0 1`, one round trip per band of the batch queue with the most bands + 1, the dead bands are pruned by the next round trip after its reads, `drop_dead_requests_by_priority`; every walk step must make progress: an exclusive bound when the next band edge rounds to the position). Decode priorities with `Resource.lock_request_priority(score)` (info results); the cross-mode `(score, acq_id)` comparison works on shifted scores as is. Infinite positions (manual writes, never written by RQL) are out of all bands: the decoding maps `-inf`/`+inf` to `MAX_LOCK_PRIORITY`/`MIN_LOCK_PRIORITY` (never `Float#floor` of an infinity: `FloatDomainError`); a request at `-inf` is always dead and no band range covers it: it is dropped by its position (`ZREMRANGEBYSCORE <queue> -inf -inf`: `drop_dead_requests_ahead` for a blocking one, `ClearDeadRequests` for the first ones of a queue); a request at `+inf` is never dead.
59
58
  - Redis never keeps empty sorted sets/hashes: an empty `ZRANGE`/`HGETALL` result already means a missing key, so don't pair such reads with `EXISTS` (`QueueInfo`, `Queues`).
60
59
  - **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']`.
61
60
  - **AcquireLock**:
@@ -67,6 +66,12 @@ paths:
67
66
  - 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.
68
67
  - Read lock data (`rql:lock_reader:<name>:<acq_id>`) uses the write-lock field format without `acq_id`/`hst_id` (`ts`, `ini_ttl`, meta, `spc_*`: both ids are derived from the acquirer id, `LockInfo.read_lock_info`/`Locks.read_lock_info` put them first in each reader and still accept older data that contains them) and the read lock TTL; it is a data carrier only (writers never read it). Its key (`read_lock_key`) is built only for read lock requests (`nil` for write requests: they never hold read locks).
69
68
  - Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
69
+ - **LockSeries** (`lock_series.rb`, `lock_series/try_to_lock_series.rb`, `lock_series/release_lock_series.rb`; overview "Lock series"):
70
+ - The attempt is the single lock attempt for N locks, built from the shared `TryToLock` helpers: lock state and queueing commands (`pipeline_lock_state`, `queue_lock_request`), decisions (`same_process_conflict_status`, `find_opposite_request_ahead`, `lock_obstacle`, `find_dead_requests_ahead`, `pipeline_dead_requests_ahead_removal`), obtaining and reentrant commands (`transact_*`). A change of these helpers applies to both attempts; a change of the single lock attempt flow is checked against `try_to_lock_series`/`precheck_lock_series` (two copies of the flow, not of the commands).
71
+ - Per-lock keys are parallel arrays in the order of the lock names (`lock_keys, rw_mode, lock_key_queues, read_lock_key_queues, lock_readers_keys, read_lock_keys, lock_names, watched_keys, acquirer_id, ...`), computed once in `lock_series`; `lock_states` (`series_lock_states`) is the per-attempt state of each lock.
72
+ - One `acquirer_position` per series in all its queues: never compute a position per lock (deadlock freedom depends on the same order in all queues).
73
+ - Nothing is obtained outside the one transaction of the attempt, nothing is held between attempts.
74
+ - Every failure and interruption goes through `drop_failed_lock_series` (the `ensure` of the acquirement) and one failure section (events, `raise_lock_series_error`, the failure result): the acquirement loop never raises.
70
75
  - **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`).
71
76
 
72
77
  ## Claude rules
@@ -90,7 +95,7 @@ paths:
90
95
  - keep write-only result shapes unchanged; add read keys/fields only when read data exists;
91
96
  - lock index (the enabled lock index only, `if lock_index_enabled`): every key-creating command is followed by the index `ZADD GT` (the obtaining `MULTI` too), every extension by `ZADD XX INCR`, names are removed only with all their keys in one `MULTI` (see "Lock index" above); the disabled lock index sends no lock index command.
92
97
  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) ? ... : NO_LOCK_DATA`, a shared frozen hash instead of a new `{}` per attempt): visitor arguments are evaluated before the visitor's guard.
93
- 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.
98
+ 14. Keep operation modules independent (see "Independence of operation modules"; the acquisition core, `AcquireLock` and `LockSeries`, is 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.
94
99
  15. Pipelines/transactions whose replies are read by position (`lock_state[N]`, `result[N]`): document the reply positions next to the commands (`NOTE: (pipeline layout)`). Conditionally sent commands (e.g. `... unless read_mode`) form a tail after all unconditional commands; read their replies only under the same condition (a command appended after a conditional tail gets a mode-dependent position and its reply is read as another command's reply). Insert new commands before the conditional tail and update the positions, the reply reading, the position constants and the layout spec in the same change. `TryToLock` lock state pipeline (`pipeline_lock_state`, shared by the attempt and the pre-check): head `[LOCK_STATE_OBTAINER_REPLY, LOCK_STATE_REDIS_TIME_REPLY, LOCK_STATE_OWN_READ_LOCK_REPLY]`, queueing commands at `LOCK_STATE_QUEUE_REPLIES_AT` (`queue_lock_request` replies, used ones at `QUEUE_HEAD_REPLY`/`OPPOSITE_QUEUE_HEAD_REPLY`), the write-only tail last (`lock_state.last`); read replies only by these constants (both reply readers: `try_to_lock` Step 0.1 and `precheck_lock`); the obstacle decision and its exit logs are shared too (`find_opposite_request_ahead`, `lock_obstacle`, `log_lock_obstacle`, the log-only priority decoding `logged_request_priority`): change them in one place; spec `lock state pipeline layout` records the pipelines of both modes with and without eager queueing and fails on a broken layout or stale position constants.
95
100
  16. Lock index (see "Lock index" above): a new command that creates a lock key must be followed (with the enabled lock index: `if lock_index_enabled`, as a tail of the pipeline/transaction) by the index `ZADD <LOCK_INDEX_KEY> GT <Resource.lock_index_time + lifetime + lock_index_grace_period> <lock_name>` in the same pipeline/transaction (after the key command), a new extension by `ZADD XX INCR <ms> <lock_name>`; a new name removal is allowed only in the same `MULTI` that deletes all indexed keys of the name; a new key family with its own lifetime must get a bounded TTL (the enabled lock index) and be covered by the index score and by `ReindexLocks`; never decrease index scores and never derive mutual exclusion from the index; never send lock index commands with the disabled lock index. Cover new index writes in `describe 'lock index'` specs (they enable the lock index explicitly).
96
101
 
@@ -98,4 +103,3 @@ paths:
98
103
  Apply to new or touched code; don't refactor existing code for these unless asked.
99
104
  1. Load Lua scripts once (`SCRIPT LOAD` + `EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), as the TODO in `extend_lock_ttl.rb` asks.
100
105
  2. Measure lock-iterating operations by the lock index size (distinct lock names x (max(queue ttl, lock ttl) + grace)): most of the visited names are dead; prefer one cheap existence check per name (`EXISTS` with several keys) before reading the details when adding new iterating features.
101
- 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).
@@ -50,12 +50,12 @@ This is a deliberate framework-level decision, **not** something to refactor int
50
50
  2. New public option: add it as an explicit keyword to **every** affected `Client` method (`x` and `x!`, e.g. `lock` and `lock!`), with the default taken from `config['...']` (add the `setting` + `validate` first) or a literal for per-call-only flags (`raise_errors: false`, `meta: nil`).
51
51
  3. Forward it explicitly by name through every layer using shorthand (`new_option:`); never forward via `**kwargs`, `...`, or `method(__method__).parameters` tricks.
52
52
  4. Keep defaults only at the public boundary (`Client`); internal functions take required keywords/positionals with no defaults so a missed forward fails loudly.
53
- 5. Internal positional lists: append new params in the established order (subject → domain data → observability tail `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`) and update every call site and the RBS signature in the same change. Lock key params keep this order: `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, acquirer_position, priority, ...` (`lock_name` is the lock index member; `acquirer_position` is the priority-shifted queue score, `priority` its band; queueing helpers take `acquirer_position, priority` right after the queue keys; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the domain data (batch sizes, `dead_ttl`, `by_priority`), before the observability tail; swarm functions: last).
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, rw_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, acquirer_position, priority, ...` (`lock_name` is the lock index member; `acquirer_position` is the priority-shifted queue score, `priority` its band; queueing helpers take `acquirer_position, priority` right after the queue keys; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the domain data (batch sizes, `dead_ttl`, `by_priority`), before the observability tail; swarm functions: last).
54
54
  6. Document each new option with its own `@option`/`@param` YARD line (type, meaning, default source) and add it to the RBS method signature as a named param/keyword.
55
55
  7. Keep hot-path code allocation-light: no per-call wrapper objects, no hash merging, no splats to build args, no `tap`/closures purely for argument plumbing; prefer passing existing locals.
56
56
  8. Silence length cops locally (`# rubocop:disable Metrics/MethodLength`) rather than shortening signatures; `Metrics/ParameterLists` is disabled project-wide on purpose.
57
57
  9. Client-wide modes are not per-call options: `config['lock_index_enabled']` is read inside the `Client` (and `Swarm`) method bodies and forwarded by name to the acquirers (`lock_index_enabled: config['lock_index_enabled']` / positional `config['lock_index_enabled']`), never added as a keyword of `lock`/`locks`/`clear_locks`/...: one call in another mode would break the lock index invariant (all lock acquirements of a client must maintain the index when its operations read it).
58
- 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`, `priority`). Client-wide registries are read from `config` inside these methods and forwarded as internal keywords, never public keywords (`lock_priorities: config['lock_priorities']`, the priority names: `lock` and `lock_series` pass it, `lock!`/`lock_series!` delegate to them).
58
+ 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (`AcquireLock.acquire_lock` and `LockSeries.lock_series` have their own keyword lists, with the same validations: duplicated on purpose). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `rw_mode`, `priority`). Client-wide registries are read from `config` inside these methods and forwarded as internal keywords, never public keywords (`lock_priorities: config['lock_priorities']`, the priority names: `lock` and `lock_series` pass it, `lock!`/`lock_series!` delegate to them).
59
59
 
60
60
  ## Recommendations (proposed, not yet project policy)
61
61
  1. For new internal functions with many same-typed params (e.g. several Integers/Booleans in a row), consider required keywords instead of positionals to prevent misordering; keep the list flat and explicit.
@@ -25,8 +25,9 @@ Related rule files (loaded for narrower paths):
25
25
  - Namespace files (`acquirer.rb`, `swarm.rb`, `logging.rb`, `swarm/swarm_element.rb`) only `require_relative` their children; the root `lib/redis_queued_locks.rb` requires the namespaces in dependency order.
26
26
  - YARD doc block on every class, module, constant, attr and method:
27
27
  `@param name [Type]`, `@option`, `@return [Type]`, blank `#` line, then `@api public|private`, `@since X.Y.Z`, optional `@version X.Y.Z` (methods: the latest behavior/signature change; classes/modules: the latest interface change, see rule 2).
28
+ - Mixin lines (`include`/`extend`/`prepend`) need no YARD block, only `# @since X.Y.Z` right above each of them: the version that added the mixin to this class/module, not the `@since` of the class/module (`AcquireLock` is `@since 1.0.0`, its `extend RedisQueuedLocks::Utilities` is `@since 1.14.0`).
28
29
  - 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").
30
+ - Operation modules (except the lock acquisition core: `AcquireLock`, `LockSeries`) are independent of each other: no cross-calls and no shared RBS types between them; common logic lives in `Resource`/`Utilities`, similar logic is duplicated on purpose (see `acquirer.md`, "Independence of operation modules").
30
31
  - 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`).
31
32
  - `Client` methods only fill defaults from `config['...']` and delegate to `Acquirer::*` / `Swarm`.
32
33
  - Redis keys come only from `RedisQueuedLocks::Resource.prepare_*` helpers and its `*_PATTERN` / `SWARM_KEY` constants. Key families: write lock `rql:lock:` (`LOCK_PATTERN`), write requests `rql:lock_queue:` (`LOCK_QUEUE_PATTERN`), readers registry `rql:lock_readers:` (`LOCK_READERS_PATTERN`), read requests `rql:lock_read_queue:` (`READ_LOCK_QUEUE_PATTERN`), reader data `rql:lock_reader:<name>:<acq_id>` (`READ_LOCK_PATTERN`); all of them match `KEY_PATTERN` (`rql:lock*`); `Resource.lock_key_family(key)` classifies a key by its prefix (`:lock`, `:lock_queue`, `:lock_readers`, `:read_lock_queue`, `:read_lock`, `nil`).
@@ -41,7 +42,7 @@ Related rule files (loaded for narrower paths):
41
42
 
42
43
  ## Claude rules
43
44
  1. Start new files with `# frozen_string_literal: true` and use compact constant paths.
44
- 2. Add a full YARD block to every new public/private method; new code gets `@since <next version>`. `@version <next version>`:
45
+ 2. Add a full YARD block to every new public/private method and `# @since <next version>` right above every new `include`/`extend`/`prepend` line; new code gets `@since <next version>`. `@version <next version>`:
45
46
  - a method: when its behavior or its signature changes; a typo fix in the method name or in a keyword argument name is a signature change too (callers change): the method gets `@version <next version>` (and the class/module header, see below); a typo fix in a positional parameter name is not a signature change (callers don't change) and bumps nothing;
46
47
  - a class/module header: only when its interface changes:
47
48
  - a method signature changes: its parameters (their kinds and names); the default values and the return values are behavior;
@@ -56,7 +57,7 @@ Related rule files (loaded for narrower paths):
56
57
  7. New config option: `setting` with default + `validate` with a type check and the valid range (TTLs, periods, batch/scan sizes are positive Integers: `val.is_a?(Integer) && val > 0`), both in `config.rb`, plus the key's overload line in `sig/redis_queued_locks/config.rbs` (`Config#[]` is typed per key; `spec/redis_queued_locks/config_spec.rb` fails on a missing/extra line); read it in `Client` as `config['key']`. A per-call numeric option (a `Client` keyword with a `config` default) is validated with the same type and range at the start of the operation module (the `Swarm` facade for the swarm actions), before any Redis command: `RedisQueuedLocks::ArgumentError` (`SwarmArgumentError` in `Swarm`) with the option name and the value (`` "`:scan_size` argument should be a positive Integer, got 0." ``).
57
58
  8. Logs and instrumentation events go through visitor modules (see `visitors.md`); event names follow `redis_queued_locks.<snake_case>`.
58
59
  9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
59
- 10. Follow the rubocop rules of the project (`.rubocop.yml` inheriting the armitage config, `.rubocop.rbs.yml` for RBS): `bundle exec rake rubocop` runs two passes (Ruby sources, then RBS) and both must be clean; read the whole output, not only its tail. Never change `.rubocop.yml` to make an offense pass: rewrite the code (e.g. `yield_self`, not `then`); a config change is a separate decision of the user. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods; never `# rubocop:disable all` (it hid real offenses until 1.21.1: an unpaired one in `client.rb` switched the linter off till the end of the file). A PoC file disables its `Metrics/*` cops by name at the top with the paired `enable` at the bottom (the only exception: the line-level `Lint/InheritException` of the base `RedisQueuedLocks::Exception < ::Exception` in `errors.rb`, see rule 15).
60
+ 10. Follow the rubocop rules of the project (`.rubocop.yml` inheriting the armitage config, `.rubocop.rbs.yml` for RBS): `bundle exec rake rubocop` runs two passes (Ruby sources, then RBS) and both must be clean; read the whole output, not only its tail. Never change `.rubocop.yml` to make an offense pass: rewrite the code (e.g. `yield_self`, not `then`); a config change is a separate decision of the user. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods (the only exception: the line-level `Lint/InheritException` of the base `RedisQueuedLocks::Exception < ::Exception` in `errors.rb`, see rule 15); never `# rubocop:disable all` (it hid real offenses until 1.21.1: an unpaired one in `client.rb` switched the linter off till the end of the file).
60
61
  11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
61
62
  12. Keep `.claude/project-overview.md` and the affected `.claude/rules/*.md` in sync in the same change (keys, options, results, events, algorithm steps, limitations); read the relevant overview sections before implementing and follow their invariants.
62
63
  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).
@@ -69,7 +70,7 @@ Related rule files (loaded for narrower paths):
69
70
  - new public errors are `@api public` and documented in README/CHANGELOG; the `Exception` branch is `@api private`.
70
71
  16. Observability of the logic (logs and instrumentation):
71
72
  - every logic that creates, changes or deletes Redis keys has both logs and instrumentation; logic that only reads the lock data (`locked?`, `queued?`, `lock_info`, `queue_info`, `locks`, `queues`, `keys`, `*_info`, zombie queries) has neither;
72
- - lock acquirement (`AcquireLock`, `LockSeriesPoC`): each meaningful step/outcome is logged and instrumented through the visitors (`visitors.md`): obtained (classic and reentrant), held and released, not obtained (`lock_obtaining_failed`, also before a raised acquirement error); the per-attempt `try_lock.*` steps are logs only (gated by `log_lock_try`), never instrumented (no `notify` per attempt);
73
+ - lock acquirement (`AcquireLock`, `LockSeries`): each meaningful step/outcome is logged and instrumented through the visitors (`visitors.md`): obtained (classic and reentrant), held and released, not obtained (`lock_obtaining_failed`, also before a raised acquirement error); the per-attempt `try_lock.*` steps are logs only (gated by `log_lock_try`), never instrumented (no `notify` per attempt);
73
74
  - lock mutation operations (release, extension, cleanup, reindexing): exactly one log line and one instrumentation event per operation with the time of the whole work of the root operation module called by the public `Client` method (the two-layer pattern of `acquirer.md`: the timer wraps the `fully_*` helper), never of its internal parts; the log line and the event have the same name (`redis_queued_locks.<event>`) and the same data in the same order (the log has no `instrument`), the payload ends with the user's `instrument`; they are written inline in the module (`run_non_critical`, gated by `Logging.should_log?` / `Instrument.should_instrument?`);
74
75
  - every such public `Client` method takes `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling keywords (defaults from `config`) and forwards them by name to the operation module;
75
76
  - known debt: the swarm elements and the manual swarm actions (`ProbeHosts`, `FlushZombies`: `#probe_hosts`, `#flush_zombies`) are neither logged nor instrumented yet (the flusher Ractor can not get the user's logger/instrumenter).
@@ -7,7 +7,7 @@ paths:
7
7
  # Test rules (`spec/`)
8
8
 
9
9
  ## Observed conventions
10
- - All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~5.6k lines), under `RSpec.describe RedisQueuedLocks`; the only other spec is the Redis-free `spec/redis_queued_locks/config_spec.rb` (the typed `Config#[]` overload table of `config.rbs` == the `setting` registry); groups: `describe 'Lock Series PoC'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks (all of them in the shared examples), and `describe 'lock index'` (outside the shared examples, see below). The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
10
+ - All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~5.6k lines), under `RSpec.describe RedisQueuedLocks`; the only other spec is the Redis-free `spec/redis_queued_locks/config_spec.rb` (the typed `Config#[]` overload table of `config.rbs` == the `setting` registry); groups: `describe 'Lock Series'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks (all of them in the shared examples), and `describe 'lock index'` (outside the shared examples, see below). The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
11
11
  - Known flaky examples (timing-dependent: they pass in isolation, rspec-retry usually hides them; check them with `RSPEC_RETRY_RETRY_COUNT=1` before blaming a change):
12
12
  - `all in + notifications`: `sleep(1)` before the lock/queue info checks; it was order-dependent (wrong `locks_info.size` in full runs, e.g. `--seed 6170`, `--seed 28462`): `detailed lock acquirement timeout error` left an unbounded waiter thread that obtained its lock in the next example (now bounded and joined); check new failures without retries before blaming a change;
13
13
  - `:queued access strategy` and `:queued access strategy (manually defined)` (the same scenario): the 4th attempt of `trd2` (~9 s, `retry_delay: 3_000`) races the expiration of the `trd1` lock (obtained at ~7 s with ttl 2 s), and the default `try_to_lock_timeout` (10 s) leaves no later attempt;
@@ -17,12 +17,12 @@ paths:
17
17
  - Tests hit a real Redis (db 0) via `let(:redis) { RedisClient.config(db: 0).new_pool(timeout: 5, size: 50, ...) }` with long timeouts (RBS runtime-check runs are slow); `all in + notifications` also flushes db 1. Check the local Redis dbs before running the suite on a machine with other data.
18
18
  - Read/write lock specs use: a monotonic `timeline` hash + `mark` lambda (guarded by a `Mutex`) to assert ordering (`reader_in < other_reader_out`, `writer_in >= reader_out`); an invariant spec with shared reader/writer counters checked inside lock blocks; helper threads that take locks without a block (each thread is a separate acquirer).
19
19
  - `before`: `FLUSHDB`, `DEL Resource::SWARM_KEY`, `RedisQueuedLocks.enable_debugger!`; `after`: `DEL SWARM_KEY`, `FLUSHDB`.
20
- - Examples are written with `specify '<feature>'` (few `it`); grouped with `describe` only for big areas (`'Lock Series PoC'`, `'swarm'`).
20
+ - Examples are written with `specify '<feature>'` (few `it`); grouped with `describe` only for big areas (`'Lock Series'`, `'swarm'`).
21
21
  - Clients are built inline: `RedisQueuedLocks::Client.new(redis) { |config| ... }`.
22
22
  - Logger/instrumenter fakes are anonymous classes (`Class.new { def debug(...); def notify(event, payload = {}) }`) collecting calls into arrays.
23
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)`.
24
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.
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. `lock series pipeline layout` does the same for the series (the commands of each lock, the separate queueing pipeline, the read lock extensions first in the transaction); `#lock_series: round trips do not depend on the size of the series` fixes the round trips.
26
26
  - Coverage: SimpleCov line + branch, HTML only; `minimum_coverage 100` is a TODO.
27
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
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.
@@ -43,7 +43,7 @@ The suite is pending a total rework (overview "Conventions"): these rules guide
43
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.
44
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).
45
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.
46
- 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.
46
+ 13. New lock features get specs for both modes when relevant (`rw_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.
47
47
 
48
48
  ## Recommendations (proposed, not yet project policy)
49
49
  Apply to new tests; don't restructure the existing suite unless asked.
@@ -12,9 +12,9 @@ paths:
12
12
  - `sig/` mirrors `lib/` one file per file (`lib/redis_queued_locks/acquirer/is_locked.rb` → `sig/redis_queued_locks/acquirer/is_locked.rbs`).
13
13
  - RBS uses fully nested `module RedisQueuedLocks / module Acquirer / ...` blocks (unlike compact Ruby constants).
14
14
  - Common aliases at the top: `use RedisQueuedLocks as RQL`, `use RedisClient as RC`; Redis connections typed as `RC::client`.
15
- - Shared duck types live in `sig/redis_queued_locks.rbs`: `_Loggable`, `_Instrumentable`, `loggerObj`, `instrObj`; samplers as `RQL::Logging::samplerObj` / `RQL::Instrument::samplerObj`; option literal unions there too: `RQL::readWriteMode` (`:read | :write`), `RQL::accessStrategy` (`:queued | :random`), `RQL::conflictStrategy` (the four conflict strategies), plus `RQL::lockPriority` (`Integer | Symbol`: an Integer priority or a priority name; `Client`, `AcquireLock.acquire_lock`, `config['default_lock_priority']`; code after the name resolution types `priority` as `Integer`) type `read_write_mode`/`rw_mode`, `access_strategy` and `conflict_strategy` everywhere (`Client`, `AcquireLock` and its mixins, visitors, `ExtendLockTTL`, the config defaults); never `Symbol` for them. The one exception is the entry point that validates the value at runtime (`AcquireLock.acquire_lock` Step 0.1 (RW), `ExtendLockTTL.extend_lock_ttl`): its `read_write_mode` stays `Symbol` with a `NOTE: (rbs test)` (the RBS runtime checks of the specs must reach the `ArgumentError` of an invalid Symbol), and the validation is a `case read_write_mode when :read, :write then nil else raise(...) end` so Steep narrows the variable to `RQL::readWriteMode` for everything below (an `if x != :read && x != :write` guard leaves `(:read | Symbol)`).
15
+ - Shared duck types live in `sig/redis_queued_locks.rbs`: `_Loggable`, `_Instrumentable`, `loggerObj`, `instrObj`; samplers as `RQL::Logging::samplerObj` / `RQL::Instrument::samplerObj`; option literal unions there too: `RQL::readWriteMode` (`:read | :write`), `RQL::accessStrategy` (`:queued | :random`), `RQL::conflictStrategy` (the four conflict strategies), plus `RQL::lockPriority` (`Integer | Symbol`: an Integer priority or a priority name; `Client`, `AcquireLock.acquire_lock`, `LockSeries.lock_series`, `config['default_lock_priority']`; code after the name resolution types `priority` as `Integer`) type `rw_mode`, `access_strategy` and `conflict_strategy` everywhere (`Client`, `AcquireLock` and its mixins, visitors, `ExtendLockTTL`, the config defaults); never `Symbol` for them. The one exception is the entry point that validates the value at runtime (`AcquireLock.acquire_lock` Step 0.1 (RW), `LockSeries.lock_series` Step 0.1 (RW), `ExtendLockTTL.extend_lock_ttl`): its `rw_mode` stays `Symbol` with a `NOTE: (rbs test)` (the RBS runtime checks of the specs must reach the `ArgumentError` of an invalid Symbol), and the validation is a `case rw_mode when :read, :write then nil else raise(...) end` so Steep narrows the variable to `RQL::readWriteMode` for everything below (an `if x != :read && x != :write` guard leaves `(:read | Symbol)`).
16
16
  - Result shapes are named record aliases inside the module, e.g. `type extendResult = { ok: bool, result: { extended_locks_count: Integer } | Symbol }`.
17
- - 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
+ - Each `Acquirer::*` operation module declares its own aliases and never references another operation module's aliases (`Locks::lockInfo` / `Locks::readerInfo` mirror `LockInfo::lockInfo` / `LockInfo::readerInfo`); `Client` signatures may reference any of them.
18
18
  - Module functions are declared `def self.name: (...) -> T`; long signatures put one param per line with names.
19
19
  - Instance variables are declared (`@config_setters: configSetters`) and attr readers typed.
20
20
  - Third-party types: `sig/vendor/*.rbs` hand-written stubs (redis_client, active_support, semantic_logger) plus `rbs collection` gems (redis-client, securerandom, timeout, logger, monitor, json) installed into `.gem_rbs_collection/`; the `gem_rbs_collection` source is pinned to a commit `revision` in `rbs_collection.yaml` (never `main`), so installs are reproducible and `rbs_collection.lock.yaml` stays stable.
@@ -24,7 +24,6 @@ paths:
24
24
  - record types with String keys (`{ 'acq_id' => String, ... }`) crash Steep's subtyping (`RuntimeError`/`Unexpected error` logged as `ERROR`/`FATAL`, while the summary still says "No type error detected"): use `Hash[String, ...]` aliases instead (see `QueueInfo::queueInfo`, `LockInfo::lockInfo`);
25
25
  - `x != nil` does not narrow `String?`/`Float?` in `elsif` branches or `&&` chains on locals typed from tuples: use truthiness (`elsif x`) or total conversions (`x.to_f > now`, `ttl.to_i`);
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
- - `LockSeriesPoC` has no RBS: new calls/definitions there need `# steep:ignore` (like the existing ones).
28
27
  - splatting a `Set` (`call(*set)`) is rejected (`Ruby::ArgumentTypeMismatch`: splat arguments must be arrays), and no honest inline annotation fixes it; at runtime Ruby converts a splatted `Set` with `Set#to_a` on every call.
29
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.
30
29
  - Runtime checking: CI runs specs with `RUBYOPT='-rbundler/setup -rrbs/test/setup' RBS_TEST_TARGET='RedisQueuedLocks::*'` (the blocking `typecheck-runtime` job, command in the workflow and README; the job stays serial (`bundle exec rspec`): parallel workers under the RBS hooks starve the timing-sensitive examples on the 2-core runner, locally the same environment works with `rake rspec:parallel`; `bundler/setup` goes first so `rbs`/`prism` come from `Gemfile.lock`, not from newer system gems), so signatures must match real runtime values, not just Steep's view. Not hooked (rbs/test hooks read `RBS.logger` and die with `Ractor::IsolationError` inside the flusher Ractor): the Ractor-side classes via `RBS_TEST_SKIP` (`Swarm::FlushZombies`, `Swarm::SwarmElement::Isolated`, `Swarm::RedisClientBuilder`) and the shared helpers they call via `%a{rbs:test:skip}` in RBS (`Resource.prepare_*`, `lock_key_family`, `lock_index_time`, `lock_name_from_readers`, `host_identifier_from_acquirer`, `calc_zombie_score`, `Utilities.thread_state`); `VoidNotifier.notify` and the samplers' `sampling_happened?` are skipped because `Instrument.valid_interface?` / `valid_sampler?` introspect their `#parameters`. `RBS_TEST_SKIP` entries need the absolute form (`::RedisQueuedLocks::...`): rbs/test compares them with absolute type names without normalizing them. Specs that pass type-invalid values on purpose skip those expectations under `RBS_RUNTIME_TEST` (`spec_helper.rb`); value-invalid cases of the right type (`:reed`) stay.