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