ActiveRecordDeadlockHandler

A Ruby gem that automatically retries ActiveRecord transactions on deadlock errors with exponential backoff and jitter.

Handles:

  • ActiveRecord::Deadlocked
  • PG::TRDeadlockDetected
  • Any database deadlock that raises through ActiveRecord's exception hierarchy

Installation

Add to your Gemfile:

gem "active_record_deadlock_handler"

Then run:

bundle install

Usage

Rails (automatic)

The gem hooks into Rails via a Railtie and automatically patches ActiveRecord::Base.transaction. No code changes required — all transactions are retried on deadlock by default.

Create an initializer to customize behavior:

# config/initializers/active_record_deadlock_handler.rb
ActiveRecordDeadlockHandler.configure do |config|
  config.max_retries   = 3      # number of retries after the first attempt
  config.base_delay    = 0.1    # seconds before first retry
  config.max_delay     = 5.0    # upper cap on delay
  config.jitter_factor = 0.25   # randomness fraction to avoid thundering herd
  config.log_level     = :warn  # :debug, :info, :warn, :error
  config.auto_patch    = true   # set false to disable automatic patching
  config.reraise_after_exhaustion = true  # re-raise after all retries fail
end

Manual wrapper (without Rails or auto-patch)

ActiveRecordDeadlockHandler.with_retry do
  User.find_or_create_by!(email: params[:email])
end

Callbacks (e.g. error tracking)

ActiveRecordDeadlockHandler.on_deadlock do |exception:, attempt:, **|
  Sentry.capture_exception(exception, extra: { retry_attempt: attempt })
end

Multiple callbacks can be registered and are all invoked on each deadlock detection.

Configuration

Option Default Description
max_retries 3 Number of retries after the initial attempt
base_delay 0.1 Base sleep duration in seconds before first retry
max_delay 5.0 Maximum sleep duration in seconds
jitter_factor 0.25 Random jitter as a fraction of the computed delay (0–1)
backoff_multiplier 2.0 Exponential growth factor per retry
logger nil Custom logger; falls back to ActiveRecord::Base.logger
log_level :warn Log level for deadlock messages
auto_patch true Automatically patch DatabaseStatements#transaction
reraise_after_exhaustion true Re-raise the error after all retries are exhausted

Backoff formula

delay = min(base_delay * backoff_multiplier^(attempt - 1), max_delay)
      + delay * jitter_factor * rand

Example with defaults — sleep durations before each retry:

Retry Base delay With jitter (approx)
1st 0.1s 0.10–0.125s
2nd 0.2s 0.20–0.250s
3rd 0.4s 0.40–0.500s

How it works

The gem prepends a module into ActiveRecord::ConnectionAdapters::DatabaseStatements, wrapping the #transaction method with retry logic. Only the outermost transaction is wrapped — nested transactions (savepoints) pass through untouched, because a deadlock always invalidates the entire outer transaction. Retrying a savepoint inside a dead transaction would corrupt state.

User.transaction do           # ← retry loop installed here
  .transaction do      # ← savepoint, passes through
    .update!(balance: 0)
  end
end
# If deadlock fires: entire outer block is re-executed

License

MIT — see LICENSE.

Contributing

Bug reports and pull requests are welcome at https://github.com/afshmini/deadlock-handler.