hanikamu-operation 0.2.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: b935969d9ffb9b34935f54e2f9d8adf907603906916a4c1b555499bd25e97f28
4
- data.tar.gz: 2fada67131e16535eb5c6480c3a80999315efd0306753aae615a6a582d9cb43d
3
+ metadata.gz: 30ca0f88ea8223a13a4b23eda4d6cefb6d010a5affedbc93e6a2d69614ba486b
4
+ data.tar.gz: 0601bee8022568f95a8a33163bb823bfe3fb09036c32d62ab2c7b2482819a06b
5
5
  SHA512:
6
- metadata.gz: cc5970b13fd6511a024b14071b273350604ef811f8bb944ca03f6839ed7f03051d106c63f550225f2b4de181d60c7698f545ecd1219faa1bb813aaa76be340a3
7
- data.tar.gz: a691099d6fb2be177d4e19bcfff74e9760716465b2a9ceaff2fa0df33a63027e199377749e285f4a8b9cad02224c93e347e4b229eab60f159aa2d4bc5e585454
6
+ metadata.gz: d097cc17ab86c0c5e29eace1bedac6fbcb89b55a85da19a035d1a012e6c0e55d06a4aefa9212605576f25303e521cbf7f090a27a6d6e7fc1381245789b6750cb
7
+ data.tar.gz: adb979d5f17ae5c50285041e38051db0b8aabf7d4c10ef5529980b2b193db2461d6bdb236f5fcc46227f60142a08a4e69605130b4783f971ee242958ad8c225c
data/CHANGELOG.md CHANGED
@@ -14,3 +14,33 @@
14
14
  - Removed `redis-client` as direct dependency (now transitive through `redlock`)
15
15
  - Updated CI to test only Ruby 3.4
16
16
  - Improved README with clearer FormError vs GuardError examples
17
+
18
+ ## [0.2.0] - 2026-03-10
19
+
20
+ - Added conditional locking for `within_mutex` via `:if` / `:unless` options: the Redis lock is
21
+ skipped entirely when the condition opts out (e.g. an optional lock-key attribute is `nil`).
22
+ Passing both `:if` and `:unless`, or a non-callable condition, raises `ArgumentError` at class
23
+ definition time.
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
+
36
+ ## [0.3.0] - 2026-08-25
37
+
38
+ - `within_mutex` is now reentrant within the same execution context (fiber-local, effectively
39
+ per-thread in the standard thread-per-request / thread-per-job model): a nested acquire of a key
40
+ the current context already holds runs inline instead of self-deadlocking. Only real acquisitions
41
+ talk to Redis. The bypass is lease-aware — each real acquire records a deadline derived from
42
+ Redis's own remaining TTL (clock-drift adjusted), and the inline bypass only applies while a lease
43
+ this context holds is still live. Once the lease could have lapsed, a nested call re-acquires for
44
+ real as its own lease (so deeper nested calls still bypass safely), or raises `Redlock::LockError`
45
+ if the key was taken over. Cross-thread / cross-fiber / cross-process locking is unchanged.
46
+ Reentrancy is the default with no opt-out flag.
data/README.md CHANGED
@@ -66,7 +66,7 @@ Requires Ruby 3.4.0 or later.
66
66
 
67
67
  ```ruby
68
68
  # Gemfile
69
- gem 'hanikamu-operation', '~> 0.2.0'
69
+ gem 'hanikamu-operation', '~> 0.3.0'
70
70
  ```
71
71
 
72
72
  ```bash
@@ -135,7 +135,7 @@ Requires Ruby 3.4.0 or later.
135
135
 
136
136
  ```ruby
137
137
  # Gemfile
138
- gem 'hanikamu-operation', '~> 0.2.0'
138
+ gem 'hanikamu-operation', '~> 0.3.0'
139
139
  ```
140
140
 
141
141
  ```bash
@@ -364,6 +364,59 @@ within_mutex(:mutex_lock, unless: -> { order_id.nil? })
364
364
 
365
365
  The lambda is evaluated via `instance_exec` on the operation instance, so it has access to all attributes and methods. Providing both `:if` and `:unless` raises `ArgumentError`.
