redis_queued_locks 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +216 -25
  3. data/.claude/rules/acquirer.md +6 -5
  4. data/.claude/rules/arguments.md +2 -2
  5. data/.claude/rules/changelog.md +27 -0
  6. data/.claude/rules/git.md +1 -1
  7. data/.claude/rules/logic.md +10 -12
  8. data/.claude/rules/swarm.md +3 -4
  9. data/.claude/rules/tests.md +6 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +5 -5
  12. data/.claude/skills/rql-code-review/SKILL.md +112 -35
  13. data/CHANGELOG.md +98 -244
  14. data/CLAUDE.md +9 -8
  15. data/README.md +275 -85
  16. data/Rakefile +11 -0
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +28 -12
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +38 -16
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +430 -63
  21. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +72 -22
  22. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +240 -45
  23. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +3 -1
  24. data/lib/redis_queued_locks/acquirer/keys.rb +6 -2
  25. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +10 -5
  26. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -9
  27. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +52 -23
  28. data/lib/redis_queued_locks/acquirer/locks.rb +6 -2
  29. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -7
  30. data/lib/redis_queued_locks/acquirer/queues.rb +17 -6
  31. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +6 -2
  32. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +6 -2
  33. data/lib/redis_queued_locks/client.rb +66 -24
  34. data/lib/redis_queued_locks/config.rb +93 -1
  35. data/lib/redis_queued_locks/resource.rb +124 -9
  36. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +6 -5
  37. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  38. data/lib/redis_queued_locks/version.rb +2 -2
  39. data/rbs_collection.lock.yaml +4 -0
  40. data/rbs_collection.yaml +2 -1
  41. data/sig/manifest.yml +1 -0
  42. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  43. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  44. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +12 -6
  45. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +21 -14
  46. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  47. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  48. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  49. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +2 -2
  50. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +5 -3
  51. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +14 -1
  52. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +2 -2
  53. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  54. data/sig/redis_queued_locks/client.rbs +10 -7
  55. data/sig/redis_queued_locks/config.rbs +54 -1
  56. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -1
  57. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  58. data/sig/redis_queued_locks/logging/sampler.rbs +3 -1
  59. data/sig/redis_queued_locks/resource.rbs +22 -12
  60. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  61. data/sig/redis_queued_locks/utilities.rbs +2 -1
  62. data/sig/redis_queued_locks.rbs +7 -0
  63. metadata +4 -3
  64. /data/sig/redis_queued_locks/{acquier.rbs → acquirer.rbs} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7aeb9d0b5ea57783c70a285b753616abf277955029184236ed7746f127148e4
4
- data.tar.gz: 9a5771fd5f0df1edad7eb6270a211576fbcd33f4049a5fb040a65e334f92a32f
3
+ metadata.gz: 87e74dea52b88c1ac5cbe1b8f9117ae7b7a3e832e3aa6202980fbf6044c9368e
4
+ data.tar.gz: a69ae8d1b952516fa303da9c9b61a8a21de818ffca3274ad856b930e961eed87
5
5
  SHA512:
6
- metadata.gz: 2c2328b3187ce087a694d3169aaaf9362ffb48557e626ab32933ffa4b70e015121edababddaeb3d2e24df7bfcb2c77980c54f1ed315c7a08d2d41e23e631351e
7
- data.tar.gz: 4a380d09471c48bc00ef7454e5755420738c5f1233c576d10d115c6ca973ecb505ce77b037d805f0299964da1eae31f02dd5999e2e1945cfef6aa5508e3b7116
6
+ metadata.gz: 5bff06b31f13302658871a0a1c2cc30409953c062f94d9dea7be1fab1091a442f5eac2e190a95bbf7eff8ba57069632e745e8453fdb4462efe59a52114b83acd
7
+ data.tar.gz: 267ba8c9174f21bff0ed11ed559fc27603df4239acbac7893cbb986d2aef4e0cdfbe22fb6d0bb07967ddfac100f24958251f529adcaf5cd157246a19c0601f3b
@@ -1,13 +1,13 @@
1
1
  # redis_queued_locks: Project Overview
2
2
 
3
3
  Ruby gem providing **distributed locks on Redis with a prioritized lock-acquisition queue**.
4
- Each lock has its own request queue; requests are processed FIFO (`:queued`) or first-come
5
- (`:random`). Requests have TTLs (with requeue), so queues never get stuck. Includes reentrancy
4
+ Each lock has its own request queue; requests are processed FIFO (`:queued`, ordered by the lock request
5
+ priority first: `priority` -100..100) or first-come (`:random`). Requests have TTLs (with requeue), so queues never get stuck. Includes reentrancy
6
6
  (conflict) strategies, timed locks, retries/fail-fast, logging, instrumentation with sampling,
7
7
  and a background "swarm" that detects dead hosts and flushes their locks.
8
8
 
9
9
  - Repo: https://github.com/0exp/redis_queued_locks (MIT); main branch `master`
10
- - Runtime dependency: `redis-client ~> 0.20` (locked 0.30.1), nothing else
10
+ - Runtime dependency: `redis-client ~> 0.20` (locked 0.31.0), nothing else
11
11
  - Ruby `>= 4.0` (`.ruby-version` = 4.0.7)
12
12
 
13
13
  ---
@@ -25,6 +25,8 @@ Client (client.rb) public API facade
25
25
  │
26
26
  ▼
27
27
  RedisClient (redis-client gem) WATCH/MULTI, Lua (EVAL), ZSET, HASH, SCAN
