idempotency_lock 0.1.2 → 0.2.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 +16 -0
- data/README.md +54 -0
- data/lib/idempotency_lock/lock.rb +4 -4
- data/lib/idempotency_lock/version.rb +1 -1
- data/lib/idempotency_lock.rb +60 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa9a2abc50beeef3b17f4b11c444373735a0c04f5cb451f7c1f17cf4592e63f8
|
|
4
|
+
data.tar.gz: 7eb8ff38a775b71eef1c607da5542b11f62c95b5a1146ff92c468da1b370d590
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: feea7d88474b9fbda1412b901c157b6634764e5a6933f297141159476198926a96005d251b8735ccad8604baa4e7accd90116cc2378dcc194d54271937396785
|
|
7
|
+
data.tar.gz: a8a13eeed8e4ec35ffca2af0b571295da250539d060198f00ae14d6d0c67c885a1d1d71cc78b9b30e1b74109aa490efeb6be91ea39e1f30ef4f5e9abb1137bd0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2025-01-24
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `IdempotencyLock.temporarily` - Execute a block with an ephemeral lock that is
|
|
8
|
+
automatically released when the block completes (success or error)
|
|
9
|
+
- `wait:` parameter for `temporarily` - Poll for lock availability with configurable
|
|
10
|
+
timeout (e.g., `wait: 5.seconds`)
|
|
11
|
+
|
|
12
|
+
## [0.1.3] - 2025-12-06
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Use `updated_at` instead of `expires_at` for optimistic locking (more reliable
|
|
17
|
+
since it's system-managed and always moves forward)
|
|
18
|
+
|
|
3
19
|
## [0.1.2] - 2025-12-06
|
|
4
20
|
|
|
5
21
|
### Fixed
|
data/README.md
CHANGED
|
@@ -73,6 +73,49 @@ IdempotencyLock.once("periodic-task", ttl: 3600) do
|
|
|
73
73
|
end
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
### Temporary Locks (Auto-Release)
|
|
77
|
+
|
|
78
|
+
Use `temporarily` when you want exclusive execution but don't need permanent idempotency. The lock is automatically released when the block completes (success or error):
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# Lock is held only while the block runs
|
|
82
|
+
IdempotencyLock.temporarily("process-payment-#{payment.id}") do
|
|
83
|
+
PaymentProcessor.charge(payment)
|
|
84
|
+
end
|
|
85
|
+
# Lock is now released - another call can acquire it
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This is useful for:
|
|
89
|
+
- Preventing concurrent processing of the same resource
|
|
90
|
+
- Mutex-style synchronization across processes/servers
|
|
91
|
+
- Operations that should be exclusive but retriable
|
|
92
|
+
|
|
93
|
+
#### Waiting for Lock Availability
|
|
94
|
+
|
|
95
|
+
By default, `temporarily` returns immediately if the lock is held. Use `wait:` to poll until the lock becomes available:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Wait up to 5 seconds for lock, then skip if still unavailable
|
|
99
|
+
result = IdempotencyLock.temporarily("exclusive-job", wait: 5.seconds) do
|
|
100
|
+
process_job
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if result.skipped?
|
|
104
|
+
puts "Could not acquire lock within timeout"
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### TTL for Crash Protection
|
|
109
|
+
|
|
110
|
+
Combine with `ttl:` to ensure locks are released even if the process crashes:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
IdempotencyLock.temporarily("long-job", ttl: 10.minutes, wait: 30.seconds) do
|
|
114
|
+
# If process crashes, lock expires after 10 minutes
|
|
115
|
+
long_running_task
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
76
119
|
### Error Handling
|
|
77
120
|
|
|
78
121
|
Control what happens when an error occurs inside the block:
|
|
@@ -172,6 +215,17 @@ class ImportantJob
|
|
|
172
215
|
end
|
|
173
216
|
```
|
|
174
217
|
|
|
218
|
+
### Exclusive Resource Processing
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
# Ensure only one process handles a payment at a time
|
|
222
|
+
def process_payment(payment_id)
|
|
223
|
+
IdempotencyLock.temporarily("payment-#{payment_id}", wait: 10.seconds) do
|
|
224
|
+
Payment.find(payment_id).process!
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
175
229
|
## Database Schema
|
|
176
230
|
|
|
177
231
|
The gem creates an `idempotency_locks` table with:
|
|
@@ -56,12 +56,12 @@ module IdempotencyLock
|
|
|
56
56
|
return false if existing.nil?
|
|
57
57
|
return false unless existing.expired?(now: now)
|
|
58
58
|
|
|
59
|
-
# Optimistic locking: use the observed
|
|
59
|
+
# Optimistic locking: use the observed updated_at in the WHERE clause.
|
|
60
60
|
# This prevents a race where two processes both see an expired lock and
|
|
61
61
|
# both try to claim it. Only one UPDATE will match; the other returns 0.
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
updated = where(name: name,
|
|
62
|
+
# We use updated_at rather than expires_at because it's system-managed
|
|
63
|
+
# and always moves forward, making it more reliable for concurrency control.
|
|
64
|
+
updated = where(name: name, updated_at: existing.updated_at)
|
|
65
65
|
.update_all(expires_at: expires_at, executed_at: now, updated_at: now)
|
|
66
66
|
|
|
67
67
|
updated.positive?
|
data/lib/idempotency_lock.rb
CHANGED
|
@@ -27,6 +27,9 @@ module IdempotencyLock
|
|
|
27
27
|
ON_ERROR_KEEP_LOCKED = :keep # Keep lock in place (default)
|
|
28
28
|
ON_ERROR_RAISE = :raise # Re-raise after cleanup/logging
|
|
29
29
|
|
|
30
|
+
# Wait retry interval for temporarily with wait option
|
|
31
|
+
WAIT_RETRY_INTERVAL = 0.1 # 100ms between retry attempts
|
|
32
|
+
|
|
30
33
|
class << self
|
|
31
34
|
# Execute a block exactly once for the given operation name.
|
|
32
35
|
#
|
|
@@ -54,6 +57,40 @@ module IdempotencyLock
|
|
|
54
57
|
# Convenience alias for `once`
|
|
55
58
|
alias wrap once
|
|
56
59
|
|
|
60
|
+
# Execute a block with a temporary lock that is automatically released
|
|
61
|
+
# when the block completes (success or error).
|
|
62
|
+
#
|
|
63
|
+
# Unlike `once`, this method always releases the lock after execution,
|
|
64
|
+
# making it suitable for mutex-style synchronization rather than idempotency.
|
|
65
|
+
#
|
|
66
|
+
# @param name [String] unique identifier for this lock
|
|
67
|
+
# @param ttl [ActiveSupport::Duration, Integer, nil] time-to-live for crash protection
|
|
68
|
+
# @param wait [ActiveSupport::Duration, Integer, nil] how long to wait for lock availability
|
|
69
|
+
# @yield The block to execute while holding the lock
|
|
70
|
+
# @return [Result] containing execution status and return value
|
|
71
|
+
def temporarily(name, ttl: nil, wait: nil)
|
|
72
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
73
|
+
|
|
74
|
+
acquired = acquire_with_wait(name, ttl: ttl, wait: wait)
|
|
75
|
+
|
|
76
|
+
unless acquired
|
|
77
|
+
log_debug("Lock already held for '#{name}', skipping execution")
|
|
78
|
+
return Result.new(executed: false, skipped: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
log_debug("Temporary lock acquired for '#{name}', executing block")
|
|
82
|
+
begin
|
|
83
|
+
value = yield
|
|
84
|
+
Result.new(executed: true, value: value)
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
log_error("Exception in temporary lock block '#{name}': #{e.class} - #{e.message}")
|
|
87
|
+
Result.new(executed: true, error: e)
|
|
88
|
+
ensure
|
|
89
|
+
Lock.release(name)
|
|
90
|
+
log_debug("Temporary lock released for '#{name}'")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
57
94
|
# Release a lock manually (useful for testing or manual intervention)
|
|
58
95
|
#
|
|
59
96
|
# @param name [String] the lock name to release
|
|
@@ -86,6 +123,29 @@ module IdempotencyLock
|
|
|
86
123
|
|
|
87
124
|
private
|
|
88
125
|
|
|
126
|
+
def acquire_with_wait(name, ttl:, wait:)
|
|
127
|
+
now = Time.current
|
|
128
|
+
expires_at = calculate_expires_at(ttl, now)
|
|
129
|
+
acquired = Lock.acquire(name, expires_at: expires_at, now: now)
|
|
130
|
+
|
|
131
|
+
return true if acquired
|
|
132
|
+
return false if wait.nil?
|
|
133
|
+
|
|
134
|
+
# Calculate deadline for waiting
|
|
135
|
+
wait_seconds = wait.respond_to?(:to_f) ? wait.to_f : wait.to_i
|
|
136
|
+
deadline = Time.current + wait_seconds
|
|
137
|
+
|
|
138
|
+
while Time.current < deadline
|
|
139
|
+
sleep(WAIT_RETRY_INTERVAL)
|
|
140
|
+
now = Time.current
|
|
141
|
+
expires_at = calculate_expires_at(ttl, now)
|
|
142
|
+
acquired = Lock.acquire(name, expires_at: expires_at, now: now)
|
|
143
|
+
return true if acquired
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
89
149
|
def calculate_expires_at(ttl, now)
|
|
90
150
|
return nil if ttl.nil?
|
|
91
151
|
|