366
366
 
367
+ **Reentrancy (same execution context, same key)**:
368
+
369
+ `within_mutex` is **reentrant within the same execution context**. If an operation holding a lock key synchronously calls another operation that locks the **same** key — for example through a synchronous callback/event handler, or a nested operation/service call — the inner acquire runs inline instead of trying to re-acquire the lock. Only the outermost holder talks to Redis.
370
+
371
+ This matters because Redlock itself is **not** reentrant: without this, the same execution context would block on its own lock until the TTL expired and then raise `Redlock::LockError`, even though there is no concurrent request. Reentrancy removes that self-deadlock, matching the semantics of `Monitor`, Java's `ReentrantLock`, and ActiveRecord's nested transactions.
372
+
373
+ > **Scope: fiber-local (effectively per-thread).** The lease bookkeeping is stored in `Thread.current[...]`, which in Ruby is *fiber-local*. In the standard thread-per-request / thread-per-job model (Puma, Sidekiq) each thread runs a single root fiber, so reentrancy behaves per-thread. Under a fiber scheduler (e.g. Falcon/async) or if you manually spawn a `Fiber` for the nested call, that separate fiber has its own lease stack and will contend on Redis normally — this is deliberate: it never bypasses a lock that a different, independently scheduled context might hold.
374
+
375
+ ```ruby
376
+ class UpdateResourceOperation < Hanikamu::Operation
377
+ attribute :resource_id, Types::Integer
378
+
379
+ within_mutex(:mutex_lock)
380
+
381
+ def execute
382
+ resource = Resource.find(resource_id)
383
+ resource.touch!
384
+
385
+ # Runs inline under the lock already held by this execution context —
386
+ # no second Redis acquire, no self-deadlock.
387
+ SyncResourceOperation.call!(resource_id: resource_id)
388
+
389
+ response resource: resource
390
+ end
391
+
392
+ def mutex_lock
393
+ "Resource$#{resource_id}"
394
+ end
395
+ end
396
+
397
+ class SyncResourceOperation < Hanikamu::Operation
398
+ attribute :resource_id, Types::Integer
399
+
400
+ within_mutex(:mutex_lock) # same key as the caller
401
+
402
+ def execute
403
+ response(synced: true)
404
+ end
405
+
406
+ def mutex_lock
407
+ "Resource$#{resource_id}"
408
+ end
409
+ end
410
+ ```
411
+
412
+ **Scope and guarantees**:
413
+
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
+ - **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
+ - **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 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
+ - **TTL is not refreshed** by nested reentrant calls — the outermost acquire's expiry still applies.
419
+
367
420
  ### Database Transactions with `within_transaction`
368
421
 
369
422
  Ensure multiple database changes succeed or fail together atomically. If any database operation raises an exception, all changes are rolled back.