28
+
29
+ Resource (resource.rb) keys, acquirer/host ids, queue score layout (priority bands)
28
30
  ```
29
31
 
30
32
  `Client.new(redis_client) { |config| ... }`:
@@ -42,6 +44,107 @@ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatt
42
44
  `QueueInfo` request formatting). Known debt: `AcquireLock::WithAcqTimeout` calls
43
45
  `LockInfo`/`QueueInfo` for detailed timeout errors.
44
46
 
47
+ ### Read/Write locks in the architecture
48
+
49
+ `read_write_mode: :write` (default) is the exclusive classic lock (its keys and data are unchanged); `:read` is a shared
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
+ for the write lock only. Mutual exclusion comes only from WATCH/MULTI on the lock state (no Lua in the RW path); the
52
+ request queues only order the requests (FIFO between modes by `(score, acq_id)`).
53
+
54
+ Keys of one lock name (all of them match `KEY_PATTERN`, classified by `Resource.lock_key_family`):
55
+
56
+ ```
57
+ rql:lock:<name> HASH write lock: owner + data write lock state
58
+ rql:lock_readers:<name> ZSET readers registry: acq_id => expiry (Redis ms) read lock state (the source of truth)
59
+ rql:lock_reader:<name>:<acq_id> HASH reader data (the write lock format without ids: a data carrier only)
60
+ rql:lock_queue:<name> ZSET write lock requests ordering only
61
+ rql:lock_read_queue:<name> ZSET read lock requests (the same scores) ordering only
62
+ ```
63
+
64
+ The path of the mode through the components:
65
+
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:,
69
+ │ all_read_locks:)
70
+ ▼
71
+ AcquireLock Step 0.1 (RW) validates the mode; the keys are computed once (read_lock_key: readers only)
72
+ ▼
73
+ TryToLock (each attempt and pre-check) WATCH: writers [lock_key, lock_readers_key], readers [lock_key] only
74
+ │ (readers never abort each other); one state pipeline: HGET acq_id,
75
+ │ TIME, own reader score, longest reader (writers); the conditions:
76
+ │ write = no write lock + no live reader (score > Redis now),
77
+ │ read = no write lock; the order: write = head of the write queue +
78
+ │ no earlier read request, read = no earlier write request; the
79
+ │ obtaining MULTI: write HSET + PEXPIRE, read ZADD expiry + registry
80
+ │ PEXPIRE NX + GT + reader data; same-acquirer conflicts by
81
+ │ conflict_strategy (held mode `sp_conflict_rw_mode`; read->write
82
+ │ upgrade never works through)
83
+ ▼
84
+ YieldExpire releases the held mode (`held_rw_mode`): write EXPIRE 0, read ZREM +
85
+ DEL of the own reader data (owner-safe)
86
+ ReleaseLock (unlock) write lock + readers + both queues in one MULTI, then one DEL of the
87
+ released readers' data (only when readers existed)
88
+ ReleaseReadLock / ExtendLockTTL one read lock (own or `acquirer_id:`) / own or all live read locks
89
+ Info, iteration, cleanup, swarm the read family everywhere: lock_info readers, queue_info read_queue,
90
+ locks/queues/keys, clear_*, clear_dead_requests, zombies
91
+ LogVisitor / InstrVisitor (+ series, try-lock) rw_mode in every lock log/event (requested; YieldExpire: held; series)
92
+ ```
93
+
94
+ - Invariants for any lock-touching code: cover the read family next to `rql:lock:*`/`rql:lock_queue:*`; writers WATCH
95
+ the registry, readers never; reader liveness only by the Redis-time score (`score > now`), never `ZCARD`/`EXISTS`;
96
+ registry and reader data TTLs keep the max with `PEXPIRE NX` + `PEXPIRE GT`; write-only result shapes stay unchanged
97
+ (read keys/fields only when read data exists); the lock index covers the read keys (enabled mode).
98
+ - Details (reentrancy matrix, fail_fast, results, release semantics, design decisions): "Read/Write locks" below; the
99
+ public description: README "Read/Write Locks".
100
+
101
+ ### Lock request prioritization in the architecture
102
+
103
+ The lock request priority orders the waiting requests of a lock (`:queued`): strict priority between priorities, FIFO
104
+ inside a priority, in both read/write modes. It is carried by the queue score only (no new keys, fields or commands per
105
+ request) and never takes part in mutual exclusion (WATCH/MULTI on the lock state decides it, as before). The path of the
106
+ priority through the components:
107
+
108
+ ```
109
+ Client#lock / #lock! / #lock_series / #lock_series! priority: (Integer or a name), default config['default_lock_priority'];
110
+ │ lock_priorities: config['lock_priorities'] (internal keyword)
111
+ ▼
112
+ Config (config.rb) lock_priorities (name => Integer) + default_lock_priority validators,
113
+ │ the default name checked against the names (configure / []=)
114
+ │
115
+ ▼
116
+ AcquireLock Step 0.1 / LockSeriesPoC name -> Integer, range -100..100 (ArgumentError); Integer only below
117
+ │
118
+ ├──▶ Resource the single source of the score layout:
119
+ │ calc_initial_acquirer_position(priority) score = enqueue time - priority * 2**32 (one band per priority)
120
+ │ dead_score_reached?(position, priority, ttl) requeue check (decodes the enqueue time)
121
+ │ lock_priority_band_floor / _dead_score the bounds of the dead requests of a band
122
+ │ lock_request_priority(score) the priority decoded from a score
123
+ │
124
+ ├──▶ TryToLock (each attempt and pre-check) queueing: the own band pruned in both queues + ZADD NX with the
125
+ │ shifted score (queue_lock_request, no extra round trip); Step 2.2:
126
+ │ dead blocking requests of other bands dropped with their bands
127
+ │ (drop_dead_requests_ahead, dead_request_priority: one round trip
128
+ │ per removal, none while nothing dead blocks); ordering: queue heads
129
+ │ and the cross-mode (score, acq_id) comparison on shifted scores
130
+ │
131
+ ├──▶ ClearDeadRequests per-band cleanup of the bands found in the queues (<= 2 round trips/batch;
132
+ │ by_priority: a walk over the first request of each band, the most
133
+ │ bands of a batch queue + 1 round trips, no full queue read)
134
+ ├──▶ QueueInfo / Queues 'priority' of each request decoded from its score
135
+ └──▶ LogVisitor / InstrVisitor (+ series, try-lock) priority in every lock acquirement log/event; try-lock logs decode
136
+ the priorities of the requests ahead (first/ahead/dead)
137
+ ```
138
+
139
+ - Unchanged by prioritization: the lock keys and their data, queue members (`acq_id`), dequeue (`ZREM`), `unlock` /
140
+ `clear_locks` (full queue clears), the swarm (hosts and zombies come from acquirer ids), the lock index, the obtaining
141
+ transactions.
142
+ - Invariants for any code that touches lock queues: never prune a queue with one `-inf..now-ttl` range (prune bands with
143
+ the `Resource` bounds); never enumerate the priority range per attempt or per queue (decode the bands from the data);
144
+ read priorities only through `Resource.lock_request_priority`; the attempt cost must not depend on the priority range.
145
+ - Details (encoding, liveness, cleanup, limitations, mixed versions): "Lock request priorities" below; the public
146
+ description: README "Lock Request Prioritization".
147
+
45
148
  ### Client → implementation map
46
149
 
47
150
  | Client method | Implementation |
@@ -56,7 +159,7 @@ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatt
56
159
  | `locks`, `locks_info`, `queues`, `queues_info`, `keys` | `acquirer/locks.rb`, `queues.rb`, `keys.rb` (keyspace SCAN or lock index: `lock_index_enabled`) |
57
160
  | `clear_locks` | `acquirer/release_all_locks.rb` |
58
161
  | `clear_locks_of`, `clear_current_locks` | `acquirer/release_locks_of.rb` |
59
- | `clear_dead_requests` | `acquirer/clear_dead_requests.rb` |
162
+ | `clear_dead_requests` | `acquirer/clear_dead_requests.rb` (`by_priority: true` reads the first request of each priority band instead of all requests) |
60
163
  | `reindex_locks` | `acquirer/reindex_locks.rb` (adds keys without lock index entries to the lock index: one SCAN pass; works in both modes) |
61
164
  | `current_acquirer_id`, `current_host_id`, `possible_host_ids` | `resource.rb` |
62
165
  | `swarmize!`, `deswarmize!`, `swarm_status`, `swarm_info`, `probe_hosts`, `flush_zombies`, `zombie_locks`, `zombie_acquirers`, `zombie_hosts`, `zombies_info` | `swarm.rb` + `swarm/*` |
@@ -67,7 +170,7 @@ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatt
67
170
 
68
171
  | Mixin | Role |
69
172
  |---|---|
