hanikamu-operation 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 565d28c9c7d700db3b5bdb2be0ddd960951d07bf027a16b946cc8c527ad45ae6
4
- data.tar.gz: 2c241ea17940bc687881d746361fa94fe85dfc3149d410c4b665c44d0367491b
3
+ metadata.gz: 30ca0f88ea8223a13a4b23eda4d6cefb6d010a5affedbc93e6a2d69614ba486b
4
+ data.tar.gz: 0601bee8022568f95a8a33163bb823bfe3fb09036c32d62ab2c7b2482819a06b
5
5
  SHA512:
6
- metadata.gz: da40b97d680e9f4f99b9819283c5d9441335b5a2e05b77143ee33b04635db03c6a3e00896f4b98f9481950d2b52721080c48b958eef8e3ba0133dda020da221d
7
- data.tar.gz: 6bf7537f8d8b3351f122c4e3a9c0de036c736a97907dcdf460fa9bd932600051f2807f39aaefaf28ee79b5f48264d7db0a9dc0132470f475cc45c4392701d8f4
6
+ metadata.gz: d097cc17ab86c0c5e29eace1bedac6fbcb89b55a85da19a035d1a012e6c0e55d06a4aefa9212605576f25303e521cbf7f090a27a6d6e7fc1381245789b6750cb
7
+ data.tar.gz: adb979d5f17ae5c50285041e38051db0b8aabf7d4c10ef5529980b2b193db2461d6bdb236f5fcc46227f60142a08a4e69605130b4783f971ee242958ad8c225c
data/CHANGELOG.md CHANGED
@@ -22,6 +22,17 @@
22
22
  Passing both `:if` and `:unless`, or a non-callable condition, raises `ArgumentError` at class
23
23
  definition time.
24
24
 
25
+ ## [0.3.1] - 2026-08-26
26
+
27
+ - Fixed `RedisClient::NoScriptError: NOSCRIPT` raised on every `within_mutex` acquire when a host
28
+ application runs its test suite with `Redlock::Client.testing_mode = :bypass` against a Redis with
29
+ an empty script cache (typically a fresh CI container). 0.3.0 read the lease window with
30
+ `get_remaining_ttl_for_resource`, which evaluates a Lua script; `:bypass` also stubs out Redlock's
31
+ script loading, so the `EVALSHA` failed and Redlock's own recovery could not reload the script.
32
+ The lease window is now taken from the `:validity` that Redlock already returns when the lock is
33
+ acquired — no Lua script, and one fewer Redis round-trip per acquire. Lease-aware reentrancy
34
+ behaviour is unchanged.
35
+
25
36
  ## [0.3.0] - 2026-08-25
26
37
 
