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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +1 -1
- data/docs/projects/20260825_hanikamu_reentrant_mutex.md +32 -17
- data/lib/hanikamu/operation.rb +21 -9
- 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: 30ca0f88ea8223a13a4b23eda4d6cefb6d010a5affedbc93e6a2d69614ba486b
|
|
4
|
+
data.tar.gz: 0601bee8022568f95a8a33163bb823bfe3fb09036c32d62ab2c7b2482819a06b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
129
|
-
#
|
|
130
|
-
# the lease
|
|
131
|
-
def _push_lease(lock_key)
|
|
132
|
-
|
|
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:** `
|
|
149
|
-
|
|
150
|
-
path is a plain `yield`. Operation responses flow through unchanged.
|
|
151
|
-
- **Exception-safe:** a lease is only pushed once
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
156
|
-
|
|
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
|
|
data/lib/hanikamu/operation.rb
CHANGED
|
@@ -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
|
-
|
|
236
|
-
|
|
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
|
|
282
|
-
#
|
|
283
|
-
# outlives the lease
|
|
284
|
-
def _push_lease(lock_key)
|
|
285
|
-
|
|
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
|
|