hanikamu-operation 0.1.2 → 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: c0b23bd074385d4c3f45c13b8c247fd39e3b367567bc64b03a2a55db400f3b2a
4
- data.tar.gz: d2c55ea2d5f6aac49462c6c964ccb2d81c568b37ac3cb85143285521eb85ab86
3
+ metadata.gz: 565d28c9c7d700db3b5bdb2be0ddd960951d07bf027a16b946cc8c527ad45ae6
4
+ data.tar.gz: 2c241ea17940bc687881d746361fa94fe85dfc3149d410c4b665c44d0367491b
5
5
  SHA512:
6
- metadata.gz: '049de88ca1deb2c3604300762ce87fb3b7dad4657f6ffcbd553c33cc086cda780971b5859e84be171ca6800dca2a75c9cbb0de439bef7ad2999bda6c96e953e8'
7
- data.tar.gz: 80f4dfb5e621dd5c914fc853596828277f940c13cbbc1935d30ed64ea2b42ceb1c79bbeeb1ab14d135a768b581bca2d77a83e2c9fbcaa1a5f9dc5728852cb88e
6
+ metadata.gz: da40b97d680e9f4f99b9819283c5d9441335b5a2e05b77143ee33b04635db03c6a3e00896f4b98f9481950d2b52721080c48b958eef8e3ba0133dda020da221d
7
+ data.tar.gz: 6bf7537f8d8b3351f122c4e3a9c0de036c736a97907dcdf460fa9bd932600051f2807f39aaefaf28ee79b5f48264d7db0a9dc0132470f475cc45c4392701d8f4
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Development environment runs via docker-compose. Use Makefile targets instead of local commands.
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # Docker-Compose Development Environment
7
+
8
+ This project runs entirely inside Docker. Never run Ruby, Bundler, or Redis commands directly on the host.
9
+
10
+ ## Makefile Targets
11
+
12
+ Use these instead of raw commands:
13
+
14
+ | Task | Command |
15
+ |------|---------|
16
+ | Run tests | `make rspec` |
17
+ | Run linter (auto-fix) | `make cops` |
18
+ | Open a shell in the container | `make shell` |
19
+ | Open an IRB console | `make console` |
20
+ | Install gems & rebuild | `make bundle` |
21
+ | Build the Docker image | `make build` |
22
+
23
+ ## Important
24
+
25
+ - All `make` targets use `docker-compose run --rm app` under the hood.
26
+ - Redis is provided as a linked service (`redis://redis:6379/15`) — no local Redis needed.
27
+ - After changing the Gemfile or gemspec, run `make build` to reinstall dependencies.
28
+ - Never use `bundle exec` directly on the host; always go through `make` or `make shell`.
29
+ - Run `make` commands directly without `cd` — use the `working_directory` parameter instead.
30
+ - After every code change, run `make rspec` to verify specs still pass before moving on.
@@ -0,0 +1,5 @@
1
+ {
2
+ "rubyLsp.rubyVersionManager": {
3
+ "identifier": "mise"
4
+ }
5
+ }
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/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Base image
2
- FROM ruby:3.4.4
2
+ FROM ruby:4.0
3
3
 
4
4
  WORKDIR "/app"
5
5
 
data/Makefile CHANGED
@@ -14,6 +14,10 @@ console: ## build the image
14
14
  rspec: ## build the image
15
15
  docker-compose run --rm app bash -c "bundle exec rspec"
16
16
 
17
+ .PHONY: bundle
18
+ bundle: ## install gems and rebuild image
19
+ docker-compose run --rm app bundle install
20
+ $(MAKE) build
17
21
 
18
22
  .PHONY: cops
19
23
  cops: ## build the image
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.1.2'
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.1.2'
138
+ gem 'hanikamu-operation', '~> 0.3.0'
139
139
  ```
140
140
 
141
141
  ```bash
@@ -332,6 +332,91 @@ def mutex_lock
332
332
  end
