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
VERSION =
"0.1.3"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.loggerObject



83
84
85
# File 'lib/idempotency_lock.rb', line 83

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



76
77
78
# File 'lib/idempotency_lock.rb', line 76

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



69
70
71
# File 'lib/idempotency_lock.rb', line 69

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)


38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/idempotency_lock.rb', line 38

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



61
62
63
# File 'lib/idempotency_lock.rb', line 61

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