@@ -0,0 +1,246 @@
1
+ # Make `within_mutex` reentrant in `hanikamu-operation`
2
+
3
+ **Status:** implemented; pending release
4
+ **Owner:** Nicolai
5
+ **Repo touched:** `hanikamu-operation`
6
+
7
+ ---
8
+
9
+ ## TL;DR
10
+
11
+ `Hanikamu::Operation`'s `within_mutex` uses a **non-reentrant** Redlock. When an operation
12
+ that holds a lock key synchronously triggers another operation that locks the **same** key —
13
+ via a synchronous event handler, or a plain nested service/operation call — the second acquire
14
+ blocks on a lock the same thread already holds. It spins for the retry window and then raises
15
+ `Redlock::LockError`. This is a **latent self-deadlock**, and it becomes a hard, reproducible
16
+ failure as soon as the mutex TTL is long enough that the outer lock hasn't expired by the time
17
+ the inner acquire runs.
18
+
19
+ The sustainable fix is to make `within_mutex` **reentrant per execution context** (fiber-local,
20
+ effectively per-thread in the standard thread-per-request / thread-per-job model): if the current context
21
+ already holds the resolved lock key, run inline instead of re-acquiring; only the outermost holder
22
+ talks to Redis. This is the default behaviour — **no opt-in flag** (see "Design decision").
23
+
24
+ ---
25
+
26
+ ## Why this happens
27
+
28
+ - A distributed lock keyed by some resource is re-entered by the **same execution context** within
29
+ one logical unit of work (a synchronous callback cascade, or a nested operation call), but Redlock
30
+ treats that re-entry as a competing writer.
31
+ - Redlock is not reentrant, so the inner acquire waits on the outer's lock. It exhausts the retry
32
+ window and raises `Redlock::LockError` — with **no concurrent request** anywhere.
33
+ - Raising the mutex TTL doesn't create the bug; it turns a race that was formerly "usually survived
34
+ by lock expiry" into a hard, deterministic failure.
35
+
36
+ ### The nesting shapes that trigger it
37
+
38
+ 1. **Synchronous callback cascade.** An operation holds `Resource$id` and publishes an event; a
39
+ **synchronous** handler for that event invokes another operation whose lock key is also
40
+ `Resource$id`.
41
+ 2. **Nested service/operation call.** An operation holds `Resource$id` and, inside `execute`, calls
42
+ another operation (directly or via a service) that locks the same `Resource$id`.
43
+
44
+ Asynchronous callbacks (handlers that run in a separate job/thread) are never the problem — they run
45
+ outside the publisher's mutex and contend on Redis normally.
46
+
47
+ ---
48
+
49
+ ## The fix (in `hanikamu-operation`)
50
+
51
+ Only `#within_mutex!` changes, plus a small thread-local registry. File: `lib/hanikamu/operation.rb`.
52
+
53
+ ### Before
54
+
55
+ ```ruby
56
+ def within_mutex!(&)
57
+ return yield if self.class._mutex_lock_key.blank?
58
+ return yield unless _should_apply_mutex?
59
+
60
+ lock_key = public_send(self.class._mutex_lock_key)
61
+ Hanikamu::Operation.redis_lock.lock!(lock_key, self.class._mutex_expire_milliseconds, &)
62
+ end
63
+ ```
64
+
65
+ ### After (reentrant)
66
+
67
+ ```ruby
68
+ def within_mutex!(&)
69
+ return yield if self.class._mutex_lock_key.blank?
70
+ return yield unless _should_apply_mutex?
71
+
72
+ # Freeze a copy so operation code can't mutate the key object and desync the registry.
73
+ lock_key = _stable_lock_key(public_send(self.class._mutex_lock_key))
74
+
75
+ # Reentrancy: a synchronous nested call while this context still holds a *live* lease
76
+ # on the key would otherwise re-acquire it and self-deadlock (Redlock is not
77
+ # reentrant). Run inline instead — but only while a lease this context holds is still
78
+ # valid (see below). Once it could have lapsed, fall through to a real acquire so a
79
+ # taken-over key still raises Redlock::LockError.
80
+ return yield if _reentrant_lease_valid?(lock_key)
81
+
82
+ _acquire_and_run(lock_key, &)
83
+ end
84
+ ```
85
+
86
+ Add to the `private` section (see `lib/hanikamu/operation.rb` for the full set):
87
+
88
+ ```ruby
89
+ # Real acquire: push this lease's deadline onto this context's per-key stack, run,
90
+ # then pop. A nested call that finds the lease expired lands here again and takes a
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.
95
+ def _acquire_and_run(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])
102
+ begin
103
+ result = yield
104
+ ensure
105
+ _pop_lease(lock_key)
106
+ end
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ def _stable_lock_key(key)
113
+ key.frozen? ? key : key.dup.freeze
114
+ end
115
+
116
+ # Fiber-local per-key stack of live-lease deadlines (Thread.current[...] is
117
+ # fiber-local): correct scope because a synchronous cascade runs in the same fiber; a
118
+ # separate job/request/fiber has its own stack and contends. A stack (not a single
119
+ # value) lets a replacement lease taken after expiry restore the previous window when
120
+ # it exits.
121
+ def _lease_stacks
122
+ Thread.current[:hanikamu_operation_lease_stacks] ||= {}
123
+ end
124
+
125
+ def _monotonic_ms
126
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
127
+ end
128
+
129
+ # Bypass only while the innermost (top) lease this context holds is still live.
130
+ def _reentrant_lease_valid?(lock_key)
131
+ stack = _lease_stacks[lock_key]
132
+ return false unless stack&.any?
133
+
134
+ _monotonic_ms < stack.last
135
+ end
136
+
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)
142
+ (_lease_stacks[lock_key] ||= []) << deadline
143
+ end
144
+
145
+ def _pop_lease(lock_key)
146
+ stack = _lease_stacks[lock_key]
147
+ return unless stack
148
+
149
+ stack.pop
150
+ _lease_stacks.delete(lock_key) if stack.empty?
151
+ end
152
+ ```
153
+
154
+ ### Why this is correct / safe
155
+
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).
166
+ - **Lease-aware, not merely lexical:** the bypass is gated on a live lease — each real acquire pushes
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
169
+ acquire). If an operation outlives its mutex TTL, a nested same-key call re-acquires for real as
170
+ its own stack frame instead of running inline — re-locking a free key (deeper nested calls then
171
+ bypass *that* replacement lease, avoiding a fresh self-deadlock) or raising `Redlock::LockError` on
172
+ takeover. Reentrancy never weakens mutual exclusion beyond Redlock's inherent TTL guarantee.
173
+ - **Stable key:** the key is snapshotted (frozen copy) before use, so an operation mutating the
174
+ original String object can't leak a stack entry or make a future call wrongly bypass Redis.
175
+ - **Distributed guarantee intact:** reentrancy is strictly same-context (same fiber) and only while
176
+ the lease is valid. Cross-thread / cross-fiber / cross-process contention is unchanged — a lock
177
+ held by a *different* context still raises `Redlock::LockError`.
178
+ - **No behaviour change for non-nesting code:** first acquire hits Redis and releases at the end,
179
+ exactly as today.
180
+ - **TTL:** nested reentrant calls don't refresh the TTL — same as today for any single long op.
181
+
182
+ ---
183
+
184
+ ## Design decision: default, not configurable
185
+
186
+ Reentrancy is the **default** with **no opt-out flag**. Rationale:
187
+
188
+ - A same-context re-acquire of a held key has **no valid non-reentrant use case** — it can only
189
+ self-deadlock then raise. Nobody opts into that.
190
+ - Matches `Monitor`, Java `ReentrantLock`, and ActiveRecord nested transactions (savepoints).
191
+ - Adding `within_mutex(:key, reentrant: false)` would only re-expose the footgun and add config
192
+ surface (violates the repo's Simplicity-First rule). If an escape hatch is ever genuinely needed,
193
+ put it at config level (`config.reentrant_mutex`, default `true`) — not per declaration.
194
+
195
+ ---
196
+
197
+ ## Specs to add (`spec/hanikamu/operation_spec.rb`, `#within_mutex` describe block)
198
+
199
+ The suite already uses a **real Redis** (`described_class.redis_lock`). Cover:
200
+
201
+ 1. **Nested, same key, same context → single Redis acquire, both run.**
202
+ Define an outer op whose `execute` calls an inner op with the **same** lock key. Spy on
203
+ `redis_lock.lock!` and assert it received the key **once**, and both ops ran (in order).
204
+ 2. **Nested, different keys → two acquires.** Outer op calls inner op with a **different** key;
205
+ assert `lock!` received both keys.
206
+ 3. **Cross-thread still contends.** Run an outer op that acquires the key and blocks inside `execute`
207
+ with its lease stack populated; a second thread calling the same key does **not** see the stack
208
+ and raises `Redlock::LockError`.
209
+ 4. **Stack cleanup (success).** After a nested run completes,
210
+ `Thread.current[:hanikamu_operation_lease_stacks]` is empty — no leak.
211
+ 5. **Stack cleanup (raise).** After a nested run that raises inside the inner block, the stack is
212
+ still empty — the `ensure` pops on the exception path.
213
+ 6. **Lease expiry → re-acquire.** An outer op with a short TTL that sleeps past its lease, then makes
214
+ a nested same-key call, must **re-acquire** (a second `lock!` for the key), not bypass.
215
+ 7. **Deep nesting after expiry (no self-deadlock).** An outer op whose short lease lapses calls a
216
+ middle op (same key) that re-acquires a fresh lease, which calls an inner op (same key): the inner
217
+ call must **bypass** the replacement lease inline and complete — proving the per-key stack tracks
218
+ the live replacement lease, not the expired outer deadline.
219
+ 8. **Mutable key snapshot.** An op whose lock-key method returns a String that `execute` mutates
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).
224
+
225
+ ---
226
+
227
+ ## Release
228
+
229
+ 1. Implement the change + specs above.
230
+ 2. Bump `spec.version` `0.2.0 → 0.3.0` in the gemspec.
231
+ 3. CHANGELOG `## [0.3.0]`: *"`within_mutex` is now reentrant within the same execution context
232
+ (fiber-local, effectively per-thread): a nested acquire of a key the current context already holds
233
+ runs inline instead of self-deadlocking. Cross-thread / cross-fiber / cross-process locking is
234
+ unchanged."*
235
+ 4. Document the reentrancy behaviour in the README `within_mutex` section.
236
+ 5. Run gem `rspec` + `rubocop`, release (MFA required per rubygems).
237
+
238
+ ---
239
+
240
+ ## Verification
241
+
242
+ - `make rspec` — the new reentrancy specs pass, and the existing `#within_mutex` specs
243
+ (single-acquire, `:if`/`:unless`, cross-thread `Redlock::LockError`) still pass unchanged.
244
+ - `make cops` — clean.
245
+ - Any downstream app that was working around this by namespacing lock keys for nested operations can
246
+ drop the workaround: locking the bare resource key from nested operations is now safe.
@@ -2,8 +2,7 @@
2
2
 
