transaction_retry_continued 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +177 -0
  3. data/.gitignore +6 -0
  4. data/Gemfile +23 -0
  5. data/LICENSE +19 -0
  6. data/README.md +107 -0
  7. data/Rakefile +11 -0
  8. data/d +1 -0
  9. data/gemfiles/Gemfile.base +6 -0
  10. data/gemfiles/activerecord-5.2/Gemfile.base +4 -0
  11. data/gemfiles/activerecord-5.2/Gemfile.mysql2 +10 -0
  12. data/gemfiles/activerecord-5.2/Gemfile.postgresql +10 -0
  13. data/gemfiles/activerecord-5.2/Gemfile.sqlite3 +10 -0
  14. data/gemfiles/activerecord-6.0/Gemfile.base +4 -0
  15. data/gemfiles/activerecord-6.0/Gemfile.mysql2 +10 -0
  16. data/gemfiles/activerecord-6.0/Gemfile.postgresql +10 -0
  17. data/gemfiles/activerecord-6.0/Gemfile.sqlite3 +10 -0
  18. data/gemfiles/activerecord-6.1/Gemfile.base +4 -0
  19. data/gemfiles/activerecord-6.1/Gemfile.mysql2 +10 -0
  20. data/gemfiles/activerecord-6.1/Gemfile.postgresql +10 -0
  21. data/gemfiles/activerecord-6.1/Gemfile.sqlite3 +10 -0
  22. data/gemfiles/activerecord-7.0/Gemfile.base +4 -0
  23. data/gemfiles/activerecord-7.0/Gemfile.mysql2 +10 -0
  24. data/gemfiles/activerecord-7.0/Gemfile.postgresql +10 -0
  25. data/gemfiles/activerecord-7.0/Gemfile.sqlite3 +10 -0
  26. data/lib/transaction_retry/active_record/base.rb +82 -0
  27. data/lib/transaction_retry/version.rb +3 -0
  28. data/lib/transaction_retry.rb +49 -0
  29. data/lib/transaction_retry_continued.rb +1 -0
  30. data/test/db/all.rb +4 -0
  31. data/test/db/db.rb +37 -0
  32. data/test/db/migrations.rb +20 -0
  33. data/test/db/queued_job.rb +2 -0
  34. data/test/integration/active_record/base/transaction_with_retry_test.rb +135 -0
  35. data/test/library_setup.rb +25 -0
  36. data/test/log/.gitkeep +0 -0
  37. data/test/test_console.rb +11 -0
  38. data/test/test_helper.rb +12 -0
  39. data/test/test_runner.rb +4 -0
  40. data/tests +6 -0
  41. data/transaction_retry_continued.gemspec +23 -0
  42. metadata +149 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f41000c855c9718808af7de74dc4acc436d76618af8b026d38b797713e7de025
