redis_queued_locks 1.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/project-overview.md +249 -36
  3. data/.claude/rules/acquirer.md +16 -14
  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 +34 -13
  8. data/.claude/rules/swarm.md +4 -5
  9. data/.claude/rules/tests.md +9 -6
  10. data/.claude/rules/type-checking.md +13 -14
  11. data/.claude/rules/visitors.md +13 -14
  12. data/.claude/skills/rql-code-review/SKILL.md +134 -43
  13. data/CHANGELOG.md +105 -125
  14. data/CLAUDE.md +11 -9
  15. data/README.md +501 -136
  16. data/Rakefile +11 -0
  17. data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +1 -0
  18. data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +68 -10
  19. data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +82 -15
  20. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +92 -14
  21. data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +441 -63
  22. data/lib/redis_queued_locks/acquirer/acquire_lock/with_acq_timeout.rb +2 -2
  23. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +1 -0
  24. data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +5 -5
  25. data/lib/redis_queued_locks/acquirer/acquire_lock.rb +176 -39
  26. data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +322 -49
  27. data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +125 -13
  28. data/lib/redis_queued_locks/acquirer/is_locked.rb +0 -1
  29. data/lib/redis_queued_locks/acquirer/is_queued.rb +0 -1
  30. data/lib/redis_queued_locks/acquirer/keys.rb +16 -3
  31. data/lib/redis_queued_locks/acquirer/lock_info.rb +31 -39
  32. data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +11 -5
  33. data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +20 -10
  34. data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +105 -36
  35. data/lib/redis_queued_locks/acquirer/locks.rb +32 -11
  36. data/lib/redis_queued_locks/acquirer/queue_info.rb +30 -8
  37. data/lib/redis_queued_locks/acquirer/queues.rb +26 -6
  38. data/lib/redis_queued_locks/acquirer/reindex_locks.rb +117 -4
  39. data/lib/redis_queued_locks/acquirer/release_all_locks.rb +37 -6
  40. data/lib/redis_queued_locks/acquirer/release_lock.rb +33 -7
  41. data/lib/redis_queued_locks/acquirer/release_locks_of.rb +45 -6
  42. data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
  43. data/lib/redis_queued_locks/client.rb +140 -56
  44. data/lib/redis_queued_locks/config.rb +105 -14
  45. data/lib/redis_queued_locks/errors.rb +34 -4
  46. data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
  47. data/lib/redis_queued_locks/logging/sampler.rb +4 -8
  48. data/lib/redis_queued_locks/resource.rb +137 -12
  49. data/lib/redis_queued_locks/swarm/flush_zombies.rb +1 -0
  50. data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
  51. data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +8 -6
  52. data/lib/redis_queued_locks/swarm/zombie_info.rb +6 -2
  53. data/lib/redis_queued_locks/swarm.rb +86 -5
  54. data/lib/redis_queued_locks/utilities.rb +1 -1
  55. data/lib/redis_queued_locks/version.rb +2 -2
  56. data/rbs_collection.lock.yaml +4 -0
  57. data/rbs_collection.yaml +2 -1
  58. data/sig/manifest.yml +1 -0
  59. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -2
  60. data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +2 -2
  61. data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +25 -5
  62. data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +32 -12
  63. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +61 -40
  64. data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +59 -11
  65. data/sig/redis_queued_locks/acquirer/acquire_lock/{yield_with_expire → yield_expire}/log_visitor.rbs +4 -4
  66. data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +4 -4
  67. data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +6 -4
  68. data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +23 -1
  69. data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +16 -3
  70. data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
  71. data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +22 -1
  72. data/sig/redis_queued_locks/acquirer/release_lock.rbs +3 -2
  73. data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
  74. data/sig/redis_queued_locks/client.rbs +24 -10
  75. data/sig/redis_queued_locks/config.rbs +54 -1
  76. data/sig/redis_queued_locks/errors.rbs +5 -2
  77. data/sig/redis_queued_locks/instrument/sampler.rbs +3 -3
  78. data/sig/redis_queued_locks/instrument/void_notifier.rbs +3 -1
  79. data/sig/redis_queued_locks/logging/sampler.rbs +3 -3
  80. data/sig/redis_queued_locks/resource.rbs +23 -13
  81. data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +1 -1
  82. data/sig/redis_queued_locks/utilities.rbs +2 -1
  83. data/sig/redis_queued_locks.rbs +7 -0
  84. data/sig/vendor/redis_client.rbs +1 -0
  85. metadata +4 -3
  86. /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: b84159ef72c995d09109b9a653637c399638329e8b02cda9a271a8eda0856795