70
- | `TryToLock` (`try_to_lock.rb`) | One attempt: `multi(watch:)` (write: lock key + readers registry, read: lock key only), one pipelined read of the lock state (`HGET acq_id`, `TIME`, own reader score, longest reader: write requests only), the lock request queueing in one pipelined round trip (`queue_lock_request`: `ZADD NX` into the queue of the requested mode (timestamp score), prune both queues, both queue heads, then (the enabled lock index only) the lock index commands: request queue `PEXPIRE NX`+`GT` (queue ttl), `ZADD GT` of the lock name into the lock index (queue ttl), `ZREMRANGEBYSCORE` of expired lock names), take the lock if allowed (see Read/Write locks; with the enabled lock index the obtaining `MULTI` ends with the lock index `ZADD GT` (lock ttl), the `:extendable_work_through` extension `MULTI` with `ZADD XX INCR`). Eager queueing (`:wait_for_lock`/`:dead_locking` without `fail_fast`) sends the queueing commands in the lock state round trip: `lock {}` = 4 round trips (`WATCH`, state + queueing, `MULTI/EXEC`, release), a failed attempt = 3. Pre-check (`precheck_lock`, retries and `fail_fast` attempts): the lock state (+ the queueing for retries) without `WATCH` in one round trip; an attempt that can not obtain the lock fails there (same results and log events: `lock_obstacle`/`log_lock_obstacle`), otherwise the attempt continues with `WATCH`/`MULTI` (a failed retry = 1 round trip, a successful one = 4; `fail_fast` on an obtained lock = 1); same-process conflicts are never decided by the pre-check. Handles reentrant conflicts (incl. read/write ones); write TTL extension via inline Lua (`PTTL` + `PEXPIRE`). |
173
+ | `TryToLock` (`try_to_lock.rb`) | One attempt: `multi(watch:)` (write: lock key + readers registry, read: lock key only), one pipelined read of the lock state (`HGET acq_id`, `TIME`, own reader score, longest reader: write requests only), the lock request queueing in one pipelined round trip (`queue_lock_request`: prune the priority band of the request in both queues (first: the old dead position of a requeued request is dropped before `ZADD NX` adds its new position), `ZADD NX` into the queue of the requested mode (timestamp score shifted by the priority band, see "Lock request priorities"), both queue heads with scores, then (the enabled lock index only) the lock index commands: request queue `PEXPIRE NX`+`GT` (queue ttl), `ZADD GT` of the lock name into the lock index (queue ttl), `ZREMRANGEBYSCORE` of expired lock names), drop the dead requests of other priority bands that block the request (the write queue head and the earlier opposite-mode request) with their bands in one more round trip that re-reads the heads (`drop_dead_requests_ahead`, Step 2.2, also in the pre-check: up to `MAX_DEAD_REQUEST_REMOVALS` = 5 such round trips per attempt, none while no dead request blocks), take the lock if allowed (see Read/Write locks; with the enabled lock index the obtaining `MULTI` ends with the lock index `ZADD GT` (lock ttl), the `:extendable_work_through` extension `MULTI` with `ZADD XX INCR`). Eager queueing (`:wait_for_lock`/`:dead_locking` without `fail_fast`) sends the queueing commands in the lock state round trip: `lock {}` = 4 round trips (`WATCH`, state + queueing, `MULTI/EXEC`, release), a failed attempt = 3. Pre-check (`precheck_lock`, retries and `fail_fast` attempts): the lock state (+ the queueing for retries) without `WATCH` in one round trip; an attempt that can not obtain the lock fails there (same results and log events in the same order: `lock_obstacle`/`log_lock_obstacle`, the dropped dead requests after the queueing events: `log_dropped_requests`), otherwise the attempt continues with `WATCH`/`MULTI` (a failed retry = 1 round trip, a successful one = 4; `fail_fast` on an obtained lock = 1); same-process conflicts are never decided by the pre-check. Handles reentrant conflicts (incl. read/write ones); write TTL extension via inline Lua (`PTTL` + `PEXPIRE`). |
71
174
  | `WithAcqTimeout` | Global acquisition timeout (`timeout`). |
72
175
  | `DelayExecution` | Retry delay + jitter between attempts. |
73
176
  | `DequeueFromLockQueue` | Removes the acquirer from the queue of the requested mode on timeout/failure. |
@@ -78,7 +181,8 @@ duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatt
78
181
 
79
182
  `ttl`, `queue_ttl`, `lock_index_grace_period` (used with the enabled lock index only; the mode itself, `config['lock_index_enabled']`,
80
183
  is client-wide: `Client` passes it to the acquirers, it is not a `lock` option), `timeout`, `timed`, `retry_count`, `retry_delay`, `retry_jitter`,
81
- `raise_errors`, `fail_fast`, `conflict_strategy`, `access_strategy`, `read_write_mode`
184
+ `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`
82
186
  (`:write` (default, exclusive) | `:read` (shared)), `identity`, `meta`, `logger`, `log_lock_try`,
83
187
  `instrumenter`, `instrument`, log/instr sampling options, `log_sample_this`, `instr_sample_this`.
84
188
 
@@ -98,8 +202,9 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
98
202
  ignored (`score > now`) and pruned on reader acquisition; registry TTL = max reader TTL (`PEXPIRE NX`
99
203
  + `PEXPIRE GT` in the same MULTI).
100
204
  - Ordering (`:queued`): FIFO between modes by `(score, acq_id)`: read waits for earlier write requests;
101
- write must be the head of the write queue and waits for earlier read requests. Every attempt prunes
102
- dead requests in both queues. `:random` ignores queues (writers can starve). Ordering is best effort
205
+ write must be the head of the write queue and waits for earlier read requests. Scores are shifted by the priority
206
+ bands (see "Lock request priorities"), so "earlier" means "ahead": a higher priority is ahead in both modes. Every attempt prunes
207
+ dead requests of its priority band in both queues (and the dead requests of other bands that block it). `:random` requests ignore the order of the queues (writers can starve), but they are queued like any request (`:queued` requests wait for them). Ordering is best effort
103
208
  (client clocks, `unlock` clears queues); it never affects mutual exclusion.
104
209
  - Read lock data: `rql:lock_reader:<name>:<acq_id>` HASH with the write-lock format without `acq_id`/`hst_id`