4
+ data.tar.gz: 51ee0b37da0f5cf18af574ed42e7214f54a1198200b562bd2bb97f7a5af2cb5c
5
+ SHA512:
6
+ metadata.gz: 82d5871454e5541cf21988354f6e7dbe0a70c40bc789f61e286998617ba7bfffdebd6b52d5fab53a5d175d497d3b4148664e86d68462cc3f76bfcd584c31dc29
7
+ data.tar.gz: eee89ab0eaa1374b3e9d0d85554c4c8b702cfdd1fae55c6363ea6149f5150f8506f0ac059d849b53fc53c00c6fc5139bfba5c4bfaff05f33366d0e7d3069382a
@@ -0,0 +1,177 @@
1
+ name: CI PR Builds
2
+ 'on':
3
+ push:
4
+ branches:
5
+ - master
6
+ pull_request:
7
+ concurrency:
8
+ group: ci-${{ github.ref }}
9
+ cancel-in-progress: true
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ ruby:
17
+ - '2.5'
18
+ - '2.7'
19
+ - '3.0'
20
+ - '3.1'
21
+ - '3.2'
22
+ - '3.3'
23
+ activerecord:
24
+ - '5.2'
25
+ - '6.0'
26
+ - '6.1'
27
+ - '7.0'
28
+ db:
29
+ - mysql2
30
+ - sqlite3
31
+ - skip
32
+ dbversion:
33
+ - skip
34
+ exclude:
35
+ - ruby: '3.0'
36
+ activerecord: '5.2'
37
+ - ruby: '3.1'
38
+ activerecord: '5.2'
39
+ - ruby: '3.2'
40
+ activerecord: '5.2'
41
+ - ruby: '3.3'
42
+ activerecord: '5.2'
43
+ - ruby: '2.5'
44
+ activerecord: '7.0'
45
+ - db: skip
46
+ dbversion: skip
47
+ include:
48
+ - ruby: '2.5'
49
+ activerecord: '5.2'
50
+ db: postgresql
51
+ dbversion: '9.6'
52
+ - ruby: '2.5'
53
+ activerecord: '6.0'
54
+ db: postgresql
55
+ dbversion: '9.6'
56
+ - ruby: '2.5'
57
+ activerecord: '6.1'
58
+ db: postgresql
59
+ dbversion: '9.6'
60
+ - ruby: '2.7'
61
+ activerecord: '5.2'
62
+ db: postgresql
63
+ dbversion: '9.6'
64
+ - ruby: '2.7'
65
+ activerecord: '6.0'
66
+ db: postgresql
67
+ dbversion: '9.6'
68
+ - ruby: '2.7'
69
+ activerecord: '6.1'
70
+ db: postgresql
71
+ dbversion: '9.6'
72
+ - ruby: '2.7'
73
+ activerecord: '7.0'
74
+ db: postgresql
75
+ dbversion: '9.6'
76
+ - ruby: '3.0'
77
+ activerecord: '6.0'
78
+ db: postgresql
79
+ dbversion: '9.6'
80
+ - ruby: '3.0'
81
+ activerecord: '6.1'
82
+ db: postgresql
83
+ dbversion: '9.6'
84
+ - ruby: '3.0'
85
+ activerecord: '7.0'
86
+ db: postgresql
87
+ dbversion: '9.6'
88
+ - ruby: '3.1'
89
+ activerecord: '6.0'
90
+ db: postgresql
91
+ dbversion: '9.6'
92
+ - ruby: '3.1'
93
+ activerecord: '6.1'
94
+ db: postgresql
95
+ dbversion: '9.6'
96
+ - ruby: '3.1'
97
+ activerecord: '7.0'
98
+ db: postgresql
99
+ dbversion: '9.6'
100
+ env:
101
+ BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}/Gemfile.${{ matrix.db }}"
102
+ MYSQL_DB_HOST: 127.0.0.1
103
+ MYSQL_DB_USER: root
104
+ MYSQL_DB_PASS: database
105
+ MYSQL_DB_NAME: transaction_retry_continued_test
106
+ POSTGRESQL_DB_HOST: 127.0.0.1
107
+ POSTGRESQL_DB_USER: transaction_retry_continued
108
+ POSTGRESQL_DB_PASS: database
109
+ POSTGRESQL_DB_NAME: transaction_retry_continued_test
110
+ steps:
111
+ - uses: actions/checkout@v2
112
+ - name: Set up Ruby
113
+ uses: ruby/setup-ruby@v1
114
+ with:
115
+ ruby-version: "${{ matrix.ruby }}"
116
+ bundler-cache: true
117
+ - name: Run bundle update
118
+ run: bundle update
119
+ - name: Start Mysql
120
+ if: matrix.db == 'mysql2'
121
+ run: |
122
+ docker run --rm --detach \
123
+ -e MYSQL_ROOT_PASSWORD=$MYSQL_DB_PASS \
124
+ -e MYSQL_DATABASE=transaction_retry_continued_test \
125
+ -p 3306:3306 \
126
+ --health-cmd "mysqladmin ping --host=127.0.0.1 --password=$MYSQL_DB_PASS --silent" \
127
+ --health-interval 5s \
128
+ --health-timeout 5s \
129
+ --health-retries 5 \
130
+ --name database mysql:5.6
131
+ - name: Start Postgresql
132
+ if: matrix.db == 'postgresql'
133
+ run: |
134
+ docker run --rm --detach \
135
+ -e POSTGRES_USER=$POSTGRESQL_DB_USER \
136
+ -e POSTGRES_PASSWORD=$POSTGRESQL_DB_PASS \
137
+ -e POSTGRES_DB=transaction_retry_continued_test \
138
+ -p 5432:5432 \
139
+ --health-cmd "pg_isready -q" \
140
+ --health-interval 5s \
141
+ --health-timeout 5s \
142
+ --health-retries 5 \
143
+ --name database postgres:${{ matrix.dbversion }}
144
+ - name: Wait for database to start
145
+ if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')"
146
+ run: |
147
+ COUNT=0
148
+ ATTEMPTS=20
149
+ until [[ $COUNT -eq $ATTEMPTS ]]; do
150
+ [ "$(docker inspect -f {{.State.Health.Status}} database)" == "healthy" ] && break
151
+ echo $(( COUNT++ )) > /dev/null
152
+ sleep 2
153
+ done
154
+ - name: test
155
+ run: echo $BUNDLE_GEMFILE
156
+ - name: Run tests
157
+ run: db=${{ matrix.db }} bundle exec rake test
158
+ - name: Shutdown database
159
+ if: always() && (matrix.db == 'postgresql' || matrix.db == 'mysql2')
160
+ run: docker stop database
161
+ # - name: Coveralls Parallel
162
+ # if: "${{ !env.ACT }}"
163
+ # uses: coverallsapp/github-action@master
164
+ # with:
165
+ # github-token: "${{ secrets.GITHUB_TOKEN }}"
166
+ # flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }}-${{ matrix.db }}-${{ matrix.dbversion }}
167
+ # parallel: true
168
+ # finish:
169
+ # needs: test
170
+ # runs-on: ubuntu-latest
171
+ # steps:
172
+ # - name: Coveralls Finished
173
+ # if: "${{ !env.ACT }}"
174
+ # uses: coverallsapp/github-action@master
175
+ # with:
176
+ # github-token: "${{ secrets.GITHUB_TOKEN }}"
177
+ # parallel-finished: true
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .idea
6
+ test/log/*.log
data/Gemfile ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "http://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gemfile_local = File.expand_path '../Gemfile.local', __FILE__
8
+ eval File.read(gemfile_local), binding, gemfile_local if File.exist? gemfile_local
9
+ #
10
+ # group :test do
11
+ # gem 'transaction_isolation', git: 'https://github.com/alittlebit/transaction_isolation'
12
+ # # Use the gem instead of a dated version bundled with Ruby
13
+ # gem 'minitest', '5.3.4'
14
+ # gem 'simplecov', :require => false
15
+ # end
16
+ #
17
+ # group :development do
18
+ # gem 'transaction_isolation', git: 'https://github.com/alittlebit/transaction_isolation'
19
+ # gem 'rake'
20
+ # # enhance irb
21
+ # gem 'awesome_print', :require => false
22
+ # gem 'pry', :require => false
23
+ # end
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2012 Piotr 'Qertoip' Włodarek
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # Transaction Retry Continued
2
+
3
+ > This is a community-driven continuation of the [`transaction_retry`](https://github.com/qertoip/transaction_retry) gem
4
+ for retrying database transactions on deadlock and serialization errors. Originally designed to support MySQL,
5
+ PostgreSQL, and SQLite databases.
6
+
7
+ The `transaction_retry` gem enhances transaction reliability by automatically retrying transactions upon encountering
8
+ deadlock and serialization errors in the database.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's `Gemfile`:
13
+
14
+ ```ruby
15
+ gem 'transaction_retry_continued'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ The gem simplifies transaction retry logic by automatically rescuing `ActiveRecord::TransactionIsolationConflict` and
27
+ retrying the transaction.
28
+
29
+ __It works out of the box with Ruby on Rails__.
30
+
31
+ If you have a standalone ActiveRecord-based project you'll need to call:
32
+
33
+ ```ruby
34
+ TransactionRetry.apply_activerecord_patch # after connecting to the database
35
+ ```
36
+
37
+ ## Database deadlock and serialization errors that are retried
38
+
39
+ #### MySQL
40
+
41
+ * Deadlock found when trying to get lock
42
+ * Lock wait timeout exceeded
43
+
44
+ #### PostgreSQL
45
+
46
+ * deadlock detected
47
+ * could not serialize access
48
+
49
+ #### SQLite
50
+
51
+ * The database file is locked
52
+ * A table in the database is locked
53
+ * Database lock protocol error
54
+
55
+ ## Configuration
56
+
57
+ You can optionally configure transaction_retry gem in your config/initializers/transaction_retry.rb (or anywhere else):
58
+
59
+ ```ruby
60
+ TransactionRetry.max_retries = 3
61
+ TransactionRetry.wait_times = [0, 1, 2, 4, 8, 16, 32] # seconds to sleep after retry n
62
+ ```
63
+
64
+ ## Features
65
+
66
+ * Supports MySQL, PostgreSQL, and SQLite (as long as you are using new drivers mysql2, pg, sqlite3).
67
+ * Exponential sleep times between retries (0, 1, 2, 4 seconds).
68
+ * Logs every retry as a warning.
69
+ * Intentionally does not retry nested transactions.
70
+ * Configurable number of retries and sleep time between them.
71
+ * Use it in your Rails application or a standalone ActiveRecord-based project.
72
+
73
+ ## Testimonials
74
+
75
+ This gem was initially developed for and successfully works in production at [Kontomierz.pl](http://kontomierz.pl) - the finest Polish personal finance app.
76
+
77
+ ## Compatibility
78
+
79
+ * Ruby 2.5, 2.6, 2.7, 3.0, 3.1, 3.2, 3.3
80
+ * ActiveRecord 5.2, 6.0, 6.1, 7.0
81
+
82
+ ## Running tests
83
+
84
+ Run tests on the selected database (mysql2 by default):
85
+
86
+ db=mysql2 bundle exec rake test
87
+ db=postgresql bundle exec rake test
88
+ db=sqlite3 bundle exec rake test
89
+
90
+ Run tests on all supported databases:
91
+
92
+ ./tests
93
+
94
+ Database configuration is hardcoded in test/db/db.rb; feel free to improve this and submit a pull request.
95
+
96
+ ## How intrusive is this gem?
97
+
98
+ You should be very suspicious about any gem that monkey patches your stock Ruby on Rails framework.
99
+
100
+ This gem is carefully written to not be more intrusive than it needs to be:
101
+
102
+ * wraps ActiveRecord::Base#transaction class method using alias_method to add new behaviour
103
+ * introduces two new private class methods in ActiveRecord::Base (with names that should never collide)
104
+
105
+ ## License
106
+
107
+ Released under the MIT license. Copyright (C) 2012 Piotr 'Qertoip' Włodarek.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs += ["test", "lib"]
7
+ t.pattern = 'test/integration/**/*_test.rb'
8
+ t.verbose = true
9
+ end
10
+
11
+ task :default => [:test]
data/d ADDED
@@ -0,0 +1 @@
1
+ bundle exec ruby test/test_console.rb
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+ gemspec path: Dir.getwd
3
+
4
+ gem 'transaction_isolation', git: 'https://github.com/iagopiimenta/transaction_isolation_continued', branch: 'setup-git-workflow'
5
+
6
+ File.exist?(gemfile_local = File.expand_path('./Gemfile.local', Dir.getwd)) and eval File.read(gemfile_local), binding, gemfile_local
@@ -0,0 +1,4 @@
1
+ base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile)
3
+
4
+ gem "activerecord", ">= 5.2.0.beta0", "< 5.3"
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "mysql2"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "pg"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcpostgresql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "sqlite3", "~> 1.4"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10
+ end
@@ -0,0 +1,4 @@
1
+ base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile)
3
+
4
+ gem "activerecord", ">= 6.0", "< 6.1"
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "mysql2"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "pg"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcpostgresql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "sqlite3", "~> 1.4"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10
+ end
@@ -0,0 +1,4 @@
1
+ base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile)
3
+
4
+ gem "activerecord", ">= 6.1", "< 6.2"
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "mysql2"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "pg"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcpostgresql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "sqlite3", "~> 1.4"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10
+ end
@@ -0,0 +1,4 @@
1
+ base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile)
3
+
4
+ gem "activerecord", ">= 7.0", "< 7.1"
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "mysql2"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcmysql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "pg"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcpostgresql-adapter'
10
+ end
@@ -0,0 +1,10 @@
1
+ base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2
+ eval File.read(base_gemfile), binding, base_gemfile
3
+
4
+ platform :ruby do
5
+ gem "sqlite3", "~> 1.4"
6
+ end
7
+
8
+ platform :jruby do
9
+ gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10
+ end
@@ -0,0 +1,82 @@
1
+ require 'active_record/base'
2
+
3
+ module TransactionRetry
4
+ module ActiveRecord
5
+ module Base
6
+
7
+ def self.included( base )
8
+ base.extend( ClassMethods )
9
+ base.class_eval do
10
+ class << self
11
+ alias_method :transaction_without_retry, :transaction
12
+ alias_method :transaction, :transaction_with_retry
13
+ end
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+
19
+ def transaction_with_retry(**objects, &block)
20
+ retry_count = 0
21
+
22
+ opts = if objects.is_a? Hash
23
+ objects
24
+ else
25
+ {}
26
+ end
27
+
28
+ retry_on = opts.delete(:retry_on)
29
+ max_retries = opts.delete(:max_retries) || TransactionRetry.max_retries
30
+
31
+ begin
32
+ transaction_without_retry(**objects, &block)
33
+ rescue *[::ActiveRecord::TransactionIsolationConflict, *retry_on]
34
+ raise if retry_count >= max_retries
35
+ raise if tr_in_nested_transaction?
36
+
37
+ retry_count += 1
38
+ postfix = { 1 => 'st', 2 => 'nd', 3 => 'rd' }[retry_count] || 'th'
39
+
40
+ type_s = case $!
41
+ when ::ActiveRecord::TransactionIsolationConflict
42
+ "Transaction isolation conflict"
43
+ else
44
+ $!.class.name
45
+ end
46
+
47
+ logger.warn "#{type_s} detected. Retrying for the #{retry_count}-#{postfix} time..." if logger
48
+ tr_exponential_pause( retry_count )
49
+ retry
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Sleep 0, 1, 2, 4, ... seconds up to the TransactionRetry.max_retries.
56
+ # Cap the sleep time at 32 seconds.
57
+ # An ugly tr_ prefix is used to minimize the risk of method clash in the future.
58
+ def tr_exponential_pause( count )
59
+ seconds = TransactionRetry.wait_times[count-1] || 32
60
+
61
+ if TransactionRetry.fuzz
62
+ fuzz_factor = [seconds * 0.25, 1].max
63
+
64
+ seconds += rand * (fuzz_factor * 2) - fuzz_factor
65
+ end
66
+
67
+ sleep( seconds ) if seconds > 0
68
+ end
69
+
70
+ # Returns true if we are in the nested transaction (the one with :requires_new => true).
71
+ # Returns false otherwise.
72
+ # An ugly tr_ prefix is used to minimize the risk of method clash in the future.
73
+ def tr_in_nested_transaction?
74
+ connection.open_transactions != 0
75
+ end
76
+
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ ActiveRecord::Base.send( :include, TransactionRetry::ActiveRecord::Base )
@@ -0,0 +1,3 @@
1
+ module TransactionRetry
2
+ VERSION = "1.1.0"
3
+ end
@@ -0,0 +1,49 @@
1
+ require "active_record"
2
+ require "transaction_isolation"
3
+
4
+ require_relative "transaction_retry/version"
5
+
6
+ module TransactionRetry
7
+
8
+ # Must be called after ActiveRecord established a connection.
9
+ # Only then we know which connection adapter is actually loaded and can be enhanced.
10
+ # Please note ActiveRecord does not load unused adapters.
11
+ def self.apply_activerecord_patch
12
+ TransactionIsolation.apply_activerecord_patch
13
+ require_relative 'transaction_retry/active_record/base'
14
+ end
15
+
16
+ if defined?( ::Rails )
17
+ # Setup applying the patch after Rails is initialized.
18
+ class Railtie < ::Rails::Railtie
19
+ config.after_initialize do
20
+ TransactionRetry.apply_activerecord_patch
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.max_retries
26
+ @@max_retries ||= 3
27
+ end
28
+
29
+ def self.max_retries=( n )
30
+ @@max_retries = n
31
+ end
32
+
33
+ def self.wait_times
34
+ @@wait_times ||= [0, 1, 2, 4, 8, 16, 32]
35
+ end
36
+
37
+ def self.wait_times=( array_of_seconds )
38
+ @@wait_times = array_of_seconds
39
+ end
40
+
41
+ def self.fuzz
42
+ @@fuzz ||= true
43
+ end
44
+
45
+ def self.fuzz=( val )
46
+ @@fuzz = val
47
+ end
48
+
49
+ end
@@ -0,0 +1 @@
1
+ require 'transaction_retry'
data/test/db/all.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'active_record'
2
+ require_relative 'db'
3
+ require_relative 'migrations'
4
+ require_relative 'queued_job'
data/test/db/db.rb ADDED
@@ -0,0 +1,37 @@
1
+ require 'fileutils'
2
+
3
+ module TransactionRetry
4
+ module Test
5
+ module Db
6
+
7
+ def self.connect_to_mysql2
8
+ ::ActiveRecord::Base.establish_connection(
9
+ :adapter => "mysql2",
10
+ :database => ENV['MYSQL_DB_NAME'],
11
+ :host => ENV['MYSQL_DB_HOST'],
12
+ :user => 'root',
13
+ :password => ENV['MYSQL_DB_PASS']
14
+ )
15
+ end
16
+
17
+ def self.connect_to_postgresql
18
+ ::ActiveRecord::Base.establish_connection(
19
+ :adapter => "postgresql",
20
+ :database => ENV['POSTGRESQL_DB_NAME'],
21
+ :host => ENV['POSTGRESQL_DB_HOST'],
22
+ :user => ENV['POSTGRESQL_DB_USER'],
23
+ :password => ENV['POSTGRESQL_DB_PASS']
24
+ )
25
+ end
26
+
27
+ def self.connect_to_sqlite3
28
+ ActiveRecord::Base.establish_connection(
29
+ :adapter => "sqlite3",
30
+ :database => ":memory:",
31
+ :verbosity => "silent"
32
+ )
33
+ end
34
+
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module TransactionRetry
2
+ module Test
3
+ module Migrations
4
+
5
+ def self.run!
6
+ c = ::ActiveRecord::Base.connection
7
+
8
+ # Queued Jobs
9
+
10
+ c.create_table "queued_jobs", :force => true do |t|
11
+ t.text "job", :null => false
12
+ t.integer "status", :default => 0, :null => false
13
+ t.timestamps
14
+ end
15
+
16
+ end
17
+
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,2 @@
1
+ class QueuedJob < ActiveRecord::Base
2
+ end
@@ -0,0 +1,135 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require 'test_helper'
4
+
5
+ class TransactionWithRetryTest < MiniTest::Unit::TestCase
6
+ class CustomError < StandardError
7
+ end
8
+
9
+ def setup
10
+ @original_max_retries = TransactionRetry.max_retries
11
+ @original_wait_times = TransactionRetry.wait_times
12
+ end
13
+
14
+ def teardown
15
+ TransactionRetry.max_retries = @original_max_retries
16
+ TransactionRetry.wait_times = @original_wait_times
17
+ QueuedJob.delete_all
18
+ end
19
+
20
+ def test_does_not_break_transaction
21
+ ActiveRecord::Base.transaction do
22
+ QueuedJob.create!( :job => 'is fun!' )
23
+ assert_equal( 1, QueuedJob.count )
24
+ end
25
+ assert_equal( 1, QueuedJob.count )
26
+ QueuedJob.first.destroy
27
+ end
28
+
29
+ def test_does_not_break_transaction_rollback
30
+ ActiveRecord::Base.transaction do
31
+ QueuedJob.create!( :job => 'gives money!' )
32
+ raise ActiveRecord::Rollback
33
+ end
34
+ assert_equal( 0, QueuedJob.count )
35
+ end
36
+
37
+ def test_retries_transaction_on_transaction_isolation_conflict
38
+ first_run = true
39
+
40
+ ActiveRecord::Base.transaction do
41
+ if first_run
42
+ first_run = false
43
+ message = "Deadlock found when trying to get lock"
44
+ raise ActiveRecord::TransactionIsolationConflict.new( message )
45
+ end
46
+ QueuedJob.create!( :job => 'is cool!' )
47
+ end
48
+ assert_equal( 1, QueuedJob.count )
49
+
50
+ QueuedJob.first.destroy
51
+ end
52
+
53
+ def test_does_not_retry_on_unknown_error
54
+ first_run = true
55
+
56
+ assert_raises( CustomError ) do
57
+ ActiveRecord::Base.transaction do
58
+ if first_run
59
+ first_run = false
60
+ message = "Deadlock found when trying to get lock"
61
+ raise CustomError, "random error"
62
+ end
63
+ QueuedJob.create!( :job => 'is cool!' )
64
+ end
65
+ end
66
+ assert_equal( 0, QueuedJob.count )
67
+ end
68
+
69
+ def test_retries_on_custom_error
70
+ first_run = true
71
+
72
+ ActiveRecord::Base.transaction(retry_on: CustomError) do
73
+ if first_run
74
+ first_run = false
75
+ message = "Deadlock found when trying to get lock"
76
+ raise CustomError, "random error"
77
+ end
78
+ QueuedJob.create!( :job => 'is cool!' )
79
+ end
80
+ assert_equal( 1, QueuedJob.count )
81
+
82
+ QueuedJob.first.destroy
83
+ end
84
+
85
+ def test_does_not_retry_transaction_more_than_max_retries_times
86
+ TransactionRetry.max_retries = 1
87
+ run = 0
88
+
89
+ assert_raises( ActiveRecord::TransactionIsolationConflict ) do
90
+ ActiveRecord::Base.transaction do
91
+ run += 1
92
+ message = "Deadlock found when trying to get lock"
93
+ raise ActiveRecord::TransactionIsolationConflict.new( message )
94
+ end
95
+ end
96
+
97
+ assert_equal( 2, run ) # normal run + one retry
98
+
99
+ TransactionRetry.max_retries = 3
100
+
101
+ run = 0
102
+
103
+ assert_raises( ActiveRecord::TransactionIsolationConflict ) do
104
+ ActiveRecord::Base.transaction(max_retries: 1) do
105
+ run += 1
106
+ message = "Deadlock found when trying to get lock"
107
+ raise ActiveRecord::TransactionIsolationConflict.new( message )
108
+ end
109
+ end
110
+
111
+ assert_equal( 2, run ) # normal run + one retry
112
+ end
113
+
114
+ def test_does_not_retry_nested_transaction
115
+ first_try = true
116
+
117
+ ActiveRecord::Base.transaction do
118
+
119
+ assert_raises( ActiveRecord::TransactionIsolationConflict ) do
120
+ ActiveRecord::Base.transaction( :requires_new => true ) do
121
+ if first_try
122
+ first_try = false
123
+ message = "Deadlock found when trying to get lock"
124
+ raise ActiveRecord::TransactionIsolationConflict.new( message )
125
+ end
126
+ QueuedJob.create!( :job => 'is cool!' )
127
+ end
128
+ end
129
+
130
+ end
131
+
132
+ assert_equal( 0, QueuedJob.count )
133
+ end
134
+
135
+ end
@@ -0,0 +1,25 @@
1
+ # Prepares application to be tested (requires files, connects to db, resets schema and data, applies patches, etc.)
2
+
3
+ # Initialize database
4
+ require 'db/all'
5
+
6
+ case ENV['db']
7
+ when 'mysql2'
8
+ TransactionRetry::Test::Db.connect_to_mysql2
9
+ when 'postgresql'
10
+ TransactionRetry::Test::Db.connect_to_postgresql
11
+ when 'sqlite3'
12
+ TransactionRetry::Test::Db.connect_to_sqlite3
13
+ else
14
+ TransactionRetry::Test::Db.connect_to_mysql2
15
+ end
16
+
17
+ require 'logger'
18
+ ActiveRecord::Base.logger = Logger.new( File.expand_path( "#{File.dirname( __FILE__ )}/log/test.log" ) )
19
+
20
+ TransactionRetry::Test::Migrations.run!
21
+
22
+ # Load the code that will be tested
23
+ require 'transaction_retry'
24
+
25
+ TransactionRetry.apply_activerecord_patch
data/test/log/.gitkeep ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ # Ensure that LOAD_PATH is the same as when running "rake test"; normally rake takes care of that
2
+ $LOAD_PATH << File.expand_path( ".", File.dirname( __FILE__ ) )
3
+ $LOAD_PATH << File.expand_path( "./lib", File.dirname( __FILE__ ) )
4
+ $LOAD_PATH << File.expand_path( "./test", File.dirname( __FILE__ ) )
5
+
6
+ # Boot the app
7
+ require_relative 'library_setup'
8
+
9
+ # Fire the console
10
+ require 'pry'
11
+ binding.pry
@@ -0,0 +1,12 @@
1
+ # Load test coverage tool (must be loaded before any code)
2
+ #require 'simplecov'
3
+ #SimpleCov.start do
4
+ # add_filter '/test/'
5
+ # add_filter '/config/'
6
+ #end
7
+
8
+ # Load and initialize the application to be tested
9
+ require 'library_setup'
10
+
11
+ # Load test frameworks
12
+ require 'minitest/autorun'
@@ -0,0 +1,4 @@
1
+ require 'test_helper'
2
+
3
+ # Load all tests
4
+ Dir.glob( "./**/*_test.rb" ).each { |test_file| require test_file }
data/tests ADDED
@@ -0,0 +1,6 @@
1
+
2
+ db=mysql2 bundle exec rake
3
+
4
+ db=postgresql bundle exec rake
5
+
6
+ db=sqlite3 bundle exec rake
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "transaction_retry/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "transaction_retry_continued"
7
+ s.version = TransactionRetry::VERSION
8
+ s.authors = ["Iago Pimenta"]
9
+ s.homepage = "https://github.com/iagopiimenta/transaction_retry_continued"
10
+ s.summary = %q{Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL and SQLite.}
11
+ s.description = %q{Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL and SQLite.}
12
+ s.required_ruby_version = '>= 2.5'
13
+
14
+ s.files = `git ls-files`.split("\n")
15
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
17
+ s.require_paths = ["lib"]
18
+
19
+ s.add_development_dependency 'rake', '~> 13.0'
20
+ s.add_development_dependency 'minitest', '5.3.4'
21
+ s.add_runtime_dependency "activerecord", ">= 5.2"
22
+ s.add_runtime_dependency "transaction_isolation_continued", ">= 1.0"
23
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: transaction_retry_continued
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Iago Pimenta
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-05-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 5.3.4
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 5.3.4
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '5.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '5.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: transaction_isolation_continued
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ description: Retries database transaction on deadlock and transaction serialization
70
+ errors. Supports MySQL, PostgreSQL and SQLite.
71
+ email:
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".github/workflows/main.yml"
77
+ - ".gitignore"
78
+ - Gemfile
79
+ - LICENSE
80
+ - README.md
81
+ - Rakefile
82
+ - d
83
+ - gemfiles/Gemfile.base
84
+ - gemfiles/activerecord-5.2/Gemfile.base
85
+ - gemfiles/activerecord-5.2/Gemfile.mysql2
86
+ - gemfiles/activerecord-5.2/Gemfile.postgresql
87
+ - gemfiles/activerecord-5.2/Gemfile.sqlite3
88
+ - gemfiles/activerecord-6.0/Gemfile.base
89
+ - gemfiles/activerecord-6.0/Gemfile.mysql2
90
+ - gemfiles/activerecord-6.0/Gemfile.postgresql
91
+ - gemfiles/activerecord-6.0/Gemfile.sqlite3
92
+ - gemfiles/activerecord-6.1/Gemfile.base
93
+ - gemfiles/activerecord-6.1/Gemfile.mysql2
94
+ - gemfiles/activerecord-6.1/Gemfile.postgresql
95
+ - gemfiles/activerecord-6.1/Gemfile.sqlite3
96
+ - gemfiles/activerecord-7.0/Gemfile.base
97
+ - gemfiles/activerecord-7.0/Gemfile.mysql2
98
+ - gemfiles/activerecord-7.0/Gemfile.postgresql
99
+ - gemfiles/activerecord-7.0/Gemfile.sqlite3
100
+ - lib/transaction_retry.rb
101
+ - lib/transaction_retry/active_record/base.rb
102
+ - lib/transaction_retry/version.rb
103
+ - lib/transaction_retry_continued.rb
104
+ - test/db/all.rb
105
+ - test/db/db.rb
106
+ - test/db/migrations.rb
107
+ - test/db/queued_job.rb
108
+ - test/integration/active_record/base/transaction_with_retry_test.rb
109
+ - test/library_setup.rb
110
+ - test/log/.gitkeep
111
+ - test/test_console.rb
112
+ - test/test_helper.rb
113
+ - test/test_runner.rb
114
+ - tests
115
+ - transaction_retry_continued.gemspec
116
+ homepage: https://github.com/iagopiimenta/transaction_retry_continued
117
+ licenses: []
118
+ metadata: {}
119
+ post_install_message:
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '2.5'
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.4.13
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Retries database transaction on deadlock and transaction serialization errors.
138
+ Supports MySQL, PostgreSQL and SQLite.
139
+ test_files:
140
+ - test/db/all.rb
141
+ - test/db/db.rb
142
+ - test/db/migrations.rb
143
+ - test/db/queued_job.rb
144
+ - test/integration/active_record/base/transaction_with_retry_test.rb
145
+ - test/library_setup.rb
146
+ - test/log/.gitkeep
147
+ - test/test_console.rb
148
+ - test/test_helper.rb
149
+ - test/test_runner.rb