The project is in a healthy, maintained state
Transparently retries ActiveRecord transactions on ActiveRecord::Deadlocked errors (including PG::TRDeadlockDetected) with configurable exponential backoff and jitter. Provides hooks for custom callbacks, structured logging, and Rails initializer support.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.12
>= 1.4

Runtime

 Project Readme

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
  account.transaction do      # ← savepoint, passes through
    account.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.