105
210
  (`ts`, `ini_ttl`, `meta`, `spc_*` reentrant counters; acquirer and host are derived from the acquirer id and are
@@ -121,7 +226,8 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
121
226
  `milliseconds` must be a positive Integer (a non-positive one would decrease/delete the lock);
122
227
  `meta` keys must not be reserved lock data keys, String or Symbol (Symbol keys are written as
123
228
  Strings: `Symbol#name`; the same check in `AcquireLock` and `LockSeriesPoC`); the other per-call
124
- numeric options are validated with the ranges of their config keys (`logic.md#7`): `queue_ttl` > 0,
229
+ numeric options are validated with the ranges of their config keys (`logic.md#7`): `priority` Integer from
230
+ `Resource::MIN_LOCK_PRIORITY` (-100) to `MAX_LOCK_PRIORITY` (100), `queue_ttl` > 0,
125
231
  `timeout`/`retry_count` nil or >= 0, `retry_delay`/`retry_jitter`/`lock_index_grace_period` >= 0
126
232
  (`AcquireLock`; `LockSeriesPoC` checks the retry delay/jitter before the lock ttl calculation),
127
233
  scan/batch sizes, `dead_ttl`, reindexing `queue_ttl` > 0 (operation modules), `zombie_ttl` and the
@@ -162,14 +268,95 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
162
268
  - RBS: `Client` types `read_write_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
163
269
  before read locks are used.
164
270
 
271
+ ### Lock request priorities (`priority`)
272
+
273
+ - Option `priority` of `lock`/`lock!`/`lock_series`/`lock_series!` (one value per series): Integer from
274
+ `Resource::MIN_LOCK_PRIORITY` (-100, the lowest) to `Resource::MAX_LOCK_PRIORITY` (100, the highest) or a priority
275
+ 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
277
+ config validators.
278
+ - Priority names: `config['lock_priorities']` (Hash Symbol => Integer in the range, default frozen
279
+ `{ 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
281
+ keyword: a client-wide registry); `AcquireLock` Step 0.1 resolves a Symbol `priority` to its Integer
282
+ (`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,
284
+ logs, events, `queue_info`) sees Integers only; a name in `default_lock_priority` must be a key of `lock_priorities`
285
+ (validators see one value: `Config#prevent_default_priority__unknown_name`, `ConfigValidationError`): `Config#configure`
286
+ checks the pair once after the block (the keys can be assigned in any order; on a failure both keys keep their values from
287
+ before the block, nested blocks: after the outer one), `Config#[]=` checks a direct assignment of either key (out of a
288
+ block) before it is applied.
289
+ - Semantics (`:queued`): strict priority, FIFO inside a priority, in both modes (the read and write queues use the same scores);
290
+ orders waiting requests only (no preemption); a `:random` request does not wait for its turn (it ignores the order and the
291
+ priorities of other requests), but it is queued at the position of its priority (`ZADD NX` like any request): `:queued`
292
+ requests wait for it; the obtained lock is never taken away.
293
+ - Encoding (`Resource`, the single source of the score layout): position (score) = enqueue time (client `Time.now.to_f`) -
294
+ `priority * Resource::LOCK_PRIORITY_BAND` (2**32 s: bands never overlap until 2106, the priority is decoded by
295
+ `Resource.lock_request_priority(score) = -floor(score / 2**32)`); priority 0 keeps the pre-priority score (old data and old
296
+ clients' default requests are unchanged). `calc_initial_acquirer_position(priority)` encodes, `dead_score_reached?(position,
297
+ priority, queue_ttl)` decodes the enqueue time for the requeue check.
298
+ - Liveness (score = order + liveness): dead requests are pruned per band, `ZREMRANGEBYSCORE <queue>
299
+ <Resource.lock_priority_band_floor(p)> <Resource.lock_priority_band_dead_score(now - queue_ttl, p)>` (the floor is `-p * 2**32`
300
+ for every band, inclusive: positions never reach band edges). Head-driven cleanup (the attempt cost does not depend on the
301
+ priority range): `queue_lock_request` prunes the band of the request in both queues before `ZADD NX` and the heads (5 queueing commands, 9
302
+ with the lock index, as before priorities) and reads both heads with scores; `drop_dead_requests_ahead` (Step 2.2 of the attempt
303
+ and of the pre-check, before the obstacle decision, `:queued` only) decodes the band and the enqueue time of the blocking requests
304
+ (the write queue head for writers, the earlier opposite-mode request for both modes) from their scores (`dead_request_priority`)
305
+ and prunes the bands of the dead ones (by the barrier of the current request: its `queue_ttl`) in both queues and re-reads both
306
+ heads in one round trip (one or two bands; a live write queue head stops the removal; `try_lock.dead_request_removed` log per
307
+ dropped request), up to `MAX_DEAD_REQUEST_REMOVALS` (5) round trips per attempt (more dead blocking requests: the next
308
+ attempts); no round trip while the blocking requests are alive or there are none. Any band is decoded (also out of the range:
309
+ newer versions). Dead requests of lower priorities never block: they are dropped by the attempts of their band (or lower),
310
+ `clear_dead_requests`, the queue TTL (enabled lock index) or the swarm. `ClearDeadRequests.drop_dead_requests` (both lookups):
311
+ one round trip reads the batch queues (`ZRANGE 0 -1 WITHSCORES`), one more prunes only the bands whose first request is dead
312
+ (the requests of a band are adjacent and ordered by time), none without dead requests; `processed_queues` of the lock index
313
+ lookup = non-empty queues. `clear_dead_requests(by_priority: true)` (`false` by default; `drop_dead_requests_by_priority`)
314
+ never reads a whole queue: a walk over the bands, each round trip reads the first request of the next band of each walked
315
+ queue (`ZRANGE <queue> <bound> +inf BYSCORE LIMIT 0 1 WITHSCORES`, from `-inf`, the next bound is
316
+ `lock_priority_band_floor(p - 1)`) and prunes the dead bands found by the previous one (the same removal commands, after the
317
+ reads); a queue is walked till a read finds nothing: the most bands of a batch queue + 1 round trips per batch, with or
318
+ without dead requests, the transfer depends on the bands, not on the requests (for long queues with few priorities);
319
+ `-inf` requests are dropped by their position (the walk goes on with `(-inf`), the walk of a queue stops at `+inf`; a
320
+ position out of the float precision of the band edges (manual writes) is passed with an exclusive bound (`(<position>`:
321
+ the next band edge rounds to the position itself); `processed_queues` of the lock index lookup = the queues with a first
322
+ request (non-empty).
323
+ - Design decision (the user's): the queue score is the only order and liveness of a lock request. A separate liveness registry
324
+ (the deadlines of the requests in another key next to the queues) was considered and rejected: a new key family for the lock
325
+ index, reindexing, release and zombie paths, more commands per attempt; the dead requests of other bands are dropped by the
326
+ head-driven removal, the attempts of their band and `clear_dead_requests` (see Limitations).
327
+ - Infinite positions (`-inf`/`+inf`: manual writes of the queues, RQL never writes them; out of all bands): the decoding never
328
+ raises (`Resource.lock_request_priority`: `-inf` => `MAX_LOCK_PRIORITY`, `+inf` => `MIN_LOCK_PRIORITY`: info results and logs);
329
+ a request at `-inf` is always dead (like before priorities: no enqueue time) and is dropped by its position
330
+ (`ZREMRANGEBYSCORE <queue> -inf -inf`, both queues) when it blocks an attempt (`drop_dead_requests_ahead`, the same round trip)
331
+ and by `clear_dead_requests` (the first requests of the queue); a request at `+inf` is never dead and never ahead of an RQL
332
+ request (kept like before priorities).
333
+ - Unchanged by priorities: keys, members (`acq_id`), `ZADD NX`, `ZRANGE 0 0`, the cross-mode `(score, acq_id)` comparison, dequeue,
334
+ `unlock`/`clear_locks` (`-inf..+inf`), swarm (hosts from acquirer ids), lock index.
335
+ - Info/observability: `queue_info`/`queues_info` requests have `'priority'` (decoded); every lock acquirement log and event
336
+ 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
338
+ another request carry its decoded priority (`first_priority_in_queue`, `ahead_priority`, `dead_priority`; decoded for logs
339
+ only: gated by `log_sampled && log_lock_try`, `TryToLock#logged_request_priority`; `first_priority_in_queue => nil` without the first request).
340
+ - Limitations: the priority range is a library constant that bounds the score shift (float precision of the shifted positions:
341
+ ~61 µs at ±100, ~0.5 ms at ±1000; requests of the same priority within this interval are ordered by acquirer ids); mixed
342
+ versions: older clients prune high-priority requests as dead ones (`-inf..now-ttl`) and never prune low-priority ones (always
343
+ behind): the order degrades, no stuck queues, no safety impact (upgrade all processes before using positive priorities; `0` and negative priorities are compatible: older clients keep them behind their own requests and never prune them, the upgraded processes drop the dead ones); strict priority
344
+ can starve lower priorities (bounded by their timeout/retry limits); the handoff is polled (`retry_delay`); dead requests are
345
+ pruned with the pruner's `queue_ttl` (existing behavior: a request that waits longer than the shorter `queue_ttl` of the
346
+ requests of its band or of the requests it blocks is dropped by their attempts and comes back at its old position on its own
347
+ ones (its own `queue_ttl` keeps the position): it loses its place and the advantage of its priority to the requests behind it
348
+ with a shorter `queue_ttl`; README recommends one `queue_ttl` for the lock requests of a lock); dead requests of lower
349
+ priorities can stay in the queues until an attempt of their band (they never block; visible in `queue_info`; an accepted
350
+ trade-off of the head-driven cleanup: README recommends a periodic `clear_dead_requests` without the lock index and the swarm).
351
+
165
352
  ### Redis data layout (`resource.rb`)
166
353
 
167
354
  | Key | Type | Purpose |
168
355
  |---|---|---|
169
356
  | `rql:lock:<name>` | HASH | lock owner + metadata (e.g. `l_spc_ts`, `l_spc_ext_ts` for reentrant cases) |
170
- | `rql:lock_queue:<name>` | ZSET | acquirer queue scored by enqueue time |
357
+ | `rql:lock_queue:<name>` | ZSET | acquirer queue scored by enqueue time shifted by the priority band (`ts - priority * 2**32`) |
171
358
  | `rql:lock_readers:<name>` | ZSET | read locks: acquirer id => expiration (Redis `TIME`, ms) |
172
- | `rql:lock_read_queue:<name>` | ZSET | read lock requests (write requests use `rql:lock_queue:<name>`) |
359
+ | `rql:lock_read_queue:<name>` | ZSET | read lock requests (write requests use `rql:lock_queue:<name>`), same scores |
173
360
  | `rql:lock_reader:<name>:<acq_id>` | HASH | read lock data (same fields as `rql:lock:<name>` incl. meta, without `acq_id`/`hst_id`; data carrier only) |
174
361
  | `rql:swarm:hsts` | HASH | swarm host heartbeats |
175
362
  | `rql:index:locks` | ZSET | lock index (the enabled lock index only): lock name => upper bound of the lifetime of all its keys (client time ms + ttl + grace), see "Lock index" |
@@ -178,7 +365,7 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
178
365
 
179
366
  - Lock lookup modes of all lock-iterating operations (`locks`/`locks_info`, `queues`/`queues_info`, `keys`, `clear_locks`, `clear_locks_of`/`clear_current_locks`, `clear_dead_requests`, `flush_zombies` and the zombie flusher, `zombie_locks`/`zombie_acquirers`/`zombies_info`), switched by `config['lock_index_enabled']` (client-wide, `false` by default; `Client`/`Swarm` read it and pass it to the modules, the zombie flusher Ractor gets a copy at spawn). Each module keeps both lookups side by side (shared processing in one copy):
180
367
  - keyspace (disabled lock index, default): one SCAN pass over `KEY_PATTERN` (or `ALL_LOCK_QUEUES_PATTERN` for queues), 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`, inline SCAN branches in `keys`/`clear_locks`/`clear_locks_of`); no lock index commands at all;
181
- - lock index (enabled): one `ZSCAN` pass over `rql:index:locks` (lock names; names with an expired score are skipped) in batches of `scan_size`/`batch_size` lock names collected across ZSCAN pages, the lock keys are derived from the names (`prepare_lock_key`, `prepare_lock_readers`, both queues, reader data via registry members), per-key commands in pipelined batches; the full keyspace is never scanned. Each module has its own copy of the iteration (`each_lock_name_batch`, duplicated on purpose: module independence; `index_locks`, `index_queues`, `flush_lock_index_zombies`, `each_lock_index_zombie_lock`). Info queries check key existence (`EXISTS`); `clear_locks` deletes the keys of a batch with one `DEL` in a `MULTI` with the `ZREM` of the batch names; `clear_locks`/`clear_dead_requests`/`flush_zombies` prune expired names first.
368
+ - lock index (enabled): one `ZSCAN` pass over `rql:index:locks` (lock names; names with an expired score are skipped: `Resource.lock_index_score_expired?`, ZSCAN scores are Strings, the `+inf` score of a key without TTL (`"inf"`) never expires) in batches of `scan_size`/`batch_size` lock names collected across ZSCAN pages, the lock keys are derived from the names (`prepare_lock_key`, `prepare_lock_readers`, both queues, reader data via registry members), per-key commands in pipelined batches; the full keyspace is never scanned. Each module has its own copy of the iteration (`each_lock_name_batch`, duplicated on purpose: module independence; `index_locks`, `index_queues`, `flush_lock_index_zombies`, `each_lock_index_zombie_lock`). Info queries check key existence (`EXISTS`); `clear_locks` deletes the keys of a batch with one `DEL` in a `MULTI` with the `ZREM` of the batch names; `clear_locks`/`clear_dead_requests`/`flush_zombies` prune expired names first.
182
369
  - `reindex_locks`: one SCAN pass over `KEY_PATTERN` (keys classified by `Resource.lock_key_family`) that adds keys without index entries to the lock index; it works regardless of the mode (it is needed after enabling the lock index).
183
370
  - Acquirer ID: `rql:acq:<pid>/<thread>/<fiber>/<ractor>/<identity>`
184
371
  - Host ID: `rql:hst:<pid>/<thread>/<ractor>/<identity>` (no fiber)
@@ -208,7 +395,7 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
208
395
  - the score is never decreased (`GT`; ttl decreases and releases do not touch the index);
209
396
  - lock request queues have TTL (`PEXPIRE NX` + `GT` = queue ttl on each queueing): the lifetime of every indexed key is bounded;
210
397
  - pruning: `ZREMRANGEBYSCORE <index> -inf <now - 1>` in every queueing pipeline (amortized, no background jobs) and in `flush_zombies`,
211
- `clear_locks`, `clear_dead_requests`; info operations skip names with an expired score;
398
+ `clear_locks`, `clear_dead_requests`; lock-iterating operations skip names with an expired score (`+inf`: never);
212
399
  - removal: only together with the deletion of all indexed keys of the name in one `MULTI` (`unlock`: `ZREM` in the release transaction;
213
400
  `clear_locks`: `DEL` of the batch keys + `ZREM` of the batch names); never remove a name while some of its keys can stay (`clear_locks_of`,
214
401
  `yield_expire` releases, `flush_zombies` keep the names).
@@ -232,8 +419,14 @@ acquirement error), each mutation operation with one log line + one event of the
232
419
  Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
233
420
  `lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
234
421
  lock series events (`lock_series_obtained`, `lock_series_hold_and_release`) carry the mode of the series;
422
+ all lock acquirement events (the lock events above, `lock_obtaining_failed` and the lock series events) carry `priority`
423
+ 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`)
425
+ log `priority => N` after `rw_mode`, `try_lock.acq_added_to_queue` too; the try-lock logs that name another request log
426
+ its priority (`first_priority_in_queue` after `first_acq_id_in_queue`, `ahead_priority` after `ahead_acq_id`);
235
427
  lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock log events: `exit__write_request_ahead`,
236
- `exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`.
428
+ `exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`. Priorities try-lock
429
+ log event: `dead_request_removed` (`dead_acq_id`, `dead_priority`: a dead blocking request of another band was dropped).
237
430
 
238
431
  ### Errors (`errors.rb`)
239
432
 
@@ -274,7 +467,7 @@ the connection (drops the WATCH state, reconnected on the next use) and re-raise
274
467
  |---|---|
275
468
  | Retry/timeouts | `retry_count` (3), `retry_delay` (200 ms), `retry_jitter` (25 ms), `try_to_lock_timeout` (10 s), `is_timed_by_default` (false) |
276
469
  | TTLs | `default_lock_ttl` (5000 ms), `default_queue_ttl` (15 s), `dead_request_ttl` (1 day, ms) |
277
- | Strategies | `default_conflict_strategy` (`:wait_for_lock`), `default_access_strategy` (`:queued`) |
470
+ | Strategies | `default_conflict_strategy` (`:wait_for_lock`), `default_access_strategy` (`:queued`), `default_lock_priority` (0, from -100 to 100 or a priority name), `lock_priorities` (priority names => Integer priorities) |
278
471
  | Batching | `lock_release_batch_size` (100), `clear_locks_of__lock_scan_size` / `__queue_scan_size` (300), `key_extraction_batch_size` (500) |
279
472
  | Lock index | `lock_index_enabled` (false; client-wide mode, config only), `lock_index_grace_period` (60_000 ms; also a `lock`/`lock!`/`lock_series`/`lock_series!`/`reindex_locks` option) |
280
473
  | Identity | `uniq_identifier` (lambda → `Resource.calc_uniq_identity`) |
@@ -299,14 +492,12 @@ lib/redis_queued_locks/
299
492
  resource.rb keys and identities
300
493
  data.rb, errors.rb, utilities.rb, utilities/lock.rb, debugger/, version.rb
301
494
  sig/ RBS mirror of lib/ + sig/vendor stubs (redis_client, active_support, semantic_logger)
302
- spec/ redis_queued_locks_spec.rb (~5.6k lines, integration: shared examples run with both lock lookup modes), spec_helper.rb, setup_simplecov.rb
495
+ spec/ redis_queued_locks_spec.rb (~5.6k lines, integration: shared examples run with both lock lookup modes), redis_queued_locks/config_spec.rb (Redis-free: `Config#[]` RBS table == `setting` registry), spec_helper.rb, setup_simplecov.rb, support/parallel_runner.rb (the `rake rspec:parallel` runner, not a spec)
303
496
  .github/workflows/ tests, lint, typecheck-static, typecheck-runtime
304
497
  bin/console, bin/setup dev scripts
305
498
  Rakefile, Steepfile, rbs_collection.yaml
306
499
  ```
307
500
 
308
- Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acquirer.rbs`).
309
-
310
501
  ---
311
502
 
312
503
  ## 5. Technology stack
@@ -329,16 +520,16 @@ Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acqu
329
520
  - rake, bundler, pry, pry-doc, reline, activesupport 8.1
330
521
 
331
522
  ### Rake tasks
332
- `rspec` (default), `rubocop` (runs `rubocop:ruby` and `rubocop:rbs`, fails if either fails), `steep:check`, plus bundler gem tasks (`build`, `release`, ...).
523
+ `rspec` (default), `rspec:parallel` (`spec/support/parallel_runner.rb`, the `Rakefile` only declares the task: the specs in `WORKERS` processes, ~1 min instead of ~6: each worker gets its own pair of Redis databases via `RQL_SPEC_DB_OFFSET` = worker index * 2, prepended to `RedisClient::Config::Common#initialize` in `spec_helper.rb` (the library, swarm and Ractor clients included; after the URL is resolved: the database of a `url:` gets the offset too); examples listed by `rspec --dry-run` and distributed by the durations of the previous runs (`tmp/rspec_parallel/durations.json`, the longest first to the least loaded worker); per-worker logs/JSON in `tmp/rspec_parallel/` (the worker files of the previous run are deleted before the workers start: a worker that dies before RSpec writes its JSON output is reported as broken, never with stale results); coverage: each worker stores only its SimpleCov result in `coverage/parallel/worker-<n>` (`rspec:worker:<n>` command names, no report), the task collates one report into `coverage/` after all workers (`SimpleCov.collate`) and drops the collated resultset (later `rspec` runs don't merge it); the environment is passed to the workers: the RBS runtime checks and `RSPEC_RETRY_RETRY_COUNT` work as with `rspec`), `rubocop` (runs `rubocop:ruby` and `rubocop:rbs`, fails if either fails), `steep:check`, plus bundler gem tasks (`build`, `release`, ...).
333
524
 
334
525
  ### CI (GitHub Actions, ubuntu-latest, Ruby 4.0, on every push)
335
526
 
336
527
  | Workflow | Command |
337
528
  |---|---|
338
- | tests | `bundle exec rake rspec` (with a Redis service) |
339
- | lint | `bundle exec rake rubocop` |
529
+ | tests | `bundle exec rake rspec:parallel` (with a Redis service; 4 workers on the 4-core runner; `tmp/rspec_parallel/durations.json` restored/saved with `actions/cache` (a new key per run, `restore-keys` prefix) to balance the workers; the worker logs are uploaded as an artifact on failure) |
530
+ | lint | `bundle exec rake rubocop` (precompiled native gems: no `BUNDLE_FORCE_RUBY_PLATFORM`, a rubydex 0.4.1 source build on the runner flags constants as `@deprecated` and breaks `Lint/DeprecatedReference`) |
340
531
  | typecheck-static | `bundle exec rbs collection install && bundle exec rake steep:check -j 10` |
341
- | typecheck-runtime | `RBS_TEST_RAISE=true RUBYOPT='-rrbs/test/setup' RBS_TEST_OPT='-I sig' RBS_TEST_TARGET='RedisQueuedLocks::*' bundle exec rspec --failure-exit-code=0` (never fails the build) |
532
+ | typecheck-runtime | `RBS_TEST_RAISE=true RUBYOPT='-rbundler/setup -rrbs/test/setup' RBS_TEST_OPT='-I sig' RBS_TEST_TARGET='RedisQueuedLocks::*' RBS_TEST_SKIP='::RedisQueuedLocks::Swarm::FlushZombies,::RedisQueuedLocks::Swarm::SwarmElement::Isolated,::RedisQueuedLocks::Swarm::RedisClientBuilder' bundle exec rspec` (blocking; serial on purpose: the RBS hooks keep a worker on a CPU core, and parallel workers on the runner (4 vCPU = 2 physical cores) starved the timing-sensitive examples: Redis connect timeouts with 4 workers, swarm flush waits with 2; locally `rake rspec:parallel` works for the RBS checks; Ractor-executed methods are skipped: `RBS_TEST_SKIP` + `%a{rbs:test:skip}` in `resource.rbs`/`utilities.rbs`) |
342
533
 
343
534
  ---
344
535
 
@@ -348,8 +539,8 @@ Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acqu
348
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`).
349
540
  - Every `lib/` change gets a matching `sig/` RBS update; use `# steep:ignore` only where Steep can't infer types.
350
541
  - New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
351
- - New config option: `setting` (+ `validate`) in `config.rb`, read via `config['key']`, document defaults.
542
+ - 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.
352
543
  - Development gems go in `Gemfile` (`Gemspec/DevelopmentDependencies: Gemfile`), not the gemspec.
353
- - This overview and `.claude/rules/*.md` are updated in the same change as the code (keys, options, results, events, algorithm steps, limitations); README and CHANGELOG `[Unreleased]` for user-visible behavior (log keys and instrumentation payloads: README `## Logging` / `### Instrumentation Events`).
354
- - Temporary debt: rspec-retry, disabled coverage minimum, runtime type-check job that can't fail.
544
+ - This overview and `.claude/rules/*.md` are updated in the same change as the code (keys, options, results, events, algorithm steps, limitations); README and CHANGELOG `[Unreleased]` for user-visible behavior (log keys and instrumentation payloads: README `## Logging` / `### Instrumentation Events`); CHANGELOG entries are compact, subject-level changes only (`.claude/rules/changelog.md`).
545
+ - Temporary debt: rspec-retry, disabled coverage minimum.
355
546
  - Specs (`spec/**/*_spec.rb`) are not ideal and will be totally reworked (header `NOTE` of `spec/redis_queued_locks_spec.rb`): until then any coding principle may be broken there; code review checks only that the library behavior is covered and that changed expectations are correct (`.claude/skills/rql-code-review`).
@@ -42,7 +42,7 @@ paths:
42
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`.
43
43
  - 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
44
  - 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
- - 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, 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`).
45
+ - 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`).
46
46
  - Never one round trip per key in either mode.
47
47
  - Lua: frozen heredoc constant (`<<~LUA_SCRIPT.strip.tr("\n", '').freeze`) + `call('EVAL', SCRIPT, 1, key, arg)`.
48
48
  - Release = `EXPIRE key 0` / `ZREMRANGEBYSCORE queue -inf +inf`; a read lock is released with `ZREM <readers> <acq_id>` + `DEL <reader data>` (owner-safe, other readers untouched); Redis TTL sentinels handled explicitly (`PTTL` `-2` = missing, `-1` = no expiry → `Float::INFINITY`).
@@ -55,14 +55,15 @@ paths:
55
55
  - 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
56
  - 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
57
  - 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.
58
59
  - 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`).
59
60
  - **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']`.
60
61
  - **AcquireLock**:
61
62
  - Main module `require_relative`s its parts, then `extend`s the mixins (`TryToLock`, `DelayExecution`, `YieldExpire`, `WithAcqTimeout`, `DequeueFromLockQueue`); mixins are plain modules with instance methods (`def try_to_lock(...)`), visitors are `class << self` modules called explicitly (see `visitors.md`).
62
63
  - The algorithm is a numbered step script (`# Step 0`, `# Step 2.1`, `# Step 2.2.a`) driven by a mutable `acq_process` hash (`:should_try`, `:tries`, `:acquired`, `:result`, `:lock_info`, timings).
63
64
  - Failure modes are Symbols (`:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`, `:conflict_lock_upgrade`, ...); exceptions are raised only when `raise_errors` is true, with a message naming the lock key / acquirer id.
64
- - `TryToLock` pre-check (retries: `acquire_lock` passes `retry_attempt = tries > 0`, and `fail_fast` attempts): `precheck_lock` reads the lock state with the same pipeline (`pipeline_lock_state`; with the queueing for retries, without it for `fail_fast`) without `WATCH`; it returns the failure result (`lock_obstacle` / `:fail_fast_no_try`) and logs the same events as the failed attempt, or `nil` to continue the attempt (lock obtainable or a same-process conflict). The pre-check never obtains the lock: mutual exclusion stays with `WATCH`/`MULTI`.
65
- - `TryToLock` attempt (both modes): `multi(watch:)` (write: `[lock_key, lock_readers_key]`, read: `[lock_key]`) → one pipelined state read (`HGET acq_id`, `TIME`, own reader score, longest reader: write requests only, the write-only tail of the pipeline) → same-process conflict detection (`sp_conflict_rw_mode` = held `:write`/`:read`) → the lock request queueing in one pipelined round trip (`queue_lock_request`: `ZADD NX` into the queue of the requested mode → prune dead requests in **both** queues → queue heads of both queues → (the enabled lock index only) request queue `PEXPIRE NX`+`GT` → lock index `ZADD GT` → lock index pruning (5 replies, 9 with the lock index; new commands go to the end, replies after the queue heads are not read); eager queueing (`:wait_for_lock`/`:dead_locking` without `fail_fast`) sends these commands in the state pipeline right after its head, so `lock {}` = 4 round trips; the queueing log events are still emitted in the classic branch in the same order) (`:queued`: write must be first in its queue; any mode waits for an earlier `(score, acq_id)` request of the opposite mode) → lock state (write held → `:lock_is_still_acquired`; live readers for write / own live read for read → `:read_lock_is_still_acquired`) → MULTI (write: `ZREM` + `HSET` + `PEXPIRE` (+ lock index `ZADD GT`); read: `ZREM` + prune registry + `ZADD expiry` + registry `PEXPIRE NX`+`GT` + reader data `DEL`+`HSET`+`PEXPIRE` (+ lock index `ZADD GT`)). With the enabled lock index the `:extendable_work_through` extension `MULTI` ends with the lock index `ZADD XX INCR` (write: 4 replies, 5 with the lock index: `result.size == (lock_index_enabled ? 5 : 4)`).
65
+ - `TryToLock` pre-check (retries: `acquire_lock` passes `retry_attempt = tries > 0`, and `fail_fast` attempts): `precheck_lock` reads the lock state with the same pipeline (`pipeline_lock_state`; with the queueing for retries, without it for `fail_fast`) without `WATCH`; it returns the failure result (`lock_obstacle` / `:fail_fast_no_try`) and logs the same events as the failed attempt in the same order (the dead requests dropped by Step 2.2 after the queueing events: `drop_dead_requests_ahead` returns them, `log_dropped_requests` logs them in both paths; a pre-check that does not fail logs them before the attempt), or `nil` to continue the attempt (lock obtainable or a same-process conflict). The pre-check never obtains the lock: mutual exclusion stays with `WATCH`/`MULTI`.
66
+ - `TryToLock` attempt (both modes): `multi(watch:)` (write: `[lock_key, lock_readers_key]`, read: `[lock_key]`) → one pipelined state read (`HGET acq_id`, `TIME`, own reader score, longest reader: write requests only, the write-only tail of the pipeline) → same-process conflict detection (`sp_conflict_rw_mode` = held `:write`/`:read`) → the lock request queueing in one pipelined round trip (`queue_lock_request`: prune dead requests of the request's priority band in **both** queues (before `ZADD NX`: the old dead position of a requeued request is dropped before its new position is added; the reverse order keeps the old position with `NX` and drops it: the request is missing in the queue for the attempt) → `ZADD NX` into the queue of the requested mode (the position is shifted by the priority band) → queue heads of both queues (`WITHSCORES`) → (the enabled lock index only) request queue `PEXPIRE NX`+`GT` → lock index `ZADD GT` → lock index pruning (5 replies, 9 with the lock index; new commands go to the end, replies after the queue heads are not read); eager queueing (`:wait_for_lock`/`:dead_locking` without `fail_fast`) sends these commands in the state pipeline right after its head, so `lock {}` = 4 round trips; the queueing log events are still emitted in the classic branch in the same order) → Step 2.2 (priorities, `:queued`, also in the pre-check, before the obstacle decision): the dead blocking requests of other bands (the write queue head for writers and the earlier opposite-mode request, decoded from their scores by `dead_request_priority`) are dropped with their bands in both queues and both heads are re-read in one more round trip (`drop_dead_requests_ahead`: one or two bands per round trip, the heads are read from the end of the pipeline, up to `MAX_DEAD_REQUEST_REMOVALS` round trips, `try_lock.dead_request_removed` log per dropped request, written by the caller (`log_dropped_requests`) after the queueing events; no round trip while the blocking requests are alive; a live write queue head stops the removal) (`:queued`: write must be first in its queue; any mode waits for an earlier `(score, acq_id)` request of the opposite mode) → lock state (write held → `:lock_is_still_acquired`; live readers for write / own live read for read → `:read_lock_is_still_acquired`) → MULTI (write: `ZREM` + `HSET` + `PEXPIRE` (+ lock index `ZADD GT`); read: `ZREM` + prune registry + `ZADD expiry` + registry `PEXPIRE NX`+`GT` + reader data `DEL`+`HSET`+`PEXPIRE` (+ lock index `ZADD GT`)). With the enabled lock index the `:extendable_work_through` extension `MULTI` ends with the lock index `ZADD XX INCR` (write: 4 replies, 5 with the lock index: `result.size == (lock_index_enabled ? 5 : 4)`).
66
67
  - 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.
67
68
  - 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).
68
69
  - Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
@@ -83,14 +84,14 @@ paths:
83
84
  12. Read/write checklist for any change in this directory:
84
85
  - lock state checks: write requires no write lock and no **live** readers; read requires no write lock; same-acquirer reader/writer cases go through `conflict_strategy` (read→write upgrade never "works through");
85
86
  - WATCH: writers watch the write key + readers registry, readers watch the write key only (never the registry: readers must not abort each other); don't modify a watched key outside MULTI after WATCH (it aborts your own EXEC);
86
- - every attempt prunes dead requests in both queues; dequeue/cleanup removes the acquirer from the queue of its mode;
87
+ - every attempt prunes dead requests of its priority band in both queues and the bands of the dead requests that block it (head-driven: never every band of the range); dequeue/cleanup removes the acquirer from the queue of its mode;
87
88
  - every release/cleanup/info/zombie path covers write lock, readers registry, reader data and both queues;
88
89
  - registry/reader-data TTLs: `PEXPIRE NX` + `PEXPIRE GT` pair to keep max TTL (`GT` alone never sets TTL on a key without one);
89
90
  - keep write-only result shapes unchanged; add read keys/fields only when read data exists;
90
91
  - 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.
91
92
  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.
92
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.
93
- 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`): 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.
94
+ 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.
94
95
  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).
95
96
 
96
97
  ## Recommendations (proposed, not yet project policy)
@@ -50,12 +50,12 @@ This is a deliberate framework-level decision, **not** something to refactor int
50
50
  2. New public option: add it as an explicit keyword to **every** affected `Client` method (`x` and `x!`, e.g. `lock` and `lock!`), with the default taken from `config['...']` (add the `setting` + `validate` first) or a literal for per-call-only flags (`raise_errors: false`, `meta: nil`).
51
51
  3. Forward it explicitly by name through every layer using shorthand (`new_option:`); never forward via `**kwargs`, `...`, or `method(__method__).parameters` tricks.
52
52
  4. Keep defaults only at the public boundary (`Client`); internal functions take required keywords/positionals with no defaults so a missed forward fails loudly.
53
- 5. Internal positional lists: append new params in the established order (subject → domain data → observability tail `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`) and update every call site and the RBS signature in the same change. Lock key params keep this order: `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, ...` (`lock_name` is the lock index member; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the batch sizes / `dead_ttl`, before the observability tail; swarm functions: last).
53
+ 5. Internal positional lists: append new params in the established order (subject → domain data → observability tail `logger, instrumenter, instrument, log_sampling_*, log_sample_this, instr_sampling_*, instr_sample_this`) and update every call site and the RBS signature in the same change. Lock key params keep this order: `lock_key, read_write_mode, lock_key_queue, read_lock_key_queue, lock_readers_key, read_lock_key, lock_name, acquirer_id, host_id, acquirer_position, priority, ...` (`lock_name` is the lock index member; `acquirer_position` is the priority-shifted queue score, `priority` its band; queueing helpers take `acquirer_position, priority` right after the queue keys; `yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); TTL params: `ttl, queue_ttl, lock_index_enabled, lock_index_grace_period`; compute keys once in `acquire_lock` and pass them down (`read_lock_key` is `nil` for write lock requests). Queueing helpers (`queue_lock_request`, `pipeline_lock_state`) take the lock index params last: `lock_name, queue_ttl, lock_index_enabled, lock_index_grace_period`. Lock-iterating modules take `lock_index_enabled` after their domain data (`scan_size:`/`with_info:` keywords + `lock_index_enabled:`; positional lists: right after the domain data (batch sizes, `dead_ttl`, `by_priority`), before the observability tail; swarm functions: last).
54
54
  6. Document each new option with its own `@option`/`@param` YARD line (type, meaning, default source) and add it to the RBS method signature as a named param/keyword.
55
55
  7. Keep hot-path code allocation-light: no per-call wrapper objects, no hash merging, no splats to build args, no `tap`/closures purely for argument plumbing; prefer passing existing locals.
56
56
  8. Silence length cops locally (`# rubocop:disable Metrics/MethodLength`) rather than shortening signatures; `Metrics/ParameterLists` is disabled project-wide on purpose.
57
57
  9. Client-wide modes are not per-call options: `config['lock_index_enabled']` is read inside the `Client` (and `Swarm`) method bodies and forwarded by name to the acquirers (`lock_index_enabled: config['lock_index_enabled']` / positional `config['lock_index_enabled']`), never added as a keyword of `lock`/`locks`/`clear_locks`/...: one call in another mode would break the lock index invariant (all lock acquirements of a client must maintain the index when its operations read it).
58
- 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`).
58
+ 10. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`, `priority`). Client-wide registries are read from `config` inside these methods and forwarded as internal keywords, never public keywords (`lock_priorities: config['lock_priorities']`, the priority names: `lock` and `lock_series` pass it, `lock!`/`lock_series!` delegate to them).
59
59
 