3
3
  module Hanikamu
4
4
  # :nodoc:
5
- # rubocop:disable Metrics/ClassLength
6
- class Operation < Hanikamu::Service
5
+ class Operation < Hanikamu::Service # rubocop:disable Metrics/ClassLength
7
6
  include ActiveModel::Validations
8
7
 
9
8
  class Error < Hanikamu::Service::Error; end
@@ -112,8 +111,7 @@ module Hanikamu
112
111
 
113
112
  # Define guard validations using a block
114
113
  # The block is evaluated in the context of a Guard class
115
- # rubocop:disable Metrics/MethodLength
116
- def guard(&block)
114
+ def guard(&block) # rubocop:disable Metrics/MethodLength
117
115
  return unless block
118
116
 
119
117
  # Thread-safe constant definition with mutex
@@ -148,7 +146,6 @@ module Hanikamu
148
146
  const_set(:Guard, guard_class)
149
147
  end
150
148
  end
151
- # rubocop:enable Metrics/MethodLength
152
149
 
153
150
  attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_mutex_if_condition, :_mutex_unless_condition,
154
151
  :_transaction_klass, :_block
@@ -193,8 +190,26 @@ module Hanikamu
193
190
  return yield if self.class._mutex_lock_key.blank?
194
191
  return yield unless _should_apply_mutex?
