active-record-deadlock-handler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5eb27e3dc7a6c988d2878216e63b5de755cc42233e9630a13a5817d999ad10bb
4
+ data.tar.gz: 7b6a3266f459b1ceb434071c782d54f17262c270781ffb3b562766d361988662
5
+ SHA512:
6
+ metadata.gz: 33a0a288d9bc1f644c07bf798b4939ecc2871a5b4458c8450364301e1e47b0d9aad9174add38141296c32adeabef7fa81207beeb0bea8f3aa7d2b7d477332ec8
7
+ data.tar.gz: 24e7b8b19e9a7956c11cb57f6decf690290b6ce24c43826e79dacc359c84ab0a26943919c7de4be7616934ac2c605765f4e21d858d25b1f8c687aada64df9b8c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 afshmini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # ActiveRecordDeadlockHandler
2
+
3
+ A Ruby gem that automatically retries ActiveRecord transactions on deadlock errors with exponential backoff and jitter.
4
+
5
+ Handles:
6
+ - `ActiveRecord::Deadlocked`
7
+ - `PG::TRDeadlockDetected`
8
+ - Any database deadlock that raises through ActiveRecord's exception hierarchy
9
+
10
+ ## Installation
11
+
12
+ Add to your `Gemfile`:
13
+
14
+ ```ruby
15
+ gem "active_record_deadlock_handler"
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Rails (automatic)
27
+
28
+ 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.
29
+
30
+ Create an initializer to customize behavior:
31
+
32
+ ```ruby
33
+ # config/initializers/active_record_deadlock_handler.rb
34
+ ActiveRecordDeadlockHandler.configure do |config|
35
+ config.max_retries = 3 # number of retries after the first attempt
36
+ config.base_delay = 0.1 # seconds before first retry
37
+ config.max_delay = 5.0 # upper cap on delay
38
+ config.jitter_factor = 0.25 # randomness fraction to avoid thundering herd
39
+ config.log_level = :warn # :debug, :info, :warn, :error
40
+ config.auto_patch = true # set false to disable automatic patching
41
+ config.reraise_after_exhaustion = true # re-raise after all retries fail
42
+ end
43
+ ```
44
+
45
+ ### Manual wrapper (without Rails or auto-patch)
46
+
47
+ ```ruby
48
+ ActiveRecordDeadlockHandler.with_retry do
49
+ User.find_or_create_by!(email: params[:email])
50
+ end
51
+ ```
52
+
53
+ ### Callbacks (e.g. error tracking)
54
+
55
+ ```ruby
56
+ ActiveRecordDeadlockHandler.on_deadlock do |exception:, attempt:, **|
57
+ Sentry.capture_exception(exception, extra: { retry_attempt: attempt })
58
+ end
59
+ ```
60
+
61
+ Multiple callbacks can be registered and are all invoked on each deadlock detection.
62
+
63
+ ## Configuration
64
+
65
+ | Option | Default | Description |
66
+ |---|---|---|
67
+ | `max_retries` | `3` | Number of retries after the initial attempt |
68
+ | `base_delay` | `0.1` | Base sleep duration in seconds before first retry |
69
+ | `max_delay` | `5.0` | Maximum sleep duration in seconds |
70
+ | `jitter_factor` | `0.25` | Random jitter as a fraction of the computed delay (0–1) |
71
+ | `backoff_multiplier` | `2.0` | Exponential growth factor per retry |
72
+ | `logger` | `nil` | Custom logger; falls back to `ActiveRecord::Base.logger` |
73
+ | `log_level` | `:warn` | Log level for deadlock messages |
74
+ | `auto_patch` | `true` | Automatically patch `DatabaseStatements#transaction` |
75
+ | `reraise_after_exhaustion` | `true` | Re-raise the error after all retries are exhausted |
76
+
77
+ ### Backoff formula
78
+
79
+ ```
80
+ delay = min(base_delay * backoff_multiplier^(attempt - 1), max_delay)
81
+ + delay * jitter_factor * rand
82
+ ```
83
+
84
+ Example with defaults — sleep durations before each retry:
85
+
86
+ | Retry | Base delay | With jitter (approx) |
87
+ |---|---|---|
88
+ | 1st | 0.1s | 0.10–0.125s |
89
+ | 2nd | 0.2s | 0.20–0.250s |
90
+ | 3rd | 0.4s | 0.40–0.500s |
91
+
92
+ ## How it works
93
+
94
+ The gem `prepend`s 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.
95
+
96
+ ```
97
+ User.transaction do # ← retry loop installed here
98
+ account.transaction do # ← savepoint, passes through
99
+ account.update!(balance: 0)
100
+ end
101
+ end
102
+ # If deadlock fires: entire outer block is re-executed
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT — see [LICENSE](LICENSE).
108
+
109
+ ## Contributing
110
+
111
+ Bug reports and pull requests are welcome at https://github.com/afshmini/deadlock-handler.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ module Backoff
5
+ # Computes sleep duration for a given retry attempt using exponential backoff with jitter.
6
+ #
7
+ # Formula: min(base_delay * multiplier^(attempt-1), max_delay) + jitter
8
+ # Jitter is a random fraction of the computed exponential delay to reduce thundering herd.
9
+ #
10
+ # @param attempt [Integer] 1-indexed retry attempt number
11
+ # @param config [Configuration]
12
+ # @return [Float] seconds to sleep
13
+ def self.compute(attempt:, config:)
14
+ exponential = [config.base_delay * (config.backoff_multiplier**(attempt - 1)), config.max_delay].min
15
+ jitter = exponential * config.jitter_factor * rand
16
+ exponential + jitter
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ module Callbacks
5
+ LOCK = Mutex.new
6
+ private_constant :LOCK
7
+
8
+ @handlers = []
9
+
10
+ class << self
11
+ # Register a callback to be invoked on each deadlock detection.
12
+ #
13
+ # @yieldparam exception [ActiveRecord::Deadlocked] the deadlock error
14
+ # @yieldparam attempt [Integer] which retry attempt triggered this (1-indexed)
15
+ # @yieldparam config [Configuration]
16
+ def register(&block)
17
+ raise ArgumentError, "block required" unless block
18
+
19
+ LOCK.synchronize { @handlers << block }
20
+ end
21
+
22
+ def clear
23
+ LOCK.synchronize { @handlers.clear }
24
+ end
25
+
26
+ # Invokes all registered callbacks with deadlock context.
27
+ # Runs outside the lock so slow callbacks don't block registration.
28
+ def run(exception:, attempt:, config:)
29
+ handlers = LOCK.synchronize { @handlers.dup }
30
+ handlers.each do |handler|
31
+ handler.call(exception: exception, attempt: attempt, config: config)
32
+ rescue StandardError => e
33
+ warn "[ActiveRecordDeadlockHandler] Callback raised an error: #{e.class}: #{e.message}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ class Configuration
5
+ DEFAULTS = {
6
+ max_retries: 3,
7
+ base_delay: 0.1,
8
+ max_delay: 5.0,
9
+ jitter_factor: 0.25,
10
+ backoff_multiplier: 2.0,
11
+ logger: nil,
12
+ log_level: :warn,
13
+ auto_patch: true,
14
+ reraise_after_exhaustion: true
15
+ }.freeze
16
+
17
+ attr_accessor(*DEFAULTS.keys)
18
+
19
+ def initialize
20
+ DEFAULTS.each { |k, v| public_send(:"#{k}=", v) }
21
+ end
22
+
23
+ def validate!
24
+ raise ArgumentError, "max_retries must be >= 0" unless max_retries >= 0
25
+ raise ArgumentError, "base_delay must be > 0" unless base_delay > 0
26
+ raise ArgumentError, "max_delay must be >= base_delay" unless max_delay >= base_delay
27
+ raise ArgumentError, "jitter_factor must be between 0 and 1" unless (0.0..1.0).cover?(jitter_factor)
28
+ raise ArgumentError, "backoff_multiplier must be >= 1" unless backoff_multiplier >= 1
29
+ raise ArgumentError, "log_level must be a symbol" unless log_level.is_a?(Symbol)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ # Prepended into ActiveRecord::ConnectionAdapters::DatabaseStatements to wrap
5
+ # every top-level transaction with deadlock retry logic.
6
+ module ConnectionAdapterPatch
7
+ # Wraps transaction with retry logic only at the outermost transaction level.
8
+ #
9
+ # Nested transactions (savepoints) are NOT wrapped individually — when a deadlock
10
+ # kills a transaction, the entire outer transaction is invalidated. Retrying only
11
+ # a savepoint inside a dead transaction would corrupt state. The outer retry loop
12
+ # handles the full re-execution.
13
+ def transaction(**options, &block)
14
+ # open_transactions > 0 means we're already inside a real transaction.
15
+ # Let it pass through; the outermost call's retry loop covers everything.
16
+ return super(**options, &block) if open_transactions > 0
17
+
18
+ RetryExecutor.run(config: ActiveRecordDeadlockHandler.configuration) do
19
+ super(**options, &block)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ module Logging
5
+ def self.log(message, exception:, attempt:, config:)
6
+ logger = resolve_logger(config)
7
+ return unless logger
8
+
9
+ error_summary = exception.message.lines.first&.strip
10
+ full_message = "[ActiveRecordDeadlockHandler] #{message} " \
11
+ "(attempt=#{attempt}, error=#{exception.class}: #{error_summary})"
12
+
13
+ logger.public_send(config.log_level, full_message)
14
+ end
15
+
16
+ def self.resolve_logger(config)
17
+ config.logger || (defined?(ActiveRecord::Base) && ActiveRecord::Base.logger)
18
+ end
19
+ private_class_method :resolve_logger
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActiveRecordDeadlockHandler
6
+ class Railtie < Rails::Railtie
7
+ initializer "active_record_deadlock_handler.configure_active_record" do
8
+ # Wait until ActiveRecord is fully loaded before patching.
9
+ ActiveSupport.on_load(:active_record) do
10
+ if ActiveRecordDeadlockHandler.configuration.auto_patch
11
+ ActiveRecord::ConnectionAdapters::DatabaseStatements.prepend(
12
+ ActiveRecordDeadlockHandler::ConnectionAdapterPatch
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ module RetryExecutor
5
+ RETRYABLE_ERRORS = [ActiveRecord::Deadlocked].freeze
6
+
7
+ # Executes the given block, retrying on deadlock errors with exponential backoff.
8
+ #
9
+ # Thread-safe: all state (attempt counter, delay) is local to the call stack.
10
+ #
11
+ # @param config [Configuration]
12
+ # @yieldreturn [Object] the return value of the block on success
13
+ # @raise [ActiveRecord::Deadlocked] if max_retries is exhausted and reraise_after_exhaustion is true
14
+ def self.run(config: ActiveRecordDeadlockHandler.configuration, &block)
15
+ attempt = 0
16
+
17
+ begin
18
+ attempt += 1
19
+ yield
20
+ rescue *RETRYABLE_ERRORS => e
21
+ Callbacks.run(exception: e, attempt: attempt, config: config)
22
+
23
+ if attempt <= config.max_retries
24
+ Logging.log("Deadlock detected, retrying...", exception: e, attempt: attempt, config: config)
25
+ delay = Backoff.compute(attempt: attempt, config: config)
26
+ sleep(delay)
27
+ retry
28
+ else
29
+ Logging.log(
30
+ "Deadlock detected, max retries (#{config.max_retries}) exhausted.",
31
+ exception: e,
32
+ attempt: attempt,
33
+ config: config
34
+ )
35
+ raise if config.reraise_after_exhaustion
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordDeadlockHandler
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "active_record_deadlock_handler/version"
5
+ require_relative "active_record_deadlock_handler/configuration"
6
+ require_relative "active_record_deadlock_handler/backoff"
7
+ require_relative "active_record_deadlock_handler/callbacks"
8
+ require_relative "active_record_deadlock_handler/logging"
9
+ require_relative "active_record_deadlock_handler/retry_executor"
10
+ require_relative "active_record_deadlock_handler/connection_adapter_patch"
11
+ require_relative "active_record_deadlock_handler/railtie" if defined?(Rails::Railtie)
12
+
13
+ module ActiveRecordDeadlockHandler
14
+ class Error < StandardError; end
15
+
16
+ @configuration = Configuration.new
17
+ @configuration_mutex = Mutex.new
18
+
19
+ class << self
20
+ # Configure the gem. Should be called once from an initializer.
21
+ #
22
+ # @example
23
+ # ActiveRecordDeadlockHandler.configure do |config|
24
+ # config.max_retries = 5
25
+ # config.base_delay = 0.05
26
+ # config.log_level = :warn
27
+ # end
28
+ def configure
29
+ @configuration_mutex.synchronize do
30
+ yield @configuration
31
+ @configuration.validate!
32
+ end
33
+ end
34
+
35
+ def configuration
36
+ @configuration
37
+ end
38
+
39
+ # Register a callback invoked on every deadlock detection.
40
+ #
41
+ # @example
42
+ # ActiveRecordDeadlockHandler.on_deadlock do |exception:, attempt:, **|
43
+ # Sentry.capture_exception(exception, extra: { retry_attempt: attempt })
44
+ # end
45
+ def on_deadlock(&block)
46
+ Callbacks.register(&block)
47
+ end
48
+
49
+ # Opt-in wrapper for use without Rails or when auto_patch is disabled.
50
+ #
51
+ # @example
52
+ # ActiveRecordDeadlockHandler.with_retry { User.find_or_create_by!(email: email) }
53
+ def with_retry(config: configuration, &block)
54
+ RetryExecutor.run(config: config, &block)
55
+ end
56
+
57
+ # Resets configuration to defaults. Primarily useful in tests.
58
+ def reset_configuration!
59
+ @configuration_mutex.synchronize do
60
+ @configuration = Configuration.new
61
+ end
62
+ end
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active-record-deadlock-handler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - afshmini
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.4'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.4'
69
+ description: |
70
+ Transparently retries ActiveRecord transactions on ActiveRecord::Deadlocked errors
71
+ (including PG::TRDeadlockDetected) with configurable exponential backoff and jitter.
72
+ Provides hooks for custom callbacks, structured logging, and Rails initializer support.
73
+ email:
74
+ - afshmini@gmail.com
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - LICENSE
80
+ - README.md
81
+ - lib/active_record_deadlock_handler.rb
82
+ - lib/active_record_deadlock_handler/backoff.rb
83
+ - lib/active_record_deadlock_handler/callbacks.rb
84
+ - lib/active_record_deadlock_handler/configuration.rb
85
+ - lib/active_record_deadlock_handler/connection_adapter_patch.rb
86
+ - lib/active_record_deadlock_handler/logging.rb
87
+ - lib/active_record_deadlock_handler/railtie.rb
88
+ - lib/active_record_deadlock_handler/retry_executor.rb
89
+ - lib/active_record_deadlock_handler/version.rb
90
+ homepage: https://github.com/afshmini/active-record-deadlock-handler
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ rubygems_mfa_required: 'true'
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 2.7.0
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.5.20
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Automatic deadlock retry with exponential backoff for ActiveRecord
114
+ test_files: []