60
60
  ## Recommendations (proposed, not yet project policy)
61
61
  1. For new internal functions with many same-typed params (e.g. several Integers/Booleans in a row), consider required keywords instead of positionals to prevent misordering; keep the list flat and explicit.
@@ -0,0 +1,27 @@
1
+ ---
2
+ paths:
3
+ - "CHANGELOG.md"
4
+ ---
5
+
6
+ # CHANGELOG rules (`CHANGELOG.md`)
7
+
8
+ ## Observed conventions
9
+ - Keep a Changelog layout: `## [Unreleased]` on top, released versions as `## [X.Y.Z] - YYYY-MM-DD`; sections `### Added`, `### Changed`, `### Fixed` (`### Removed` once); entries are `- ` bullets, mostly ending with `;`.
10
+ - Markers: `[**Breaking**]` for incompatible changes, `*Brand New Feature*` for a fundamentally new feature (rule 8; older spellings: `Brand new Feature`, `Brand New Extremely Major Feature` of the swarm mode).
11
+ - Code identifiers in backticks: methods `#lock`/`#lock!`, options `priority:`, config keys `config['key']`, errors `RedisQueuedLocks::ArgumentError`, events `redis_queued_locks.<event>`.
12
+ - Anti-pattern: the 1.18.0-1.21.0 and lock priorities sections grew into implementation reports (algorithm steps, Redis commands, round trips, score formulas, full lists of affected events, multi-line sub-bullets) before they were compacted by this rule (`git show 28adc76:CHANGELOG.md` keeps the full text).
13
+
14
+ ## Claude rules
15
+ 1. Compact entries: one bullet per subject-level change, 1-2 lines (3 at most). A subject-level change is what a gem user gets, loses or has to do: a new method/option/config key/event/log key/error, a changed default/result shape/behavior, a fixed wrong behavior, a breaking change or an upgrade requirement.
16
+ 2. No deep details: no algorithm steps, Redis commands, round trips/pipelines/transactions, internal modules/methods/constants, key layouts, score formulas, costs, implementation edge cases, reasoning or alternatives. They live in README (usage), YARD, `.claude/project-overview.md` and the rules; an entry may point to its README section instead.
17
+ 3. Internal-only changes (refactoring, specs, CI, internal RBS, `.claude` docs, perf work without a visible effect) get no entry; a visible effect of such work (a typing change for Steep users, a notable speedup) gets one short line.
18
+ 4. Sub-bullets only to list separate user-visible items of one feature (its new options, config keys, events), one short line each; one nesting level at most.
19
+ 5. Collapse lists that share one change: "lock acquirement logs and instrumentation events carry the Integer `priority`", not the 20 event names (the exact lists live in README `## Logging` / `### Instrumentation Events`).
20
+ 6. A fix: the wrong behavior and the scope in one sentence (`` `#zombies_info`: the default `lock_scan_size` was read from the wrong config key ``), not the mechanism of the bug.
21
+ 7. Breaking changes and upgrade requirements: the `[**Breaking**]` marker or one sentence of what to do (`upgrade all processes before using positive priorities`), without the reasoning.
22
+ 8. A fundamentally new feature gets its own entry marked `*Brand New Feature*`: a new capability of the library as a whole (Swarm Mode, Lock series, Read/Write locks, Lock Index, Lock Request Prioritization) or globally new support (a Redis-compatible server such as Garnet, a new runtime or platform); not an extension of the existing functionality (a new option, method variant, result field, event or log of an existing feature). Format: `- *Brand New Feature*: **<Feature Name>**: <one-sentence summary> (see README "<section>"):` first in `### Added`, the feature's methods/options/config keys/events and upgrade requirements as its sub-bullets (rule 4).
23
+ 9. One feature = one entry: a later step of the same feature updates its entry instead of adding a bullet per step. Released sections are not rewritten unless asked.
24
+
25
+ ## Example
26
+ - Bad: `` - every lock request drops dead lock requests of its own priority in both lock queues (in the same queueing pipeline: no new commands and round trips); dead lock requests of other priorities that block the lock request ... are dropped ... in one more round trip of the same attempt (up to 5 such round trips per attempt); ``
27
+ - Good: `` - *Brand New Feature*: **Lock Request Prioritization**: `priority:` of `#lock`/`#lock_series` (`-100..100` or a priority name, `0` by default) orders waiting lock requests (strict priority, FIFO inside a priority), see README "Lock Request Prioritization"; ``
data/.claude/rules/git.md CHANGED
@@ -28,7 +28,7 @@ Applies to every commit Claude makes in this repository (no `paths:` scope: alwa
28
28
 
29
29
  **When to commit**
30
30
  7. Never commit on your own. When a step with changes reaches completion, ask the user for permission to commit and wait for the answer:
31
- - completion = the feature/functionality required by the prompt is fully implemented (code, RBS, specs, README/CHANGELOG, `.claude/project-overview.md` and rules kept in sync) and checks are green: the touched examples and the full suite (`bundle exec rake rspec`), `bundle exec rake rubocop`, `bundle exec rake steep:check` (no `ERROR`/`FATAL` lines); changes without code (docs, `.claude` rules) need no test run;
31
+ - completion = the feature/functionality required by the prompt is fully implemented (code, RBS, specs, README/CHANGELOG, `.claude/project-overview.md` and rules kept in sync) and checks are green: the touched examples and the full suite (`bundle exec rake rspec:parallel` by default, the serial `bundle exec rake rspec` when the change needs a check in one process: `tests.md#1`), `bundle exec rake rubocop`, `bundle exec rake steep:check` (no `ERROR`/`FATAL` lines); changes without code (docs, `.claude` rules) need no test run;
32
32
  - pre-commit review: once the step is complete, and only then, run the `rql-code-review` skill on the step's changes (scope `uncommitted`) before the question; never during the implementation or for incomplete/red states. Blocker/Major findings in the step's own changes mean the step is not complete yet: fix them, re-run the affected checks and review the fixes; findings that need the user's decision are reported instead of the question (rule 9);
33
33
  - the question contains: the proposed commit subject (following the format rules above), a short recap of what was done in the changes (features/fixes, touched areas, check results), the review verdict with the remaining Minor/Nit findings and the list of files that will be committed;
34
34
  - commit only after an explicit "yes" (or with the subject the user corrects); on "no" leave the changes uncommitted (staging also only on request).