195
192
 
196
- lock_key = public_send(self.class._mutex_lock_key)
197
- Hanikamu::Operation.redis_lock.lock!(lock_key, self.class._mutex_expire_milliseconds, &)
193
+ # Snapshot the resolved key: it is documented as a String, and freezing a
194
+ # copy prevents operation code from mutating the same object mid-run and
195
+ # desyncing the reentrancy registry / lease bookkeeping (the cleanup would
196
+ # otherwise look up a different value and leak the entry).
197
+ lock_key = _stable_lock_key(public_send(self.class._mutex_lock_key))
198
+
199
+ # Reentrancy: a nested operation invoked synchronously (e.g. from a synchronous
200
+ # event handler, or a nested service call) while this context still holds this
201
+ # exact key would otherwise re-acquire it. Redlock is not reentrant, so the same
202
+ # execution context would block on itself until the TTL expires and then raise
203
+ # Redlock::LockError. Skip the re-acquire and run inline; only real acquisitions
204
+ # talk to Redis. Different fibers/threads/processes still contend normally.
205
+ #
206
+ # The bypass is gated on a *live* lease, not merely lexical nesting: we bypass
207
+ # only while the innermost lease this context holds on the key is still valid.
208
+ # Once it could have lapsed we fall through to a real acquire — which re-acquires
209
+ # the key if it is free, or raises Redlock::LockError if it was taken over.
210
+ return yield if _reentrant_lease_valid?(lock_key)
211
+
212
+ _acquire_and_run(lock_key, &)
198
213
  end
