ActiveRecordDeadlockHandler
A Ruby gem that automatically retries ActiveRecord transactions on deadlock errors with exponential backoff and jitter.
Handles:
ActiveRecord::DeadlockedPG::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.