333
333
  ```
334
334
 
335
+ **Conditional locking with `:if` / `:unless`**:
336
+
337
+ When the lock key is derived from an optional attribute, a `nil` value produces a degenerate shared key (e.g., `"Order$"`) that serializes all operations. Use `:if` or `:unless` to skip the Redis lock entirely when it isn't needed:
338
+
339
+ ```ruby
340
+ class ProcessOrderOperation < Hanikamu::Operation
341
+ attribute :order_id?, Types::Params::Integer.optional
342
+
343
+ within_mutex(:mutex_lock, if: -> { order_id.present? })
344
+
345
+ def execute
346
+ # ...
347
+ response(successful: true)
348
+ end
349
+
350
+ def mutex_lock
351
+ "Order$#{order_id}"
352
+ end
353
+ end
354
+ ```
355
+
356
+ - When `order_id` is present, the lock is acquired on `"Order$42"` as usual.
357
+ - When `order_id` is `nil`, no Redis round-trip occurs — the operation runs without a lock.
358
+
359
+ The `:unless` option works inversely:
360
+
361
+ ```ruby
362
+ within_mutex(:mutex_lock, unless: -> { order_id.nil? })
363
+ ```
364
+
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
+
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
+
335
420
  ### Database Transactions with `within_transaction`
336
421
 
337
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,11 +2,10 @@
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
- Error = Class.new(Hanikamu::Service::Error)
8
+ class Error < Hanikamu::Service::Error; end
10
9
 
11
10
  # Error classes
12
11
  class FormError < Hanikamu::Service::Error
@@ -91,9 +90,15 @@ module Hanikamu
91
90
  end
92
91
 
93
92
  # DSL methods
94
- def within_mutex(lock_key, expire_milliseconds: nil)
93
+ def within_mutex(lock_key, expire_milliseconds: nil, if: nil, unless: nil)
94
+ if_condition = binding.local_variable_get(:if)
95
+ unless_condition = binding.local_variable_get(:unless)
96
+ validate_mutex_conditions!(if_condition, unless_condition)
97
+
95
98
  @_mutex_lock_key = lock_key
96
99
  @_mutex_expire_milliseconds = expire_milliseconds || Hanikamu::Operation.config.mutex_expire_milliseconds
100
+ @_mutex_if_condition = if_condition
101
+ @_mutex_unless_condition = unless_condition
97
102
  end
98
103
 
99
104
  def within_transaction(klass)
@@ -106,8 +111,7 @@ module Hanikamu
106
111
 
107
112
  # Define guard validations using a block
108
113
  # The block is evaluated in the context of a Guard class
109
- # rubocop:disable Metrics/MethodLength
110
- def guard(&block)
114
+ def guard(&block) # rubocop:disable Metrics/MethodLength
111
115
  return unless block
112
116
 
113
117
  # Thread-safe constant definition with mutex
@@ -142,9 +146,25 @@ module Hanikamu
142
146
  const_set(:Guard, guard_class)
143
147
  end
144
148
  end
145
- # rubocop:enable Metrics/MethodLength
146
149
 
147
- attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_transaction_klass, :_block
150
+ attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_mutex_if_condition, :_mutex_unless_condition,
151
+ :_transaction_klass, :_block
152
+
153
+ private
154
+
155
+ def validate_mutex_conditions!(if_condition, unless_condition)
156
+ if if_condition && unless_condition
157
+ raise ArgumentError, "Cannot specify both :if and :unless conditions for within_mutex"
158
+ end
159
+
160
+ if if_condition && !if_condition.respond_to?(:call)
161
+ raise ArgumentError, "within_mutex :if condition must be a callable (e.g. a Proc or lambda)"
162
+ end
163
+
164
+ return unless unless_condition && !unless_condition.respond_to?(:call)
165
+
166
+ raise ArgumentError, "within_mutex :unless condition must be a callable (e.g. a Proc or lambda)"
167
+ end
148
168
  end
149
169
 
150
170
  def call!(&block)
@@ -167,15 +187,29 @@ module Hanikamu
167
187
  end
168
188
 
169
189
  def within_mutex!(&)
170
- return yield if _lock_key.nil?
171
-
172
- Hanikamu::Operation.redis_lock.lock!(_lock_key, self.class._mutex_expire_milliseconds, &)
173
- end
174
-
175
- def _lock_key
176
- return if self.class._mutex_lock_key.blank?
177
-
178
- public_send(self.class._mutex_lock_key)
190
+ return yield if self.class._mutex_lock_key.blank?
191
+ return yield unless _should_apply_mutex?
192
+
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, &)
179
213
  end
180
214
 
181
215
  def within_transaction!(&)
@@ -186,6 +220,81 @@ module Hanikamu
186
220
 
187
221
  private
188
222
 
223
+ def _should_apply_mutex?
224
+ return false if self.class._mutex_if_condition && !instance_exec(&self.class._mutex_if_condition)
225
+ return false if self.class._mutex_unless_condition && instance_exec(&self.class._mutex_unless_condition)
226
+
227
+ true
228
+ end
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
+
189
298
  def transaction_class
190
299
  return if self.class._transaction_klass.nil?
191
300
  return ActiveRecord::Base if self.class._transaction_klass == :base
@@ -211,5 +320,4 @@ module Hanikamu
211
320
  raise Hanikamu::Operation::GuardError, @guard
212
321
  end
213
322
  end
214
- # rubocop:enable Metrics/ClassLength
215
323
  end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanikamu-operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup
8
8
  - Alejandro Jimenez
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2025-12-05 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: activemodel
@@ -102,11 +101,12 @@ dependencies:
102
101
  description: |
103
102
  Ruby gem for building robust service operations with guard validations, distributed mutex locks via Redlock,
104
103
  database transactions, and comprehensive error handling. Thread-safe and designed for production Rails applications.
105
- email:
106
104
  executables: []
107
105
  extensions: []
108
106
  extra_rdoc_files: []
109
107
  files:
108
+ - ".cursor/rules/docker-environment.mdc"
109
+ - ".vscode/settings.json"
110
110
  - CHANGELOG.md
111
111
  - Dockerfile
112
112
  - LICENSE.txt
@@ -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
@@ -124,7 +125,6 @@ metadata:
124
125
  source_code_uri: https://github.com/Hanikamu/hanikamu-operation
125
126
  changelog_uri: https://github.com/Hanikamu/hanikamu-operation/blob/main/CHANGELOG.md
126
127
  rubygems_mfa_required: 'true'
127
- post_install_message:
128
128
  rdoc_options: []
129
129
  require_paths:
130
130
  - lib
@@ -139,8 +139,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
139
  - !ruby/object:Gem::Version
140
140
  version: '0'
141
141
  requirements: []
142
- rubygems_version: 3.5.3
143
- signing_key:
142
+ rubygems_version: 4.0.3
144
143
  specification_version: 4
145
144
  summary: Service objects with guards, distributed locks, and transactions
146
145
  test_files: []