hanikamu-operation 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b935969d9ffb9b34935f54e2f9d8adf907603906916a4c1b555499bd25e97f28
4
- data.tar.gz: 2fada67131e16535eb5c6480c3a80999315efd0306753aae615a6a582d9cb43d
3
+ metadata.gz: 565d28c9c7d700db3b5bdb2be0ddd960951d07bf027a16b946cc8c527ad45ae6
4
+ data.tar.gz: 2c241ea17940bc687881d746361fa94fe85dfc3149d410c4b665c44d0367491b
5
5
  SHA512:
6
- metadata.gz: cc5970b13fd6511a024b14071b273350604ef811f8bb944ca03f6839ed7f03051d106c63f550225f2b4de181d60c7698f545ecd1219faa1bb813aaa76be340a3
7
- data.tar.gz: a691099d6fb2be177d4e19bcfff74e9760716465b2a9ceaff2fa0df33a63027e199377749e285f4a8b9cad02224c93e347e4b229eab60f159aa2d4bc5e585454
6
+ metadata.gz: da40b97d680e9f4f99b9819283c5d9441335b5a2e05b77143ee33b04635db03c6a3e00896f4b98f9481950d2b52721080c48b958eef8e3ba0133dda020da221d
7
+ data.tar.gz: 6bf7537f8d8b3351f122c4e3a9c0de036c736a97907dcdf460fa9bd932600051f2807f39aaefaf28ee79b5f48264d7db0a9dc0132470f475cc45c4392701d8f4
data/CHANGELOG.md CHANGED
@@ -14,3 +14,22 @@
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.0] - 2026-08-25
26
+
27
+ - `within_mutex` is now reentrant within the same execution context (fiber-local, effectively
28
+ per-thread in the standard thread-per-request / thread-per-job model): a nested acquire of a key
29
+ the current context already holds runs inline instead of self-deadlocking. Only real acquisitions
30
+ talk to Redis. The bypass is lease-aware — each real acquire records a deadline derived from
31
+ Redis's own remaining TTL (clock-drift adjusted), and the inline bypass only applies while a lease
32
+ this context holds is still live. Once the lease could have lapsed, a nested call re-acquires for
33
+ real as its own lease (so deeper nested calls still bypass safely), or raises `Redlock::LockError`
34
+ if the key was taken over. Cross-thread / cross-fiber / cross-process locking is unchanged.
35
+ 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 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.
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,231 @@
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
+ 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)
95
+ begin
96
+ yield
97
+ ensure
98
+ _pop_lease(lock_key)
99
+ end
100
+ end
101
+ end
102
+
103
+ def _stable_lock_key(key)
104
+ key.frozen? ? key : key.dup.freeze
105
+ end
106
+
107
+ # Fiber-local per-key stack of live-lease deadlines (Thread.current[...] is
108
+ # fiber-local): correct scope because a synchronous cascade runs in the same fiber; a
109
+ # separate job/request/fiber has its own stack and contends. A stack (not a single
110
+ # value) lets a replacement lease taken after expiry restore the previous window when
111
+ # it exits.
112
+ def _lease_stacks
113
+ Thread.current[:hanikamu_operation_lease_stacks] ||= {}
114
+ end
115
+
116
+ def _monotonic_ms
117
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
118
+ end
119
+
120
+ # Bypass only while the innermost (top) lease this context holds is still live.
121
+ def _reentrant_lease_valid?(lock_key)
122
+ stack = _lease_stacks[lock_key]
123
+ return false unless stack&.any?
124
+
125
+ _monotonic_ms < stack.last
126
+ end
127
+
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)
134
+ (_lease_stacks[lock_key] ||= []) << deadline
135
+ end
136
+
137
+ def _pop_lease(lock_key)
138
+ stack = _lease_stacks[lock_key]
139
+ return unless stack
140
+
141
+ stack.pop
142
+ _lease_stacks.delete(lock_key) if stack.empty?
143
+ end
144
+ ```
145
+
146
+ ### Why this is correct / safe
147
+
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.
154
+ - **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
157
+ acquire). If an operation outlives its mutex TTL, a nested same-key call re-acquires for real as
158
+ its own stack frame instead of running inline — re-locking a free key (deeper nested calls then
159
+ bypass *that* replacement lease, avoiding a fresh self-deadlock) or raising `Redlock::LockError` on
160
+ takeover. Reentrancy never weakens mutual exclusion beyond Redlock's inherent TTL guarantee.
161
+ - **Stable key:** the key is snapshotted (frozen copy) before use, so an operation mutating the
162
+ original String object can't leak a stack entry or make a future call wrongly bypass Redis.
163
+ - **Distributed guarantee intact:** reentrancy is strictly same-context (same fiber) and only while
164
+ the lease is valid. Cross-thread / cross-fiber / cross-process contention is unchanged — a lock
165
+ held by a *different* context still raises `Redlock::LockError`.
166
+ - **No behaviour change for non-nesting code:** first acquire hits Redis and releases at the end,
167
+ exactly as today.
168
+ - **TTL:** nested reentrant calls don't refresh the TTL — same as today for any single long op.
169
+
170
+ ---
171
+
172
+ ## Design decision: default, not configurable
173
+
174
+ Reentrancy is the **default** with **no opt-out flag**. Rationale:
175
+
176
+ - A same-context re-acquire of a held key has **no valid non-reentrant use case** — it can only
177
+ self-deadlock then raise. Nobody opts into that.
178
+ - Matches `Monitor`, Java `ReentrantLock`, and ActiveRecord nested transactions (savepoints).
179
+ - Adding `within_mutex(:key, reentrant: false)` would only re-expose the footgun and add config
180
+ surface (violates the repo's Simplicity-First rule). If an escape hatch is ever genuinely needed,
181
+ put it at config level (`config.reentrant_mutex`, default `true`) — not per declaration.
182
+
183
+ ---
184
+
185
+ ## Specs to add (`spec/hanikamu/operation_spec.rb`, `#within_mutex` describe block)
186
+
187
+ The suite already uses a **real Redis** (`described_class.redis_lock`). Cover:
188
+
189
+ 1. **Nested, same key, same context → single Redis acquire, both run.**
190
+ Define an outer op whose `execute` calls an inner op with the **same** lock key. Spy on
191
+ `redis_lock.lock!` and assert it received the key **once**, and both ops ran (in order).
192
+ 2. **Nested, different keys → two acquires.** Outer op calls inner op with a **different** key;
193
+ assert `lock!` received both keys.
194
+ 3. **Cross-thread still contends.** Run an outer op that acquires the key and blocks inside `execute`
195
+ with its lease stack populated; a second thread calling the same key does **not** see the stack
196
+ and raises `Redlock::LockError`.
197
+ 4. **Stack cleanup (success).** After a nested run completes,
198
+ `Thread.current[:hanikamu_operation_lease_stacks]` is empty — no leak.
199
+ 5. **Stack cleanup (raise).** After a nested run that raises inside the inner block, the stack is
200
+ still empty — the `ensure` pops on the exception path.
201
+ 6. **Lease expiry → re-acquire.** An outer op with a short TTL that sleeps past its lease, then makes
202
+ a nested same-key call, must **re-acquire** (a second `lock!` for the key), not bypass.
203
+ 7. **Deep nesting after expiry (no self-deadlock).** An outer op whose short lease lapses calls a
204
+ middle op (same key) that re-acquires a fresh lease, which calls an inner op (same key): the inner
205
+ call must **bypass** the replacement lease inline and complete — proving the per-key stack tracks
206
+ the live replacement lease, not the expired outer deadline.
207
+ 8. **Mutable key snapshot.** An op whose lock-key method returns a String that `execute` mutates
208
+ still cleans up the stack (no leaked entry under the pre-mutation value).
209
+
210
+ ---
211
+
212
+ ## Release
213
+
214
+ 1. Implement the change + specs above.
215
+ 2. Bump `spec.version` `0.2.0 → 0.3.0` in the gemspec.
216
+ 3. CHANGELOG `## [0.3.0]`: *"`within_mutex` is now reentrant within the same execution context
217
+ (fiber-local, effectively per-thread): a nested acquire of a key the current context already holds
218
+ runs inline instead of self-deadlocking. Cross-thread / cross-fiber / cross-process locking is
219
+ unchanged."*
220
+ 4. Document the reentrancy behaviour in the README `within_mutex` section.
221
+ 5. Run gem `rspec` + `rubocop`, release (MFA required per rubygems).
222
+
223
+ ---
224
+
225
+ ## Verification
226
+
227
+ - `make rspec` — the new reentrancy specs pass, and the existing `#within_mutex` specs
228
+ (single-acquire, `:if`/`:unless`, cross-thread `Redlock::LockError`) still pass unchanged.
229
+ - `make cops` — clean.
230
+ - Any downstream app that was working around this by namespacing lock keys for nested operations can
231
+ 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,74 @@ 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
+ 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)
237
+ begin
238
+ yield
239
+ ensure
240
+ _pop_lease(lock_key)
241
+ end
242
+ end
243
+ end
244
+
245
+ # A key is documented as a String; freeze a copy so it is a stable, immutable
246
+ # registry key regardless of what the operation does with the original object.
247
+ # Already-immutable values (frozen strings, symbols, integers) pass through.
248
+ def _stable_lock_key(key)
249
+ key.frozen? ? key : key.dup.freeze
250
+ end
251
+
252
+ # Per-key stack of monotonic deadlines (ms) for the real Redlock leases this context
253
+ # currently holds, scoped to the current execution context. Storage is
254
+ # `Thread.current[...]`, which in Ruby is fiber-local: this is the correct scope
255
+ # because a synchronous event cascade or nested call runs in the same fiber as the
256
+ # publishing operation, so it must be treated as the same holder. In the standard
257
+ # thread-per-request / thread-per-job model (Puma, Sidekiq) each thread has a single
258
+ # root fiber, so this is effectively per-thread. A separate job / request — or an
259
+ # independently scheduled fiber under a fiber scheduler — has its own stack and must
260
+ # still contend on Redis, which is exactly what we want (never bypass a lease held
261
+ # elsewhere). A stack (not a single value) is required so that a replacement lease
262
+ # taken after an outer lease expired restores the previous window when it exits.
263
+ def _lease_stacks
264
+ Thread.current[:hanikamu_operation_lease_stacks] ||= {}
265
+ end
266
+
267
+ def _monotonic_ms
268
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
269
+ end
270
+
271
+ # Bypass is valid only while the innermost lease this context holds on the key is
272
+ # still within its lease window (the top of the stack is the most recently acquired,
273
+ # hence longest-living, lease).
274
+ def _reentrant_lease_valid?(lock_key)
275
+ stack = _lease_stacks[lock_key]
276
+ return false unless stack&.any?
277
+
278
+ _monotonic_ms < stack.last
279
+ end
280
+
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)
287
+ (_lease_stacks[lock_key] ||= []) << deadline
288
+ end
289
+
290
+ def _pop_lease(lock_key)
291
+ stack = _lease_stacks[lock_key]
292
+ return unless stack
293
+
294
+ stack.pop
295
+ _lease_stacks.delete(lock_key) if stack.empty?
296
+ end
297
+
215
298
  def transaction_class
216
299
  return if self.class._transaction_klass.nil?
217
300
  return ActiveRecord::Base if self.class._transaction_klass == :base
@@ -237,5 +320,4 @@ module Hanikamu
237
320
  raise Hanikamu::Operation::GuardError, @guard
238
321
  end
239
322
  end
240
- # rubocop:enable Metrics/ClassLength
241
323
  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.0
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