ruby 90 lines · 3 tabs

Deadlock-Aware Retry Wrapper

Shared by codesnips Jan 2026
3 tabs
module RetryableTransaction
  module_function

  RETRIABLE = [ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout].freeze

  def run(max_retries: 3, base_delay: 0.05, &block)
    attempts = 0
    begin
      attempts += 1
      ActiveRecord::Base.transaction(requires_new: true, &block)
    rescue *RETRIABLE => e
      raise e unless attempts <= max_retries

      Rails.logger.warn(
        "[RetryableTransaction] #{e.class} on attempt #{attempts}/#{max_retries + 1}, retrying"
      )
      sleep(backoff_for(attempts, base_delay))
      retry
    end
  end

  def backoff_for(attempt, base_delay)
    exponential = base_delay * (2**(attempt - 1))
    jitter = rand * base_delay
    exponential + jitter
  end
end
3 files · ruby Explain with highlit

Concurrent writes that touch the same rows in different orders eventually collide. When two transactions each hold a lock the other needs, the database picks a victim and aborts it with a deadlock error rather than hanging forever. These aborts are transient and safe to retry, but only if the entire transaction is replayed from scratch — retrying a half-applied unit of work would corrupt data. This snippet packages that discipline into a reusable helper.

In RetryableTransaction, the run method wraps a block in ActiveRecord::Base.transaction and rescues the specific transient errors: ActiveRecord::Deadlocked and ActiveRecord::LockWaitTimeout. Both are subclasses of StatementInvalid that Rails raises for the underlying Postgres/MySQL error codes, so matching on them is more precise than rescuing generic exceptions. The retryable? check ensures unrelated failures propagate immediately. On a retriable error the method sleeps for a randomized exponential backoff via backoff_for, which spreads out competing retries so two victims do not simply re-collide in lockstep. After max_retries attempts it re-raises so the caller — or a job runner — can fail loudly.

The key correctness point is that the transaction boundary lives inside the retry loop. Each attempt opens a fresh transaction, so any partial writes from a failed attempt are rolled back before the block runs again. The block must therefore be idempotent and free of external side effects like emails, since it can execute multiple times.

In AccountTransfer service, call uses RetryableTransaction.run to move money between accounts. Rows are locked with lock! in a consistent order (lowest id first) — ordering lock acquisition is the real fix for deadlocks, while the retry wrapper handles the residual races that ordering cannot fully eliminate. with_advisory_ordering sorts the accounts so every caller grabs locks the same way.

In ProcessTransferJob, the ActiveJob defines retry_on for genuinely persistent problems but delegates transient lock contention to the wrapper, keeping job-level retries cheap and rare. This layering — order locks, retry deadlocks in-process, escalate the rest to the queue — is the standard approach for reliable financial writes under load.


Related snips

Share this code

Here's the card — post it anywhere.

Deadlock-Aware Retry Wrapper — share card
Link copied