4
- data.tar.gz: 45d47d23c4167b241fe80bc23cc698fbbceda4a6fd67a777f2cb379538d6e91d
3
+ metadata.gz: 87e74dea52b88c1ac5cbe1b8f9117ae7b7a3e832e3aa6202980fbf6044c9368e
4
+ data.tar.gz: a69ae8d1b952516fa303da9c9b61a8a21de818ffca3274ad856b930e961eed87
5
5
  SHA512:
6
- metadata.gz: '068d7991fc00ffc8118f9e00a816916158fe4f2a962598ef41ffd96b7a4202ebe5cff51c9c6b515ace936cebb0736173b61527c03c774aea5f3794e173a5353d'
7
- data.tar.gz: bb7541f8c08b1852239366cf43d3de16817a06b36207b2a4c1d2e14a9b6723e1bb7c5d3c8067629b225026d18a83adb9086a41ef590675e26f78888666c66333
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
@@ -113,8 +218,20 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
113
218
  - Try results (internal symbols): `:write_request_is_ahead`, `:read_request_is_ahead`,
114
219
  `:read_lock_is_still_acquired`, `:conflict_lock_upgrade`, `:read_lock_is_expired_during_extension`;
115
220
  try success result carries internal `rw_mode` (held mode), not exposed in the public `lock` result.
116
- - Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; read `ttl` must be a positive
117
- Integer (`RedisQueuedLocks::ArgumentError`).
221
+ - Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; `ttl` must be a positive
222
+ Integer in both modes (`RedisQueuedLocks::ArgumentError`; write: `PEXPIRE` with a non-positive ttl would
223
+ delete the lock key in the obtaining `MULTI`, read: the reader expiration is computed from it);
224
+ `LockSeriesPoC` checks the series `ttl` the same way before computing the lock ttls (they include
225
+ the retry delay and jitter); `config['default_lock_ttl']` must be positive too; `extend_lock_ttl`
226
+ `milliseconds` must be a positive Integer (a non-positive one would decrease/delete the lock);
227
+ `meta` keys must not be reserved lock data keys, String or Symbol (Symbol keys are written as
228
+ Strings: `Symbol#name`; the same check in `AcquireLock` and `LockSeriesPoC`); the other per-call
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,
231
+ `timeout`/`retry_count` nil or >= 0, `retry_delay`/`retry_jitter`/`lock_index_grace_period` >= 0
232
+ (`AcquireLock`; `LockSeriesPoC` checks the retry delay/jitter before the lock ttl calculation),
233
+ scan/batch sizes, `dead_ttl`, reindexing `queue_ttl` > 0 (operation modules), `zombie_ttl` and the
234
+ zombie scan sizes > 0 (`Swarm` facade: `SwarmArgumentError`).
118
235
  - RW-aware operations: `unlock` (write lock + readers + reader data + both queues + the lock index entry (the enabled lock index), same result shape; one release
119
236
  `MULTI` that also reads the readers registry, then one `DEL` of the released readers' data only when readers existed:
120
237
  1 round trip without readers; trade-off: a released reader that obtains a new read lock in between loses its new data),
@@ -123,8 +240,8 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
123
240
  (zombie readers reported as their registry key), `locked?`, `queued?`, `lock_info` (read info:
124
241
  `'rw_mode' => 'read'`, `'rem_ttl'`, `'readers' => [reader data + rem_ttl]` when no write lock),
125
242
  `queue_info` (`'read_lock_queue'`/`'read_queue'` when present), `locks`/`locks_info`, `queues`/`queues_info`;
126
- write lock info in `lock_info`/`locks_info` has `'rw_mode' => 'write'` (computed on formatting, not stored),
127
- so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
243
+ write lock info in `lock_info`/`locks_info` has `'lock_key'` and `'rw_mode' => 'write'` (computed on formatting,
244
+ not stored), so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
128
245
  every request in `queue_info`/`queues_info` is `{ 'acq_id', 'score', 'rw_mode' => 'write'|'read' }`
129
246
  (`queues_info` derives the mode from the queue key via `Resource.lock_queue_rw_mode`).
