redis_queued_locks 1.16.2 → 1.18.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.
- checksums.yaml +4 -4
- data/.claude/project-overview.md +281 -0
- data/.claude/rules/acquirer.md +81 -0
- data/.claude/rules/arguments.md +60 -0
- data/.claude/rules/git.md +46 -0
- data/.claude/rules/logic.md +63 -0
- data/.claude/rules/swarm.md +120 -0
- data/.claude/rules/tests.md +48 -0
- data/.claude/rules/type-checking.md +50 -0
- data/.claude/rules/visitors.md +69 -0
- data/.rubocop.rbs.yml +31 -0
- data/.rubocop.yml +3 -3
- data/.ruby-version +1 -1
- data/CHANGELOG.md +80 -0
- data/CLAUDE.md +36 -0
- data/README.md +367 -82
- data/Rakefile +31 -2
- data/github_ci/.keep +0 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/delay_execution.rb +2 -2
- data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +6 -3
- data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rb +9 -7
- data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +20 -10
- data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +34 -16
- data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +240 -28
- data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +398 -132
- data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +12 -6
- data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +33 -9
- data/lib/redis_queued_locks/acquirer/acquire_lock.rb +73 -24
- data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +9 -1
- data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +182 -3
- data/lib/redis_queued_locks/acquirer/is_locked.rb +19 -2
- data/lib/redis_queued_locks/acquirer/is_queued.rb +6 -2
- data/lib/redis_queued_locks/acquirer/lock_info.rb +96 -6
- data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +8 -4
- data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +18 -7
- data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +126 -29
- data/lib/redis_queued_locks/acquirer/locks.rb +103 -6
- data/lib/redis_queued_locks/acquirer/queue_info.rb +38 -9
- data/lib/redis_queued_locks/acquirer/queues.rb +28 -4
- data/lib/redis_queued_locks/acquirer/release_all_locks.rb +35 -1
- data/lib/redis_queued_locks/acquirer/release_lock.rb +40 -9
- data/lib/redis_queued_locks/acquirer/release_locks_of.rb +43 -3
- data/lib/redis_queued_locks/acquirer/release_read_lock.rb +143 -0
- data/lib/redis_queued_locks/acquirer.rb +1 -0
- data/lib/redis_queued_locks/client.rb +274 -158
- data/lib/redis_queued_locks/config/dsl.rb +4 -10
- data/lib/redis_queued_locks/resource.rb +117 -9
- data/lib/redis_queued_locks/swarm/flush_zombies.rb +46 -6
- data/lib/redis_queued_locks/swarm/supervisor.rb +1 -1
- data/lib/redis_queued_locks/swarm/swarm_element/isolated.rb +166 -44
- data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +45 -47
- data/lib/redis_queued_locks/swarm/zombie_info.rb +42 -0
- data/lib/redis_queued_locks/version.rb +2 -2
- data/rbs_collection.lock.yaml +10 -2
- data/rbs_collection.yaml +0 -2
- data/redis_queued_locks.gemspec +1 -1
- data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rbs +2 -1
- data/sig/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue.rbs +0 -1
- data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +5 -0
- data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +12 -6
- data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rbs +74 -7
- data/sig/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rbs +4 -2
- data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +3 -0
- data/sig/redis_queued_locks/acquirer/acquire_lock/yield_with_expire/log_visitor.rbs +4 -2
- data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +19 -1
- data/sig/redis_queued_locks/acquirer/lock_info.rbs +9 -1
- data/sig/redis_queued_locks/acquirer/locks.rbs +10 -1
- data/sig/redis_queued_locks/acquirer/queue_info.rbs +2 -4
- data/sig/redis_queued_locks/acquirer/queues.rbs +1 -1
- data/sig/redis_queued_locks/acquirer/release_lock.rbs +8 -1
- data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +44 -0
- data/sig/redis_queued_locks/client.rbs +22 -2
- data/sig/redis_queued_locks/resource.rbs +9 -2
- data/sig/redis_queued_locks/swarm/flush_zombies.rbs +1 -1
- data/sig/redis_queued_locks/swarm/swarm_element/isolated.rbs +13 -5
- data/sig/redis_queued_locks/swarm/swarm_element/threaded.rbs +6 -6
- data/sig/redis_queued_locks/swarm/zombie_info.rbs +5 -0
- metadata +17 -5
- data/github_ci/ruby3.3.gemfile +0 -17
- data/github_ci/ruby3.3.gemfile.lock +0 -216
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa1fdae0ce1bb54cce7b2f58a6ef0f172e56aca4d98c3a665b5789c04ca49b49
|
|
4
|
+
data.tar.gz: b39fac8b2a421081a7a7505401ea23e76485a348cf99dab799179fb3a6eefeac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a54e76f314b9187dc57fc7b60d7fdd2975b9a9b7010551c60dc37492f6f6804a9e5af2ae757a99d192fcbf94d3c948c881d9a922747951cc2897f980088d3858
|
|
7
|
+
data.tar.gz: 7a19035979ebe77171273bb7f514575a75244b02fe4c0cc94636f31e22ff93a9332c82014fe0b186df6e9e16ad42043ac8fcf6d5192cc7a6b78e682af5cafe22
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# redis_queued_locks: Project Overview
|
|
2
|
+
|
|
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
|
|
6
|
+
(conflict) strategies, timed locks, retries/fail-fast, logging, instrumentation with sampling,
|
|
7
|
+
and a background "swarm" that detects dead hosts and flushes their locks.
|
|
8
|
+
|
|
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
|
|
11
|
+
- Ruby `>= 4.0` (`.ruby-version` = 4.0.7)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
RedisQueuedLocks (lib/redis_queued_locks.rb) entry point; requires all parts; extends Debugger::Interface
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
Client (client.rb) public API facade
|
|
22
|
+
├── Config (config.rb + config/dsl.rb) declarative settings + validators
|
|
23
|
+
├── Swarm (swarm.rb + swarm/*) supervised background workers
|
|
24
|
+
└── Acquirer::* modules one stateless module per operation
|
|
25
|
+
│
|
|
26
|
+
▼
|
|
27
|
+
RedisClient (redis-client gem) WATCH/MULTI, Lua (EVAL), ZSET, HASH, SCAN
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`Client.new(redis_client) { |config| ... }`:
|
|
31
|
+
1. builds `Config` from the block,
|
|
32
|
+
2. computes `uniq_identity` via `config['uniq_identifier']`,
|
|
33
|
+
3. creates `Swarm` and starts it when `config['swarm.auto_swarm']` is true.
|
|
34
|
+
|
|
35
|
+
Every other public method is a thin wrapper: it fills option defaults from `config[...]` and calls
|
|
36
|
+
an `Acquirer::*` module function with `redis_client`.
|
|
37
|
+
|
|
38
|
+
Operation modules are independent of each other (all `Acquirer::*` except the `AcquireLock` core
|
|
39
|
+
and PoC modules such as `LockSeriesPoC`, which may reuse other modules and `AcquireLock`): no
|
|
40
|
+
cross-calls and no shared RBS types; common logic lives in `Resource`/`Utilities`, similar logic is
|
|
41
|
+
duplicated on purpose (`Locks` duplicates the `LockInfo` write/read lock formatting, `Queues` the
|
|
42
|
+
`QueueInfo` request formatting). Known debt: `AcquireLock::WithAcqTimeout` calls
|
|
43
|
+
`LockInfo`/`QueueInfo` for detailed timeout errors.
|
|
44
|
+
|
|
45
|
+
### Client → implementation map
|
|
46
|
+
|
|
47
|
+
| Client method | Implementation |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `lock` / `lock!` | `acquirer/acquire_lock.rb` + `acquirer/acquire_lock/*` |
|
|
50
|
+
| `lock_series` / `lock_series!` | `acquirer/lock_series_poc.rb` (proof of concept; one `read_write_mode` for the whole series) |
|
|
51
|
+
| `unlock` | `acquirer/release_lock.rb` |
|
|
52
|
+
| `unlock_read` / `release_read_lock` | `acquirer/release_read_lock.rb` (own read lock only) |
|
|
53
|
+
| `extend_lock_ttl` | `acquirer/extend_lock_ttl.rb` (`read_write_mode: :read` extends own read lock, `+ all_read_locks: true` all live read locks) |
|
|
54
|
+
| `locked?` / `queued?` | `acquirer/is_locked.rb` / `acquirer/is_queued.rb` |
|
|
55
|
+
| `lock_info` / `queue_info` | `acquirer/lock_info.rb` / `acquirer/queue_info.rb` |
|
|
56
|
+
| `locks`, `locks_info`, `queues`, `queues_info`, `keys` | `acquirer/locks.rb`, `queues.rb`, `keys.rb` (SCAN-based) |
|
|
57
|
+
| `clear_locks` | `acquirer/release_all_locks.rb` |
|
|
58
|
+
| `clear_locks_of`, `clear_current_locks` | `acquirer/release_locks_of.rb` |
|
|
59
|
+
| `clear_dead_requests` | `acquirer/clear_dead_requests.rb` |
|
|
60
|
+
| `current_acquirer_id`, `current_host_id`, `possible_host_ids` | `resource.rb` |
|
|
61
|
+
| `swarmize!`, `deswarmize!`, `swarm_status`, `swarm_info`, `probe_hosts`, `flush_zombies`, `zombie_locks`, `zombie_acquirers`, `zombie_hosts`, `zombies_info` | `swarm.rb` + `swarm/*` |
|
|
62
|
+
|
|
63
|
+
### Lock acquisition flow (`acquirer/acquire_lock.rb` + `acquire_lock/`)
|
|
64
|
+
|
|
65
|
+
`AcquireLock` is assembled from mixins (`extend`):
|
|
66
|
+
|
|
67
|
+
| Mixin | Role |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `TryToLock` (`try_to_lock.rb`) | One attempt: `multi(watch:)` (write: lock key + readers registry, read: lock key only), one pipelined read of the lock state (`HGET acq_id`, `TIME`, own reader score, longest reader), `ZADD NX` into the queue of the requested mode (timestamp score), prune both queues, take the lock if allowed (see Read/Write locks). Handles reentrant conflicts (incl. read/write ones); write TTL extension via inline Lua (`PTTL` + `PEXPIRE`). |
|
|
70
|
+
| `WithAcqTimeout` | Global acquisition timeout (`timeout`). |
|
|
71
|
+
| `DelayExecution` | Retry delay + jitter between attempts. |
|
|
72
|
+
| `DequeueFromLockQueue` | Removes the acquirer from the queue of the requested mode on timeout/failure. |
|
|
73
|
+
| `YieldExpire` | Runs the user block (optionally `timed`), then releases/expires the lock of the held mode (write: `EXPIRE 0`; read: `ZREM` own reader + `DEL` own read lock data; extendable reentrant: decrease). |
|
|
74
|
+
| `LogVisitor` / `InstrVisitor` | One method per lifecycle event for logs and instrumentation. |
|
|
75
|
+
|
|
76
|
+
### Main `lock` options (defaults from config)
|
|
77
|
+
|
|
78
|
+
`ttl`, `queue_ttl`, `timeout`, `timed`, `retry_count`, `retry_delay`, `retry_jitter`,
|
|
79
|
+
`raise_errors`, `fail_fast`, `conflict_strategy`, `access_strategy`, `read_write_mode`
|
|
80
|
+
(`:write` (default, exclusive) | `:read` (shared)), `identity`, `meta`, `logger`, `log_lock_try`,
|
|
81
|
+
`instrumenter`, `instrument`, log/instr sampling options, `log_sample_this`, `instr_sample_this`.
|
|
82
|
+
|
|
83
|
+
- `access_strategy`: `:queued` (FIFO, default) or `:random`.
|
|
84
|
+
- `conflict_strategy` (same process re-acquires its own lock): `:wait_for_lock` (default),
|
|
85
|
+
`:work_through`, `:extendable_work_through`, `:dead_locking`.
|
|
86
|
+
- `read_write_mode`: `:write` (default) | `:read`, see "Read/Write locks" below.
|
|
87
|
+
|
|
88
|
+
### Read/Write locks (`read_write_mode`)
|
|
89
|
+
|
|
90
|
+
- Semantics: `:write` is exclusive (waits for the write lock and all live read locks); `:read` is
|
|
91
|
+
shared (waits for the write lock only). Default `:write` keeps the classic behavior and keys.
|
|
92
|
+
- Safety (no Lua, WATCH/MULTI only): readers WATCH the write lock key; writers WATCH the write lock
|
|
93
|
+
key + readers registry. Readers never invalidate each other; a new reader aborts a concurrent
|
|
94
|
+
writer's EXEC and vice versa. Queues never participate in safety.
|
|
95
|
+
- Reader liveness: registry score = expiration in Redis server time (`TIME`, ms); expired members are
|
|
96
|
+
ignored (`score > now`) and pruned on reader acquisition; registry TTL = max reader TTL (`PEXPIRE NX`
|
|
97
|
+
+ `PEXPIRE GT` in the same MULTI).
|
|
98
|
+
- Ordering (`:queued`): FIFO between modes by `(score, acq_id)`: read waits for earlier write requests;
|
|
99
|
+
write must be the head of the write queue and waits for earlier read requests. Every attempt prunes
|
|
100
|
+
dead requests in both queues. `:random` ignores queues (writers can starve). Ordering is best effort
|
|
101
|
+
(client clocks, `unlock` clears queues); it never affects mutual exclusion.
|
|
102
|
+
- Read lock data: `rql:lock_reader:<name>:<acq_id>` HASH with the write-lock format (`acq_id`, `hst_id`,
|
|
103
|
+
`ts`, `ini_ttl`, `meta`, `spc_*` reentrant counters), TTL = read lock TTL; data carrier only (the
|
|
104
|
+
registry decides existence). Recreated (DEL + HSET) on each read acquisition.
|
|
105
|
+
- Reentrancy (same acquirer): read under own write/read lock -> `conflict_strategy` as usual
|
|
106
|
+
(`held_rw_mode` decides which lock is extended/decreased/released); read->write upgrade ->
|
|
107
|
+
`:conflict_lock_upgrade` (`ConflictLockObtainError`) for `:work_through`/`:extendable_work_through`,
|
|
108
|
+
`:conflict_dead_lock` for `:dead_locking`, waits for own read expiration for `:wait_for_lock`.
|
|
109
|
+
- `fail_fast`: write fails on write holder or live readers; read fails on write holder (or own live read).
|
|
110
|
+
- Try results (internal symbols): `:write_request_is_ahead`, `:read_request_is_ahead`,
|
|
111
|
+
`:read_lock_is_still_acquired`, `:conflict_lock_upgrade`, `:read_lock_is_expired_during_extension`;
|
|
112
|
+
try success result carries internal `rw_mode` (held mode), not exposed in the public `lock` result.
|
|
113
|
+
- Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; read `ttl` must be a positive
|
|
114
|
+
Integer (`RedisQueuedLocks::ArgumentError`).
|
|
115
|
+
- RW-aware operations: `unlock` (write lock + readers + reader data + both queues, same result shape),
|
|
116
|
+
`clear_locks`, `clear_locks_of`/`clear_current_locks` (host derived from acq id via
|
|
117
|
+
`Resource.host_identifier_from_acquirer`), `clear_dead_requests`, `flush_zombies`/`zombie_*`
|
|
118
|
+
(zombie readers reported as their registry key), `locked?`, `queued?`, `lock_info` (read info:
|
|
119
|
+
`'rw_mode' => 'read'`, `'rem_ttl'`, `'readers' => [reader data + rem_ttl]` when no write lock),
|
|
120
|
+
`queue_info` (`'read_lock_queue'`/`'read_queue'` when present), `locks`/`locks_info`, `queues`/`queues_info`;
|
|
121
|
+
write lock info in `lock_info`/`locks_info` has `'rw_mode' => 'write'` (computed on formatting, not stored),
|
|
122
|
+
so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
|
|
123
|
+
every request in `queue_info`/`queues_info` is `{ 'acq_id', 'score', 'rw_mode' => 'write'|'read' }`
|
|
124
|
+
(`queues_info` derives the mode from the queue key via `Resource.lock_queue_rw_mode`).
|
|
125
|
+
- `lock_series` (both modes): one mode per series; `release_lock_series` releases only locks the series
|
|
126
|
+
obtained itself (`process == :lock_obtaining`, reentrant ones are kept) and only the current acquirer's:
|
|
127
|
+
write = compare-and-delete (`WATCH` + `HGET acq_id` + `DEL`), read = `ZREM` + `DEL` reader data. It runs
|
|
128
|
+
after the block and on any failure (exception or `raise_errors: false`), once per failed series.
|
|
129
|
+
- Own read lock operations (current acquirer via `current_acquirer_id(identity:)`): `unlock_read` /
|
|
130
|
+
`release_read_lock` (`ZREM` + `DEL` reader data, event `explicit_read_lock_release`, result
|
|
131
|
+
`{ rel_time:, rel_key:, rel_acq_id:, lock_res: }`) and `extend_lock_ttl(..., read_write_mode: :read)`
|
|
132
|
+
(`multi(watch: [lock_key])`: extends only a live read lock while no write lock exists, never revives
|
|
133
|
+
an expired one; registry and reader data TTLs via `PEXPIRE NX`+`GT`). `unlock` releases everything.
|
|
134
|
+
- `extend_lock_ttl(..., read_write_mode: :read, all_read_locks: true)` (`false` by default, ignored for
|
|
135
|
+
`:write`; non-boolean in `:read` => `ArgumentError`): same `multi(watch: [lock_key])` (registry not
|
|
136
|
+
watched, so concurrent readers don't abort it), `ZRANGE WITHSCORES` snapshot, every live reader gets
|
|
137
|
+
`ZADD XX INCR` + reader data `PEXPIRE NX`+`GT`, registry TTL = longest extended reader;
|
|
138
|
+
`extended_locks_count` = the number of non-nil `ZADD` results (0 => `:async_expire_or_no_lock`).
|
|
139
|
+
`extend_lock_ttl` success result (all modes): `{ ok: true, result: { extended_locks_count: Integer } }`
|
|
140
|
+
(write / own read = 1); failure stays `{ ok: false, result: :async_expire_or_no_lock }`. Internal param order: `read_write_mode, all_read_locks, acquirer_id`.
|
|
141
|
+
- Design decisions (release semantics, not limitations to "fix"): read->write upgrade is an error unless
|
|
142
|
+
`:wait_for_lock`; nested reads of one acquirer go through `conflict_strategy` (no hold counting);
|
|
143
|
+
`:random` gives no fairness between modes; cross-host FIFO is best effort; no Lua in the RW path.
|
|
144
|
+
- RBS: `Client` types `read_write_mode` as `:read | :write`. Older gem versions ignore readers: all processes must be upgraded
|
|
145
|
+
before read locks are used.
|
|
146
|
+
|
|
147
|
+
### Redis data layout (`resource.rb`)
|
|
148
|
+
|
|
149
|
+
| Key | Type | Purpose |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `rql:lock:<name>` | HASH | lock owner + metadata (e.g. `l_spc_ts`, `l_spc_ext_ts` for reentrant cases) |
|
|
152
|
+
| `rql:lock_queue:<name>` | ZSET | acquirer queue scored by enqueue time |
|
|
153
|
+
| `rql:lock_readers:<name>` | ZSET | read locks: acquirer id => expiration (Redis `TIME`, ms) |
|
|
154
|
+
| `rql:lock_read_queue:<name>` | ZSET | read lock requests (write requests use `rql:lock_queue:<name>`) |
|
|
155
|
+
| `rql:lock_reader:<name>:<acq_id>` | HASH | read lock data (same fields as `rql:lock:<name>` incl. meta; data carrier only) |
|
|
156
|
+
| `rql:swarm:hsts` | HASH | swarm host heartbeats |
|
|
157
|
+
|
|
158
|
+
- Acquirer ID: `rql:acq:<pid>/<thread>/<fiber>/<ractor>/<identity>`
|
|
159
|
+
- Host ID: `rql:hst:<pid>/<thread>/<ractor>/<identity>` (no fiber)
|
|
160
|
+
|
|
161
|
+
### Instrumentation events
|
|
162
|
+
|
|
163
|
+
`redis_queued_locks.` + `lock_obtained`, `reentrant_lock_obtained`,
|
|
164
|
+
`extendable_reentrant_lock_obtained`, `lock_hold_and_release`, `reentrant_lock_hold_completes`,
|
|
165
|
+
`lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
|
|
166
|
+
`explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`.
|
|
167
|
+
Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
|
|
168
|
+
`lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
|
|
169
|
+
lock series events (`lock_series_obtained`, `lock_series_hold_and_release`) carry the mode of the series;
|
|
170
|
+
lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock log events: `exit__write_request_ahead`,
|
|
171
|
+
`exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade`.
|
|
172
|
+
|
|
173
|
+
### Errors (`errors.rb`)
|
|
174
|
+
|
|
175
|
+
Base `RedisQueuedLocks::Error < StandardError`, plus `ArgumentError`, `LockAlreadyObtainedError`,
|
|
176
|
+
`LockAcquirementTimeoutError`, `LockAcquirementRetryLimitError`, `TimedLockTimeoutError`,
|
|
177
|
+
`ConflictLockObtainError`, `SwarmError`, `SwarmArgumentError`, `ConfigError`
|
|
178
|
+
(`ConfigNotFoundError`, `ConfigValidationError`). Internal `*IntermediateTimeoutError`s
|
|
179
|
+
inherit from `Timeout::Error`.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 2. Architecture patterns by module
|
|
184
|
+
|
|
185
|
+
| Module | Patterns |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `Client` | Facade; constructor injection (caller-supplied `RedisClient`); `x` returns a result hash, `x!` raises |
|
|
188
|
+
| `Acquirer::*` | One module per command; stateless `class << self` functions; uniform `{ ok:, result: }` returns (public API contract; swarm element internals use bare scalars/primitives) |
|
|
189
|
+
| `AcquireLock` | Composition via `extend` mixins; optimistic concurrency (WATCH/MULTI; asymmetric WATCH for read/write locks) + Lua only for write TTL extension/decrease; strategy options (`access_strategy`, `conflict_strategy`, `read_write_mode`) |
|
|
190
|
+
| Log/Instr visitors | Visitor-style event hooks keep observability out of the algorithm; percent sampling via `sampling_happened?(percent)` |
|
|
191
|
+
| `Logging` / `Instrument` | Null Object defaults (`VoidLogger`, `VoidNotifier`); Adapter (`instrument/active_support.rb`); duck typing (`::Logger` API, `#notify(event, payload)`) |
|
|
192
|
+
| `Config` | Declarative DSL: `setting(key, default)` and `validate(key) { }` registries; access as `config['a.b']`; runtime `Client#configure` |
|
|
193
|
+
| `Swarm` | Template base classes `SwarmElement::Threaded` (Thread) and `SwarmElement::Isolated` (Ractor; commands/replies via a per-element pair of `Ractor::Port`s, termination via `Ractor#monitor`). `ProbeHosts < Threaded`; `FlushZombies < Isolated` with its own connection from `RedisClientBuilder` (plain, pooled or sentinel). `Supervisor` watchdog thread restarts dead elements. |
|
|
194
|
+
| `Resource` | Single source of truth for key names and identity strings |
|
|
195
|
+
| Misc | `Data < Hash` result object; `Utilities::Lock` mutex wrapper; global `Debugger` toggle |
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## 3. Configuration (`config.rb`)
|
|
200
|
+
|
|
201
|
+
| Group | Keys (default) |
|
|
202
|
+
|---|---|
|
|
203
|
+
| Retry/timeouts | `retry_count` (3), `retry_delay` (200 ms), `retry_jitter` (25 ms), `try_to_lock_timeout` (10 s), `is_timed_by_default` (false) |
|
|
204
|
+
| TTLs | `default_lock_ttl` (5000 ms), `default_queue_ttl` (15 s), `dead_request_ttl` (1 day, ms) |
|
|
205
|
+
| Strategies | `default_conflict_strategy` (`:wait_for_lock`), `default_access_strategy` (`:queued`) |
|
|
206
|
+
| Batching | `lock_release_batch_size` (100), `clear_locks_of__lock_scan_size` / `__queue_scan_size` (300), `key_extraction_batch_size` (500) |
|
|
207
|
+
| Identity | `uniq_identifier` (lambda → `Resource.calc_uniq_identity`) |
|
|
208
|
+
| Logging | `logger` (VoidLogger), `log_lock_try`, `log_sampling_enabled`, `log_sampling_percent` (15), `log_sampler` |
|
|
209
|
+
| Instrumentation | `instrumenter` (VoidNotifier), `instr_sampling_enabled`, `instr_sampling_percent` (15), `instr_sampler` |
|
|
210
|
+
| Errors | `detailed_acq_timeout_error` (false) |
|
|
211
|
+
| Swarm | `swarm.auto_swarm` (false), `swarm.supervisor.liveness_probing_period` (2 s), `swarm.probe_hosts.*` (enabled, `probe_period` 2 s, `redis_config.*`), `swarm.flush_zombies.*` (enabled, `zombie_ttl` 15000 ms, scan sizes 500, `zombie_flush_period` 10, `redis_config.*`) |
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 4. Directory map
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
lib/redis_queued_locks.rb entry point
|
|
219
|
+
lib/redis_queued_locks/
|
|
220
|
+
client.rb public API
|
|
221
|
+
config.rb, config/dsl.rb settings DSL + defaults
|
|
222
|
+
acquirer/*.rb one operation per file
|
|
223
|
+
acquirer/acquire_lock/* lock algorithm mixins + visitors
|
|
224
|
+
swarm.rb, swarm/* supervisor, swarm elements, zombie logic, redis client builder
|
|
225
|
+
logging/, instrument/ void defaults, samplers, ActiveSupport adapter
|
|
226
|
+
resource.rb keys and identities
|
|
227
|
+
data.rb, errors.rb, utilities.rb, utilities/lock.rb, debugger/, version.rb
|
|
228
|
+
sig/ RBS mirror of lib/ + sig/vendor stubs (redis_client, active_support, semantic_logger)
|
|
229
|
+
spec/ redis_queued_locks_spec.rb (~3.4k lines, integration), spec_helper.rb, setup_simplecov.rb
|
|
230
|
+
.github/workflows/ tests, lint, typecheck-static, typecheck-runtime
|
|
231
|
+
bin/console, bin/setup dev scripts
|
|
232
|
+
Rakefile, Steepfile, rbs_collection.yaml
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acquirer.rbs`).
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 5. Technology stack
|
|
240
|
+
|
|
241
|
+
### Runtime
|
|
242
|
+
- Ruby >= 4.0; stdlib `timeout`, `securerandom`, `logger`; Thread, Fiber, Ractor, Mutex
|
|
243
|
+
- Redis server: WATCH/MULTI, EVAL (Lua), ZSET, HASH, PTTL/PEXPIRE, SCAN
|
|
244
|
+
- `redis-client ~> 0.20`
|
|
245
|
+
- Optional, duck-typed: any `::Logger`-compatible logger; any `#notify(event, payload)` instrumenter (ActiveSupport adapter included; SemanticLogger has only an RBS stub)
|
|
246
|
+
|
|
247
|
+
### Test / development
|
|
248
|
+
- RSpec 3.13: random order, no monkey patching, `expect` syntax, `Thread.abort_on_exception = true`
|
|
249
|
+
- rspec-retry 0.6.2: 5 retries per example (marked temporary)
|
|
250
|
+
- A real Redis is required (CI: `supercharge/redis-github-action@1.8.1`)
|
|
251
|
+
- SimpleCov 1.3.2: line + branch coverage, HTML report; 100% minimum disabled (TODO)
|
|
252
|
+
- RBS 4.2 + `rbs collection` + tsort; Steep 2.1 (static); RBS runtime testing via `rbs/test/setup`
|
|
253
|
+
- RuboCop 1.91 through `armitage-rubocop`, in two separate runs so Ruby cops (and their rubydex project index, `AllCops/UseProjectIndex`) never see RBS files:
|
|
254
|
+
- `.rubocop.yml`: Ruby sources (general, rake, rspec presets; plugins rubocop-rspec, -performance, -rake, -thread_safety); several Metrics cops disabled
|
|
255
|
+
- `.rubocop.rbs.yml`: RBS cops only (rbs preset, plugin rubocop-on-rbs, no project index): `RBS/*` on `sig/**/*.rbs` and `RBSInline/*` on inline annotations in `lib/**/*.rb`; Ruby cop departments are excluded explicitly because inline `# rubocop:enable` directives would re-enable them despite `DisabledByDefault`
|
|
256
|
+
- rake, bundler, pry, pry-doc, reline, activesupport 8.1
|
|
257
|
+
|
|
258
|
+
### Rake tasks
|
|
259
|
+
`rspec` (default), `rubocop` (runs `rubocop:ruby` and `rubocop:rbs`, fails if either fails), `steep:check`, plus bundler gem tasks (`build`, `release`, ...).
|
|
260
|
+
|
|
261
|
+
### CI (GitHub Actions, ubuntu-latest, Ruby 4.0, on every push)
|
|
262
|
+
|
|
263
|
+
| Workflow | Command |
|
|
264
|
+
|---|---|
|
|
265
|
+
| tests | `bundle exec rake rspec` (with a Redis service) |
|
|
266
|
+
| lint | `bundle exec rake rubocop` |
|
|
267
|
+
| typecheck-static | `bundle exec rbs collection install && bundle exec rake steep:check -j 10` |
|
|
268
|
+
| 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) |
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 6. Conventions
|
|
273
|
+
|
|
274
|
+
- `# frozen_string_literal: true` in every Ruby file.
|
|
275
|
+
- YARD tags on every class/method: `@api public|private`, `@since`, `@version` (bump `@version` when changing behavior).
|
|
276
|
+
- Every `lib/` change gets a matching `sig/` RBS update; use `# steep:ignore` only where Steep can't infer types.
|
|
277
|
+
- New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
|
|
278
|
+
- New config option: `setting` (+ `validate`) in `config.rb`, read via `config['key']`, document defaults.
|
|
279
|
+
- Development gems go in `Gemfile` (`Gemspec/DevelopmentDependencies: Gemfile`), not the gemspec.
|
|
280
|
+
- 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`).
|
|
281
|
+
- Temporary debt: rspec-retry, disabled coverage minimum, runtime type-check job that can't fail.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "lib/redis_queued_locks/acquirer.rb"
|
|
4
|
+
- "lib/redis_queued_locks/acquirer/**/*.rb"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Acquirer modules (`lib/redis_queued_locks/acquirer/**`)
|
|
8
|
+
|
|
9
|
+
## Observed style and patterns
|
|
10
|
+
- **Module shape**: `module RedisQueuedLocks::Acquirer::<CamelName>` in `acquirer/<snake_name>.rb`, `# @api private`, a single public entry function inside `class << self` named after the file (`ReleaseLock.release_lock`, `IsLocked.locked?`), helpers under `private` in the same `class << self`.
|
|
11
|
+
- **Utilities**: modules that time or instrument `extend RedisQueuedLocks::Utilities` (gives `clock_gettime`, `run_non_critical`).
|
|
12
|
+
- **Independence of operation modules** (core principle): the modules behind `Client` public methods that don't acquire locks (`ClearDeadRequests`, `ExtendLockTTL`, `IsLocked`, `IsQueued`, `Keys`, `LockInfo`, `Locks`, `QueueInfo`, `Queues`, `ReleaseAllLocks`, `ReleaseLock`, `ReleaseLocksOf`, `ReleaseReadLock`) never call each other and never reference each other's RBS types. They share logic only through `Resource`, `Utilities` and other non-operation helpers; similar logic is duplicated on purpose (independence wins over DRY):
|
|
13
|
+
- `Locks.extract_locks_info` / `Locks.read_lock_info` duplicate the write/read lock formatting of `LockInfo.lock_info` / `LockInfo.read_lock_info`;
|
|
14
|
+
- `Queues.extract_queues_info` duplicates the request formatting of `QueueInfo.queue_info`;
|
|
15
|
+
- each module declares its own RBS aliases (`Locks::lockInfo` / `Locks::readerInfo` mirror `LockInfo::lockInfo` / `LockInfo::readerInfo`); only `Client` signatures reference them.
|
|
16
|
+
- `AcquireLock` is the lock acquisition core, not an operation module of this list.
|
|
17
|
+
- PoC modules (`*PoC`, e.g. `LockSeriesPoC`) are not covered by this principle: they may reuse operation modules and the `AcquireLock` core (`LockSeriesPoC` uses `AcquireLock.acquire_lock` and its `YieldExpire` mixin).
|
|
18
|
+
- Known debt (don't copy): `AcquireLock::WithAcqTimeout` calls `LockInfo.lock_info` / `QueueInfo.queue_info` for detailed timeout errors (its TODO asks to make `AcquireLock` independent of them).
|
|
19
|
+
- **Signatures** (see `arguments.md` for the design rationale):
|
|
20
|
+
- Read-only queries: few positional args `(redis_client, lock_name)`; collection queries use keywords `(redis_client, scan_size:, with_info:)`.
|
|
21
|
+
- Mutating operations: long positional lists ending with the fixed observability tail
|
|
22
|
+
`logger, instrumenter, instrument, log_sampling_enabled, log_sampling_percent, log_sampler, log_sample_this, instr_sampling_enabled, instr_sampling_percent, instr_sampler, instr_sample_this`
|
|
23
|
+
(order of `logger`/`instrumenter` varies between modules; check the existing signature).
|
|
24
|
+
- Only `AcquireLock.acquire_lock` and `LockSeriesPoC.lock_series_poc` use keyword args (`process_id:`, `thread_id:`, ...).
|
|
25
|
+
- **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`):
|
|
26
|
+
1. `rel_start_time = clock_gettime`
|
|
27
|
+
2. call a private `fully_*` helper returning `{ ok:, result: }` and destructure it: `fully_x(...) => { ok:, result: }`
|
|
28
|
+
3. `time_at = Time.now.to_f`; `rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)` (microseconds → ms)
|
|
29
|
+
4. `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
|
|
30
|
+
5. `run_non_critical { instrumenter.notify('redis_queued_locks.<event>', { at:, rel_time:, ... }) } if instr_sampled`
|
|
31
|
+
6. return `{ ok: true, result: { ..., rel_time: } }`
|
|
32
|
+
- **Results**: always `{ ok: Boolean, result: ... }` for operations; `result` is a Symbol status (`:async_expire_or_no_lock`, `:released`, `:nothing_to_release`) or a Symbol-keyed Hash (`rel_key_cnt`, `tch_queue_cnt`, `rel_time`, `extended_locks_count`). Info queries return a String-keyed Hash / Set or `nil` when absent.
|
|
33
|
+
- **Redis access**:
|
|
34
|
+
- Keys only via `Resource.prepare_lock_key` / `prepare_lock_queue` / `prepare_read_lock_queue` / `prepare_lock_readers` / `prepare_read_lock_key(lock_name, acquirer_id)` and `Resource::*_PATTERN` (`LOCK_PATTERN`, `LOCK_QUEUE_PATTERN`, `READ_LOCK_QUEUE_PATTERN`, `LOCK_READERS_PATTERN`, `READ_LOCK_PATTERN`); `Resource.lock_name_from_readers` / `lock_key_from_readers` map registry keys back.
|
|
35
|
+
- Several independent reads inside one attempt/operation go into one `pipelined` round trip (it works inside `multi(watch:)` too: reads are sent after WATCH).
|
|
36
|
+
- Raw commands: `redis.call('CMD', ...)` with uppercase string command names and string args (`'0'`, `'-inf'`, `'+inf'`).
|
|
37
|
+
- Pooled connection: wrap multi-command work in `redis.with do |rconn| ... end`.
|
|
38
|
+
- Atomic writes: `rconn.multi do |transact| ... end` (or `multi(watch: [lock_key])` in `TryToLock`); batch reads: `pipelined do |pipeline| ... end` then index `result[0]`, `result[1]` into named vars (`hget_cmd_res`, `pttl_cmd_res`).
|
|
39
|
+
- Iteration: `scan('MATCH', PATTERN, count: scan_size) { |key| ... }`, collecting into `Set.new.tap { |set| ... }`; deletes are batched by scan size.
|
|
40
|
+
- Lua: frozen heredoc constant (`<<~LUA_SCRIPT.strip.tr("\n", '').freeze`) + `call('EVAL', SCRIPT, 1, key, arg)`.
|
|
41
|
+
- Release = `EXPIRE key 0` / `ZREMRANGEBYSCORE queue -inf +inf`; a read lock is released with `ZREM <readers> <acq_id>` + `DEL <reader data>` (owner-safe, other readers untouched); Redis TTL sentinels handled explicitly (`PTTL` `-2` = missing, `-1` = no expiry → `Float::INFINITY`).
|
|
42
|
+
- Readers registry members are live only while `score > Redis TIME (ms)` (`Resource.redis_time_ms`); expired members can stay till cleanup, so info/checks must filter by score, never by `ZCARD`/`EXISTS` alone.
|
|
43
|
+
- **Data normalization**: Redis hash strings are converted with `Float(...)` / `Integer(...)` inside `hget_cmd_res.tap do |lock_data| ... end`, optional fields guarded with `if lock_data['x']`.
|
|
44
|
+
- **AcquireLock**:
|
|
45
|
+
- Main module `require_relative`s its parts, then `extend`s the mixins (`TryToLock`, `DelayExecution`, `YieldExpire`, `WithAcqTimeout`, `DequeueFromLockQueue`); mixins are plain modules with instance methods (`def try_to_lock(...)`), visitors are `class << self` modules called explicitly (see `visitors.md`).
|
|
46
|
+
- The algorithm is a numbered step script (`# Step 0`, `# Step 2.1`, `# Step 2.2.a`) driven by a mutable `acq_process` hash (`:should_try`, `:tries`, `:acquired`, `:result`, `:lock_info`, timings).
|
|
47
|
+
- Failure modes are Symbols (`:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`, `:conflict_lock_upgrade`, ...); exceptions are raised only when `raise_errors` is true, with a message naming the lock key / acquirer id.
|
|
48
|
+
- `TryToLock` attempt (both modes): `multi(watch:)` (write: `[lock_key, lock_readers_key]`, read: `[lock_key]`) → one pipelined state read (`HGET acq_id`, `TIME`, own reader score, longest reader) → same-process conflict detection (`sp_conflict_rw_mode` = held `:write`/`:read`) → `ZADD NX` into the queue of the requested mode → prune dead requests in **both** queues → queue heads of both queues (`:queued`: write must be first in its queue; any mode waits for an earlier `(score, acq_id)` request of the opposite mode) → lock state (write held → `:lock_is_still_acquired`; live readers for write / own live read for read → `:read_lock_is_still_acquired`) → MULTI (write: `ZREM` + `HSET` + `PEXPIRE`; read: `ZREM` + prune registry + `ZADD expiry` + registry `PEXPIRE NX`+`GT` + reader data `DEL`+`HSET`+`PEXPIRE`).
|
|
49
|
+
- The try result carries internal `rw_mode` (the **held** lock mode); `acquire_lock` stores it as `acq_process[:held_rw_mode]` and passes it to `yield_expire`, which releases/decreases the held lock. The public `lock` result shape does not include it.
|
|
50
|
+
- Read lock data (`rql:lock_reader:<name>:<acq_id>`) uses the write-lock field format (`acq_id`, `hst_id`, `ts`, `ini_ttl`, meta, `spc_*`) and the read lock TTL; it is a data carrier only (writers never read it).
|
|
51
|
+
- Timing via monotonic `clock_gettime` (microseconds); wall time only for `at:`/`ts` fields.
|
|
52
|
+
- **Inline typing**: `# @type var result: Symbol`, `{} #: Hash[String,String|Float|Integer]`, and `# steep:ignore` on pattern-matching destructures and splats (`rconn.call('DEL', *keys) # steep:ignore`).
|
|
53
|
+
|
|
54
|
+
## Claude rules
|
|
55
|
+
1. New operation: create `acquirer/<snake_name>.rb` with `module RedisQueuedLocks::Acquirer::<CamelName>`, one public `class << self` entry function named after the file, private helpers below `private`; add `require_relative` to `acquirer.rb` and a mirrored `sig/redis_queued_locks/acquirer/<snake_name>.rbs`.
|
|
56
|
+
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).
|
|
57
|
+
3. Keep the observability tail parameters in the established order of the closest sibling module; pass them through unchanged from `Client`.
|
|
58
|
+
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`.
|
|
59
|
+
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.
|
|
60
|
+
6. Use uppercase string Redis commands and string numeric args; handle `PTTL`/`TTL` sentinel values explicitly.
|
|
61
|
+
7. Iterate keys with `scan('MATCH', Resource::*_PATTERN, count:)`, never `KEYS`; batch deletes by scan size.
|
|
62
|
+
8. Measure durations with `clock_gettime` and report ms with `/ 1_000.0).ceil(2)`; use `Time.now.to_f` only for event timestamps.
|
|
63
|
+
9. Wrap every `instrumenter.notify` / logger call in `run_non_critical` (or a visitor) and gate it with `Instrument.should_instrument?` / `Logging.should_log?`; observability must never break locking. Payload changes of inline `notify` calls are documented in README `### Instrumentation Events` in the same change (see `visitors.md` rule 14).
|
|
64
|
+
10. In `AcquireLock`, add behavior as a new step or mixin rather than growing `acquire_lock`; keep the `# Step N.x` comment numbering, update `acq_process` keys consistently, and add a matching `LogVisitor`/`InstrVisitor` method for each new lifecycle event.
|
|
65
|
+
11. When normalizing lock hash fields, follow the `Float()` / `Integer()` conversion pattern; if a new lock field is added, update every copy of the formatting: write locks in `LockInfo.lock_info` and `Locks.extract_locks_info`, readers in `LockInfo.read_lock_info` and `Locks.read_lock_info` (reader data uses the same fields).
|
|
66
|
+
12. Read/write checklist for any change in this directory:
|
|
67
|
+
- lock state checks: write requires no write lock and no **live** readers; read requires no write lock; same-acquirer reader/writer cases go through `conflict_strategy` (read→write upgrade never "works through");
|
|
68
|
+
- WATCH: writers watch the write key + readers registry, readers watch the write key only (never the registry: readers must not abort each other); don't modify a watched key outside MULTI after WATCH (it aborts your own EXEC);
|
|
69
|
+
- every attempt prunes dead requests in both queues; dequeue/cleanup removes the acquirer from the queue of its mode;
|
|
70
|
+
- every release/cleanup/info/zombie path covers write lock, readers registry, reader data and both queues;
|
|
71
|
+
- registry/reader-data TTLs: `PEXPIRE NX` + `PEXPIRE GT` pair to keep max TTL (`GT` alone never sets TTL on a key without one);
|
|
72
|
+
- keep write-only result shapes unchanged; add read keys/fields only when read data exists.
|
|
73
|
+
13. Values used only for logs (e.g. `HGETALL` of lock data) are fetched only when the log is enabled (`(log_sampled && log_lock_try) ? ... : {}`): visitor arguments are evaluated before the visitor's guard.
|
|
74
|
+
14. Keep operation modules independent (see "Independence of operation modules"; PoC modules such as `LockSeriesPoC` are exempt): never call another operation module's function (public or private) or reference its RBS types from an operation module. When similar logic is needed, duplicate it inside the module (same method name and shape as the original copy, with a `NOTE` that it is duplicated on purpose) or move a pure, data-only helper (key names, id parsing, time conversion) to `Resource` / `Utilities`. Helpers used by one module stay `private`. When changing logic that has copies, update every copy in the same change.
|
|
75
|
+
|
|
76
|
+
## Recommendations (proposed, not yet project policy)
|
|
77
|
+
Apply to new or touched code; don't refactor existing code for these unless asked.
|
|
78
|
+
1. Load Lua scripts once (`SCRIPT LOAD` + `EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), as the TODO in `extend_lock_ttl.rb` asks.
|
|
79
|
+
2. Prefer indexed lookups over full `SCAN` loops for new features (see TODOs in `release_locks_of.rb`, `locks.rb`, `queues.rb`).
|
|
80
|
+
3. Treat `lock_series_poc.rb` as experimental: don't build new features on it without asking. Its locks are released only through `release_lock_series` (obtained-by-series locks only, owner-checked, on success and on every failure path).
|
|
81
|
+
4. Unify the observability tail order (`logger, instrumenter` vs `instrumenter, logger` in `release_lock`) when those signatures are next changed.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "lib/redis_queued_locks/client.rb"
|
|
4
|
+
- "lib/redis_queued_locks/acquirer/**/*.rb"
|
|
5
|
+
- "lib/redis_queued_locks/swarm.rb"
|
|
6
|
+
- "lib/redis_queued_locks/config.rb"
|
|
7
|
+
- "sig/redis_queued_locks/client.rbs"
|
|
8
|
+
- "sig/redis_queued_locks/acquirer/**/*.rbs"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Long explicit keyword lists (core API design principle)
|
|
12
|
+
|
|
13
|
+
## The pattern
|
|
14
|
+
The public API and the lock pipeline use **long, flat, explicit argument lists**: every tunable is a named keyword with its default declared directly in the signature (usually `config['...']`), and every value is forwarded explicitly, name by name, through each layer:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Client (public DSL surface)
|
|
18
|
+
def lock(lock_name, ttl: config['default_lock_ttl'], queue_ttl: config['default_queue_ttl'],
|
|
19
|
+
..., instr_sample_this: false, &block)
|
|
20
|
+
RedisQueuedLocks::Acquirer::AcquireLock.acquire_lock(
|
|
21
|
+
redis_client, lock_name,
|
|
22
|
+
process_id: RedisQueuedLocks::Resource.get_process_id, ...,
|
|
23
|
+
ttl:, queue_ttl:, ..., instr_sample_this:, &block
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This is a deliberate framework-level decision, **not** something to refactor into parameter objects, option hashes, context structs or builders.
|
|
29
|
+
|
|
30
|
+
## Why (framework-engineering rationale)
|
|
31
|
+
- **Minimal object allocation on the hot path.** Lock acquisition runs in tight retry loops under contention. Keywords forwarded as `key:` shorthand and positional args into stateless `class << self` functions cost no extra objects; a parameter/context object, `**opts` hash, `Data`/`Struct` or builder allocates on every call (and per retry, per event). Fewer allocations = less GC pressure and more predictable latency in a concurrency primitive.
|
|
32
|
+
- **The signature is the DSL.** `client.lock('name', ttl: 1_000, fail_fast: true, conflict_strategy: :work_through) { ... }` reads as a declarative per-call configuration. Users override exactly what they need; everything else falls back to `config[...]`. There is one place to look for what a call accepts.
|
|
33
|
+
- **Layered defaults without merging.** Global config (`Config` DSL) → per-call keyword override → explicit forwarding. No hash merging, no hidden `opts.fetch`, no precedence rules to learn.
|
|
34
|
+
- **Fail-fast on typos.** Ruby rejects unknown keywords (`ArgumentError: unknown keyword`) at the call boundary; an options hash would silently accept `tll:`.
|
|
35
|
+
- **Typed and documented contract.** Every option has its own YARD `@option` line and RBS signature entry, so Steep and RBS runtime checks validate each value individually; opaque bags of options can't be typed this precisely.
|
|
36
|
+
- **Stateless, thread/Ractor-friendly internals.** No intermediate object carries mutable state between layers; each function receives everything it needs as plain values, which keeps `Acquirer::*` modules pure and shareable.
|
|
37
|
+
- **Grep-ability and traceability.** Searching for `log_sample_this:` shows every hop of a value from the public API down to the visitor that uses it.
|
|
38
|
+
|
|
39
|
+
## How it is applied (layer by layer)
|
|
40
|
+
| Layer | Style |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `Client` public methods | Positional subject (`lock_name`) + long keyword list with defaults from `config['...']` or literals; trailing `&block`. Paired `x` / `x!` methods duplicate the full keyword list (no `**kwargs` delegation). |
|
|
43
|
+
| `Client` → `Acquirer` | Explicit forwarding with Ruby 3.1 shorthand (`ttl:`, `queue_ttl:`); runtime identity values injected here (`process_id:`, `thread_id:`, `fiber_id:`, `ractor_id:`). |
|
|
44
|
+
| `AcquireLock.acquire_lock` | Required keywords without defaults (`ttl:`, `timeout:`, ...): defaults live only in `Client`. |
|
|
45
|
+
| Other `Acquirer::*`, mixins, visitors | Positional parameters in a fixed, documented order (subject, data, observability tail) for the cheapest possible internal calls. |
|
|
46
|
+
| Results | Public API (`Client`, `Acquirer::*`, `Swarm` facade actions): small literal Hashes (`{ ok:, result: }`), not result classes. Internal swarm element APIs: bare scalars/primitives (see `swarm.md`). |
|
|
47
|
+
|
|
48
|
+
## Claude rules
|
|
49
|
+
1. **Do not introduce** parameter objects, context/options structs (`Data.define`, `Struct`, `OpenStruct`), builders, or `**opts` / `options = {}` hashes in `Client`, `Acquirer::*`, mixins or visitors. Long explicit lists are the intended design.
|
|
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
|
+
3. Forward it explicitly by name through every layer using shorthand (`new_option:`); never forward via `**kwargs`, `...`, or `method(__method__).parameters` tricks.
|
|
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, acquirer_id, host_id, ...` (`yield_expire`: `lock_key, lock_readers_key, read_lock_key, rw_mode, acquirer_id, ...`); compute keys once in `acquire_lock` and pass them down.
|
|
54
|
+
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
|
+
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
|
+
8. Silence length cops locally (`# rubocop:disable Metrics/MethodLength`) rather than shortening signatures; `Metrics/ParameterLists` is disabled project-wide on purpose.
|
|
57
|
+
9. `lock`-family options exist in four `Client` methods: `lock`, `lock!`, `lock_series`, `lock_series!` (and `LockSeriesPoC.lock_series_poc` forwards them to `AcquireLock.acquire_lock`). A new lock option is added to all of them; `lock_series` keeps one value for the whole series (e.g. `read_write_mode`).
|
|
58
|
+
|
|
59
|
+
## Recommendations (proposed, not yet project policy)
|
|
60
|
+
1. For new internal functions with many same-typed params (e.g. several Integers/Booleans in a row), consider required keywords instead of positionals to prevent misordering; keep the list flat and explicit.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Git commit rules
|
|
2
|
+
|
|
3
|
+
Applies to every commit Claude makes in this repository (no `paths:` scope: always loaded).
|
|
4
|
+
|
|
5
|
+
## Observed conventions (git history analysis)
|
|
6
|
+
- 653 of 687 non-merge subjects use the `[<scope>] <summary>` form: `[readme] minor updates`, `[gem] bump to 1.17.0`, `[lock_series] correct lock series releasing when blokc of code is failed with an exception`.
|
|
7
|
+
- Scope = the area of the change, lowercase:
|
|
8
|
+
- docs and project files: `readme` (most frequent), `roadmap`, `changelog`, `docs`, `yardoc`, `license`;
|
|
9
|
+
- gem lifecycle: `gem` (`bump to X.Y.Z`, dependency updates), `new-release`;
|
|
10
|
+
- tooling: `rbs`, `types`, `ci`, `rubocop`, `linting`, `specs`, `dev`;
|
|
11
|
+
- features/modules, named like the code or the README section: `swarm`, `lock_series`, `logging`, `instrumentation`, `reentrant-locks`, `release_locks_of/release_current_locks`, `#clear_dead_requests`.
|
|
12
|
+
- Sub-areas are joined with `/` (`ci/typechecking`, `readme/roadmap`, `features/rel_of_acquirer/rel_of_host`); multi-word scopes use `-` or keep the code identifier (`dead-locks-and-reentrant-locks`, `lock_series`).
|
|
13
|
+
- Summary: short, lowercase, no trailing period; imperative and past tense are mixed (`update`, `updated`, `bump`, `fixed typo`); several changes are joined with `+` or commas (`feature + doc updates`, `release_locks_of, release_current_locks`).
|
|
14
|
+
- Several areas in one commit (mostly squash-merged PRs): `[a] + [b] + [c]` (`[claude instructions] + [gem update] + [migration to Ractor 4 API]`).
|
|
15
|
+
- Version bumps: `[gem] bump to X.Y.Z`.
|
|
16
|
+
- Squash merges of PRs end with ` (#N)`; GitHub appends it, it is never typed by hand.
|
|
17
|
+
- Bodies are practically absent: only GitHub squash bullet lists and `Co-Authored-By:` trailers.
|
|
18
|
+
- Anti-patterns found in history (don't repeat): meaningless subjects (`dev`, `cheburek`, `work in progress`, `huuuge updates`), scope typos (`[roadma]`), stash/WIP entries.
|
|
19
|
+
|
|
20
|
+
## Claude rules
|
|
21
|
+
**Message format**
|
|
22
|
+
1. Subject only, one line: `[<scope>] <summary>`. Don't write a commit body (no description, overview, bullet lists or explanations). The only allowed extra lines are the attribution trailers the harness requires (`Co-Authored-By: ...`), separated by a blank line.
|
|
23
|
+
2. Scope: one lowercase area from the list above, or the feature/module name (`read-write-locks`, `swarm`, `lock_series`, `extend_lock_ttl`). Use `/` for a sub-area (`ci/typechecking`). Name the dominant area: specs, RBS, README/CHANGELOG and `.claude` docs that accompany a feature don't get their own scope.
|
|
24
|
+
3. Summary: a short, meaningful description of what the change does (the feature or the fix), not of the process (`changes`, `updates`, `fixes`, `wip` alone are not allowed). Lowercase start (code identifiers keep their spelling), no trailing period, imperative mood, aim for <= 72 characters in total.
|
|
25
|
+
4. Several features in one commit: list each feature in at most two words, comma-separated: `[read-write-locks] unlock_read, extend all-readers, rw_mode info`. Don't use `+` chains or sentences for lists.
|
|
26
|
+
5. Unrelated areas: prefer separate commits per area; if one commit is unavoidable, use the historical form `[a] + [b]` with a 1-2 word summary per scope.
|
|
27
|
+
6. Releases: `[gem] bump to X.Y.Z`. Never add ` (#N)` by hand.
|
|
28
|
+
|
|
29
|
+
**When to commit**
|
|
30
|
+
7. Never commit on your own. When a step with changes reaches completion, ask the user for permission to commit and wait for the answer:
|
|
31
|
+
- completion = the feature/functionality required by the prompt is fully implemented (code, RBS, specs, README/CHANGELOG, `.claude/project-overview.md` and rules kept in sync) and checks are green: the touched examples and the full suite (`bundle exec rake rspec`), `bundle exec rake rubocop`, `bundle exec rake steep:check` (no `ERROR`/`FATAL` lines); changes without code (docs, `.claude` rules) need no test run;
|
|
32
|
+
- the question contains: the proposed commit subject (following the format rules above), a short recap of what was done in the changes (features/fixes, touched areas, check results) and the list of files that will be committed;
|
|
33
|
+
- commit only after an explicit "yes" (or with the subject the user corrects); on "no" leave the changes uncommitted (staging also only on request).
|
|
34
|
+
8. An explicit user request to commit ("commit", "закоммить") is the permission itself: commit right away with the given subject (or a subject built by the rules) without asking again.
|
|
35
|
+
9. Don't propose a commit for incomplete or red states (failing specs, lint/type errors, half-done requirements); report the blocker instead.
|
|
36
|
+
10. One commit per completed step. Stage only the files of that step (`git status` first); never commit environment artifacts: `rbs_collection.lock.yaml` rewritten by `rbs collection install` (restore it with `git checkout --`), `coverage/`, `.gem_rbs_collection/`, local settings.
|
|
37
|
+
11. Never commit to `master`: create a feature branch first (named after the feature, e.g. `read-write-locks-realisation`). Never push, amend, rebase, reset or force anything unless the user asks.
|
|
38
|
+
12. After committing, report the short hash and the subject.
|
|
39
|
+
|
|
40
|
+
**Examples**
|
|
41
|
+
- `[read-write-locks] extend all read locks`
|
|
42
|
+
- `[read-write-locks] unlock_read, rw_mode info, meta reservation`
|
|
43
|
+
- `[extend_lock_ttl] extended locks count result`
|
|
44
|
+
- `[rbs] remove duplicate collection gems`
|
|
45
|
+
- `[ci/typechecking] blocking runtime checks`
|
|
46
|
+
- `[gem] bump to 1.18.0`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "lib/**/*.rb"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Logic rules: general (`lib/`)
|
|
7
|
+
|
|
8
|
+
Related rule files (loaded for narrower paths):
|
|
9
|
+
- `acquirer.md`: acquirer operations, Redis access, `AcquireLock` algorithm
|
|
10
|
+
- `visitors.md`: log & instrumentation visitors
|
|
11
|
+
- `arguments.md`: long explicit keyword/parameter lists (core API design principle)
|
|
12
|
+
- `swarm.md`: swarm architecture, element lifecycle, Ractor/Thread coding rules
|
|
13
|
+
|
|
14
|
+
## Swarm overview (details in `swarm.md`)
|
|
15
|
+
- Purpose: zombie-lock elimination. `ProbeHosts` periodically `HSET`s every host id (`rql:hst:<pid>/<thread>/<ractor>/<identity>`) with `Time.now.to_f` into `rql:swarm:hsts`; `FlushZombies` treats hosts older than `zombie_ttl` (ms) as zombies and deletes their write locks, their read locks (registry members + reader data), their entries in both request queues and the hosts themselves.
|
|
16
|
+
- `Client#swarm` → `Swarm` facade (one per client) owns a `Supervisor` and the swarm elements; started by `swarmize!` / `swarm.auto_swarm`, stopped by `deswarmize!`.
|
|
17
|
+
- Elements are independent background units: a control unit (`SwarmElement::Threaded` = Thread + `SizedQueue` command channel; `SwarmElement::Isolated` = Ractor driven via its own pair of `Ractor::Port`s: results port created in the main Ractor, where the Swarm and Supervisor live; command port created inside the element Ractor) that spawns, stops and reports on a main-loop Thread with its own Redis connection (`Swarm::RedisClientBuilder`).
|
|
18
|
+
- `ProbeHosts` is Threaded (it must see the client's ractor threads); `FlushZombies` is Isolated (copied config values only).
|
|
19
|
+
- `Supervisor` is one Thread that calls `reswarm_if_dead!` on every element each `liveness_probing_period`, restarting dead control units or stopped main loops; `swarm_status` aggregates `{ running:, state: }` of all of them.
|
|
20
|
+
- Redis work lives in stateless class-level functions (`ProbeHosts.probe_hosts`, `FlushZombies.flush_zombies`, `ZombieInfo.*`, `Acquirers.acquirers`), shared by main loops and the manual public API.
|
|
21
|
+
|
|
22
|
+
## Observed conventions
|
|
23
|
+
- Every file starts with `# frozen_string_literal: true`.
|
|
24
|
+
- Constants are defined compactly: `class RedisQueuedLocks::Acquirer::IsLocked` / `module ...`, never nested `module A; module B`.
|
|
25
|
+
- Namespace files (`acquirer.rb`, `swarm.rb`, `logging.rb`, `swarm/swarm_element.rb`) only `require_relative` their children; the root `lib/redis_queued_locks.rb` requires the namespaces in dependency order.
|
|
26
|
+
- YARD doc block on every class, module, constant, attr and method:
|
|
27
|
+
`@param name [Type]`, `@option`, `@return [Type]`, blank `#` line, then `@api public|private`, `@since X.Y.Z`, optional `@version X.Y.Z` (latest behavior change).
|
|
28
|
+
- Operations are stateless modules with `class << self` functions; dependencies (`redis_client`, logger, instrumenter, sampling options) are passed as explicit args, never read from globals.
|
|
29
|
+
- Operation modules (except PoC modules such as `LockSeriesPoC`) are independent of each other: no cross-calls and no shared RBS types between them; common logic lives in `Resource`/`Utilities`, similar logic is duplicated on purpose (see `acquirer.md`, "Independence of operation modules").
|
|
30
|
+
- Public API results are hashes `{ ok: Boolean, result: Symbol|Hash }` (`Client`, `Acquirer::*`, `Swarm` facade actions); `Client` `!` methods raise `RedisQueuedLocks::*Error`. Internal swarm element APIs return bare scalars/primitives (see `swarm.md`).
|
|
31
|
+
- `Client` methods only fill defaults from `config['...']` and delegate to `Acquirer::*` / `Swarm`.
|
|
32
|
+
- Redis keys come only from `RedisQueuedLocks::Resource.prepare_*` helpers and its `*_PATTERN` / `SWARM_KEY` constants. Key families: write lock `rql:lock:` (`LOCK_PATTERN`), write requests `rql:lock_queue:` (`LOCK_QUEUE_PATTERN`), readers registry `rql:lock_readers:` (`LOCK_READERS_PATTERN`), read requests `rql:lock_read_queue:` (`READ_LOCK_QUEUE_PATTERN`), reader data `rql:lock_reader:<name>:<acq_id>` (`READ_LOCK_PATTERN`); all of them match `KEY_PATTERN` (`rql:lock*`).
|
|
33
|
+
- New key families use a distinct **prefix** (`rql:lock_<kind>:<name>`), never a suffix of the lock name: the lock name is an arbitrary string, so `rql:lock_queue:<name>:read` would collide with the queue of a lock named `<name>:read`.
|
|
34
|
+
- Time: lock expirations that affect safety use Redis time (`PEXPIRE`, or `TIME` via `Resource.redis_time_ms` for reader scores); client `Time.now.to_f` is used only for queue positions, `ts` fields and zombie probes.
|
|
35
|
+
- Config: `setting('key', default)` + `validate('key') { |val| ... }` in `config.rb`; dotted keys for nested groups (`swarm.flush_zombies.zombie_ttl`); units in a trailing `# NOTE: in milliseconds` comment.
|
|
36
|
+
- Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass.
|
|
37
|
+
- Comments use `# NOTE:`, `# TODO:` and `# @type var x: T` / `#: T` for inline type hints.
|
|
38
|
+
- Rubocop is suppressed locally with `# rubocop:disable Metrics/MethodLength` etc. (paired `enable`), mostly for large algorithm methods.
|
|
39
|
+
- Shared mutable state is guarded by `RedisQueuedLocks::Utilities::Lock#synchronize`; Ractor code must not capture non-shareable objects (build a fresh Redis client via `Swarm::RedisClientBuilder`).
|
|
40
|
+
|
|
41
|
+
## Claude rules
|
|
42
|
+
1. Start new files with `# frozen_string_literal: true` and use compact constant paths.
|
|
43
|
+
2. Add a full YARD block to every new public/private method; new code gets `@since <next version>`; when changing existing behavior, add/bump `@version`.
|
|
44
|
+
3. New operation = new `Acquirer::*` module + thin `Client` method delegating to it (+ `!` variant only if it must raise); details in `acquirer.md`.
|
|
45
|
+
4. Return `{ ok:, result: }` hashes from public API operations (`Client`, `Acquirer::*`, `Swarm` facade actions); raise only in `!` methods or on invalid arguments (`RedisQueuedLocks::ArgumentError`). Internal APIs, and swarm element internals in particular, use bare scalars/primitives (`true`/`false`, String, Symbol, Integer, `nil`, a flat Hash of scalars) without the wrapper.
|
|
46
|
+
5. Never hardcode `rql:` strings; add a `Resource.prepare_*` helper or constant instead.
|
|
47
|
+
6. Use WATCH/MULTI or a Lua constant for any read-modify-write on lock/queue keys; never do check-then-set in Ruby without a transaction.
|
|
48
|
+
7. New config option: `setting` with default + `validate` with a type check, both in `config.rb`; read it in `Client` as `config['key']`.
|
|
49
|
+
8. Logs and instrumentation events go through visitor modules (see `visitors.md`); event names follow `redis_queued_locks.<snake_case>`.
|
|
50
|
+
9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
|
|
51
|
+
10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods.
|
|
52
|
+
11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
|
|
53
|
+
12. Keep `.claude/project-overview.md` and the affected `.claude/rules/*.md` in sync in the same change (keys, options, results, events, algorithm steps, limitations); read the relevant overview sections before implementing and follow their invariants.
|
|
54
|
+
13. Read/write awareness: every new or changed operation over locks/queues (release, cleanup, info, zombies, series) must handle the read key family too (see the key families above) and keep the result shape for write-only usage unchanged (add keys/branches only when read data exists).
|
|
55
|
+
14. Never derive safety from queues or client clocks: mutual exclusion is decided by the lock state checked under WATCH (writers WATCH the write key + readers registry, readers WATCH the write key only), queues and positions only order requests.
|
|
56
|
+
|
|
57
|
+
## Recommendations (proposed, not yet project policy)
|
|
58
|
+
Apply to new or touched code; don't refactor existing code for these unless asked.
|
|
59
|
+
1. Avoid `# rubocop:disable all` (used in `client.rb` lock_series and `lock_series_poc.rb`); disable only the specific cops.
|
|
60
|
+
2. Document every option fully in YARD.
|
|
61
|
+
3. Spell new identifiers correctly and don't copy existing typos (`swarm_element__termiante` method, `Acquier` in comments and the `acquier.rbs` filename); fix them only in a dedicated change.
|
|
62
|
+
4. Include context in raised errors (lock name, acquirer id, timeout) so failures are diagnosable.
|
|
63
|
+
5. Prefer `then` (the modern alias) over `yield_self` in new code.
|