redis_queued_locks 1.20.0 → 1.21.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 +35 -13
- data/.claude/rules/acquirer.md +10 -9
- data/.claude/rules/logic.md +28 -5
- data/.claude/rules/swarm.md +2 -2
- data/.claude/rules/tests.md +6 -3
- data/.claude/rules/visitors.md +9 -10
- data/.claude/skills/rql-code-review/SKILL.md +24 -10
- data/CHANGELOG.md +126 -0
- data/CLAUDE.md +2 -1
- data/README.md +245 -70
- data/lib/redis_queued_locks/acquirer/acquire_lock/dequeue_from_lock_queue/log_visitor.rb +1 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rb +42 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/log_visitor.rb +45 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock/log_visitor.rb +1 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/try_to_lock.rb +12 -1
- data/lib/redis_queued_locks/acquirer/acquire_lock/with_acq_timeout.rb +2 -2
- data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire/log_visitor.rb +1 -0
- data/lib/redis_queued_locks/acquirer/acquire_lock/yield_expire.rb +5 -5
- data/lib/redis_queued_locks/acquirer/acquire_lock.rb +111 -24
- data/lib/redis_queued_locks/acquirer/clear_dead_requests.rb +86 -8
- data/lib/redis_queued_locks/acquirer/extend_lock_ttl.rb +122 -12
- data/lib/redis_queued_locks/acquirer/is_locked.rb +0 -1
- data/lib/redis_queued_locks/acquirer/is_queued.rb +0 -1
- data/lib/redis_queued_locks/acquirer/keys.rb +10 -1
- data/lib/redis_queued_locks/acquirer/lock_info.rb +31 -39
- data/lib/redis_queued_locks/acquirer/lock_series_poc/instr_visitor.rb +3 -2
- data/lib/redis_queued_locks/acquirer/lock_series_poc/log_visitor.rb +5 -4
- data/lib/redis_queued_locks/acquirer/lock_series_poc.rb +55 -15
- data/lib/redis_queued_locks/acquirer/locks.rb +26 -9
- data/lib/redis_queued_locks/acquirer/queue_info.rb +0 -1
- data/lib/redis_queued_locks/acquirer/queues.rb +10 -1
- data/lib/redis_queued_locks/acquirer/reindex_locks.rb +117 -4
- data/lib/redis_queued_locks/acquirer/release_all_locks.rb +31 -4
- data/lib/redis_queued_locks/acquirer/release_lock.rb +33 -7
- data/lib/redis_queued_locks/acquirer/release_locks_of.rb +39 -4
- data/lib/redis_queued_locks/acquirer/release_read_lock.rb +22 -3
- data/lib/redis_queued_locks/client.rb +82 -40
- data/lib/redis_queued_locks/config.rb +12 -13
- data/lib/redis_queued_locks/errors.rb +34 -4
- data/lib/redis_queued_locks/instrument/sampler.rb +4 -7
- data/lib/redis_queued_locks/logging/sampler.rb +4 -8
- data/lib/redis_queued_locks/resource.rb +14 -4
- data/lib/redis_queued_locks/swarm/flush_zombies.rb +1 -0
- data/lib/redis_queued_locks/swarm/redis_client_builder.rb +8 -4
- data/lib/redis_queued_locks/swarm/swarm_element/threaded.rb +3 -2
- data/lib/redis_queued_locks/swarm.rb +86 -5
- data/lib/redis_queued_locks/utilities.rb +1 -1
- data/lib/redis_queued_locks/version.rb +2 -2
- data/sig/redis_queued_locks/acquirer/acquire_lock/instr_visitor.rbs +14 -0
- data/sig/redis_queued_locks/acquirer/acquire_lock/log_visitor.rbs +13 -0
- data/sig/redis_queued_locks/acquirer/acquire_lock/yield_expire.rbs +2 -2
- data/sig/redis_queued_locks/acquirer/acquire_lock.rbs +1 -1
- data/sig/redis_queued_locks/acquirer/clear_dead_requests.rbs +9 -0
- data/sig/redis_queued_locks/acquirer/extend_lock_ttl.rbs +15 -2
- data/sig/redis_queued_locks/acquirer/reindex_locks.rbs +22 -1
- data/sig/redis_queued_locks/acquirer/release_lock.rbs +3 -2
- data/sig/redis_queued_locks/acquirer/release_read_lock.rbs +1 -1
- data/sig/redis_queued_locks/client.rbs +14 -3
- data/sig/redis_queued_locks/errors.rbs +5 -2
- data/sig/redis_queued_locks/instrument/sampler.rbs +0 -2
- data/sig/redis_queued_locks/logging/sampler.rbs +0 -2
- data/sig/redis_queued_locks/resource.rbs +1 -1
- data/sig/vendor/redis_client.rbs +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7aeb9d0b5ea57783c70a285b753616abf277955029184236ed7746f127148e4
|
|
4
|
+
data.tar.gz: 9a5771fd5f0df1edad7eb6270a211576fbcd33f4049a5fb040a65e334f92a32f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c2328b3187ce087a694d3169aaaf9362ffb48557e626ab32933ffa4b70e015121edababddaeb3d2e24df7bfcb2c77980c54f1ed315c7a08d2d41e23e631351e
|
|
7
|
+
data.tar.gz: 4a380d09471c48bc00ef7454e5755420738c5f1233c576d10d115c6ca973ecb505ce77b037d805f0299964da1eae31f02dd5999e2e1945cfef6aa5508e3b7116
|
data/.claude/project-overview.md
CHANGED
|
@@ -113,8 +113,19 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
|
|
|
113
113
|
- Try results (internal symbols): `:write_request_is_ahead`, `:read_request_is_ahead`,
|
|
114
114
|
`:read_lock_is_still_acquired`, `:conflict_lock_upgrade`, `:read_lock_is_expired_during_extension`;
|
|
115
115
|
try success result carries internal `rw_mode` (held mode), not exposed in the public `lock` result.
|
|
116
|
-
- Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`;
|
|
117
|
-
Integer (`RedisQueuedLocks::ArgumentError`
|
|
116
|
+
- Validation (`AcquireLock`): `read_write_mode` must be `:read`/`:write`; `ttl` must be a positive
|
|
117
|
+
Integer in both modes (`RedisQueuedLocks::ArgumentError`; write: `PEXPIRE` with a non-positive ttl would
|
|
118
|
+
delete the lock key in the obtaining `MULTI`, read: the reader expiration is computed from it);
|
|
119
|
+
`LockSeriesPoC` checks the series `ttl` the same way before computing the lock ttls (they include
|
|
120
|
+
the retry delay and jitter); `config['default_lock_ttl']` must be positive too; `extend_lock_ttl`
|
|
121
|
+
`milliseconds` must be a positive Integer (a non-positive one would decrease/delete the lock);
|
|
122
|
+
`meta` keys must not be reserved lock data keys, String or Symbol (Symbol keys are written as
|
|
123
|
+
Strings: `Symbol#name`; the same check in `AcquireLock` and `LockSeriesPoC`); the other per-call
|
|
124
|
+
numeric options are validated with the ranges of their config keys (`logic.md#7`): `queue_ttl` > 0,
|
|
125
|
+
`timeout`/`retry_count` nil or >= 0, `retry_delay`/`retry_jitter`/`lock_index_grace_period` >= 0
|
|
126
|
+
(`AcquireLock`; `LockSeriesPoC` checks the retry delay/jitter before the lock ttl calculation),
|
|
127
|
+
scan/batch sizes, `dead_ttl`, reindexing `queue_ttl` > 0 (operation modules), `zombie_ttl` and the
|
|
128
|
+
zombie scan sizes > 0 (`Swarm` facade: `SwarmArgumentError`).
|
|
118
129
|
- RW-aware operations: `unlock` (write lock + readers + reader data + both queues + the lock index entry (the enabled lock index), same result shape; one release
|
|
119
130
|
`MULTI` that also reads the readers registry, then one `DEL` of the released readers' data only when readers existed:
|
|
120
131
|
1 round trip without readers; trade-off: a released reader that obtains a new read lock in between loses its new data),
|
|
@@ -123,8 +134,8 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
|
|
|
123
134
|
(zombie readers reported as their registry key), `locked?`, `queued?`, `lock_info` (read info:
|
|
124
135
|
`'rw_mode' => 'read'`, `'rem_ttl'`, `'readers' => [reader data + rem_ttl]` when no write lock),
|
|
125
136
|
`queue_info` (`'read_lock_queue'`/`'read_queue'` when present), `locks`/`locks_info`, `queues`/`queues_info`;
|
|
126
|
-
write lock info in `lock_info`/`locks_info` has `'rw_mode' => 'write'` (computed on formatting,
|
|
127
|
-
so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
|
|
137
|
+
write lock info in `lock_info`/`locks_info` has `'lock_key'` and `'rw_mode' => 'write'` (computed on formatting,
|
|
138
|
+
not stored), so `'rw_mode'` is a reserved `meta` key (validated in `AcquireLock` and `LockSeriesPoC`);
|
|
128
139
|
every request in `queue_info`/`queues_info` is `{ 'acq_id', 'score', 'rw_mode' => 'write'|'read' }`
|
|
129
140
|
(`queues_info` derives the mode from the queue key via `Resource.lock_queue_rw_mode`).
|
|
130
141
|
- `lock_series` (both modes): one mode per series; `release_lock_series` releases only locks the series
|
|
@@ -212,8 +223,12 @@ is client-wide: `Client` passes it to the acquirers, it is not a `lock` option),
|
|
|
212
223
|
|
|
213
224
|
`redis_queued_locks.` + `lock_obtained`, `reentrant_lock_obtained`,
|
|
214
225
|
`extendable_reentrant_lock_obtained`, `lock_hold_and_release`, `reentrant_lock_hold_completes`,
|
|
215
|
-
`lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
|
|
216
|
-
`explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release
|
|
226
|
+
`lock_obtaining_failed`, `lock_series_obtained`, `lock_series_hold_and_release`, `explicit_lock_release`,
|
|
227
|
+
`explicit_all_locks_release`, `release_locks_of`, `explicit_read_lock_release`, `extend_lock_ttl`, `clear_dead_requests`,
|
|
228
|
+
`reindex_locks`. Observability rule (`logic.md#16`): logic that changes keys is logged and instrumented, the lock
|
|
229
|
+
acquirement per meaningful outcome (visitors; `lock_obtaining_failed` with the failure `result` also before a raised
|
|
230
|
+
acquirement error), each mutation operation with one log line + one event of the whole operation (the same name and data,
|
|
231
|
+
`instrument` last in the payload, inline in the module); lock info queries are neither; the swarm is known debt.
|
|
217
232
|
Lock events (`lock_obtained`, `reentrant_lock_obtained`, `extendable_reentrant_lock_obtained`,
|
|
218
233
|
`lock_hold_and_release`, `reentrant_lock_hold_completes`) carry `rw_mode` (requested mode) in the payload;
|
|
219
234
|
lock series events (`lock_series_obtained`, `lock_series_hold_and_release`) carry the mode of the series;
|
|
@@ -222,11 +237,18 @@ lock log lines (incl. lock series logs) carry `rw_mode => '...'`. RW try-lock lo
|
|
|
222
237
|
|
|
223
238
|
### Errors (`errors.rb`)
|
|
224
239
|
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
`
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
Global base errors per branch (`logic.md#15`): `RedisQueuedLocks::Error < StandardError` (the main
|
|
241
|
+
hierarchy: `LockAlreadyObtainedError`, `LockAcquirementTimeoutError`, `LockAcquirementRetryLimitError`,
|
|
242
|
+
`TimedLockTimeoutError`, `ConflictLockObtainError`, `SwarmError`, `ConfigError` (`ConfigNotFoundError`,
|
|
243
|
+
`ConfigValidationError`)), `RedisQueuedLocks::ArgumentError < ::ArgumentError` (`SwarmArgumentError`),
|
|
244
|
+
`RedisQueuedLocks::Exception < ::Exception` (`@api private`, super exceptional cases only: the internal
|
|
245
|
+
`*IntermediateTimeoutError`s of `Timeout.timeout` for `timeout` and `timed`; like `Timeout::ExitException`,
|
|
246
|
+
`rescue => e`/`rescue StandardError`/`rescue Timeout::Error`/`rescue nil` of the interrupted code (the user
|
|
247
|
+
block, logger and instrumenter calls) can not swallow them; the library converts them to the public errors/results).
|
|
248
|
+
redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so every `with` block
|
|
249
|
+
around a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC` write release) rescues
|
|
250
|
+
both internal timeouts and `Timeout::ExitException` (the user's `Timeout.timeout` without an exception class), closes
|
|
251
|
+
the connection (drops the WATCH state, reconnected on the next use) and re-raises.
|
|
230
252
|
|
|
231
253
|
---
|
|
232
254
|
|
|
@@ -277,7 +299,7 @@ lib/redis_queued_locks/
|
|
|
277
299
|
resource.rb keys and identities
|
|
278
300
|
data.rb, errors.rb, utilities.rb, utilities/lock.rb, debugger/, version.rb
|
|
279
301
|
sig/ RBS mirror of lib/ + sig/vendor stubs (redis_client, active_support, semantic_logger)
|
|
280
|
-
spec/ redis_queued_locks_spec.rb (~
|
|
302
|
+
spec/ redis_queued_locks_spec.rb (~5.6k lines, integration: shared examples run with both lock lookup modes), spec_helper.rb, setup_simplecov.rb
|
|
281
303
|
.github/workflows/ tests, lint, typecheck-static, typecheck-runtime
|
|
282
304
|
bin/console, bin/setup dev scripts
|
|
283
305
|
Rakefile, Steepfile, rbs_collection.yaml
|
|
@@ -323,7 +345,7 @@ Known quirk: `sig/redis_queued_locks/acquier.rbs` is misspelled (should be `acqu
|
|
|
323
345
|
## 6. Conventions
|
|
324
346
|
|
|
325
347
|
- `# frozen_string_literal: true` in every Ruby file.
|
|
326
|
-
- YARD tags on every class/method: `@api public|private`, `@since`, `@version` (bump
|
|
348
|
+
- YARD tags on every class/method: `@api public|private`, `@since`, `@version` (methods: bump on a behavior/signature change; classes/modules: only on an interface change: signatures, added/removed methods, `extend`/`include`/`prepend` or the superclass; `logic.md#2`).
|
|
327
349
|
- Every `lib/` change gets a matching `sig/` RBS update; use `# steep:ignore` only where Steep can't infer types.
|
|
328
350
|
- New operation: new `Acquirer::*` module, thin `Client` method (+ `!` variant if it should raise), RBS, spec.
|
|
329
351
|
- New config option: `setting` (+ `validate`) in `config.rb`, read via `config['key']`, document defaults.
|
data/.claude/rules/acquirer.md
CHANGED
|
@@ -22,15 +22,16 @@ paths:
|
|
|
22
22
|
- Read-only queries: few positional args `(redis_client, lock_name)`; collection queries use keywords `(redis_client, scan_size:, with_info:)`.
|
|
23
23
|
- Mutating operations: long positional lists ending with the fixed observability tail
|
|
24
24
|
`logger, instrumenter, instrument, log_sampling_enabled, log_sampling_percent, log_sampler, log_sample_this, instr_sampling_enabled, instr_sampling_percent, instr_sampler, instr_sample_this`
|
|
25
|
-
(order
|
|
25
|
+
(the same order in all mutating modules).
|
|
26
26
|
- Only `AcquireLock.acquire_lock` and `LockSeriesPoC.lock_series_poc` use keyword args (`process_id:`, `thread_id:`, ...).
|
|
27
|
-
- **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`):
|
|
28
|
-
1. `rel_start_time = clock_gettime`
|
|
29
|
-
2. call a private `fully_*` helper
|
|
27
|
+
- **Two-layer mutating operations** (`release_lock`, `release_read_lock`, `release_all_locks`, `release_locks_of`, `extend_lock_ttl`, `clear_dead_requests`, `reindex_locks`; observability rule: `logic.md#16`):
|
|
28
|
+
1. argument validations (`RedisQueuedLocks::ArgumentError`) first, then `rel_start_time = clock_gettime` (`ext_`/`idx_` prefixes for the extension/reindexing)
|
|
29
|
+
2. call a private `fully_*` helper with the Redis work (returns `{ ok:, result: }`, destructured with `fully_x(...) => { ok:, result: }`, or the raw data: a `Set`/`Integer`)
|
|
30
30
|
3. `time_at = Time.now.to_f`; `rel_time = ((rel_end_time - rel_start_time) / 1_000.0).ceil(2)` (microseconds → ms)
|
|
31
|
-
4. `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
|
|
32
|
-
5. `run_non_critical {
|
|
33
|
-
6.
|
|
31
|
+
4. `log_sampled = RedisQueuedLocks::Logging.should_log?(...)`; `instr_sampled = RedisQueuedLocks::Instrument.should_instrument?(...)`
|
|
32
|
+
5. `run_non_critical { logger.debug { "[redis_queued_locks.<event>] key => value ..." } } if log_sampled`
|
|
33
|
+
6. `run_non_critical { instrumenter.notify('redis_queued_locks.<event>', { ..., instrument: }) } if instr_sampled` (the same data as the log line, `instrument` last)
|
|
34
|
+
7. return `{ ok: true, result: { ..., rel_time: } }` (or the result of `fully_*`)
|
|
34
35
|
- **Results**: always `{ ok: Boolean, result: ... }` for operations; `result` is a Symbol status (`:async_expire_or_no_lock`, `:released`, `:nothing_to_release`) or a Symbol-keyed Hash (`rel_key_cnt`, `tch_queue_cnt`, `rel_time`, `extended_locks_count`). Info queries return a String-keyed Hash / Set or `nil` when absent.
|
|
35
36
|
- **Redis access**:
|
|
36
37
|
- Keys only via `Resource.prepare_lock_key` / `prepare_lock_queue` / `prepare_read_lock_queue` / `prepare_lock_readers` / `prepare_read_lock_key(lock_name, acquirer_id)` and `Resource::*_PATTERN` (`KEY_PATTERN`, `LOCK_PATTERN`, `LOCK_QUEUE_PATTERN`, `READ_LOCK_QUEUE_PATTERN`, `LOCK_READERS_PATTERN`, `READ_LOCK_PATTERN`, `ALL_LOCK_QUEUES_PATTERN`) and `Resource::LOCK_INDEX_KEY`; `Resource.lock_key_family` classifies scanned keys (keyspace lookups, `ReindexLocks`); `Resource.lock_name_from_readers` / `lock_key_from_readers` map registry keys back.
|
|
@@ -38,6 +39,7 @@ paths:
|
|
|
38
39
|
- Raw commands: `redis.call('CMD', ...)` with uppercase string command names and string args (`'0'`, `'-inf'`, `'+inf'`).
|
|
39
40
|
- Pooled connection: wrap multi-command work in `redis.with do |rconn| ... end`.
|
|
40
41
|
- Atomic writes: `rconn.multi do |transact| ... end` (or `multi(watch: [lock_key])` in `TryToLock`); batch reads: `pipelined do |pipeline| ... end` then index `result[0]`, `result[1]` into named vars (`hget_cmd_res`, `pttl_cmd_res`).
|
|
42
|
+
- Internal timeouts (`LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`) inherit from `RedisQueuedLocks::Exception` (`::Exception`, `logic.md#15`): redis-client sends `UNWATCH` on an interrupted `multi(watch:)` only for `StandardError`s, so a connection interrupted inside a WATCH block would return to the pool with the active WATCH (the next `EXEC` on it is aborted when a watched key changes). Every `redis.with do |rconn| ... end` block with a `multi(watch:)` (`TryToLock`, `ExtendLockTTL` read extensions, `LockSeriesPoC.release_write_lock_series`) ends with `rescue <both internal timeouts>, ::Timeout::ExitException` (the user's `Timeout.timeout` without an exception class interrupts with it the same way) → `rconn.close` → `raise`.
|
|
41
43
|
- Iteration: two lock lookup modes, switched by `config['lock_index_enabled']` (client-wide, `false` by default): `Client` (and the `Swarm` facade, the zombie flusher Ractor via a copied value) passes `lock_index_enabled` to every lock-iterating module (`Locks`, `Queues`, `Keys`, `ReleaseAllLocks`, `ReleaseLocksOf`, `ClearDeadRequests`, `FlushZombies`, `ZombieInfo`). Each module keeps both lookups side by side and shares the processing (lock info formatting, flush/release helpers, instrumentation) in one copy; the lookup is chosen in one place (`lock_index_enabled ? index_x : scan_x` or one `if lock_index_enabled ... else ... end` with `(lock index)` / `(keyspace)` step comments):
|
|
42
44
|
- keyspace (the disabled lock index, the pre-index code): one SCAN pass over `KEY_PATTERN` (or `ALL_LOCK_QUEUES_PATTERN` for queues) per operation, keys classified by `Resource.lock_key_family`, per-key commands in pipelined batches collected across SCAN pages (`scan_locks`, `scan_queues`, `each_lock_queue`, `flush_keyspace_zombies`, `each_keyspace_zombie_lock`); no lock index command is sent;
|
|
43
45
|
- lock index (enabled): one `ZSCAN` pass over the lock index per operation (`each_lock_name_batch(rconn, scan_size) { |lock_names| ... }`: `zscan(Resource::LOCK_INDEX_KEY, count: scan_size)`, names with an expired score are skipped, batches of `scan_size` names collected across ZSCAN pages, the rest yielded after the pass); keys are derived from the names (`Resource.prepare_*`, reader data via registry members) and per-key commands are sent in one pipeline (or one variadic command / `MULTI`) per batch; info queries check existence (`EXISTS`, empty `ZRANGE` = missing registry/queue); the keyspace is never scanned (only `ReindexLocks` scans `KEY_PATTERN` with `Resource.lock_key_family` to adopt keys without index entries, in both modes). Each operation module has its own `each_lock_name_batch` copy (same name and shape, duplicated on purpose; the swarm modules iterate inline: `flush_lock_index_zombies`, `each_lock_index_zombie_lock`).
|
|
@@ -71,7 +73,7 @@ paths:
|
|
|
71
73
|
2. Read-only query → small positional args or `scan_size:` / `with_info:` keywords, return data or `nil`. Mutating operation → follow the two-layer pattern (public timing/instrumentation wrapper + private `fully_*` helper).
|
|
72
74
|
3. Keep the observability tail parameters in the established order of the closest sibling module; pass them through unchanged from `Client`.
|
|
73
75
|
4. Return `{ ok:, result: }`; use Symbol statuses for simple outcomes and abbreviated Symbol keys (`rel_*`, `*_cnt`, `*_time`) consistent with existing results. Don't raise from acquirer modules except through the `raise_errors` path in `AcquireLock`.
|
|
74
|
-
5. Use `redis.with { |rconn| ... }` for any multi-command work; use `multi` for writes that must be atomic, `pipelined` for independent reads, `multi(watch:)` or Lua when a write depends on a read.
|
|
76
|
+
5. Use `redis.with { |rconn| ... }` for any multi-command work; use `multi` for writes that must be atomic, `pipelined` for independent reads, `multi(watch:)` or Lua when a write depends on a read. A new `multi(watch:)` gets the timeouts `rescue` (both internal timeouts and `Timeout::ExitException`: close the connection, re-raise) in its `with` block (see "Internal timeouts" above); never make the internal timeouts `StandardError`s again (the interrupted code would swallow them).
|
|
75
77
|
6. Use uppercase string Redis commands and string numeric args; handle `PTTL`/`TTL` sentinel values explicitly.
|
|
76
78
|
7. Iterate locks in one pass with both lookups (see "Iteration"): a new lock-iterating operation takes `lock_index_enabled` (from `Client`: `config['lock_index_enabled']`) and implements the keyspace lookup (one SCAN pass over `KEY_PATTERN`/`ALL_LOCK_QUEUES_PATTERN`, `Resource.lock_key_family`) and the lock index lookup (one `ZSCAN` pass, `each_lock_name_batch`, keys derived from the lock names) next to each other, sharing the rest of the logic in one copy; never `KEYS`, never a keyspace `SCAN` with the enabled lock index (except `ReindexLocks`); send per-key commands in pipelined batches of `scan_size` keys/lock names (plus the rest after the pass), never one round trip per key; cover the remainder batches of both modes in specs (`lock-iterating operations: one pass (keyspace or lock index) with small batches` runs in both modes) and assert the absence of `SCAN` (lock index) / `ZSCAN` and lock index commands (keyspace) in recorded-command specs (`lock-iterating operations use the lock index`, `the disabled lock index (default): ...`).
|
|
77
79
|
8. Measure durations with `clock_gettime` and report ms with `/ 1_000.0).ceil(2)`; use `Time.now.to_f` only for event timestamps.
|
|
@@ -96,4 +98,3 @@ Apply to new or touched code; don't refactor existing code for these unless aske
|
|
|
96
98
|
1. Load Lua scripts once (`SCRIPT LOAD` + `EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), as the TODO in `extend_lock_ttl.rb` asks.
|
|
97
99
|
2. Measure lock-iterating operations by the lock index size (distinct lock names x (max(queue ttl, lock ttl) + grace)): most of the visited names are dead; prefer one cheap existence check per name (`EXISTS` with several keys) before reading the details when adding new iterating features.
|
|
98
100
|
3. Treat `lock_series_poc.rb` as experimental: don't build new features on it without asking. Its locks are released only through `release_lock_series` (obtained-by-series locks only, owner-checked, on success and on every failure path).
|
|
99
|
-
4. Unify the observability tail order (`logger, instrumenter` vs `instrumenter, logger` in `release_lock`) when those signatures are next changed.
|
data/.claude/rules/logic.md
CHANGED
|
@@ -24,7 +24,7 @@ Related rule files (loaded for narrower paths):
|
|
|
24
24
|
- Constants are defined compactly: `class RedisQueuedLocks::Acquirer::IsLocked` / `module ...`, never nested `module A; module B`.
|
|
25
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
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).
|
|
27
|
+
`@param name [Type]`, `@option`, `@return [Type]`, blank `#` line, then `@api public|private`, `@since X.Y.Z`, optional `@version X.Y.Z` (methods: the latest behavior/signature change; classes/modules: the latest interface change, see rule 2).
|
|
28
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
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
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`).
|
|
@@ -34,26 +34,49 @@ Related rule files (loaded for narrower paths):
|
|
|
34
34
|
- Lock index `rql:index:locks` (`Resource::LOCK_INDEX_KEY`), opt-in via the client-wide `config['lock_index_enabled']` (`false` by default: lock-iterating operations scan the keyspace, no lock index commands, queues without TTL): a global ZSET of lock names (score = upper bound of the lifetime of all lock keys, client ms + ttl + `lock_index_grace_period`); not matched by `KEY_PATTERN`. With the enabled lock index every key-creating command is followed by the index `ZADD GT` (queueing pipeline, obtaining `MULTI`), every extension by `ZADD XX INCR`, names are removed only with all their keys (`unlock`, `clear_locks`), lock request queues have TTL; all lock-iterating operations use it (`ZSCAN`), only `reindex_locks` scans the keyspace. Every lock-iterating module keeps both lookups (keyspace and lock index) side by side with the shared processing in one copy (details in `acquirer.md`, "Iteration" and "Lock index").
|
|
35
35
|
- 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; lock index scores use client ms (`Resource.lock_index_time`) with the `lock_index_grace_period` margin (clock skew tolerance; the index never affects mutual exclusion).
|
|
36
36
|
- 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.
|
|
37
|
-
- Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass.
|
|
37
|
+
- Errors: subclasses in `errors.rb`, written as `class XError < Error; end` (not `Class.new`) so RBS/Steep can see the superclass. Each error inherits from the global base error of its branch: `Error < ::StandardError` (the main hierarchy), `ArgumentError < ::ArgumentError` (`SwarmArgumentError`), `Exception < ::Exception` (the internal timeouts `LockAcquirementIntermediateTimeoutError`, `TimedLockIntermediateTimeoutError`); inside `module RedisQueuedLocks` the bare `Error`/`ArgumentError`/`Exception` are these bases.
|
|
38
38
|
- Comments use `# NOTE:`, `# TODO:` and `# @type var x: T` / `#: T` for inline type hints.
|
|
39
39
|
- Rubocop is suppressed locally with `# rubocop:disable Metrics/MethodLength` etc. (paired `enable`), mostly for large algorithm methods.
|
|
40
40
|
- 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`).
|
|
41
41
|
|
|
42
42
|
## Claude rules
|
|
43
43
|
1. Start new files with `# frozen_string_literal: true` and use compact constant paths.
|
|
44
|
-
2. Add a full YARD block to every new public/private method; new code gets `@since <next version
|
|
44
|
+
2. Add a full YARD block to every new public/private method; new code gets `@since <next version>`. `@version <next version>`:
|
|
45
|
+
- a method: when its behavior or its signature changes;
|
|
46
|
+
- a class/module header: only when its interface changes:
|
|
47
|
+
- a method signature changes: its parameters (their kinds and names); the default values and the return values are behavior;
|
|
48
|
+
- a method is added or removed (public or private, `attr_*` and aliases too);
|
|
49
|
+
- the ancestors change: `extend`/`include`/`prepend` of a module or a changed superclass (e.g. the internal timeout errors in 1.21.0);
|
|
50
|
+
- behavior changes inside the methods, constants, config settings (`setting`/`validate`: `Config` has no interface changes since 1.13.0) and comments bump only the method tags, never the class/module header;
|
|
51
|
+
- the header keeps the version of the latest interface change; no `@version` when the interface never changed since `@since` (the headers of the versions >= 1.17.0 are aligned with this rule).
|
|
45
52
|
3. New operation = new `Acquirer::*` module + thin `Client` method delegating to it (+ `!` variant only if it must raise); details in `acquirer.md`.
|
|
46
53
|
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.
|
|
47
54
|
5. Never hardcode `rql:` strings; add a `Resource.prepare_*` helper or constant instead.
|
|
48
55
|
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.
|
|
49
|
-
7. New config option: `setting` with default + `validate` with a type check, both in `config.rb`; read it in `Client` as `config['key']`.
|
|
56
|
+
7. New config option: `setting` with default + `validate` with a type check and the valid range (TTLs, periods, batch/scan sizes are positive Integers: `val.is_a?(Integer) && val > 0`), both in `config.rb`; read it in `Client` as `config['key']`. A per-call numeric option (a `Client` keyword with a `config` default) is validated with the same type and range at the start of the operation module (the `Swarm` facade for the swarm actions), before any Redis command: `RedisQueuedLocks::ArgumentError` (`SwarmArgumentError` in `Swarm`) with the option name and the value (`` "`:scan_size` argument should be a positive Integer, got 0." ``).
|
|
50
57
|
8. Logs and instrumentation events go through visitor modules (see `visitors.md`); event names follow `redis_queued_locks.<snake_case>`.
|
|
51
58
|
9. Keep thread/Ractor safety: guard shared state with `Utilities::Lock`, never share a `RedisClient` across Ractors.
|
|
52
|
-
10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods.
|
|
59
|
+
10. Disable rubocop cops only locally with a matching `rubocop:enable`, and only for Metrics/Layout on large methods (the only exception: the line-level `Lint/InheritException` of the base `RedisQueuedLocks::Exception < ::Exception` in `errors.rb`, see rule 15).
|
|
53
60
|
11. After any change here, update the mirrored `sig/` file (see `type-checking.md`) and add/adjust a spec (see `tests.md`).
|
|
54
61
|
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.
|
|
55
62
|
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).
|
|
56
63
|
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.
|
|
64
|
+
15. Error hierarchy (`errors.rb`, mirrored in `sig/redis_queued_locks/errors.rbs`):
|
|
65
|
+
- every `RedisQueuedLocks` error inherits from the global base error of its branch, never directly from a Ruby class; the main base is `Error < ::StandardError`;
|
|
66
|
+
- a branch that can not be a part of the main hierarchy (a Ruby core error that our error must stay, e.g. `ArgumentError < ::ArgumentError`) gets its own base error named after the Ruby class (`class TypeError < ::TypeError` for a new one), listed in the `errors.rb` header and in the overview "Errors";
|
|
67
|
+
- errors are built per case on the `StandardError` hierarchy or on the Ruby core errors (through the base of their branch), not on `::Exception`;
|
|
68
|
+
- `Exception < ::Exception` is for super exceptional cases only (internal interrupts that the interrupted user/observability code must not rescue: `rescue => e`, `rescue StandardError`, `rescue nil`); each error of this branch states in its comment why it is not a `StandardError` and where the library rescues it (it never reaches the public API: it is converted to a public error/result); only the base disables `Lint/InheritException` (subclasses use the relative `< Exception`);
|
|
69
|
+
- new public errors are `@api public` and documented in README/CHANGELOG; the `Exception` branch is `@api private`.
|
|
70
|
+
16. Observability of the logic (logs and instrumentation):
|
|
71
|
+
- every logic that creates, changes or deletes Redis keys has both logs and instrumentation; logic that only reads the lock data (`locked?`, `queued?`, `lock_info`, `queue_info`, `locks`, `queues`, `keys`, `*_info`, zombie queries) has neither;
|
|
72
|
+
- lock acquirement (`AcquireLock`, `LockSeriesPoC`): each meaningful step/outcome is logged and instrumented through the visitors (`visitors.md`): obtained (classic and reentrant), held and released, not obtained (`lock_obtaining_failed`, also before a raised acquirement error); the per-attempt `try_lock.*` steps are logs only (gated by `log_lock_try`), never instrumented (no `notify` per attempt);
|
|
73
|
+
- lock mutation operations (release, extension, cleanup, reindexing): exactly one log line and one instrumentation event per operation with the time of the whole work of the root operation module called by the public `Client` method (the two-layer pattern of `acquirer.md`: the timer wraps the `fully_*` helper), never of its internal parts; the log line and the event have the same name (`redis_queued_locks.<event>`) and the same data in the same order (the log has no `instrument`), the payload ends with the user's `instrument`; they are written inline in the module (`run_non_critical`, gated by `Logging.should_log?` / `Instrument.should_instrument?`);
|
|
74
|
+
- every such public `Client` method takes `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling keywords (defaults from `config`) and forwards them by name to the operation module;
|
|
75
|
+
- known debt: the swarm elements and the manual swarm actions (`ProbeHosts`, `FlushZombies`: `#probe_hosts`, `#flush_zombies`) are neither logged nor instrumented yet (the flusher Ractor can not get the user's logger/instrumenter).
|
|
76
|
+
17. Debug methods of `Resource`: `Resource` may keep methods that the library does not use when they are justified for debugging: they let you check a subtle part of the logic by hand at runtime (in a console), e.g. `lock_key_from_queue` maps a lock request queue of any mode to its lock. Each of them:
|
|
77
|
+
- has a `# NOTE: (debug)` comment in its YARD block: that it is not used by the library and what it checks (with a console example);
|
|
78
|
+
- is correct for every key family it can get (write and read keys), has the full YARD block, the RBS signature and a spec;
|
|
79
|
+
- is not a dead code finding; an unused method without the `(debug)` note is dead code (remove it); a debug method that the library starts to use loses the note.
|
|
57
80
|
|
|
58
81
|
## Recommendations (proposed, not yet project policy)
|
|
59
82
|
Apply to new or touched code; don't refactor existing code for these unless asked.
|
data/.claude/rules/swarm.md
CHANGED
|
@@ -29,7 +29,7 @@ The swarm removes **zombie locks**: locks and queue entries left by dead workers
|
|
|
29
29
|
| `ProbeHosts` | `swarm/probe_hosts.rb` | `< Threaded` | periodic host liveness probes |
|
|
30
30
|
| `FlushZombies` | `swarm/flush_zombies.rb` | `< Isolated` | periodic zombie flushing |
|
|
31
31
|
| `Acquirers`, `ZombieInfo` | `swarm/acquirers.rb`, `swarm/zombie_info.rb` | stateless `class << self` modules | read-only queries (`HGETALL` swarm hash, keyspace `SCAN`s or lock index `ZSCAN`s) |
|
|
32
|
-
| `RedisClientBuilder` | `swarm/redis_client_builder.rb` | stateless module | `build(pooled:, sentinel:, config:, pool_config:)` returns a fresh `RedisClient` or `RedisClient::Pooled
|
|
32
|
+
| `RedisClientBuilder` | `swarm/redis_client_builder.rb` | stateless module | `build(pooled:, sentinel:, config:, pool_config:)` returns a fresh `RedisClient` or `RedisClient::Pooled`; never mutates the passed hashes (client config values: can be frozen or shared with the flusher Ractor) |
|
|
33
33
|
|
|
34
34
|
`Client` methods `swarmize!`, `deswarmize!`, `swarm_status`/`swarm_state`, `swarm_info`, `probe_hosts`, `flush_zombies`, `zombie_locks`, `zombie_acquirers`, `zombie_hosts`, `zombies_info`/`zombies` only fill defaults from `config['swarm.*']` and delegate to `Swarm`. `Client#initialize` calls `swarm.swarm!` when `swarm.auto_swarm` is set.
|
|
35
35
|
|
|
@@ -114,7 +114,7 @@ Public element API (called by `Swarm`/`Supervisor` only):
|
|
|
114
114
|
16. Swarm specs (in `describe 'swarm'`) deal with real timing: set short periods via config, kill elements with `try_kill!`, wait longer than `liveness_probing_period`, and match states loosely (`eq('running').or(eq('blocking'))`, `'sleep'`/`'run'`).
|
|
115
115
|
|
|
116
116
|
## Known quirks (don't copy; fix only in a dedicated change)
|
|
117
|
-
-
|
|
117
|
+
- Typo in a method name: `swarm_element__termiante` (rename it only in a dedicated change).
|
|
118
118
|
- `swarm_loop__stop` is not used by the swarm itself, only by specs.
|
|
119
119
|
- Killing an element while its main loop is still connecting to Redis makes Ruby's `Socket.tcp` helper threads print `terminated with exception ... IO#write` reports. This is harmless noise from the stdlib.
|
|
120
120
|
- `sleep(0.1)` "give a timespot" waits in `swarm!`/`deswarm!`/`observe!` instead of real readiness signalling.
|
data/.claude/rules/tests.md
CHANGED
|
@@ -7,8 +7,11 @@ paths:
|
|
|
7
7
|
# Test rules (`spec/`)
|
|
8
8
|
|
|
9
9
|
## Observed conventions
|
|
10
|
-
- All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~
|
|
11
|
-
- Known flaky
|
|
10
|
+
- All behavior specs live in one integration file, `spec/redis_queued_locks_spec.rb` (~5.6k lines), under `RSpec.describe RedisQueuedLocks`; groups: `describe 'Lock Series PoC'`, `describe 'swarm'`, `describe 'read/write locks'`, plus top-level `specify` blocks (all of them in the shared examples), and `describe 'lock index'` (outside the shared examples, see below). The file header says it will be reworked; rspec-retry (5 retries) masks flakiness meanwhile.
|
|
11
|
+
- Known flaky examples (timing-dependent: they pass in isolation, rspec-retry usually hides them; check them with `RSPEC_RETRY_RETRY_COUNT=1` before blaming a change):
|
|
12
|
+
- `all in + notifications`: `sleep(1)` before the lock/queue info checks; in full runs it can fail all retries in a row in the keyspace lookup mode (`locks_info.size` is 1 instead of 2): order-dependent (reproducible with `--seed 6170`, also without retried swarm examples), the example that leaks the state is not found yet (`rspec --bisect` of the suite is too slow);
|
|
13
|
+
- `:queued access strategy`: the 4th attempt of `trd2` (~9 s, `retry_delay: 3_000`) races the expiration of the `trd1` lock (obtained at ~7 s with ttl 2 s), and the default `try_to_lock_timeout` (10 s) leaves no later attempt;
|
|
14
|
+
- `(auto-swarming!): zombie locks (with hosts and acquirers)` and `manual zombie flushing (with statuses)`: swarm timings (`sleep`-based waits for the probes and the zombie flusher).
|
|
12
15
|
- `.rspec` auto-requires `spec_helper`; `spec_helper.rb` loads SimpleCov first (`setup_simplecov.rb`), then `rspec/retry`, `pry`, the gem.
|
|
13
16
|
- RSpec config: random order (`Kernel.srand config.seed`), `disable_monkey_patching!`, `expect` syntax only, `filter_run_when_matching :focus`, `Thread.abort_on_exception = true`.
|
|
14
17
|
- Tests hit a real Redis (db 0) via `let(:redis) { RedisClient.config(db: 0).new_pool(timeout: 5, size: 50, ...) }` with long timeouts (RBS runtime-check runs are slow); `all in + notifications` also flushes db 1. Check the local Redis dbs before running the suite on a machine with other data.
|
|
@@ -36,7 +39,7 @@ The suite is pending a total rework (overview "Conventions"): these rules guide
|
|
|
36
39
|
6. Clean up everything an example starts: release locks, `deswarmize!` swarm clients, join/kill threads.
|
|
37
40
|
7. Keep `sleep`-based waits minimal and comment why (`# give a timespot to ...`); prefer polling with a bounded timeout when adding new async checks.
|
|
38
41
|
8. Do not add new rspec-retry reliance or lower retry settings; do not enable `minimum_coverage` without being asked.
|
|
39
|
-
9. Specs are also run under RBS runtime checks, so pass correctly typed arguments to public API calls (type violations are logged in CI); e.g. test invalid
|
|
42
|
+
9. Specs are also run under RBS runtime checks, so pass correctly typed arguments to public API calls (type violations are logged in CI); e.g. test an invalid lock ttl with `ttl: 0`, not `ttl: nil` (`Client#lock` types `ttl` as `Integer`).
|
|
40
43
|
10. Check new or changed examples for flakiness without retries: `RSPEC_RETRY_RETRY_COUNT=1 bundle exec rspec spec/redis_queued_locks_spec.rb -e '<group>'`, several runs in a row; also watch for `RSpec::Retry: 2nd try` lines in normal runs.
|
|
41
44
|
11. Threads in specs: `Thread.abort_on_exception = true`, so never raise expected errors inside threads (call non-raising `lock` there and assert results in the main thread); keep references to helper threads until the end of the example (acquirer ids contain `Thread#object_id`, which can be reused after GC).
|
|
42
45
|
12. Timing assertions on TTLs allow the redis time shift error: extendable reentrant locks return the extension minus the time spent in the inner block and `Resource::REDIS_TIMESHIFT_ERROR` (2 ms), so the remaining TTL can slightly exceed the initial one.
|
data/.claude/rules/visitors.md
CHANGED
|
@@ -17,14 +17,14 @@ The project calls these modules "visitors", but they are not GoF Visitor (no `ac
|
|
|
17
17
|
## Where they live
|
|
18
18
|
| Visitor | File | Events |
|
|
19
19
|
|---|---|---|
|
|
20
|
-
| `AcquireLock::LogVisitor` | `acquire_lock/log_visitor.rb` |
|
|
21
|
-
| `AcquireLock::InstrVisitor` | `acquire_lock/instr_visitor.rb` |
|
|
20
|
+
| `AcquireLock::LogVisitor` | `acquire_lock/log_visitor.rb` | 7: `start_lock_obtaining`, `start_try_to_lock_cycle`, `dead_score_reached__reset_acquirer_position`, `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained`, `lock_obtaining_failed` |
|
|
21
|
+
| `AcquireLock::InstrVisitor` | `acquire_lock/instr_visitor.rb` | 6: `extendable_reentrant_lock_obtained`, `reentrant_lock_obtained`, `lock_obtained`, `lock_obtaining_failed`, `reentrant_lock_hold_completes`, `lock_hold_and_release` |
|
|
22
22
|
| `AcquireLock::TryToLock::LogVisitor` | `acquire_lock/try_to_lock/log_visitor.rb` | 18 step-level events (`start`, `rconn_fetched`, `acq_added_to_queue`, `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
|
| `LockSeriesPoC::LogVisitor` / `InstrVisitor` | `lock_series_poc/*_visitor.rb` | lock series events (no RBS, `# steep:ignore`): `start_lock_series_obtaining`, `lock_series_obtained`, `expire_lock_series` / `lock_series_obtained`, `lock_series_hold_and_release`; all of them log/carry `rw_mode` (the mode of the series) |
|
|
26
26
|
|
|
27
|
-
Not covered by visitors: `
|
|
27
|
+
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
28
|
|
|
29
29
|
## Observed conventions
|
|
30
30
|
- **Placement**: a visitor sits in a subdirectory named after the component it reports on (`<component>/log_visitor.rb`, `<component>/instr_visitor.rb`) and is loaded by that component with `require_relative` at the top of the module body.
|
|
@@ -40,12 +40,12 @@ Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks
|
|
|
40
40
|
- **Never raise**: every emit ends with `rescue nil` (modifier), so a broken logger/instrumenter can't affect lock correctness.
|
|
41
41
|
- **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
42
|
- **Typing**: each visitor (except lock_series_poc) has an RBS file with `def self.<event>: (RQL::loggerObj logger, bool log_sampled, ...) -> void` / `RQL::instrObj instrumenter`.
|
|
43
|
-
- **User docs**: README documents every log event with its logged keys (`## Logging`: the default
|
|
43
|
+
- **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
44
|
|
|
45
45
|
## Claude rules
|
|
46
46
|
**When to use**
|
|
47
47
|
1. Any new log line or instrumentation event inside `AcquireLock` (and its mixins) or `LockSeriesPoC` must go through a visitor method; never call `logger.*` or `instrumenter.notify` directly in algorithm code.
|
|
48
|
-
2. Add a LogVisitor event for each new meaningful step or branch of the algorithm (start, decision, exit, success). Add an InstrVisitor event
|
|
48
|
+
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
49
|
3. Fine-grained per-attempt logging belongs in `TryToLock::LogVisitor` and must be gated by `log_lock_try`.
|
|
50
50
|
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.
|
|
51
51
|
|
|
@@ -59,11 +59,10 @@ Not covered by visitors: `release_lock`, `release_read_lock`, `release_all_locks
|
|
|
59
59
|
11. Add the method to the mirrored RBS visitor file (`RQL::loggerObj` / `RQL::instrObj`, `bool` sampled flag, `-> void`) and cover the new event in specs via the fake logger/notifier (assert on the `[redis_queued_locks.<event>]` prefix or event name).
|
|
60
60
|
12. Never pass Redis reads (or other costly computations) as visitor arguments unconditionally: arguments are evaluated before the visitor's `return unless` guard. Gate them at the call site with the same condition (`(log_sampled && log_lock_try) ? rconn.call('HGETALL', ...).to_h : NO_LOCK_DATA`; the fallback is the shared frozen `TryToLock::NO_LOCK_DATA`, not a new `{}`).
|
|
61
61
|
13. A new branch/exit of the read/write algorithm gets its own TryToLock event (`exit__write_request_ahead`, `exit__read_request_ahead`, `exit__read_lock_still_obtained`, `single_process_lock_conflict__lock_upgrade` are the existing ones). Don't add events to the default write success path: specs assert its exact log sequence (10 lines with `log_lock_try`).
|
|
62
|
-
14. Any change of observable data is documented in README in the same change: a new log event or a new/renamed/removed logged key (`LogVisitor`s) → `## Logging` (both lists and the matching `config['logger']` / `config['log_lock_try']` comments in `### Logging Configuration`); a new instrumentation event or a new/renamed/removed payload key (`InstrVisitor`s and the inline `notify` of
|
|
62
|
+
14. Any change of observable data is documented in README in the same change: a new log event or a new/renamed/removed logged key (`LogVisitor`s) → `## Logging` (both lists and the matching `config['logger']` / `config['log_lock_try']` comments in `### Logging Configuration`); a new instrumentation event or a new/renamed/removed payload key (`InstrVisitor`s and the inline `notify` of the mutation modules) → `### Instrumentation Events` (event list + payload line with type and semantics, e.g. requested vs held `rw_mode`). Add a CHANGELOG `[Unreleased]` line as well. Before finishing, compare the README lists with the visitor code (event names and key order).
|
|
63
63
|
|
|
64
64
|
## Recommendations (proposed, not yet project policy)
|
|
65
65
|
Apply to new or touched code; don't refactor existing code for these unless asked.
|
|
66
|
-
1.
|
|
67
|
-
2.
|
|
68
|
-
3.
|
|
69
|
-
4. Fix the RBS path mismatch: `sig/.../acquire_lock/yield_with_expire/log_visitor.rbs` mirrors `lib/.../acquire_lock/yield_expire/log_visitor.rb`; add RBS for `lock_series_poc` visitors when the PoC stabilizes.
|
|
66
|
+
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
|
+
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. Fix the RBS path mismatch: `sig/.../acquire_lock/yield_with_expire/log_visitor.rbs` mirrors `lib/.../acquire_lock/yield_expire/log_visitor.rb`; add RBS for `lock_series_poc` visitors when the PoC stabilizes.
|
|
@@ -88,8 +88,11 @@ locks", key layout, "Lock index", lock lookup modes, events, errors, conventions
|
|
|
88
88
|
`lock_series_poc*` has no RBS.
|
|
89
89
|
- Next version for `@since`/`@version`: the version the unreleased work already uses (`git diff <base> -- lib |
|
|
90
90
|
grep -oE '^\+.*@(since|version) [0-9.]+'`, the CHANGELOG `[Unreleased]` code), otherwise the next minor of
|
|
91
|
-
`RedisQueuedLocks::VERSION` (`version.rb`)
|
|
92
|
-
A value older than `VERSION` on new code,
|
|
91
|
+
`RedisQueuedLocks::VERSION` (`version.rb`); don't take the maximum over `lib/` (old tags can be wrong).
|
|
92
|
+
A value older than `VERSION` on new code, a method behavior/signature change without the method `@version` bump, or a
|
|
93
|
+
class/module header `@version` that does not match its latest interface change (`logic.md#2`: signatures,
|
|
94
|
+
added/removed methods, `extend`/`include`/`prepend`, the superclass; behavior changes never bump the header) is a
|
|
95
|
+
Minor finding.
|
|
93
96
|
|
|
94
97
|
## 3. Spec files are exempt (until the suite rework)
|
|
95
98
|
|
|
@@ -214,9 +217,12 @@ Still in scope (the exemption covers how specs are written, not whether the libr
|
|
|
214
217
|
key / acquirer id in the message.
|
|
215
218
|
- Keys only via `Resource.prepare_*`, `*_PATTERN`, `SWARM_KEY`, `LOCK_INDEX_KEY` (`logic.md#5`): flag every new
|
|
216
219
|
`'rql:...'` literal outside `resource.rb`.
|
|
217
|
-
- Config: `setting` + `validate` in `config.rb` (booleans `val == true || val == false`, Integers with ranges
|
|
220
|
+
- Config: `setting` + `validate` in `config.rb` (booleans `val == true || val == false`, Integers with ranges; per-call
|
|
221
|
+
numeric options validated with the same ranges in the operation module, `logic.md#7`), dotted
|
|
218
222
|
keys for groups, unit in a trailing `# NOTE:` comment, read as `config['key']` (`logic.md#7`). Errors:
|
|
219
|
-
`class XError < Error; end` in `errors.rb
|
|
223
|
+
`class XError < Error; end` in `errors.rb`, each under the global base of its branch (`Error`, `ArgumentError`,
|
|
224
|
+
`Exception`: super exceptional cases only, with the reason in the comment; `logic.md#15`). Runtime dependencies:
|
|
225
|
+
`redis-client` only; dev gems in `Gemfile`.
|
|
220
226
|
- `AcquireLock`: new behavior as a new step or mixin with `# Step N.x` numbering and consistent `acq_process` keys
|
|
221
227
|
(`acquirer.md#10`); `lock_series_poc.rb` is experimental: don't accept new features built on it without the
|
|
222
228
|
user's request.
|
|
@@ -236,8 +242,13 @@ Still in scope (the exemption covers how specs are written, not whether the libr
|
|
|
236
242
|
status shapes `{ running:, state: }` with `'non_initialized'` and their RBS (`#10`).
|
|
237
243
|
|
|
238
244
|
### G. Logging and instrumentation (`visitors.md`)
|
|
239
|
-
- Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`);
|
|
240
|
-
modules
|
|
245
|
+
- Logs/events of `AcquireLock` and its mixins and of `LockSeriesPoC` go through visitor methods (`#1`); mutation
|
|
246
|
+
modules write one log line and one event inline inside `run_non_critical`, gated by `log_sampled`/`instr_sampled`
|
|
247
|
+
(`acquirer.md#9`).
|
|
248
|
+
- Observability coverage (`logic.md#16`): a new or changed logic that creates/changes/deletes keys without logs and
|
|
249
|
+
instrumentation, a mutation event/log that times an internal part instead of the whole root operation, a mutation
|
|
250
|
+
payload without `instrument`, a `Client` mutation method without the observability keywords, or instrumented lock
|
|
251
|
+
info queries are findings; the swarm elements are known debt.
|
|
241
252
|
- Visitor shape: `class << self`, method name = event name (`__` between phase and detail), same name in the
|
|
242
253
|
Log and Instr visitors, first line `return unless log_sampled` (`&& log_lock_try` for `TryToLock` steps), one
|
|
243
254
|
`logger.debug do ... end rescue nil` with `"[redis_queued_locks.<event>] "` and `key => 'value'` pairs, or
|
|
@@ -264,8 +275,9 @@ Still in scope (the exemption covers how specs are written, not whether the libr
|
|
|
264
275
|
- `# frozen_string_literal: true`; compact constant paths (`module RedisQueuedLocks::Acquirer::Keys`); namespace
|
|
265
276
|
files only `require_relative` (`logic.md#1`).
|
|
266
277
|
- YARD on every class, module, constant, attr and method: `@param`/`@option`/`@return` (`Format: { ... }` for hash
|
|
267
|
-
results), blank `#`, `@api public|private`, `@since <next>`; behavior changes bump `@version <next>` on
|
|
268
|
-
method
|
|
278
|
+
results), blank `#`, `@api public|private`, `@since <next>`; behavior/signature changes bump `@version <next>` on
|
|
279
|
+
the method; the module/class header `@version` changes only with the interface (signatures, added/removed methods,
|
|
280
|
+
`extend`/`include`/`prepend`, the superclass), never for behavior changes inside the methods (`logic.md#2`).
|
|
269
281
|
- Comments: `# NOTE:` with `# - item;` lists, `# Step N.x` in algorithms, `(RW)`/`(lock index)`/`(keyspace)`
|
|
270
282
|
tags, `NOTE: (pipeline layout)`/`(transaction layout)` for positional replies.
|
|
271
283
|
- Redis: uppercase String commands, String numeric args (`'0'`, `'-1'`, `'-inf'`, `'+inf'`), `redis.with do |rconn|`
|
|
@@ -297,12 +309,14 @@ Still in scope (the exemption covers how specs are written, not whether the libr
|
|
|
297
309
|
read→write upgrade as an error unless `:wait_for_lock`; nested reads through `conflict_strategy`; no fairness
|
|
298
310
|
between modes with `:random`; best-effort cross-host FIFO; no Lua in the RW path; one mode per lock series;
|
|
299
311
|
`lock_index_enabled` as a config-only mode; the lock index defaulting to `false`.
|
|
312
|
+
- Unused `Resource` methods marked `# NOTE: (debug)` (manual runtime checks, `logic.md#17`); report a debug method
|
|
313
|
+
only when it is wrong for a key family it can get.
|
|
300
314
|
- Known limitations kept by the user: `YieldExpire` write release (`EXPIRE 0`) and ttl decrease without an owner
|
|
301
315
|
check; `HMGET`→`DEL` without `WATCH` in `ReleaseLocksOf`/`FlushZombies`; `unlock` dropping the data of a reader
|
|
302
316
|
re-acquired between its two round trips; `zombie_acquirers` vs `flush_zombies` acquirer sets.
|
|
303
317
|
- Known debt: `WithAcqTimeout` → `LockInfo`/`QueueInfo`; rspec-retry, disabled coverage minimum, non-blocking runtime
|
|
304
|
-
type checks; `acquier.rbs` and the `yield_with_expire` RBS path;
|
|
305
|
-
|
|
318
|
+
type checks; `acquier.rbs` and the `yield_with_expire` RBS path; the `swarm_element__termiante` typo;
|
|
319
|
+
`sleep(0.1)` readiness waits and missing logging in the swarm;
|
|
306
320
|
one flusher per process; `EVAL` without `SCRIPT LOAD`; `# rubocop:disable all` around lock series.
|
|
307
321
|
- "Recommendations (proposed, not yet project policy)" in the rule files: never violations; mention one only as an
|
|
308
322
|
optional suggestion for touched code.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,131 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.21.0] - 2026-10-13
|
|
4
|
+
### Added
|
|
5
|
+
- Logging and instrumentation of all operations that change Redis keys (the swarm is not covered yet):
|
|
6
|
+
- the lock acquirement: the new `redis_queued_locks.lock_obtaining_failed` instrumentation event and log
|
|
7
|
+
(`#lock`/`#lock!` and each lock of `#lock_series`/`#lock_series!`: the lock is not obtained, the failure `result`
|
|
8
|
+
(`:timeout_reached`, `:retry_limit_reached`, `:fail_fast_no_try`, `:fail_fast_after_try`, `:conflict_dead_lock`,
|
|
9
|
+
`:conflict_lock_upgrade`) is in the payload; with `raise_errors: true` it is reported before the error is raised);
|
|
10
|
+
- each lock mutation operation is reported with one log line and one instrumentation event with the time of the whole
|
|
11
|
+
operation (the same name and data; the payload ends with `instrument`): the new `redis_queued_locks.extend_lock_ttl`
|
|
12
|
+
(`#extend_lock_ttl`), `redis_queued_locks.clear_dead_requests` (`#clear_dead_requests`), `redis_queued_locks.reindex_locks`
|
|
13
|
+
(`#reindex_locks`) events, and the new log lines of the existing `explicit_lock_release` (`#unlock`),
|
|
14
|
+
`explicit_read_lock_release` (`#unlock_read`), `explicit_all_locks_release` (`#clear_locks`), `release_locks_of`
|
|
15
|
+
(`#clear_locks_of`/`#clear_current_locks`) events (the `logger:` option of these methods was not used);
|
|
16
|
+
- `#reindex_locks`: `logger:`, `instrumenter:`, `instrument:` and the log/instrumentation sampling options;
|
|
17
|
+
### Changed
|
|
18
|
+
- stricter validation (the details are in "Fixed"): invalid values raise `RedisQueuedLocks::ArgumentError`
|
|
19
|
+
(`RedisQueuedLocks::SwarmArgumentError` for the swarm methods, `RedisQueuedLocks::ConfigValidationError` for the config)
|
|
20
|
+
instead of the previous silent misbehavior:
|
|
21
|
+
- `ttl` of `#lock`/`#lock!`/`#lock_series`/`#lock_series!` should be a positive `Integer` in both modes (`ttl: 0` was
|
|
22
|
+
accepted in the write mode);
|
|
23
|
+
- `#extend_lock_ttl`: `milliseconds` should be a positive `Integer` (`extend_lock_ttl(name, 0)` was a successful no-op);
|
|
24
|
+
- the per-call numeric options should be `Integer`s in the ranges of their config keys: `Float` values (e.g.
|
|
25
|
+
`timeout: 0.5`, `retry_delay: 10.0`) are rejected now;
|
|
26
|
+
- the config: positive TTLs, periods and batch/scan sizes;
|
|
27
|
+
- `:meta`: reserved lock data keys are rejected as Symbol keys too;
|
|
28
|
+
- the internal timeout errors of `timed: true` and `timeout` inherit from `RedisQueuedLocks::Exception < ::Exception`
|
|
29
|
+
(were `Timeout::Error`s): a `rescue Timeout::Error` (or `rescue => e`) inside the block does not catch the lock timeout
|
|
30
|
+
anymore; the public errors/results are the same;
|
|
31
|
+
- `#locks_info`: the info of write locks has the `"lock_key"` field (like `#lock_info`);
|
|
32
|
+
- instrumentation payloads: `explicit_lock_release`, `explicit_all_locks_release` and `release_locks_of` have `instrument`;
|
|
33
|
+
### Fixed
|
|
34
|
+
- the `instrument:` option of `#unlock`, `#clear_locks`, `#clear_locks_of`/`#clear_current_locks` (README: "passed to
|
|
35
|
+
the instrumenter's payload") was not passed to the payload (`#unlock` did not even forward it); the payloads of
|
|
36
|
+
`explicit_lock_release`, `explicit_all_locks_release` and `release_locks_of` have `instrument` now; `#extend_lock_ttl` and
|
|
37
|
+
`#clear_dead_requests` had no instrumentation events at all (see "Added");
|
|
38
|
+
- `#lock`/`#lock!` with a non-positive `ttl` in the write mode: the lock was reported as obtained, but `PEXPIRE` with
|
|
39
|
+
a non-positive ttl deleted the lock key in the obtaining transaction, so other acquirers could obtain the same lock
|
|
40
|
+
at the same time (no mutual exclusion); `ttl` should be a positive `Integer` in both modes now
|
|
41
|
+
(`RedisQueuedLocks::ArgumentError`, previously validated for read locks only); `ttl: nil` was never supported
|
|
42
|
+
(the YARD docs described it as "without timeout");
|
|
43
|
+
- `#lock_series`/`#lock_series!` (PoC) with a non-positive `ttl` (both modes): the locks of the series were obtained with
|
|
44
|
+
the ttl made of the retry delay and jitter only; the series `ttl` should be a positive `Integer` now
|
|
45
|
+
(`RedisQueuedLocks::ArgumentError`);
|
|
46
|
+
- `config['default_lock_ttl']` should be a positive `Integer` (`RedisQueuedLocks::ConfigValidationError`);
|
|
47
|
+
- the config validation accepted zero and negative TTLs, periods and batch/scan sizes: `config['default_queue_ttl'] = 0`
|
|
48
|
+
dropped the own lock request in the queueing pipeline (endless `:dead_score_reached`), zero batch/scan sizes failed
|
|
49
|
+
`SCAN`/`ZSCAN` (`COUNT 0`: `ERR syntax error`) and `each_slice(0)`, zero/negative swarm periods killed the swarm main loops
|
|
50
|
+
and the supervisor (`sleep` with a negative value) or made them busy loops, a non-positive `zombie_ttl` made all hosts
|
|
51
|
+
zombies; these keys should be positive `Integer`s now (`RedisQueuedLocks::ConfigValidationError`): `default_queue_ttl`,
|
|
52
|
+
`lock_release_batch_size`, `clear_locks_of__lock_scan_size`, `clear_locks_of__queue_scan_size`, `key_extraction_batch_size`,
|
|
53
|
+
`swarm.supervisor.liveness_probing_period`, `swarm.probe_hosts.probe_period`, `swarm.flush_zombies.zombie_ttl`,
|
|
54
|
+
`swarm.flush_zombies.zombie_flush_period`, `swarm.flush_zombies.zombie_lock_scan_size`,
|
|
55
|
+
`swarm.flush_zombies.zombie_queue_scan_size`;
|
|
56
|
+
- `#extend_lock_ttl` with a non-positive `milliseconds` decreased the lock TTL (or deleted the lock) and reported a successful
|
|
57
|
+
extension: `milliseconds` should be a positive `Integer` now (`RedisQueuedLocks::ArgumentError`);
|
|
58
|
+
- `#lock`/`#lock!`/`#lock_series`/`#lock_series!` (both modes): reserved lock data keys were rejected in `:meta` only as
|
|
59
|
+
String keys, while Symbol keys (`meta: { acq_id: '...' }`) passed the validation and overwrote the lock data fields
|
|
60
|
+
(`acq_id`, `hst_id`, `ts`, `ini_ttl`, `spc_*`, ...: reentrant locks, owner checks of lock releases and lock info were
|
|
61
|
+
broken while the lock lived); Symbol keys are validated too now (`RedisQueuedLocks::ArgumentError`);
|
|
62
|
+
- `timed: true` and `timeout`: the internal timeout errors inherited from `Timeout::Error` (a `StandardError`), so `rescue => e`,
|
|
63
|
+
`rescue StandardError`, `rescue Timeout::Error` and `rescue nil` of the interrupted code swallowed them: a `timed` block with
|
|
64
|
+
such a `rescue` continued after the lock TTL (another acquirer could obtain the expired lock, and the write lock release
|
|
65
|
+
after the block deleted the lock of that acquirer), and the acquirement timeout that interrupted a logger/instrumenter
|
|
66
|
+
call (`rescue nil` of the log/instrumentation events) was lost (with `retry_count: nil` the acquirement waited without
|
|
67
|
+
the timeout); the internal timeouts inherit from the new private base `RedisQueuedLocks::Exception < ::Exception` now
|
|
68
|
+
(like `Timeout::ExitException`): only `rescue Exception` catches them, `ensure` still runs, the public errors/results
|
|
69
|
+
are the same (`RedisQueuedLocks::TimedLockTimeoutError`, `RedisQueuedLocks::LockAcquirementTimeoutError`,
|
|
70
|
+
`:timeout_reached`); a connection interrupted inside a `WATCH` block is closed (redis-client sends `UNWATCH` only on
|
|
71
|
+
`StandardError`s: the next transaction on the connection would be aborted);
|
|
72
|
+
- `#lock_series`/`#lock_series!` (PoC): the log events and the `redis_queued_locks.lock_series_hold_and_release`
|
|
73
|
+
instrumentation event raised the errors of the logger/instrumenter: a failing logger broke the series call (after
|
|
74
|
+
obtaining all the locks of the series the locks were not released till their ttl), a failing instrumenter replaced
|
|
75
|
+
the block result with its error; the logger/instrumenter errors are swallowed now (like in all other events);
|
|
76
|
+
- `#locks_info` (and `#locks(with_info: true)`): the info of write locks had no `"lock_key"` field (the info of read locks and
|
|
77
|
+
`#lock_info` have it), so the info shape depended on the lock mode; the write lock info has `"lock_key"` now (the same
|
|
78
|
+
fields as `#lock_info`);
|
|
79
|
+
- `RedisQueuedLocks::Logging::Sampler` and `RedisQueuedLocks::Instrument::Sampler` (the default log/instrumentation samplers)
|
|
80
|
+
sampled `sampling_percent` of 101 values (`SecureRandom.rand(0..100)`): `0%` sampled ~1% of the cases (instead of none),
|
|
81
|
+
`15%` sampled ~15.8%; they sample exactly `sampling_percent` of 100 values now (`0%` - never, `100%` - always);
|
|
82
|
+
the private `SAMPLING_PERCENT_RANGE` constants are removed;
|
|
83
|
+
- swarm: the Redis client builder of the swarm elements mutated the `swarm.*.redis_config.config`/`pool_config` hashes of
|
|
84
|
+
the client config (`transform_keys!`): with frozen hashes each start of the host probing and zombie flushing main loops
|
|
85
|
+
failed with `FrozenError` (the hosts were not probed, zombie locks were not flushed); the hashes are not mutated now;
|
|
86
|
+
- RBS: the `RedisQueuedLocks::Client#current_acq_id` alias was declared as `ccurrent_acq_id` (a typo: the public method was
|
|
87
|
+
missing in the signatures); `RedisQueuedLocks::Client#queues_info` returned the `queues` union (`queueList | queuesInfo`)
|
|
88
|
+
instead of `queuesInfo`;
|
|
89
|
+
- `RedisQueuedLocks::Resource.lock_key_from_queue` (a private debug helper for manual checks in a console, not used by
|
|
90
|
+
the library) returned a wrong key for the read lock request queues (`rql:lock:ueue:<name>`); it supports the write and
|
|
91
|
+
read lock request queues now and returns `nil` for other keys;
|
|
92
|
+
- a connection interrupted inside a `WATCH` block by a `Timeout.timeout` of your code without an exception class
|
|
93
|
+
(`Timeout::ExitException`, e.g. `Timeout.timeout(5) { rql.lock("x") { ... } }`) returned to the connection pool with
|
|
94
|
+
the active `WATCH` (redis-client sends `UNWATCH` only on `StandardError`s): the next transaction on the connection could
|
|
95
|
+
be aborted (e.g. a lock release); the connection is closed now (like with the internal timeouts);
|
|
96
|
+
- the per-call numeric options were not validated (zero/negative values broke the operations like the same config values
|
|
97
|
+
before the config validation fix): they are validated with the same ranges as their config keys now
|
|
98
|
+
(`RedisQueuedLocks::ArgumentError`; `RedisQueuedLocks::SwarmArgumentError` for the swarm methods), before any Redis
|
|
99
|
+
command; the values should be `Integer`s (like the config values: `Float` values such as `timeout: 0.5` or
|
|
100
|
+
`retry_delay: 10.0` are rejected now):
|
|
101
|
+
- `#lock`/`#lock!`/`#lock_series`/`#lock_series!`: `queue_ttl` > 0, `timeout` and `retry_count` - `nil` or >= 0,
|
|
102
|
+
`retry_delay`, `retry_jitter` and `lock_index_grace_period` >= 0;
|
|
103
|
+
- `#locks`, `#locks_info`, `#queues`, `#queues_info`, `#keys`, `#clear_dead_requests`, `#reindex_locks`: `scan_size` > 0;
|
|
104
|
+
`#clear_locks`: `batch_size` > 0; `#clear_locks_of`/`#clear_current_locks`: `lock_scan_size`, `queue_scan_size` > 0;
|
|
105
|
+
`#clear_dead_requests`: `dead_ttl` > 0; `#reindex_locks`: `queue_ttl` > 0, `lock_index_grace_period` >= 0;
|
|
106
|
+
- `#swarm_info`, `#flush_zombies`, `#zombie_locks`, `#zombie_acquirers`, `#zombie_hosts`, `#zombies_info`: `zombie_ttl`
|
|
107
|
+
and the scan sizes > 0;
|
|
108
|
+
- README/YARD documentation that did not match the code:
|
|
109
|
+
- config keys: `config['instr_sampling_enabled']` in the configuration example (was `nstr_sampling_enabled`),
|
|
110
|
+
`config['log_sampling_enabled']` (was `log_samplign_enabled`), `*_sampling_enabled` (was `*_sampling_enalbed`);
|
|
111
|
+
- `#lock` results: `:retry_limit_reached` (was `:retry_count_reached`), `:conflict_lock_upgrade` is listed, `:unknown`
|
|
112
|
+
is not returned, `:dead_locking` is not a `:process` value (it fails with `:conflict_dead_lock`), the result has `hst_id`;
|
|
113
|
+
- `#lock_info`: `"ini_ttl"` (was `"init_ttl"`), `"spc_cnt"` (was `"spc_count"`), host ids `rql:hst:...` in the examples;
|
|
114
|
+
- `#unlock`: `rel_time` is in milliseconds (was "in seconds");
|
|
115
|
+
- `#clear_dead_requests` returns `{ ok: true, result: { processed_queues: Set<String> } }` (write and read request queues);
|
|
116
|
+
- `#extend_lock_ttl`: the write lock TTL is extended atomically by one Lua script, without the lock owner check
|
|
117
|
+
(was "non-atomic, two commands");
|
|
118
|
+
- the log lists of the `### Configuration` section: `rw_mode`, the key order, the read/write and lock series logs
|
|
119
|
+
(the same as in the `## Logging` section);
|
|
120
|
+
- the `#lock!` link of the Usage list and the `#lock` link of `#lock_info`, the Usage list and the table of contents: `unlock_read`, `clear_locks_of`,
|
|
121
|
+
`clear_current_locks`, `reindex_locks`;
|
|
122
|
+
- YARD: the `#clear_current_locks` options (`lock_scan_size`, `queue_scan_size` instead of the missing `batch_size`), the tag
|
|
123
|
+
typos (`@sine`, `@since ?.?.?`, `@since 19.0.0`, `@api ppublic`/`pulic`/`pub;ic`); the inert `# steep:ingore` comments
|
|
124
|
+
are removed;
|
|
125
|
+
- YARD: the `@version` tags of the classes/modules (versions >= 1.17.0) follow the interface changes only (method
|
|
126
|
+
signatures, added/removed methods, `extend`/`include`/`prepend`, the superclass), not the behavior changes inside
|
|
127
|
+
the methods;
|
|
128
|
+
|
|
3
129
|
## [1.20.0] - 2026-10-12
|
|
4
130
|
### Added
|
|
5
131
|
- **Lock index** (opt-in: `config['lock_index_enabled']`, `false` by default: lock-iterating operations scan the keyspace as before):
|
data/CLAUDE.md
CHANGED
|
@@ -26,7 +26,7 @@ Full details (config keys, key layout, events, errors, stack, CI): `.claude/proj
|
|
|
26
26
|
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), `git.md` (commit messages and commit flow, always loaded).
|
|
27
27
|
- 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.
|
|
28
28
|
- Update the matching `sig/*.rbs` for every `lib/` change; keep Steep green.
|
|
29
|
-
- Keep `# frozen_string_literal: true` and YARD `@api`/`@since`/`@version` tags.
|
|
29
|
+
- 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`).
|
|
30
30
|
- `{ 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`).
|
|
31
31
|
- New operation: `Acquirer::*` module, thin `Client` method (+ `!` variant), RBS, spec.
|
|
32
32
|
- Operation modules behind `Client` methods (all `Acquirer::*` except the `AcquireLock` acquisition core and PoC modules such as `LockSeriesPoC`, which may reuse other modules) are independent: they never call each other or reference each other's RBS types, share logic only via `Resource`/`Utilities`, and duplicate similar logic on purpose (independence over DRY; e.g. `Locks` duplicates the `LockInfo` formatting). Details: `acquirer.md`.
|
|
@@ -34,5 +34,6 @@ Detailed, path-scoped rules in `.claude/rules/`: `logic.md` (lib, general), `acq
|
|
|
34
34
|
- Dev gems go in `Gemfile`, not the gemspec.
|
|
35
35
|
- 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.
|
|
36
36
|
- Before implementing, read the relevant sections of `.claude/project-overview.md` (key layout, lock flow, "Read/Write locks", events) and keep their invariants. In the same change, update the overview and the affected `.claude/rules/*.md` for every new/changed key, option, result shape, event, algorithm step or limitation (and README/CHANGELOG `[Unreleased]` for user-visible behavior). New or changed log keys / instrumentation payload keys (visitors or inline `notify`) also go to README `## Logging` / `### Instrumentation Events` (see `visitors.md`).
|
|
37
|
+
- Observability (`logic.md#16`): logic that creates/changes/deletes keys is logged and instrumented: the lock acquirement per meaningful outcome (visitors), each mutation operation with one log line + one event of the whole root operation called by `Client` (inline, `instrument` last in the payload); lock info queries are neither; the swarm elements are known debt.
|
|
37
38
|
- Lock-touching code is read/write-aware and index-aware: any operation over locks, queues, zombies or lock info must cover the read family (`rql:lock_readers:*`, `rql:lock_read_queue:*`, `rql:lock_reader:*`) next to `rql:lock:*`/`rql:lock_queue:*`, and any new key-creating/extending command must keep the lock index a superset of the existing locks (enabled mode); a new lock-iterating operation implements both lookups (keyspace and lock index, `lock_index_enabled`). Mutual exclusion comes only from WATCH/MULTI on lock state (no Lua in the RW acquisition path unless asked); queues never guarantee safety.
|
|
38
39
|
- Known debt: rspec-retry, disabled coverage minimum, runtime type-check CI uses `--failure-exit-code=0`.
|