27
38
  - `within_mutex` is now reentrant within the same execution context (fiber-local, effectively
data/README.md CHANGED
@@ -414,7 +414,7 @@ end
414
414
  - **Same execution context only**: reentrancy is keyed to the current fiber (via a fiber-local lease stack), which in the usual thread-per-request / thread-per-job model means the current thread. A synchronous callback cascade or nested call runs in that same context, so it is treated as the same holder.
415
415
  - **Cross-context is unchanged**: a different thread, process, or independently scheduled fiber (e.g. a separate background job or web request) still contends on Redis and still raises `Redlock::LockError` when the key is held elsewhere.
416
416
  - **No configuration, no opt-out**: a same-context re-acquire of a held key can only self-deadlock, so there is no valid non-reentrant use case. Reentrancy is always on.
417
- - **Lease-aware, not just lexical**: the inline bypass only happens while a lease this context actually holds on the key is still live. Each real acquire records a deadline derived from Redis's own remaining TTL (already clock-drift adjusted), so the bypass window never outlives the lease Redis granted. If an operation runs past its mutex TTL (so the lease could have lapsed and been taken over), a nested same-key call does **not** run inline — it performs a real acquire, which re-locks the key if it is free (its own lease frame, so deeper nested calls still bypass safely) or raises `Redlock::LockError` if another context now owns it. Reentrancy therefore never weakens mutual exclusion beyond what Redlock itself guarantees.
417
+ - **Lease-aware, not just lexical**: the inline bypass only happens while a lease this context actually holds on the key is still live. Each real acquire records a deadline from the `:validity` Redlock returns at acquire time (the TTL minus acquisition time minus clock drift), so the bypass window never outlives the lease Redis granted — and no extra Redis round-trip or Lua script is needed, which keeps this working under `Redlock::Client.testing_mode = :bypass`. If an operation runs past its mutex TTL (so the lease could have lapsed and been taken over), a nested same-key call does **not** run inline — it performs a real acquire, which re-locks the key if it is free (its own lease frame, so deeper nested calls still bypass safely) or raises `Redlock::LockError` if another context now owns it. Reentrancy therefore never weakens mutual exclusion beyond what Redlock itself guarantees.
418
418
  - **TTL is not refreshed** by nested reentrant calls — the outermost acquire's expiry still applies.
419
419
 
420
420
  ### Database Transactions with `within_transaction`
@@ -89,15 +89,24 @@ Add to the `private` section (see `lib/hanikamu/operation.rb` for the full set):
89
89
  # Real acquire: push this lease's deadline onto this context's per-key stack, run,
90
90
  # then pop. A nested call that finds the lease expired lands here again and takes a
91
91
  # fresh, independent lease (its own stack frame), so it never contends with itself.
92
+ # Uses `lock` (not `lock!`) because only `lock` yields the lock_info carrying
93
+ # Redlock's drift-adjusted `:validity`; `lock` returns `!!lock_info`, so the
94
+ # operation's own result is captured and returned explicitly.
92
95
  def _acquire_and_run(lock_key, &)
93
- Hanikamu::Operation.redis_lock.lock!(lock_key, self.class._mutex_expire_milliseconds) do
94
- _push_lease(lock_key)
96
+ result = nil
97
+
98
+ Hanikamu::Operation.redis_lock.lock(lock_key, self.class._mutex_expire_milliseconds) do |lock_info|
99
+ raise Redlock::LockError, lock_key unless lock_info
100
+
101
+ _push_lease(lock_key, lock_info[:validity])
95
102
  begin
96
- yield
103
+ result = yield
97
104
  ensure
98
105
  _pop_lease(lock_key)
99
106
  end
100
107
  end
108
+
109
+ result
101
110
  end
102
111
 
103
112
  def _stable_lock_key(key)
@@ -125,12 +134,11 @@ def _reentrant_lease_valid?(lock_key)
125
134
  _monotonic_ms < stack.last
126
135
  end
127
136
 
128
- # Anchor the deadline to Redis's authoritative remaining TTL (already clock-drift
129
- # adjusted by Redlock), captured right after acquisition, so the window never outlives
130
- # the lease Redis actually granted even if acquisition retried/took time.
131
- def _push_lease(lock_key)
132
- remaining = Hanikamu::Operation.redis_lock.get_remaining_ttl_for_resource(lock_key)
133
- deadline = _monotonic_ms + (remaining || self.class._mutex_expire_milliseconds)
137
+ # Anchor the deadline to the lease Redlock actually granted: `:validity` is the TTL
138
+ # minus acquisition time minus Redlock's clock drift allowance, so the window never
139
+ # outlives the real lease even when the acquire was slow or retried.
140
+ def _push_lease(lock_key, validity_ms)
141
+ deadline = _monotonic_ms + (validity_ms || self.class._mutex_expire_milliseconds)
134
142
  (_lease_stacks[lock_key] ||= []) << deadline
135
143
  end
136
144
 
@@ -145,15 +153,19 @@ end
145
153
 
146
154
  ### Why this is correct / safe
147
155
 
148
- - **Return value preserved:** `redis_lock.lock!` returns the block's value; `_acquire_and_run`
149
- returns the value of `yield` (the `ensure` around `_pop_lease` doesn't override it), and the bypass
150
- path is a plain `yield`. Operation responses flow through unchanged.
151
- - **Exception-safe:** a lease is only pushed once we're inside the `lock!` block, and the `ensure`
152
- always pops it. If `lock!` itself fails to acquire (real contention from another context), the
153
- block never runs, so nothing is pushed and nothing leaks.
156
+ - **Return value preserved:** `_acquire_and_run` captures the value of `yield` and returns it (the
157
+ `ensure` around `_pop_lease` doesn't override it, and `lock`'s own `!!lock_info` return is
158
+ discarded), and the bypass path is a plain `yield`. Operation responses flow through unchanged.
159
+ - **Exception-safe:** a lease is only pushed once acquisition succeeded, and the `ensure` always pops
160
+ it. If the acquire fails (real contention from another context), `lock_info` is falsy, we raise
161
+ `Redlock::LockError` before pushing, so nothing leaks.
162
+ - **No Lua script on the mutex path:** the lease window comes from the `:validity` Redlock returns at
163
+ acquire time, not from a follow-up TTL query. That avoids a second Redis round-trip and, crucially,
164
+ works under `Redlock::Client.testing_mode = :bypass`, which stubs out script loading — a
165
+ script-based TTL read raises `NOSCRIPT` there on a cold Redis (see 0.3.1 in the CHANGELOG).
154
166
  - **Lease-aware, not merely lexical:** the bypass is gated on a live lease — each real acquire pushes
155
- a deadline derived from Redis's own remaining TTL (already clock-drift adjusted), captured right
156
- after acquisition, so the window never outlives the lease Redis granted (even under a slow/retried
167
+ a deadline derived from Redlock's drift-adjusted `:validity` (TTL minus the time acquisition took),
168
+ so the window never outlives the lease Redis granted (even under a slow/retried
157
169
  acquire). If an operation outlives its mutex TTL, a nested same-key call re-acquires for real as
158
170
  its own stack frame instead of running inline — re-locking a free key (deeper nested calls then
159
171
  bypass *that* replacement lease, avoiding a fresh self-deadlock) or raising `Redlock::LockError` on
@@ -206,6 +218,9 @@ The suite already uses a **real Redis** (`described_class.redis_lock`). Cover:
206
218
  the live replacement lease, not the expired outer deadline.
207
219
  8. **Mutable key snapshot.** An op whose lock-key method returns a String that `execute` mutates
208
220
  still cleans up the stack (no leaked entry under the pre-mutation value).
221
+ 9. **Redlock `:bypass` on a cold Redis.** With `Redlock::Client.testing_mode = :bypass` and the
222
+ script cache flushed, a nested same-key run must complete without raising — proving the mutex
223
+ path evaluates no Lua script (regression guard for the 0.3.1 `NOSCRIPT` fix).
209
224
 
210
225
  ---
211
226
 
@@ -231,15 +231,28 @@ module Hanikamu
231
231
  # key, run, then release. Nested reentrant calls ride on this lease without touching
232
232
  # Redis; a nested call that finds the lease expired lands here again and takes a
233
233
  # fresh, independent lease (its own stack frame), so it never contends with itself.
234
+ #
235
+ # Uses `lock` rather than `lock!` because only `lock` yields the lock_info, which
236
+ # carries Redlock's own drift-adjusted `:validity`. Reading the lease window from
237
+ # the acquire result costs no extra Redis round-trip and evaluates no Lua script —
238
+ # the latter matters because under `Redlock::Client.testing_mode = :bypass` the
239
+ # scripts are never loaded, so any EVALSHA raises NOSCRIPT on a cold Redis.
240
+ # `lock` returns `!!lock_info`, so the operation's own result is captured instead.
234
241
  def _acquire_and_run(lock_key, &)
235
- Hanikamu::Operation.redis_lock.lock!(lock_key, self.class._mutex_expire_milliseconds) do
236
- _push_lease(lock_key)
242
+ result = nil
243
+
244
+ Hanikamu::Operation.redis_lock.lock(lock_key, self.class._mutex_expire_milliseconds) do |lock_info|
245
+ raise Redlock::LockError, lock_key unless lock_info
246
+
247
+ _push_lease(lock_key, lock_info[:validity])
237
248
  begin
238
- yield
249
+ result = yield
239
250
  ensure
240
251
  _pop_lease(lock_key)
241
252
  end
242
253
  end
254
+
255
+ result
243
256
  end
244
257
 
245
258
  # A key is documented as a String; freeze a copy so it is a stable, immutable
@@ -278,12 +291,11 @@ module Hanikamu
278
291
  _monotonic_ms < stack.last
279
292
  end
280
293
 
281
- # Anchor the deadline to Redis's authoritative remaining TTL (already clock-drift
282
- # adjusted by Redlock), captured right after acquisition, so the window never
283
- # outlives the lease Redis actually granted even if acquisition retried/took time.
284
- def _push_lease(lock_key)
285
- remaining = Hanikamu::Operation.redis_lock.get_remaining_ttl_for_resource(lock_key)
286
- deadline = _monotonic_ms + (remaining || self.class._mutex_expire_milliseconds)
294
+ # Anchor the deadline to the lease Redlock actually granted: `:validity` is the TTL
295
+ # minus the time acquisition took minus Redlock's clock drift allowance, so the
296
+ # window never outlives the real lease even when the acquire was slow or retried.
297
+ def _push_lease(lock_key, validity_ms)
298
+ deadline = _monotonic_ms + (validity_ms || self.class._mutex_expire_milliseconds)
287
299
  (_lease_stacks[lock_key] ||= []) << deadline
288
300
  end
289
301
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanikamu-operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup