idempotency_lock 0.1.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4920afd8d5429063037ecb787cf54915a0837d01dcc0e347143117c92aa1e842
4
- data.tar.gz: fb92062859dc7f2dad7ecc897c6f327ddf486376b1177f2adbee01b595853429
3
+ metadata.gz: aa9a2abc50beeef3b17f4b11c444373735a0c04f5cb451f7c1f17cf4592e63f8
4
+ data.tar.gz: 7eb8ff38a775b71eef1c607da5542b11f62c95b5a1146ff92c468da1b370d590
5
5
  SHA512:
6
- metadata.gz: e81784700e7745eb981190b9aa53268c79f47ee1897ff20ea04669254e684abd1e012247d35de67b6af499539e4034651a9a28486e9c3494137e7749af22a80d
7
- data.tar.gz: 32838477ada126fce6e6ff26f5a07e6b363d34a846314e46808917e90a934cb283a62d21bb0312c3a451cf2f3cff67e20dd31a5cd8f77f67a7df536fb2f92e5c
6
+ metadata.gz: feea7d88474b9fbda1412b901c157b6634764e5a6933f297141159476198926a96005d251b8735ccad8604baa4e7accd90116cc2378dcc194d54271937396785
7
+ data.tar.gz: a8a13eeed8e4ec35ffca2af0b571295da250539d060198f00ae14d6d0c67c885a1d1d71cc78b9b30e1b74109aa490efeb6be91ea39e1f30ef4f5e9abb1137bd0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## [0.1.3] - 2025-12-06
4
13
 
5
14
  ### Changed
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:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IdempotencyLock
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idempotency_lock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nagro