Module: IdempotencyLock

Defined in:
lib/idempotency_lock.rb,
lib/idempotency_lock/lock.rb,
lib/idempotency_lock/result.rb,
lib/idempotency_lock/railtie.rb,
lib/idempotency_lock/version.rb,
lib/generators/idempotency_lock/install_generator.rb

Overview

Database-backed idempotency locks for Ruby on Rails.

Ensures operations run exactly once using database-backed locks with support for TTL expiration, multiple error handling strategies, and clean return value handling.

Examples:

Basic usage

result = IdempotencyLock.once("send-welcome-email-user-123") do
  UserMailer.welcome(user).deliver_now
end
puts "Email sent!" if result.executed?

With TTL

IdempotencyLock.once("daily-report", ttl: 24.hours) { generate_report }

Defined Under Namespace

Modules: Generators Classes: Error, Lock, Railtie, Result

Constant Summary collapse

ON_ERROR_UNLOCK =

Error handling strategies

:unlock
ON_ERROR_KEEP_LOCKED =

Remove lock so operation can be retried

:keep
ON_ERROR_RAISE =

Keep lock in place (default)

:raise
WAIT_RETRY_INTERVAL =

Wait retry interval for temporarily with wait option

0.1
VERSION =
"0.2.0"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.loggerObject



120
121
122
# File 'lib/idempotency_lock.rb', line 120

def logger
  @logger ||= defined?(Rails) ? Rails.logger : nil
end

Class Method Details

.cleanup_expiredInteger

Clean up all expired locks

Returns:

  • (Integer)

    number of locks cleaned up



113
114
115
# File 'lib/idempotency_lock.rb', line 113

def cleanup_expired
  Lock.cleanup_expired
end

.locked?(name) ⇒ Boolean

Check if an operation is currently locked

Parameters:

  • name (String)

    the lock name to check

Returns:

  • (Boolean)

    true if locked and not expired



106
107
108
# File 'lib/idempotency_lock.rb', line 106

def locked?(name)
  Lock.locked?(name)
end

.once(name, ttl: nil, on_error: ON_ERROR_KEEP_LOCKED) { ... } ⇒ Result Also known as: wrap

Execute a block exactly once for the given operation name.

Parameters:

  • name (String)

    unique identifier for this operation

  • ttl (ActiveSupport::Duration, Integer, nil) (defaults to: nil)

    time-to-live for the lock

  • on_error (Symbol, Proc) (defaults to: ON_ERROR_KEEP_LOCKED)

    error handling strategy (:keep, :unlock, :raise, or Proc)

Yields:

  • The block to execute (exactly once per lock name)

Returns:

  • (Result)

    containing execution status and return value

Raises:

  • (ArgumentError)


41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/idempotency_lock.rb', line 41

def once(name, ttl: nil, on_error: ON_ERROR_KEEP_LOCKED, &)
  raise ArgumentError, "Block required" unless block_given?

  now = Time.current
  expires_at = calculate_expires_at(ttl, now)
  acquired = Lock.acquire(name, expires_at: expires_at, now: now)

  unless acquired
    log_debug("Lock already exists for '#{name}', skipping execution")
    return Result.new(executed: false, skipped: true)
  end

  log_debug("Lock acquired for '#{name}', executing block")
  execute_with_lock(name, on_error, &)
end

.release(name) ⇒ Boolean

Release a lock manually (useful for testing or manual intervention)

Parameters:

  • name (String)

    the lock name to release

Returns:

  • (Boolean)

    true if a lock was released



98
99
100
# File 'lib/idempotency_lock.rb', line 98

def release(name)
  Lock.release(name)
end

.temporarily(name, ttl: nil, wait: nil) { ... } ⇒ Result

Execute a block with a temporary lock that is automatically released when the block completes (success or error).

Unlike once, this method always releases the lock after execution, making it suitable for mutex-style synchronization rather than idempotency.

Parameters:

  • name (String)

    unique identifier for this lock

  • ttl (ActiveSupport::Duration, Integer, nil) (defaults to: nil)

    time-to-live for crash protection

  • wait (ActiveSupport::Duration, Integer, nil) (defaults to: nil)

    how long to wait for lock availability

Yields:

  • The block to execute while holding the lock

Returns:

  • (Result)

    containing execution status and return value

Raises:

  • (ArgumentError)


71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/idempotency_lock.rb', line 71

def temporarily(name, ttl: nil, wait: nil)
  raise ArgumentError, "Block required" unless block_given?

  acquired = acquire_with_wait(name, ttl: ttl, wait: wait)

  unless acquired
    log_debug("Lock already held for '#{name}', skipping execution")
    return Result.new(executed: false, skipped: true)
  end

  log_debug("Temporary lock acquired for '#{name}', executing block")
  begin
    value = yield
    Result.new(executed: true, value: value)
  rescue StandardError => e
    log_error("Exception in temporary lock block '#{name}': #{e.class} - #{e.message}")
    Result.new(executed: true, error: e)
  ensure
    Lock.release(name)
    log_debug("Temporary lock released for '#{name}'")
  end
end