Class: IdempotencyLock::Lock

Inherits:
ActiveRecord::Base
  • Object
show all
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

Instance Method Summary collapse

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.

Parameters:

  • name (String)

    unique identifier for the operation

  • expires_at (Time, nil) (defaults to: nil)

    when the lock should expire (nil = never)

  • now (Time) (defaults to: Time.current)

    current time reference (injectable for testing)

Returns:

  • (Boolean)

    true if lock was acquired



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_expiredInteger

Clean up expired locks

Returns:

  • (Integer)

    number of locks removed



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

Parameters:

  • name (String)

    unique identifier for the operation

Returns:

  • (Boolean)


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)

Parameters:

  • name (String)

    unique identifier for the operation

Returns:

  • (Boolean)

    true if a lock was removed



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

Returns:

  • (Boolean)


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)

Returns:

  • (Boolean)


33
34
35
# File 'lib/idempotency_lock/lock.rb', line 33

def valid_lock?(now: Time.current)
  expires_at.nil? || expires_at >= now
end