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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +55 -2
- data/docs/projects/20260825_hanikamu_reentrant_mutex.md +231 -0
- data/lib/hanikamu/operation.rb +90 -8
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 565d28c9c7d700db3b5bdb2be0ddd960951d07bf027a16b946cc8c527ad45ae6
|
|
4
|
+
data.tar.gz: 2c241ea17940bc687881d746361fa94fe85dfc3149d410c4b665c44d0367491b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
data/lib/hanikamu/operation.rb
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
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.
|
|
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
|