130
247
  - `lock_series` (both modes): one mode per series; `release_lock_series` releases only locks the series
@@ -151,14 +268,95 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
151
268
  - RBS: `Client` types `read_write_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
152
269
  before read locks are used.
153
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
+
154
352
  ### Redis data layout (`resource.rb`)
155
353
 
156
354
  | Key | Type | Purpose |
157
355
  |---|---|---|
158
356
  | `rql:lock:<name>` | HASH | lock owner + metadata (e.g. `l_spc_ts`, `l_spc_ext_ts` for reentrant cases) |
159
- | `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`) |
160
358
  | `rql:lock_readers:<name>` | ZSET | read locks: acquirer id => expiration (Redis `TIME`, ms) |
161
- | `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 |
162
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) |
163
361
  | `rql:swarm:hsts` | HASH | swarm host heartbeats |
164
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" |
@@ -167,7 +365,7 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
167
365
 
168
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):
169
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;
170
- - 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.
171
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).
172
370
  - Acquirer ID: `rql:acq:<pid>/<thread>/<fiber>/<ractor>/<identity>`
173
371
  - Host ID: `rql:hst:<pid>/<thread>/<ractor>/<identity>` (no fiber)
@@ -197,7 +395,7 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
197
395
  - the score is never decreased (`GT`; ttl decreases and releases do not touch the index);
198
396
  - lock request queues have TTL (`PEXPIRE NX` + `GT` = queue ttl on each queueing): the lifetime of every indexed key is bounded;
199
397
  - pruning: `ZREMRANGEBYSCORE <index> -inf <now - 1>` in every queueing pipeline (amortized, no background jobs) and in `flush_zombies`,
200
- `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);
201
399
  - removal: only together with the deletion of all indexed keys of the name in one `MULTI` (`unlock`: `ZREM` in the release transaction;
202
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`,
203
401
  `yield_expire` releases, `flush_zombies` keep the names).
@@ -212,21 +410,38 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
212
410
 
213
411
  `redis_queued_locks.` + `lock_obtained`, `reentrant_lock_obtained`,
214
412
  `extendable_reentrant_lock_obtained`, `lock_hold_and_release`, `reentrant_lock_hold_completes`,
215
- `lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
216
- `explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`.
413
+ `lock_obtaining_failed`, `lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
414
+ `explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`, `extend_lock_ttl`, `clear_dead_requests`,
415
+ `reindex_locks`. Observability rule (`logic.md#16`): logic that changes keys is logged and instrumented, the lock
416
+ acquirement per meaningful outcome (visitors; `lock_obtaining_failed` with the failure `result` also before a raised
417
+ acquirement error), each mutation operation with one log line + one event of the whole operation (the same name and data,
418
+ `instrument` last in the payload, inline in the module); lock info queries are neither; the swarm is known debt.
217
419
  Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
218
420
  `lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
219
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`);
220
427
  lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock log events: `exit__write_request_ahead`,
