redis_queued_locks 1.22.0 → 1.23.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +90 -38
  3. data/.claude/rules/acquirer.md +12 -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 +30 -1
  12. data/CLAUDE.md +5 -4
  13. data/README.md +259 -103
  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/yield_expire.rb +29 -12
  17. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +36 -27
  18. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +13 -13
  19. data/lib/redis_queued_locks/acquirer/lock_series/instr_visitor.rb +243 -0
  20. data/lib/redis_queued_locks/acquirer/lock_series/log_visitor.rb +472 -0
  21. data/lib/redis_queued_locks/acquirer/lock_series/release_lock_series.rb +181 -0
  22. data/lib/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rb +1051 -0
  23. data/lib/redis_queued_locks/acquirer/lock_series.rb +957 -0
  24. data/lib/redis_queued_locks/acquirer.rb +1 -1
  25. data/lib/redis_queued_locks/client.rb +55 -25
  26. data/lib/redis_queued_locks/errors.rb +8 -0
  27. data/lib/redis_queued_locks/version.rb +2 -2
  28. data/redis_queued_locks.gemspec +21 -14
  29. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +1 -1
  30. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +106 -5
  31. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +1 -1
  32. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  33. data/sig/redis_queued_locks/acquirer/lock_series/instr_visitor.rbs +102 -0
  34. data/sig/redis_queued_locks/acquirer/lock_series/log_visitor.rbs +154 -0
  35. data/sig/redis_queued_locks/acquirer/lock_series/release_lock_series.rbs +37 -0
  36. data/sig/redis_queued_locks/acquirer/lock_series/try_to_lock_series.rbs +198 -0
  37. data/sig/redis_queued_locks/acquirer/lock_series.rbs +106 -0
  38. data/sig/redis_queued_locks/client.rbs +70 -3
  39. data/sig/redis_queued_locks/errors.rbs +6 -0
  40. metadata +32 -11
  41. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +0 -61
  42. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +0 -104
  43. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +0 -513
@@ -22,7 +22,10 @@ The project calls these modules "visitors", but they are not GoF Visitor (no `ac
22
22
  | `AcquireLock::TryToLock::LogVisitor` | `acquire_lock/try_to_lock/log_visitor.rb` | 19 step-level events (`start`, `rconn_fetched`, `acq_added_to_queue`, `dead_request_removed`, `exit__no_first`, `exit__read_lock_still_obtained`, `obtain__free_to_acquire`, ...); every lock event logs `rw_mode` |
23
23
  | `AcquireLock::YieldExpire::LogVisitor` | `acquire_lock/yield_expire/log_visitor.rb` | `expire_lock`, `decrease_lock` |
24
24
  | `AcquireLock::DequeueFromLockQueue::LogVisitor` | `acquire_lock/dequeue_from_lock_queue/log_visitor.rb` | `dequeue_from_lock_queue` |
25
- | `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) |
25
+ | `LockSeries::LogVisitor` | `lock_series/log_visitor.rb` | 11: `start_lock_series_obtaining`, `start_try_to_lock_series_cycle`, `dead_score_reached__reset_series_position`, `lock_series_intermediate_lock_obtained`, `lock_series_lock_obtained`, `lock_series_lock_obtaining_failed`, `lock_series_obtained`, `lock_series_obtaining_failed`, `dequeue_from_lock_series_queues`, `expire_lock_series`, `decrease_lock_series`; `rw_mode` (the mode of the series) and `priority` in all of them |
26
+ | `LockSeries::InstrVisitor` | `lock_series/instr_visitor.rb` | 6: `lock_series_intermediate_lock_obtained`, `lock_series_lock_obtained`, `lock_series_lock_obtaining_failed`, `lock_series_obtained`, `lock_series_hold_and_release`, `lock_series_obtaining_failed`; `rw_mode` and `priority` in all of them |
27
+
28
+ `TryToLockSeries` writes the try-lock logs of each lock through `TryToLock::LogVisitor` (the `LogVisitor` constant there: `TryToLock` is included; the per-lock loops are gated by `log_sampled && log_lock_try`): no own try-lock events.
26
29
 
27
30
  Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`, `ReleaseReadLock`, `ReleaseAllLocks`, `ReleaseLocksOf`, `ExtendLockTTL`, `ClearDeadRequests`, `ReindexLocks` write one log line (`logger.debug`) and one event (`instrumenter.notify`) inline inside `run_non_critical` after the whole operation: `explicit_lock_release`, `explicit_read_lock_release`, `explicit_all_locks_release`, `release_locks_of`, `extend_lock_ttl`, `clear_dead_requests`, `reindex_locks` (the log line has the event name and the payload data in the same order, without `instrument`; the payload ends with `instrument`). `lock_obtaining_failed` is reported by `acquire_lock` in Step 3.b (the failure result) and in the method-level `rescue` of the raised acquirement errors (Step 3.c: only while the lock is not acquired; errors of the block of code are not reported).
28
31
 
@@ -31,20 +34,20 @@ Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`
31
34
  - **Shape**: `module <Component>::LogVisitor` / `::InstrVisitor`, `# @api private`, all methods inside `class << self`, full YARD block per method; return `void`.
