Class: IdempotencyLock::Lock
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- IdempotencyLock::Lock
- Defined in:
- lib/idempotency_lock/lock.rb
Overview
ActiveRecord model for storing idempotency locks.
This model expects a database table with the following schema:
- name: string (unique, indexed, max 255 chars)
- expires_at: datetime (nullable, indexed)
- executed_at: datetime
- created_at: datetime
- updated_at: datetime
Use the provided generator to create the migration:
rails generate idempotency_lock:install
Constant Summary collapse
- MAX_NAME_LENGTH =
Maximum length for lock names. Ensures compatibility with database unique index limits (MySQL utf8mb4 = 191, most others = 255+).
255
Class Method Summary collapse
-
.acquire(name, expires_at: nil, now: Time.current) ⇒ Boolean
Attempt to acquire a lock for the given name.
-
.cleanup_expired ⇒ Integer
Clean up expired locks.
-
.locked?(name) ⇒ Boolean
Check if a valid (non-expired) lock exists for the given name.
-
.release(name) ⇒ Boolean
Release a lock by name (removes the record entirely).
Instance Method Summary collapse
-
#expired?(now: Time.current) ⇒ Boolean
Check if this lock has expired.
-
#valid_lock?(now: Time.current) ⇒ Boolean
Check if this lock is still valid (not expired).
Class Method Details
.acquire(name, expires_at: nil, now: Time.current) ⇒ Boolean
Attempt to acquire a lock for the given name. Returns true if the lock was acquired, false otherwise.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/idempotency_lock/lock.rb', line 45 def acquire(name, expires_at: nil, now: Time.current) # Try to insert first (most common case for new keys) begin create!(name: name, expires_at: expires_at, executed_at: now) return true rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid # Lock exists, check if it's expired end # Check if an existing lock is expired and can be claimed existing = find_by(name: name) return false if existing.nil? return false unless existing.expired?(now: now) # Optimistic locking: use the observed updated_at in the WHERE clause. # This prevents a race where two processes both see an expired lock and # both try to claim it. Only one UPDATE will match; the other returns 0. # We use updated_at rather than expires_at because it's system-managed # and always moves forward, making it more reliable for concurrency control. updated = where(name: name, updated_at: existing.updated_at) .update_all(expires_at: expires_at, executed_at: now, updated_at: now) updated.positive? end |
.cleanup_expired ⇒ Integer
Clean up expired locks
93 94 95 |
# File 'lib/idempotency_lock/lock.rb', line 93 def cleanup_expired where("expires_at IS NOT NULL AND expires_at < ?", Time.current).delete_all end |
.locked?(name) ⇒ Boolean
Check if a valid (non-expired) lock exists for the given name
83 84 85 86 87 88 |
# File 'lib/idempotency_lock/lock.rb', line 83 def locked?(name) lock = find_by(name: name) return false if lock.nil? lock.valid_lock? end |
.release(name) ⇒ Boolean
Release a lock by name (removes the record entirely)
74 75 76 77 |
# File 'lib/idempotency_lock/lock.rb', line 74 def release(name) deleted = where(name: name).delete_all deleted.positive? end |
Instance Method Details
#expired?(now: Time.current) ⇒ Boolean
Check if this lock has expired
27 28 29 |
# File 'lib/idempotency_lock/lock.rb', line 27 def expired?(now: Time.current) expires_at.present? && expires_at < now end |
#valid_lock?(now: Time.current) ⇒ Boolean
Check if this lock is still valid (not expired)
33 34 35 |
# File 'lib/idempotency_lock/lock.rb', line 33 def valid_lock?(now: Time.current) expires_at.nil? || expires_at >= now end |