221
- `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).
222
430
 
223
431
  ### Errors (`errors.rb`)
224
432
 
225
- Base `RedisQueuedLocks::Error < StandardError`, plus `ArgumentError`, `LockAlreadyObtainedError`,
226
- `LockAcquirementTimeoutError`, `LockAcquirementRetryLimitError`, `TimedLockTimeoutError`,
227
- `ConflictLockObtainError`, `SwarmError`, `SwarmArgumentError`, `ConfigError`
228
- (`ConfigNotFoundError`, `ConfigValidationError`). Internal `*IntermediateTimeoutError`s
229
- inherit from `Timeout::Error`.
433
+ Global base errors per branch (`logic.md#15`): `RedisQueuedLocks::Error < StandardError` (the main
434
+ hierarchy: `LockAlreadyObtainedError`, `LockAcquirementTimeoutError`, `LockAcquirementRetryLimitError`,
435
+ `TimedLockTimeoutError`, `ConflictLockObtainError`, `SwarmError`, `ConfigError` (`ConfigNotFoundError`,
436
+ `ConfigValidationError`)), `RedisQueuedLocks::ArgumentError < ::ArgumentError` (`SwarmArgumentError`),
437
+ `RedisQueuedLocks::Exception < ::Exception` (`@api private`, super exceptional cases only: the internal
438
+ `*IntermediateTimeoutError`s of `Timeout.timeout` for `timeout` and `timed`; like `Timeout::ExitException`,
439
+ `rescue => e`/`rescue StandardError`/`rescue Timeout::Error`/`rescue nil` of the interrupted code (the user
440
+ block, logger and instrumenter calls) can not swallow them; the library converts them to the public errors/results).
441
+ redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so every `with` block
442
+ around a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC` write release) rescues
443
+ both internal timeouts and `Timeout::ExitException` (the user's `Timeout.timeout` without an exception class), closes
444
+ the connection (drops the WATCH state, reconnected on the next use) and re-raises.
230
445
 
231
446
  ---
232
447
 
@@ -252,7 +467,7 @@ inherit from `Timeout::Error`.
252
467
  |---|---|
253
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) |
254
469
  | TTLs | `default_lock_ttl` (5000 ms), `default_queue_ttl` (15 s), `dead_request_ttl` (1 day, ms) |
255
- | 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) |
256
471
  | Batching | `lock_release_batch_size` (100), `clear_locks_of__lock_scan_size` / `__queue_scan_size` (300), `key_extraction_batch_size` (500) |
257
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) |
258
473
  | Identity | `uniq_identifier` (lambda → `Resource.calc_uniq_identity`) |
@@ -277,14 +492,12 @@ lib/redis_queued_locks/
277
492
  resource.rb keys and identities
278
493
  data.rb, errors.rb, utilities.rb, utilities/lock.rb, debugger/, version.rb
279
494
  sig/ RBS mirror of lib/ + sig/vendor stubs (redis_client, active_support, semantic_logger)
280
- spec/ redis_queued_locks_spec.rb (~4.9k 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)
281
496
  .github/workflows/ tests, lint, typecheck-static, typecheck-runtime
282
497
  bin/console, bin/setup dev scripts
283
498
  Rakefile, Steepfile, rbs_collection.yaml
284
499
  ```
285
500
 
286
- Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acquirer.rbs`).
287
-
288
501
  ---
289
502
 
290
503
  ## 5. Technology stack
@@ -307,27 +520,27 @@ Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acqu
307
520
  - rake, bundler, pry, pry-doc, reline, activesupport 8.1
308
521
 
309
522
  ### Rake tasks
310
- `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`, ...).
311
524
 
312
525
  ### CI (GitHub Actions, ubuntu-latest, Ruby 4.0, on every push)
313
526
 
314
527
  | Workflow | Command |
315
528
  |---|---|
316
- | tests | `bundle exec rake rspec` (with a Redis service) |
317
- | 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`) |
318
531
  | typecheck-static | `bundle exec rbs collection install && bundle exec rake steep:check -j 10` |
319
- | 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`) |
320
533
 
321
534
  ---
322
535
 
323
536
  ## 6. Conventions
324
537
 
325
538
  - `# frozen_string_literal: true` in every Ruby file.
326
- - YARD tags on every class/method: `@api public|private`, `@since`, `@version` (bump `@version` when changing behavior).
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`).
327
540
  - Every `lib/` change gets a matching `sig/` RBS update; use `# steep:ignore` only where Steep can't infer types.
328
541
  - New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
329
- - 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.
330
543
  - Development gems go in `Gemfile` (`Gemspec/DevelopmentDependencies: Gemfile`), not the gemspec.
331
- - 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`).
332
- - 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.
333
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`).
@@ -22,15 +22,16 @@ paths:
22
22
  - Read-only queries: few positional args `(redis_client, lock_name)`; collection queries use keywords `(redis_client, scan_size:, with_info:)`.
23
23
  - Mutating operations: long positional lists ending with the fixed observability tail
24
24
  `logger, instrumenter, instrument, log_sampling_enabled, log_sampling_percent, log_sampler, log_sample_this, instr_sampling_enabled, instr_sampling_percent, instr_sampler, instr_sample_this`
25
- (order of `logger`/`instrumenter` varies between modules; check the existing signature).
25
+ (the same order in all mutating modules).
26
26
  - Only `AcquireLock.acquire_lock` and `LockSeriesPoC.lock_series_poc` use keyword args (`process_id:`, `thread_id:`, ...).