32
35
  - **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
36
  - **Parameter order**: `(port, sampled_flag, [extra gate], lock_key, ...event data..., [instrument])`:
34
- - LogVisitor: `(logger, log_sampled, ...)`; `TryToLock::LogVisitor` adds `log_lock_try` as the third arg (fine-grained step logs are opt-in through `config['log_lock_try']`). Lock lifecycle events (AcquireLock, TryToLock, YieldExpire, DequeueFromLockQueue, LockSeriesPoC) take `rw_mode` right after `access_strategy`, before event-specific data, and log it as `rw_mode => '<mode>'` right after `acs_strat`; the lock acquirement logs of `AcquireLock::LogVisitor` (all of them), `LockSeriesPoC::LogVisitor` (the priority of the series) and `TryToLock::LogVisitor.acq_added_to_queue` take `priority` right after `rw_mode` and log `priority => N` right after it (the release logs of `YieldExpire` and `DequeueFromLockQueue` don't); try-lock logs that name another request log its decoded priority right after its acquirer (`first_priority_in_queue` after `first_acq_id_in_queue`, `ahead_priority` after `ahead_acq_id`, `dead_priority` after `dead_acq_id`; `Integer?` params decoded at the call site only when `log_sampled && log_lock_try`, by one helper: `TryToLock#logged_request_priority`, see rule 12).
37
+ - 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, LockSeries) take `rw_mode` right after `access_strategy`, before event-specific data, and log it as `rw_mode => '<mode>'` right after `acs_strat`; the lock acquirement logs of `AcquireLock::LogVisitor` (all of them), `LockSeries::LogVisitor` (the priority of the series) and `TryToLock::LogVisitor.acq_added_to_queue` take `priority` right after `rw_mode` and log `priority => N` right after it (the release logs of `YieldExpire` and `DequeueFromLockQueue` don't); try-lock logs that name another request log its decoded priority right after its acquirer (`first_priority_in_queue` after `first_acq_id_in_queue`, `ahead_priority` after `ahead_acq_id`, `dead_priority` after `dead_acq_id`; `Integer?` params decoded at the call site only when `log_sampled && log_lock_try`, by one helper: `TryToLock#logged_request_priority`, see rule 12).
35
38
  - InstrVisitor: `(instrumenter, instr_sampled, lock_key, rw_mode, ttl, acq_id, hst_id, ts, acq_time, [hold_time], instrument)`; the payload carries `rw_mode:` (requested mode); the user's `instrument` value is always last. All lock acquirement events (`lock_obtained`, `lock_hold_and_release`, `lock_obtaining_failed`, the reentrant ones: the requested priority, they never queue; the lock series ones: the priority of the series) take `priority` (the lock request priority: always the Integer, a priority name is resolved before) right after `rw_mode` (payload `priority:` after `rw_mode:`).
36
- - `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`.
39
+ - `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 LockSeries events it is the mode of the series (one for all locks of the series). LockSeries instrumentation takes `rw_mode` right after `lock_keys`: the series events have `lock_keys` instead of `lock_key`, the events of a lock of the series (`lock_series_*lock_*`) have both (`lock_key, lock_keys`). The per-lock series events are not per-attempt events (`logic.md#16`): `lock_series_intermediate_lock_obtained` is reported once per lock by `lock_series`, out of the WATCH block of the attempt.
37
40
  - **Guard first**: `return unless log_sampled` (`&& log_lock_try` for try-lock steps) / `return unless instr_sampled`. The sampling decision is computed **once per operation** in the caller via `Logging.should_log?` / `Instrument.should_instrument?` and passed down as a boolean.
38
41
  - **Log format**: one `logger.debug { ... }` block (lazy string), message = `"[redis_queued_locks.<event>] "` (`[redis_queued_locks.try_lock.<event>]` for TryToLock steps) followed by `key => value` pairs; string values in single quotes (`lock_key => '...'`), numbers bare (a missing number of an `Integer?` param is logged as `nil` with `#inspect`, never as an empty value: `first_priority_in_queue => nil`); abbreviated keys `acq_id`, `hst_id`, `acs_strat`. Built with `\` line continuations.
39
42
  - **Instrumentation format**: `instrumenter.notify('redis_queued_locks.<event>', { lock_key:, ttl:, acq_id:, hst_id:, ts:, acq_time:, instrument: })` using shorthand hash syntax and Symbol keys.
40
43
  - **Never raise**: every emit ends with `rescue nil` (modifier), so a broken logger/instrumenter can't affect lock correctness.
41
44
  - **Call sites**: plain module calls with positional args, usually grouped on 2-3 lines (`LogVisitor.lock_obtained(logger, log_sampled, lock_key, ...)`); data comes from local vars or the `result` hash of the step.
42
- - **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`.
45
+ - **Typing**: each visitor has an RBS file with `def self.<event>: (RQL::loggerObj logger, bool log_sampled, ...) -> void` / `RQL::instrObj instrumenter`.
43
46
  - **User docs**: README documents every log event with its logged keys (`## Logging`: the default, `log_lock_try`, lock series and operation logs lists, mirrored in the `config['logger']` / `config['log_lock_try']` comments of `### Logging Configuration` and `### Configuration`) and every instrumentation event with its payload keys, types and semantics (`### Instrumentation Events`).
44
47
 
45
48
  ## Claude rules
46
49
  **When to use**
47
- 1. Any new log line or instrumentation event inside `AcquireLock` (and its mixins) or `LockSeriesPoC` must go through a visitor method; never call `logger.*` or `instrumenter.notify` directly in algorithm code.
50
+ 1. Any new log line or instrumentation event inside `AcquireLock`, `LockSeries` (and their mixins) must go through a visitor method; never call `logger.*` or `instrumenter.notify` directly in algorithm code.
48
51
  2. Add a LogVisitor event for each new meaningful step or branch of the algorithm (start, decision, exit, success). Add an InstrVisitor event for the outcomes users would want to measure (obtained, not obtained, held/released, completed); not for internal per-attempt steps (`logic.md#16`).
49
52
  3. Fine-grained per-attempt logging belongs in `TryToLock::LogVisitor` and must be gated by `log_lock_try`.
50
53
  4. For a new algorithm component (new mixin under `acquire_lock/`), create its own `<component>/log_visitor.rb` (and `instr_visitor.rb` if needed) and `require_relative` it from the component.
@@ -65,4 +68,3 @@ Not covered by visitors (lock mutation operations, `logic.md#16`): `ReleaseLock`
65
68
  Apply to new or touched code; don't refactor existing code for these unless asked.
66
69
  1. Use one error-swallowing style consistently (visitors use modifier `rescue nil`, mutation modules use `run_non_critical`). Both silently hide bugs such as a `NoMethodError` inside the log block, so consider reporting swallowed errors when the debugger is enabled.
67
70
  2. Keep log and instrumentation event names in one registry constant (e.g. `RedisQueuedLocks::Instrument::EVENTS`) so README, specs and RBS can be checked against it.
68
- 3. Add RBS for the `lock_series_poc` visitors when the PoC stabilizes.
@@ -82,16 +82,18 @@ priorities", key layout, "Lock index", lock lookup modes, events, errors, conven
82
82
  - `EXTEND_LOCK_PTTL` in `TryToLock` and `ExtendLockTTL`;
83
83
  - lock state reply reading in `try_to_lock` Step 0.1 and `precheck_lock` (shared `pipeline_lock_state`,
84
84
  position constants, `lock_obstacle`, `log_lock_obstacle`);
85
- - reserved `meta` keys validation in `AcquireLock` and `LockSeriesPoC`; the `priority` resolution and range check
86
- in `AcquireLock` Step 0.1 and `LockSeriesPoC` (the same message);
85
+ - the argument validations of `AcquireLock` Step 0 and `LockSeries` Step 0 (reserved `meta` keys, numeric ranges, the
86
+ `priority` resolution and range check: the same messages);
87
+ - the attempt flow of `TryToLock#try_to_lock`/`precheck_lock` and `TryToLockSeries#try_to_lock_series`/`precheck_lock_series`
88
+ (the commands and decisions are shared helpers, the flow exists twice), `drop_dead_requests_ahead` and
89
+ `drop_series_dead_requests_ahead`;
87
90
  - the band liveness rule (a request is dead when its score is `<= Resource.lock_priority_band_dead_score(dead_score,
88
91
  band)`): `TryToLock#dead_request_priority` / `pipeline_dead_requests_removal` and `ClearDeadRequests.drop_dead_requests` /
89
92
  `drop_dead_requests_by_priority`;
90
93
  - the `'priority'` field of a request (`Resource.lock_request_priority`) in `QueueInfo.queue_info` and
91
94
  `Queues.extract_queues_info`;
92
- - lock-family options in `Client#lock`, `#lock!`, `#lock_series`, `#lock_series!` and their forwarding in
93
- `LockSeriesPoC.lock_series_poc`.
94
- - Mirror path exception: `lock_series_poc*` has no RBS.
95
+ - lock-family options in `Client#lock`, `#lock!`, `#lock_series`, `#lock_series!` and the keyword lists of
96
+ `AcquireLock.acquire_lock` and `LockSeries.lock_series`.
95
97
  - Next version for `@since`/`@version`: the version the unreleased work already uses (`git diff <base> -- lib |
96
98
  grep -oE '^\+.*@(since|version) [0-9.]+'`, the CHANGELOG `[Unreleased]` code), otherwise the next minor of
97
99
  `RedisQueuedLocks::VERSION` (`version.rb`); don't take the maximum over `lib/` (old tags can be wrong).
@@ -169,12 +171,14 @@ Still in scope (the exemption covers how specs are written, not whether the libr
169
171
  `yield_expire` (a read request that works through the own write lock holds the write lock); a reader is released
170
172
  owner-safe (`ZREM` of the own member + `DEL` of the own reader data), `unlock` releases everything; the internal
171
173
  `rw_mode` never appears in the public `lock` result.
172
- - Read lock extension (`extend_lock_ttl(..., read_write_mode: :read)`): `multi(watch: [lock_key])`, only a live read
174
+ - Read lock extension (`extend_lock_ttl(..., rw_mode: :read)`): `multi(watch: [lock_key])`, only a live read
173
175
  lock and only while no write lock exists, never revives an expired reader; `all_read_locks: true` extends every
174
176
  live reader (`ZADD XX INCR`), the registry/reader data TTLs keep the max (`PEXPIRE NX` + `GT`).
175
177
  - `fail_fast`: write fails on a write holder or live readers, read fails on a write holder (or the own live read);
176
- lock series: one mode for the whole series, `release_lock_series` releases only the locks the series obtained and
177
- only the current acquirer's (write: compare-and-delete in one transaction, read: `ZREM` + `DEL`).
178
+ lock series: one mode for the whole series, one `acquirer_position` in all its queues, all locks in one transaction,
179
+ nothing held between attempts, `release_lock_series` releases only the locks the series obtained and only the current
180
+ acquirer's (write: compare-and-delete in one transaction, read: `ZREM` + `DEL`), extended reentrant locks get their
181
+ ttl back (overview "Lock series").
178
182
  - Write-only result shapes stay unchanged; read keys/fields appear only when read data exists.
179
183
  - `read_lock_key` is `nil` for write requests; reader data is a data carrier only (the registry decides existence),
180
184
  without `acq_id`/`hst_id`; don't store derivable values; a new lock field updates every formatting copy (§2,
@@ -206,7 +210,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
206
210
  - The option: `priority:` in all four lock `Client` methods (default `config['default_lock_priority']`), an Integer
207
211
  `MIN_LOCK_PRIORITY..MAX_LOCK_PRIORITY` or a name of `config['lock_priorities']` (the client-wide registry is
208
212
  forwarded as the internal keyword `lock_priorities:`, never a public keyword); the name is resolved and the range
209
- checked in `AcquireLock` Step 0.1 and before a lock series (`LockSeriesPoC`): code below sees Integers only (RBS
213
+ checked in `AcquireLock` Step 0.1 and `LockSeries` Step 0.1: code below sees Integers only (RBS
210
214
  `RQL::lockPriority` at the boundary, `Integer` below); config validators for both keys, and a name of
211
215
  `default_lock_priority` checked against `lock_priorities` by `Config` (after a `configure` block, before a direct `[]=`).
212
216
  - Observability: every lock acquirement log line and event (classic, reentrant: the requested priority, lock series:
@@ -256,11 +260,11 @@ Still in scope (the exemption covers how specs are written, not whether the libr
256
260
  (`arguments.md#2`, `#10`) and is forwarded by name with shorthand; defaults live only in `Client`, internal
257
261
  params are required (`arguments.md#4`); positional order: subject → domain data → observability tail
258
262
  `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`, lock key
259
- params `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name,
263
+ params `lock_key, rw_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name,
260
264
  acquirer_id, host_id, ...`, TTL params `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`
261
265
  (`arguments.md#5`); every call site and the RBS updated with the signature.
262
266
  - Operation modules are independent (`acquirer.md#14`): no calls to another `Acquirer::*` module and no references to
263
- its RBS types (exceptions: the `AcquireLock` core, `*PoC` modules); shared logic only via `Resource`/`Utilities`;
267
+ its RBS types (exception: the acquisition core, `AcquireLock` and `LockSeries`); shared logic only via `Resource`/`Utilities`;
264
268
  a duplicate keeps the original name and shape with a `NOTE` that it is duplicated on purpose; every copy changes
265
269
  in the same change. Don't copy the `WithAcqTimeout` → `LockInfo`/`QueueInfo` debt.
266
270
  - New operation: `acquirer/<snake_name>.rb` with one public `class << self` entry named after the file and private
@@ -282,8 +286,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
282
286
  `Exception`: super exceptional cases only, with the reason in the comment; `logic.md#15`). Runtime dependencies:
283
287
  `redis-client` only; dev gems in `Gemfile`.
284
288
  - `AcquireLock`: new behavior as a new step or mixin with `# Step N.x` numbering and consistent `acq_process` keys
285
- (`acquirer.md#10`); `lock_series_poc.rb` is experimental: don't accept new features built on it without the
286
- user's request.
289
+ (`acquirer.md#10`).
287
290
 
288
291
  ### G. Swarm, Ractors, threads (`swarm.md`)
289
292
  - Redis work in stateless class functions shared by the main loop and the manual API (`#1`); main loops build
@@ -300,7 +303,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
300
303
  status shapes `{ running:, state: }` with `'non_initialized'` and their RBS (`#10`).
301
304
 
302
305
  ### H. Logging and instrumentation (`visitors.md`)
303
- - Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`); mutation
306
+ - Logs/events of `AcquireLock`, `LockSeries` and their mixins go through visitor methods (`#1`); mutation
304
307
  modules write one log line and one event inline inside `run_non_critical`, gated by `log_sampled`/`instr_sampled`
305
308
  (`acquirer.md#9`).
306
309
  - Observability coverage (`logic.md#16`): a new or changed logic that creates/changes/deletes keys without logs and
@@ -313,7 +316,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
313
316
  `instrumenter.notify('redis_queued_locks.<event>', { ... }) rescue nil` with shorthand Symbol keys (`#5`-`#9`).
314
317
  - Parameter order: port, sampled flag, extra gates, `lock_key`, data; `rw_mode` after `access_strategy` (logs) or
315
318
  after `lock_key` (instrumentation), user `instrument` last (`#6`). `rw_mode` is the requested mode in
316
- `AcquireLock`/`TryToLock`/`DequeueFromLockQueue`, the held mode in `YieldExpire`, the series mode in `LockSeriesPoC`.
319
+ `AcquireLock`/`TryToLock`/`DequeueFromLockQueue`, the held mode in `YieldExpire`, the series mode in `LockSeries`.
317
320
  - Sampling decided once per operation (`Logging.should_log?` / `Instrument.should_instrument?`) and passed down (`#7`).
318
321
  - Event names and payload keys are public API: no rename/removal without the user's request; no new event on
319
322
  the default write success path (`#13`); new or changed log keys/payload keys go to README `## Logging` /
@@ -333,9 +336,11 @@ Still in scope (the exemption covers how specs are written, not whether the libr
333
336
  - `# frozen_string_literal: true`; compact constant paths (`module RedisQueuedLocks::Acquirer::Keys`); namespace
334
337
  files only `require_relative` (`logic.md#1`).
335
338
  - YARD on every class, module, constant, attr and method: `@param`/`@option`/`@return` (`Format: { ... }` for hash
336
- results), blank `#`, `@api public|private`, `@since <next>`; behavior/signature changes bump `@version <next>` on
337
- the method; the module/class header `@version` changes only with the interface (signatures, added/removed methods,
338
- `extend`/`include`/`prepend`, the superclass), never for behavior changes inside the methods (`logic.md#2`).
339
+ results), blank `#`, `@api public|private`, `@since <next>`; a new `include`/`extend`/`prepend` line has
340
+ `# @since <next>` right above it (no YARD block; the version that added the mixin); behavior/signature changes bump
341
+ `@version <next>` on the method; the module/class header `@version` changes only with the interface (signatures,
342
+ added/removed methods, `extend`/`include`/`prepend`, the superclass), never for behavior changes inside the methods
343
+ (`logic.md#2`).
339
344
  - Comments: `# NOTE:` with `# - item;` lists, `# Step N.x` in algorithms, `(RW)`/`(lock index)`/`(keyspace)`
340
345
  tags, `NOTE: (pipeline layout)`/`(transaction layout)` for positional replies.
341
346
  - Redis: uppercase String commands, String numeric args (`'0'`, `'-1'`, `'-inf'`, `'+inf'`), `redis.with do |rconn|`
@@ -399,7 +404,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
399
404
 
400
405
  - Confirm it in the code (call sites, copies, RBS, specs), not by pattern match: concurrency claims name the
401
406
  interleaving (acquirer A at step X, B at step Y) and why `WATCH` doesn't catch it; layout claims count the
402
- commands and positions; mode claims check both `lock_index_enabled` values and both `read_write_mode` values;
407
+ commands and positions; mode claims check both `lock_index_enabled` values and both `rw_mode` values;
403
408
  priority claims check a positive, the default (`0`) and a negative band, a request of another band ahead (alive and
404
409
  dead) and requests in both queues.
405
410
  - Drop findings contradicted by §5, by a design `NOTE` in the code or by the overview. Mark the rest
@@ -410,7 +415,7 @@ Still in scope (the exemption covers how specs are written, not whether the libr
410
415
  - `git grep -n LOCK_INDEX_KEY -- lib`: every use reachable only with `lock_index_enabled` (an `if`/`return unless`
411
416
  guard or a lock index lookup branch), except `ReindexLocks` and `Resource`;
412
417
  - cross-module calls: `git grep -n "RedisQueuedLocks::Acquirer::" -- lib/redis_queued_locks/acquirer` (only the
413
- known `WithAcqTimeout` debt and `LockSeriesPoC` are allowed);
418
+ known `WithAcqTimeout` debt and the `LockSeries` uses of the `AcquireLock` mixins are allowed);
414
419
  - `git diff --name-only <base>` against the `sig/` mirror (§2 exceptions); `git diff --check <base>`.
415
420
  - Checks (read-only): a review on request of local changes runs `bundle exec rake rubocop` and
416
421
  `bundle exec rbs collection install && bundle exec rake steep:check` (grep the output for `ERROR`/`FATAL`, restore a
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.7
1
+ 4.0.9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.23.1] = 2026-11-01
4
+ ### Added
5
+ - `timed: true` of `#lock`/`#lock_series`: separate errors of the failures, both are `TimedLockTimeoutError` subclasses: `LockTtlTimeBudgetIsSpentError` (the block was not invoked: no time left) and `TimedBlockExceedsLockTtlError` (the block exceeded the lock TTL);
6
+ ### Fixed
7
+ - `timed: true` of `#lock`/`#lock_series`: in some cases the time budget of the block could have been a little bit longer than the lock lifetime (2ms);
8
+
9
+ ## [1.23.0] - 2026-11-01
10
+ ### Changed
11
+ - [**Breaking**] `#lock_series`/`#lock_series!` are no longer a proof of concept. The final feature design:
12
+ - all locks are obtained atomically with the same;
13
+ TTL, nothing is held while the series waits, any order of the lock names is deadlock-free (see README "lock_series"):
14
+ - each lock gets exactly `ttl:` (it was `position * (ttl + retry_delay + retry_jitter)`);
15
+ - `timeout:`, `retry_count:` and `queue_ttl:` limit the whole series (they limited each lock of the series);
16
+ - a series without a block returns `{ ok: true, result: { lock_series:, rql_lock_series:, acq_id:, hst_id:, ts:, ttl: } }` (it returned `nil`);
17
+ - a failed series has empty `acquired_locks:` and all locks in `missing_locks:`, `failed_on_lock:` can be `nil`;
18
+ - the events and logs of `#lock` are not raised for the locks of the series: they are replaced by the `lock_series_*` events of each lock
19
+ (`log_lock_try` logs stay per lock);
20
+ - the messages of the retry limit and timeout errors name the series and the lock that blocks it;
21
+ - an empty series raises `RedisQueuedLocks::ArgumentError`, duplicated lock names are one lock;
22
+ - upgrade all processes that share these locks: older versions obtain a series lock by lock;
23
+ - [**Breaking**] `read_write_mode:` option of `#lock`/`#lock!`/`#lock_series`/`#lock_series!`/`#extend_lock_ttl` is renamed to `rw_mode:`;
24
+ ### Added
25
+ - `#lock_series` instrumentation events and logs: `lock_series_obtaining_failed` of the series and the events of each lock of the series:
26
+ `lock_series_intermediate_lock_obtained`, `lock_series_lock_obtained`, `lock_series_lock_obtaining_failed`;
27
+ - `reentrant_lock_keys` key of the `lock_series_obtained` and `lock_series_hold_and_release` events and logs;
28
+ - the logs of the series acquirement: `start_try_to_lock_series_cycle`, `dead_score_reached__reset_series_position`,
29
+ `dequeue_from_lock_series_queues`, `decrease_lock_series`;
30
+ - RBS signatures of `#lock_series`/`#lock_series!`;
31
+
3
32
  ## [1.22.0] - 2026-10-27
4
33
  ### Added
5
34
  - *Brand New Feature*: **Lock Request Prioritization**: the `priority:` option of `#lock`/`#lock!`/`#lock_series`/`#lock_series!`
@@ -168,7 +197,7 @@
168
197
  ## [1.16.0] - 2026-01-27
169
198
  ### Added
170
199
  - Brand new Feature **"Lock series of locks"**
171
- - (*Proof of Concpet*-based implementation at the moment)
200
+ - (*Proof of Concpet*-based implementation at the moment)
172
201
  - lock series of lock simultainously (and release them all at the finish of your the invocation of block of code under the obtained locks);
173
202
  - exmaple: `client.lock_series("a", "b", "c")` (or in exceptional-based way `client.lock_series!("a", "b", "c")`;
174
203
  ### Changed
data/CLAUDE.md CHANGED
@@ -13,10 +13,11 @@ Full details (config keys, key layout, events, errors, stack, CI): `.claude/proj
13
13
  - `acquirer/*.rb`: one stateless module per operation (`class << self`), returns `{ ok:, result: }`; `!` client methods raise.
14
14
  - `acquirer/acquire_lock.rb`: composed via `extend` mixins in `acquire_lock/` (try_to_lock, with_acq_timeout, delay_execution, dequeue_from_lock_queue, yield_expire) + `LogVisitor`/`InstrVisitor` event hooks.
15
15
  - Locking: `ZADD NX` into the request queue, then WATCH/MULTI; Lua only for write TTL extension/decrease.
16
- - Lock request priorities (`priority:` -100..100 or a priority name of `config['lock_priorities']`, resolved to its Integer in `AcquireLock` Step 0.1 and before a lock series; `config['default_lock_priority']`): queue score = enqueue time - priority * 2**32 (`Resource.calc_initial_acquirer_position`), strict priority + FIFO inside a priority; dead requests are pruned per priority band (never one `-inf..now-ttl` range): the own band on each queueing, the bands of the dead requests that block the attempt (the write queue head, the earlier opposite-mode request) in one more round trip (`TryToLock#drop_dead_requests_ahead`, band and liveness decoded from the score); the attempt cost never depends on the priority range; every lock acquirement log/event carries the Integer `priority`. Component map: overview "Lock request prioritization in the architecture"; details: overview "Lock request priorities".
17
- - Read/Write locks (`read_write_mode: :write|:read`): write lock `rql:lock:<name>` + queue `rql:lock_queue:<name>`; readers registry `rql:lock_readers:<name>` (ZSET acq_id => Redis-time expiry), read queue `rql:lock_read_queue:<name>`, per-reader data `rql:lock_reader:<name>:<acq_id>`. Readers WATCH the write key only, writers WATCH the write key + registry; queues give ordering only. Component map: overview "Read/Write locks in the architecture"; details: overview "Read/Write locks".
16
+ - Lock request priorities (`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; `config['default_lock_priority']`): queue score = enqueue time - priority * 2**32 (`Resource.calc_initial_acquirer_position`), strict priority + FIFO inside a priority; dead requests are pruned per priority band (never one `-inf..now-ttl` range): the own band on each queueing, the bands of the dead requests that block the attempt (the write queue head, the earlier opposite-mode request) in one more round trip (`TryToLock#drop_dead_requests_ahead`, band and liveness decoded from the score); the attempt cost never depends on the priority range; every lock acquirement log/event carries the Integer `priority`. Component map: overview "Lock request prioritization in the architecture"; details: overview "Lock request priorities".
17
+ - Read/Write locks (`rw_mode: :write|:read`): write lock `rql:lock:<name>` + queue `rql:lock_queue:<name>`; readers registry `rql:lock_readers:<name>` (ZSET acq_id => Redis-time expiry), read queue `rql:lock_read_queue:<name>`, per-reader data `rql:lock_reader:<name>:<acq_id>`. Readers WATCH the write key only, writers WATCH the write key + registry; queues give ordering only. Component map: overview "Read/Write locks in the architecture"; details: overview "Read/Write locks".
18
18
  - Lock lookup modes: `config['lock_index_enabled']` (client-wide, `false` by default) is read by `Client`/`Swarm` and passed to every lock-iterating module (swarm, `locks`, `queues`, `keys`, `clear_*`) and to the hot path; each module keeps the keyspace lookup (one `SCAN` pass, the pre-index code) and the lock index lookup (one `ZSCAN` pass) side by side with the shared processing in one copy.
19
19
  - Lock index `rql:index:locks` (enabled mode only; ZSET lock name => upper bound of the lifetime of all lock keys, client ms + ttl + `lock_index_grace_period`): every key-creating command is followed by `ZADD GT` (queueing pipeline, obtaining `MULTI`), every extension by `ZADD XX INCR`, names are removed only with all their keys (`unlock`, `clear_locks`), queues have TTL, expired names are pruned on queueing; lock index commands are conditional tails of their pipelines/transactions (none in the disabled mode); `reindex_locks` (one keyspace `SCAN`, both modes) adopts keys without entries after enabling. Details: overview "Lock index".
20
+ - Lock series (`acquirer/lock_series.rb`, `lock_series/`): all locks obtained by one WATCH/MULTI attempt with one `ttl` (all or nothing, nothing held while waiting), one queue position in all queues of the series (deadlock-free for any order of names), limits of the whole series; details: overview "Lock series".
20
21
  - Strategies: `access_strategy` `:queued`|`:random`; `conflict_strategy` `:wait_for_lock`|`:work_through`|`:extendable_work_through`|`:dead_locking`.
21
22
  - `resource.rb`: all key names and acquirer/host IDs. `config.rb`: `setting`/`validate` DSL, read as `config['a.b']`.
22
23
  - `swarm/`: zombie-lock cleanup. `Supervisor` thread revives `ProbeHosts` (`SwarmElement::Threaded`) and `FlushZombies` (`SwarmElement::Isolated`, Ractor talking via `Ractor::Port`s); each element runs a main-loop thread with its own Redis connection.
@@ -27,10 +28,10 @@ Full details (config keys, key layout, events, errors, stack, CI): `.claude/proj
27
28
  Detailed, path-scoped rules in `.claude/rules/`: `logic.md` (lib, general), `acquirer.md` (acquirer modules, Redis access), `visitors.md` (log/instrumentation visitors), `arguments.md` (long keyword lists), `swarm.md` (swarm supervisor/elements, Ractor/Thread rules), `tests.md` (spec), `type-checking.md` (sig/Steep), `changelog.md` (CHANGELOG entries), `git.md` (commit messages and commit flow, always loaded).
28
29
  - Long explicit keyword/parameter lists are intentional (minimal allocations, signature-as-DSL): never introduce parameter objects, option structs or `**opts`; add new options as explicit keywords to every `Client` variant and forward them by name.
29
30
  - Update the matching `sig/*.rbs` for every `lib/` change; keep Steep green.
30
- - Keep `# frozen_string_literal: true` and YARD `@api`/`@since`/`@version` tags (class/module `@version` changes only with the interface: signatures, added/removed methods, `extend`/`include`/`prepend`, the superclass; `logic.md#2`).
31
+ - Keep `# frozen_string_literal: true` and YARD `@api`/`@since`/`@version` tags (`# @since` right above each `include`/`extend`/`prepend` line; class/module `@version` changes only with the interface: signatures, added/removed methods, `extend`/`include`/`prepend`, the superclass; `logic.md#2`).
31
32
  - `{ ok:, result: }` is for the public API only (`Client`, `Acquirer::*`, `Swarm` facade actions). Internal swarm element APIs (commands, replies, helpers) use bare scalars/primitives (see `swarm.md`).
32
33
  - New operation: `Acquirer::*` module, thin `Client` method (+ `!` variant), RBS, spec.
33
- - Operation modules behind `Client` methods (all `Acquirer::*` except the `AcquireLock` acquisition core and PoC modules such as `LockSeriesPoC`, which may reuse other modules) are independent: they never call each other or reference each other's RBS types, share logic only via `Resource`/`Utilities`, and duplicate similar logic on purpose (independence over DRY; e.g. `Locks` duplicates the `LockInfo` formatting). Details: `acquirer.md`.
34
+ - Operation modules behind `Client` methods (all `Acquirer::*` except the lock acquisition core: `AcquireLock` and `LockSeries`, which reuses the `AcquireLock` mixins) are independent: they never call each other or reference each other's RBS types, share logic only via `Resource`/`Utilities`, and duplicate similar logic on purpose (independence over DRY; e.g. `Locks` duplicates the `LockInfo` formatting). Details: `acquirer.md`.
34
35
  - New option: `setting` (+ `validate`) in `config.rb` + its `Config#[]` overload line in `sig/redis_queued_locks/config.rbs` (typed per key).
35
36
  - Dev gems go in `Gemfile`, not the gemspec.
36
37
  - Commits (`git.md`): subject only `[<scope>] <summary>` (several features: 1-2 words each, comma-separated), no body; when a step is complete (requirement fully implemented, specs/rubocop/steep green), and only then, run the `rql-code-review` skill on the uncommitted changes, then ask before committing, with the proposed subject, a recap of the changes and the review verdict; an explicit "commit" request needs no extra question and no review (a commit authorized in advance for a task still gets the review); never on `master`, never push.