199
214
 
200
215
  def within_transaction!(&)
@@ -212,6 +227,86 @@ module Hanikamu
212
227
  true
213
228
  end
214
229
 
230
+ # Acquire a real Redlock lease, push its deadline onto this context's stack for the
231
+ # key, run, then release. Nested reentrant calls ride on this lease without touching
232
+ # Redis; a nested call that finds the lease expired lands here again and takes a
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.
241
+ def _acquire_and_run(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])
248
+ begin
249
+ result = yield
250
+ ensure
251
+ _pop_lease(lock_key)
252
+ end
253
+ end
254
+
255
+ result
256
+ end
257
+
258
+ # A key is documented as a String; freeze a copy so it is a stable, immutable
259
+ # registry key regardless of what the operation does with the original object.
260
+ # Already-immutable values (frozen strings, symbols, integers) pass through.
261
+ def _stable_lock_key(key)
262
+ key.frozen? ? key : key.dup.freeze
263
+ end
264
+
265
+ # Per-key stack of monotonic deadlines (ms) for the real Redlock leases this context
266
+ # currently holds, scoped to the current execution context. Storage is
267
+ # `Thread.current[...]`, which in Ruby is fiber-local: this is the correct scope
268
+ # because a synchronous event cascade or nested call runs in the same fiber as the
269
+ # publishing operation, so it must be treated as the same holder. In the standard
270
+ # thread-per-request / thread-per-job model (Puma, Sidekiq) each thread has a single
271
+ # root fiber, so this is effectively per-thread. A separate job / request — or an
272
+ # independently scheduled fiber under a fiber scheduler — has its own stack and must
273
+ # still contend on Redis, which is exactly what we want (never bypass a lease held
274
+ # elsewhere). A stack (not a single value) is required so that a replacement lease
275
+ # taken after an outer lease expired restores the previous window when it exits.
276
+ def _lease_stacks
277
+ Thread.current[:hanikamu_operation_lease_stacks] ||= {}
278
+ end
279
+
280
+ def _monotonic_ms
281
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
282
+ end
283
+
284
+ # Bypass is valid only while the innermost lease this context holds on the key is
285
+ # still within its lease window (the top of the stack is the most recently acquired,
286
+ # hence longest-living, lease).
287
+ def _reentrant_lease_valid?(lock_key)
288
+ stack = _lease_stacks[lock_key]
289
+ return false unless stack&.any?
290
+
291
+ _monotonic_ms < stack.last
292
+ end
293
+
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)
299
+ (_lease_stacks[lock_key] ||= []) << deadline
300
+ end
301
+
302
+ def _pop_lease(lock_key)
303
+ stack = _lease_stacks[lock_key]
304
+ return unless stack
305
+
306
+ stack.pop
307
+ _lease_stacks.delete(lock_key) if stack.empty?
308
+ end
309
+
215
310
  def transaction_class
216
311
  return if self.class._transaction_klass.nil?
217
312
  return ActiveRecord::Base if self.class._transaction_klass == :base
@@ -237,5 +332,4 @@ module Hanikamu
237
332
  raise Hanikamu::Operation::GuardError, @guard
238
333
  end
239
334
  end
240
- # rubocop:enable Metrics/ClassLength
241
335
  end
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.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup
@@ -114,6 +114,7 @@ files:
114
114
  - README.md
115
115
  - Rakefile
116
116
  - docker-compose.yml
117
+ - docs/projects/20260825_hanikamu_reentrant_mutex.md
117
118
  - lib/hanikamu-operation.rb
118
119
  - lib/hanikamu/operation.rb
119
120
  homepage: https://github.com/Hanikamu/hanikamu-operation