27
- - **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`):
28
- 1. `rel_start_time = clock_gettime`
29
- 2. call a private `fully_*` helper returning `{ ok:, result: }` and destructure it: `fully_x(...) => { ok:, result: }`
27
+ - **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`, `extend_lock_ttl`, `clear_dead_requests`, `reindex_locks`; observability rule: `logic.md#16`):
28
+ 1. argument validations (`RedisQueuedLocks::ArgumentError`) first, then `rel_start_time = clock_gettime` (`ext_`/`idx_` prefixes for the extension/reindexing)
29
+ 2. call a private `fully_*` helper with the Redis work (returns `{ ok:, result: }`, destructured with `fully_x(...) => { ok:, result: }`, or the raw data: a `Set`/`Integer`)
30
30
  3. `time_at = Time.now.to_f`; `rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)` (microseconds → ms)
31
- 4. `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
32
- 5. `run_non_critical { instrumenter.notify('redis_queued_locks.<event>', { at:, rel_time:, ... }) } if instr_sampled`
33
- 6. return `{ ok: true, result: { ..., rel_time: } }`
31
+ 4. `log_sampled = RedisQueuedLocks::Logging.should_log?(...)`; `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
32
+ 5. `run_non_critical { logger.debug { "[redis_queued_locks.<event>] key => value ..." } } if log_sampled`
33
+ 6. `run_non_critical { instrumenter.notify('redis_queued_locks.<event>', { ..., instrument: }) } if instr_sampled` (the same data as the log line, `instrument` last)
34
+ 7. return `{ ok: true, result: { ..., rel_time: } }` (or the result of `fully_*`)
34
35
  - **Results**: always `{ ok: Boolean, result: ... }` for operations; `result` is a Symbol status (`:async_expire_or_no_lock`, `:released`, `:nothing_to_release`) or a Symbol-keyed Hash (`rel_key_cnt`, `tch_queue_cnt`, `rel_time`, `extended_locks_count`). Info queries return a String-keyed Hash / Set or `nil` when absent.
35
36
  - **Redis access**:
36
37
  - Keys only via `Resource.prepare_lock_key` / `prepare_lock_queue` / `prepare_read_lock_queue` / `prepare_lock_readers` / `prepare_read_lock_key(lock_name, acquirer_id)` and `Resource::*_PATTERN` (`KEY_PATTERN`, `LOCK_PATTERN`, `LOCK_QUEUE_PATTERN`, `READ_LOCK_QUEUE_PATTERN`, `LOCK_READERS_PATTERN`, `READ_LOCK_PATTERN`, `ALL_LOCK_QUEUES_PATTERN`) and `Resource::LOCK_INDEX_KEY`; `Resource.lock_key_family` classifies scanned keys (keyspace lookups, `ReindexLocks`); `Resource.lock_name_from_readers` / `lock_key_from_readers` map registry keys back.
@@ -38,9 +39,10 @@ paths:
38
39
  - Raw commands: `redis.call('CMD', ...)` with uppercase string command names and string args (`'0'`, `'-inf'`, `'+inf'`).
39
40
  - Pooled connection: wrap multi-command work in `redis.with do |rconn| ... end`.
40
41
  - Atomic writes: `rconn.multi do |transact| ... end` (or `multi(watch: [lock_key])` in `TryToLock`); batch reads: `pipelined do |pipeline| ... end` then index `result[0]`, `result[1]` into named vars (`hget_cmd_res`, `pttl_cmd_res`).
42
+ - Internal timeouts (`LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`) inherit from `RedisQueuedLocks::Exception` (`::Exception`, `logic.md#15`): redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so a connection interrupted inside a WATCH block would return to the pool with the active WATCH (the next `EXEC` on it is aborted when a watched key changes). Every `redis.with do |rconn| ... end` block with a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC.release_write_lock_series`) ends with `rescue <both internal timeouts>, ::Timeout::ExitException` (the user's `Timeout.timeout` without an exception class interrupts with it the same way) → `rconn.close` → `raise`.
41
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):
42
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;
43
- - 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`).
44
46
  - Never one round trip per key in either mode.
45
47
  - Lua: frozen heredoc constant (`<<~LUA_SCRIPT.strip.tr("\n", '').freeze`) + `call('EVAL', SCRIPT, 1, key, arg)`.
46
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`).
@@ -53,14 +55,15 @@ paths:
53
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);
54
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;
55
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.
56
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`).
57
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']`.
58
61
  - **AcquireLock**:
59
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`).
60
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).
61
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.
62
- - `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`.
63
- - `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)`).
64
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.
65
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).
66
69
  - Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
@@ -71,7 +74,7 @@ paths:
71
74
  2. Read-only query → small positional args or `scan_size:` / `with_info:` keywords, return data or `nil`. Mutating operation → follow the two-layer pattern (public timing/instrumentation wrapper + private `fully_*` helper).
72
75
  3. Keep the observability tail parameters in the established order of the closest sibling module; pass them through unchanged from `Client`.
73
76
  4. Return `{ ok:, result: }`; use Symbol statuses for simple outcomes and abbreviated Symbol keys (`rel_*`, `*_cnt`, `*_time`) consistent with existing results. Don't raise from acquirer modules except through the `raise_errors` path in `AcquireLock`.
74
- 5. Use `redis.with { |rconn| ... }` for any multi-command work; use `multi` for writes that must be atomic, `pipelined` for independent reads, `multi(watch:)` or Lua when a write depends on a read.
77
+ 5. Use `redis.with { |rconn| ... }` for any multi-command work; use `multi` for writes that must be atomic, `pipelined` for independent reads, `multi(watch:)` or Lua when a write depends on a read. A new `multi(watch:)` gets the timeouts `rescue` (both internal timeouts and `Timeout::ExitException`: close the connection, re-raise) in its `with` block (see "Internal timeouts" above); never make the internal timeouts `StandardError`s again (the interrupted code would swallow them).
75
78
  6. Use uppercase string Redis commands and string numeric args; handle `PTTL`/`TTL` sentinel values explicitly.
76
79
  7. Iterate locks in one pass with both lookups (see "Iteration"): a new lock-iterating operation takes `lock_index_enabled` (from `Client`: `config['lock_index_enabled']`) and implements the keyspace lookup (one SCAN pass over `KEY_PATTERN`/`ALL_LOCK_QUEUES_PATTERN`, `Resource.lock_key_family`) and the lock index lookup (one `ZSCAN` pass, `each_lock_name_batch`, keys derived from the lock names) next to each other, sharing the rest of the logic in one copy; never `KEYS`, never a keyspace `SCAN` with the enabled lock index (except `ReindexLocks`); send per-key commands in pipelined batches of `scan_size` keys/lock names (plus the rest after the pass), never one round trip per key; cover the remainder batches of both modes in specs (`lock-iterating operations: one pass (keyspace or lock index) with small batches` runs in both modes) and assert the absence of `SCAN` (lock index) / `ZSCAN` and lock index commands (keyspace) in recorded-command specs (`lock-iterating operations use the lock index`, `the disabled lock index (default): ...`).
77
80
  8. Measure durations with `clock_gettime` and report ms with `/ 1_000.0).ceil(2)`; use `Time.now.to_f` only for event timestamps.
@@ -81,14 +84,14 @@ paths:
81
84
  12. Read/write checklist for any change in this directory:
82
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");
83
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);
84
- - 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;
85
88
  - every release/cleanup/info/zombie path covers write lock, readers registry, reader data and both queues;
86
89
  - registry/reader-data TTLs: `PEXPIRE NX` + `PEXPIRE GT` pair to keep max TTL (`GT` alone never sets TTL on a key without one);
87
90
  - keep write-only result shapes unchanged; add read keys/fields only when read data exists;
88
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.
89
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.
90
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.
91
- 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.
92
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).
93
96
 
94
97
  ## Recommendations (proposed, not yet project policy)
@@ -96,4 +99,3 @@ Apply to new or touched code; don't refactor existing code for these unless aske
96
99
  1. Load Lua scripts once (`SCRIPT LOAD` + `EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), as the TODO in `extend_lock_ttl.rb` asks.
97
100
  2. Measure lock-iterating operations by the lock index size (distinct lock names x (max(queue ttl, lock ttl) + grace)): most of the visited names are dead; prefer one cheap existence check per name (`EXISTS` with several keys) before reading the details when adding new iterating features.
98
101
  3. Treat `lock_series_poc.rb` as experimental: don't build new features on it without asking. Its locks are released only through `release_lock_series` (obtained-by-series locks only, owner-checked, on success and on every failure path).
99
- 4. Unify the observability tail order (`logger, instrumenter` vs `instrumenter, logger` in `release_lock`) when those signatures are next changed.