openstax_transaction_retry 1.2.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 +7 -0
- data/.github/workflows/ci.yml +39 -0
- data/.gitignore +6 -0
- data/.rubocop.yml +95 -0
- data/.ruby-version +1 -0
- data/.semaphore/semaphore.yml +15 -0
- data/CHANGELOG.md +16 -0
- data/Gemfile +25 -0
- data/LICENSE +19 -0
- data/README.md +114 -0
- data/Rakefile +13 -0
- data/d +1 -0
- data/lib/open_stax_transaction_retry/active_record/base.rb +78 -0
- data/lib/open_stax_transaction_retry/version.rb +5 -0
- data/lib/open_stax_transaction_retry.rb +64 -0
- data/open_stax_transaction_retry.gemspec +23 -0
- data/test/db/all.rb +6 -0
- data/test/db/db.rb +37 -0
- data/test/db/migrations.rb +19 -0
- data/test/db/queued_job.rb +4 -0
- data/test/integration/active_record/base/transaction_with_retry_test.rb +209 -0
- data/test/library_setup.rb +27 -0
- data/test/log/.gitkeep +0 -0
- data/test/test_console.rb +14 -0
- data/test/test_helper.rb +14 -0
- data/test/test_runner.rb +6 -0
- data/tests +6 -0
- metadata +101 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1c9efa231f44b802b7971bc7f31fb06e0f01bb47c40d16fd56a4c0843f99c72f
|
4
|
+
data.tar.gz: 8b415b5023c270aa9dec5000eb0c60f2d2dbb0f26fd5b4f489b20c7d6842b9f9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 155c201291db2070d61814d2ea73dbdc69c58d284552a7bd84c1be367a3b33aa5e9dfc78cd8be24aa8e1d51f1cac15f74087da33b1314d91d72b19bd7682c28b
|
7
|
+
data.tar.gz: 5ca4998b159bf2a83a9839d4cb2f57aeb20a16c33730320e233a7733af712189fea737b969628937c88c97c553520a062042a913ab0d65dc43c31930be85447e
|
@@ -0,0 +1,39 @@
|
|
1
|
+
name: Tests
|
2
|
+
|
3
|
+
on:
|
4
|
+
pull_request:
|
5
|
+
push:
|
6
|
+
branches:
|
7
|
+
- main
|
8
|
+
|
9
|
+
env:
|
10
|
+
DB_NAME: oxtr_test
|
11
|
+
DB_USERNAME: oxtr
|
12
|
+
DB_PASSWORD: oxtr_pass
|
13
|
+
DB_HOST: localhost
|
14
|
+
DB_PORT: 5432
|
15
|
+
|
16
|
+
jobs:
|
17
|
+
lint-and-test:
|
18
|
+
timeout-minutes: 10
|
19
|
+
runs-on: ubuntu-22.04
|
20
|
+
services:
|
21
|
+
db:
|
22
|
+
image: postgres:12
|
23
|
+
ports:
|
24
|
+
- 5432:5432
|
25
|
+
env:
|
26
|
+
POSTGRES_DB: oxtr_test
|
27
|
+
POSTGRES_USER: oxtr
|
28
|
+
POSTGRES_PASSWORD: oxtr_pass
|
29
|
+
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
30
|
+
|
31
|
+
steps:
|
32
|
+
- uses: actions/checkout@v3
|
33
|
+
- uses: ruby/setup-ruby@v1
|
34
|
+
with:
|
35
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
36
|
+
- name: Run Tests
|
37
|
+
run: |
|
38
|
+
bundle exec rubocop
|
39
|
+
db=postgresql bundle exec rake test
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
AllCops:
|
2
|
+
TargetRubyVersion: 2.6
|
3
|
+
SuggestExtensions: false
|
4
|
+
NewCops: enable
|
5
|
+
Exclude:
|
6
|
+
- 'db/**/*.rb'
|
7
|
+
- 'app/bindings/**/*.rb'
|
8
|
+
- 'node_modules/**/*'
|
9
|
+
- 'tmp/**/*'
|
10
|
+
- 'vendor/**/*'
|
11
|
+
- '.git/**/*'
|
12
|
+
|
13
|
+
Layout/EmptyLinesAroundClassBody:
|
14
|
+
Enabled: false
|
15
|
+
|
16
|
+
Layout/EmptyLinesAroundBlockBody:
|
17
|
+
Enabled: false
|
18
|
+
|
19
|
+
Layout/LineLength:
|
20
|
+
Max: 100
|
21
|
+
AllowedPatterns: ['(\A|\s)#']
|
22
|
+
Exclude:
|
23
|
+
- '*.gemspec'
|
24
|
+
|
25
|
+
Metrics/AbcSize:
|
26
|
+
Max: 50
|
27
|
+
|
28
|
+
Naming/PredicateName:
|
29
|
+
Enabled: false
|
30
|
+
|
31
|
+
Metrics/ClassLength:
|
32
|
+
Max: 500
|
33
|
+
CountAsOne: ['heredoc']
|
34
|
+
|
35
|
+
Metrics/MethodLength:
|
36
|
+
Max: 50
|
37
|
+
CountAsOne: ['heredoc']
|
38
|
+
|
39
|
+
Metrics/BlockLength:
|
40
|
+
CountAsOne: ['heredoc']
|
41
|
+
Max: 30
|
42
|
+
Exclude:
|
43
|
+
- '**/*_open_api.rb'
|
44
|
+
- '**/*.rake'
|
45
|
+
- 'lib/patches/api/**/*.rb'
|
46
|
+
|
47
|
+
Metrics/ModuleLength:
|
48
|
+
CountAsOne: ['heredoc']
|
49
|
+
|
50
|
+
Style/Alias:
|
51
|
+
EnforcedStyle: prefer_alias_method
|
52
|
+
|
53
|
+
Style/Documentation:
|
54
|
+
Enabled: false
|
55
|
+
|
56
|
+
Style/OpenStructUse:
|
57
|
+
Enabled: false
|
58
|
+
|
59
|
+
Style/StringLiterals:
|
60
|
+
ConsistentQuotesInMultiline: true
|
61
|
+
|
62
|
+
Layout/SpaceAroundEqualsInParameterDefault:
|
63
|
+
EnforcedStyle: no_space
|
64
|
+
|
65
|
+
Style/AccessorGrouping:
|
66
|
+
EnforcedStyle: separated
|
67
|
+
|
68
|
+
Style/SingleLineMethods:
|
69
|
+
Enabled: false # don't abuse it, but sometimes it is right
|
70
|
+
|
71
|
+
Style/RegexpLiteral:
|
72
|
+
AllowInnerSlashes: true
|
73
|
+
|
74
|
+
Style/SymbolProc:
|
75
|
+
AllowedMethods:
|
76
|
+
- respond_to
|
77
|
+
- define_method
|
78
|
+
|
79
|
+
Layout/ClosingParenthesisIndentation:
|
80
|
+
Enabled: false # when true hanging parens look weird
|
81
|
+
|
82
|
+
Layout/MultilineMethodCallBraceLayout:
|
83
|
+
Enabled: false
|
84
|
+
|
85
|
+
Layout/MultilineMethodCallIndentation:
|
86
|
+
EnforcedStyle: indented_relative_to_receiver
|
87
|
+
|
88
|
+
Style/ClassAndModuleChildren:
|
89
|
+
Enabled: false
|
90
|
+
|
91
|
+
Style/SymbolArray:
|
92
|
+
EnforcedStyle: brackets
|
93
|
+
|
94
|
+
Style/Lambda:
|
95
|
+
Enabled: false
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.2.1
|
@@ -0,0 +1,15 @@
|
|
1
|
+
version: v1.0
|
2
|
+
name: Initial Pipeline
|
3
|
+
agent:
|
4
|
+
machine:
|
5
|
+
type: e1-standard-2
|
6
|
+
os_image: ubuntu1804
|
7
|
+
blocks:
|
8
|
+
- name: 'Block #1'
|
9
|
+
task:
|
10
|
+
jobs:
|
11
|
+
- name: 'Job #1'
|
12
|
+
commands:
|
13
|
+
- checkout
|
14
|
+
- bundle install --path vendor/bundle
|
15
|
+
- db=sqlite3 bundle exec rake test
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
### Unreleased
|
2
|
+
|
3
|
+
### 1.2.0 - 2023-08-24
|
4
|
+
* Forked and renamed to OpenStaxTransactionRetry
|
5
|
+
* Fix bug with calling overloaded transaction with a hash
|
6
|
+
|
7
|
+
|
8
|
+
### 1.1.0 - 2019-06-17
|
9
|
+
|
10
|
+
* Add `TransactionRetry.before_retry` configuration option to run Proc before transaction retry
|
11
|
+
* Add `TransactionRetry.retry_on` configuration option to include more Errors to retry on
|
12
|
+
* Update dependency activerecord >= 5.1
|
13
|
+
* Update dependency ruby 2.2.2
|
14
|
+
* Upgrade dependency transaction_isolation to 1.0.5
|
15
|
+
* Adapt gemspec info
|
16
|
+
* Forked gem from [transaction_retry 1.0.3](https://github.com/qertoip/transaction_retry)
|
data/Gemfile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'http://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in kontolib.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
group :test do
|
9
|
+
# Use the gem instead of a dated version bundled with Ruby
|
10
|
+
gem 'minitest'
|
11
|
+
|
12
|
+
gem 'simplecov', require: false
|
13
|
+
|
14
|
+
gem 'mysql2'
|
15
|
+
gem 'pg'
|
16
|
+
gem 'rubocop'
|
17
|
+
gem 'sqlite3'
|
18
|
+
end
|
19
|
+
|
20
|
+
group :development do
|
21
|
+
gem 'rake'
|
22
|
+
# enhance irb
|
23
|
+
gem 'awesome_print', require: false
|
24
|
+
gem 'pry', require: false
|
25
|
+
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,114 @@
|
|
1
|
+
# transaction_retry
|
2
|
+
|
3
|
+
Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL, and SQLite.
|
4
|
+
|
5
|
+
This is a forked project from
|
6
|
+
|
7
|
+
* otimalworkshop: https://github.com/optimalworkshop/transaction_retry
|
8
|
+
* qertoip: https://github.com/qertoip/transaction_retry
|
9
|
+
|
10
|
+
OpenStax forked the project to correct a bug with Ruby version 3 or above that caused
|
11
|
+
an **ArgumentError**: wrong number of arguments (given 1, expected 0)
|
12
|
+
|
13
|
+
when calling ActiveRecord::Base.transaction with a hash such as:
|
14
|
+
|
15
|
+
`ActiveRecord::Base.transaction(requires_new: true)`
|
16
|
+
|
17
|
+
Details of the fix are in commit https://github.com/openstax/transaction_retry/commit/9184c88ab917271026e08d6dd5e890740f7fdd48
|
18
|
+
|
19
|
+
## Example
|
20
|
+
|
21
|
+
The gem works automatically by rescuing ActiveRecord::TransactionIsolationConflict and retrying the transaction.
|
22
|
+
|
23
|
+
## Installation
|
24
|
+
|
25
|
+
Add this to your Gemfile:
|
26
|
+
|
27
|
+
gem 'transaction_retry', git: 'https://github.com/optimalworkshop/transaction_retry.git'
|
28
|
+
|
29
|
+
Then run:
|
30
|
+
|
31
|
+
bundle
|
32
|
+
|
33
|
+
__It works out of the box with Ruby on Rails__.
|
34
|
+
|
35
|
+
If you have a standalone ActiveRecord-based project you'll need to call:
|
36
|
+
|
37
|
+
OpenStaxTransactionRetry.apply_activerecord_patch # after connecting to the database
|
38
|
+
|
39
|
+
__after__ connecting to the database.
|
40
|
+
|
41
|
+
## Database deadlock and serialization errors that are retried
|
42
|
+
|
43
|
+
#### MySQL
|
44
|
+
|
45
|
+
* Deadlock found when trying to get lock
|
46
|
+
* Lock wait timeout exceeded
|
47
|
+
|
48
|
+
#### PostgreSQL
|
49
|
+
|
50
|
+
* deadlock detected
|
51
|
+
* could not serialize access
|
52
|
+
|
53
|
+
#### SQLite
|
54
|
+
|
55
|
+
* The database file is locked
|
56
|
+
* A table in the database is locked
|
57
|
+
* Database lock protocol error
|
58
|
+
|
59
|
+
## Configuration
|
60
|
+
|
61
|
+
You can optionally configure transaction_retry gem in your config/initializers/transaction_retry.rb (or anywhere else):
|
62
|
+
|
63
|
+
```
|
64
|
+
OpenStaxTransactionRetry.max_retries = 3
|
65
|
+
OpenStaxTransactionRetry.wait_times = [0, 1, 2, 4, 8, 16, 32] # seconds to sleep after retry n
|
66
|
+
OpenStaxTransactionRetry.retry_on = CustomErrorClass # To add another error class to retry on (ActiveRecord::TransactionIsolationConflict always included)
|
67
|
+
or
|
68
|
+
OpenStaxTransactionRetry.retry_on = [<custom error classes>]
|
69
|
+
OpenStaxTransactionRetry.before_retry = ->(retry_num, error) { ... }
|
70
|
+
```
|
71
|
+
|
72
|
+
## Features
|
73
|
+
|
74
|
+
* Supports MySQL, PostgreSQL, and SQLite (as long as you are using new drivers mysql2, pg, sqlite3).
|
75
|
+
* Exponential sleep times between retries (0, 1, 2, 4 seconds).
|
76
|
+
* Logs every retry as a warning.
|
77
|
+
* Intentionally does not retry nested transactions.
|
78
|
+
* Configurable number of retries and sleep time between them.
|
79
|
+
* Configure a custom hook to run before every retry.
|
80
|
+
* Use it in your Rails application or a standalone ActiveRecord-based project.
|
81
|
+
|
82
|
+
## Testimonials
|
83
|
+
|
84
|
+
This gem was initially developed for and successfully works in production at [Kontomierz.pl](http://kontomierz.pl) - the finest Polish personal finance app.
|
85
|
+
|
86
|
+
## Requirements
|
87
|
+
|
88
|
+
* ruby 2.2.2+
|
89
|
+
* activerecord 5.1+
|
90
|
+
|
91
|
+
## Running tests
|
92
|
+
|
93
|
+
Run tests on the selected database (mysql2 by default):
|
94
|
+
|
95
|
+
db=mysql2 DB_USERNAME=<db user> DB_PASSWORD=<db password> bundle exec rake test
|
96
|
+
db=postgresql DB_USERNAME=<db user> DB_PASSWORD=<db password> bundle exec rake test
|
97
|
+
db=sqlite3 bundle exec rake test
|
98
|
+
|
99
|
+
Run tests on all supported databases:
|
100
|
+
|
101
|
+
./tests
|
102
|
+
|
103
|
+
## How intrusive is this gem?
|
104
|
+
|
105
|
+
You should be very suspicious about any gem that monkey patches your stock Ruby on Rails framework.
|
106
|
+
|
107
|
+
This gem is carefully written to not be more intrusive than it needs to be:
|
108
|
+
|
109
|
+
* wraps ActiveRecord::Base#transaction class method using alias_method to add new behaviour
|
110
|
+
* introduces two new private class methods in ActiveRecord::Base (with names that should never collide)
|
111
|
+
|
112
|
+
## License
|
113
|
+
|
114
|
+
Released under the MIT license. Copyright (C) 2012 Piotr 'Qertoip' Włodarek.
|
data/Rakefile
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record/base'
|
4
|
+
|
5
|
+
module OpenStaxTransactionRetry
|
6
|
+
module ActiveRecord
|
7
|
+
module Base
|
8
|
+
def self.included(base)
|
9
|
+
base.extend(ClassMethods)
|
10
|
+
base.class_eval do
|
11
|
+
class << self
|
12
|
+
alias_method :transaction_without_retry, :transaction
|
13
|
+
alias_method :transaction, :transaction_with_retry
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module ClassMethods
|
19
|
+
# rubocop:todo Metrics/PerceivedComplexity
|
20
|
+
def transaction_with_retry(**objects, &block) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
21
|
+
retry_count = 0
|
22
|
+
|
23
|
+
opts = if objects.is_a? Hash
|
24
|
+
objects
|
25
|
+
elsif objects.is_a?(Array) && objects.last.is_a?(Hash)
|
26
|
+
objects.last
|
27
|
+
else
|
28
|
+
{}
|
29
|
+
end
|
30
|
+
|
31
|
+
retry_on = opts.delete(:retry_on) || OpenStaxTransactionRetry.retry_on
|
32
|
+
max_retries = opts.delete(:max_retries) || OpenStaxTransactionRetry.max_retries
|
33
|
+
before_retry = opts.delete(:before_retry) || OpenStaxTransactionRetry.before_retry
|
34
|
+
|
35
|
+
begin
|
36
|
+
transaction_without_retry(**objects, &block)
|
37
|
+
rescue ::ActiveRecord::TransactionIsolationConflict, *retry_on => e
|
38
|
+
raise if retry_count >= max_retries
|
39
|
+
raise if tr_in_nested_transaction?
|
40
|
+
|
41
|
+
retry_count += 1
|
42
|
+
logger&.warn "#{e.class.name} detected. Retry num #{retry_count}..."
|
43
|
+
before_retry&.call(retry_count, e)
|
44
|
+
tr_exponential_pause(retry_count)
|
45
|
+
retry
|
46
|
+
end
|
47
|
+
end
|
48
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# Sleep 0, 1, 2, 4, ... seconds up to the OpenStaxTransactionRetry.max_retries.
|
53
|
+
# Cap the sleep time at 32 seconds.
|
54
|
+
# An ugly tr_ prefix is used to minimize the risk of method clash in the future.
|
55
|
+
def tr_exponential_pause(count)
|
56
|
+
seconds = OpenStaxTransactionRetry.wait_times[count - 1] || 32
|
57
|
+
|
58
|
+
if OpenStaxTransactionRetry.fuzz
|
59
|
+
fuzz_factor = [seconds * 0.25, 1].max
|
60
|
+
|
61
|
+
seconds += (rand * (fuzz_factor * 2)) - fuzz_factor
|
62
|
+
end
|
63
|
+
|
64
|
+
sleep(seconds) if seconds.positive?
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns true if we are in the nested transaction (the one with :requires_new => true).
|
68
|
+
# Returns false otherwise.
|
69
|
+
# An ugly tr_ prefix is used to minimize the risk of method clash in the future.
|
70
|
+
def tr_in_nested_transaction?
|
71
|
+
connection.open_transactions != 0
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
ActiveRecord::Base.include OpenStaxTransactionRetry::ActiveRecord::Base
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
require 'transaction_isolation'
|
5
|
+
require_relative 'open_stax_transaction_retry/version'
|
6
|
+
|
7
|
+
module OpenStaxTransactionRetry
|
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 'open_stax_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
|
+
OpenStaxTransactionRetry.apply_activerecord_patch
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.before_retry=(lambda_block)
|
26
|
+
@@before_retry = lambda_block # rubocop:todo Style/ClassVars
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.before_retry
|
30
|
+
@@before_retry ||= nil # rubocop:todo Style/ClassVars
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.retry_on=(error_classes)
|
34
|
+
@@retry_on = Array(error_classes) # rubocop:todo Style/ClassVars
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.retry_on
|
38
|
+
@@retry_on ||= [] # rubocop:todo Style/ClassVars
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.max_retries
|
42
|
+
@@max_retries ||= 3 # rubocop:todo Style/ClassVars
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.max_retries=(n) # rubocop:todo Naming/MethodParameterName
|
46
|
+
@@max_retries = n # rubocop:todo Style/ClassVars
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.wait_times
|
50
|
+
@@wait_times ||= [0, 1, 2, 4, 8, 16, 32] # rubocop:todo Style/ClassVars
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.wait_times=(array_of_seconds)
|
54
|
+
@@wait_times = array_of_seconds # rubocop:todo Style/ClassVars
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.fuzz
|
58
|
+
@@fuzz ||= true # rubocop:todo Style/ClassVars
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.fuzz=(val)
|
62
|
+
@@fuzz = val # rubocop:todo Style/ClassVars
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
4
|
+
require 'open_stax_transaction_retry/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = 'openstax_transaction_retry'
|
8
|
+
s.version = OpenStaxTransactionRetry::VERSION
|
9
|
+
s.authors = ['Nathan Stitt', 'Optimal Workshop', 'Piotr \'Qertoip\' Włodarek']
|
10
|
+
s.email = []
|
11
|
+
s.homepage = 'https://github.com/openstax/transaction_retry'
|
12
|
+
s.summary = 'Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL and SQLite.'
|
13
|
+
s.description = 'Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL and SQLite (as long as you are using new drivers mysql2, pg, sqlite3).'
|
14
|
+
s.required_ruby_version = '>= 2.6'
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
18
|
+
s.require_paths = ['lib']
|
19
|
+
|
20
|
+
s.add_runtime_dependency 'activerecord', '>= 5.1'
|
21
|
+
s.add_runtime_dependency 'transaction_isolation', '>= 1.0.5'
|
22
|
+
s.metadata['rubygems_mfa_required'] = 'true'
|
23
|
+
end
|
data/test/db/all.rb
ADDED
data/test/db/db.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module OpenStaxTransactionRetry
|
6
|
+
module Test
|
7
|
+
module Db
|
8
|
+
def self.connect_to_mysql2
|
9
|
+
::ActiveRecord::Base.establish_connection(
|
10
|
+
adapter: 'mysql2',
|
11
|
+
database: 'transaction_retry_test',
|
12
|
+
username: ENV.fetch('DB_USERNAME'),
|
13
|
+
password: ENV.fetch('DB_PASSWORD', nil)
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.connect_to_postgresql
|
18
|
+
::ActiveRecord::Base.establish_connection(
|
19
|
+
adapter: 'postgresql',
|
20
|
+
host: ENV.fetch('DB_HOST', nil),
|
21
|
+
port: ENV.fetch('DB_PORT', nil),
|
22
|
+
database: ENV.fetch('DB_NAME', nil),
|
23
|
+
user: ENV.fetch('DB_USERNAME', nil),
|
24
|
+
password: ENV.fetch('DB_PASSWORD', nil)
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.connect_to_sqlite3
|
29
|
+
ActiveRecord::Base.establish_connection(
|
30
|
+
adapter: 'sqlite3',
|
31
|
+
database: ':memory:',
|
32
|
+
verbosity: 'silent'
|
33
|
+
)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenStaxTransactionRetry
|
4
|
+
module Test
|
5
|
+
module Migrations
|
6
|
+
def self.run!
|
7
|
+
c = ::ActiveRecord::Base.connection
|
8
|
+
|
9
|
+
# Queued Jobs
|
10
|
+
|
11
|
+
c.create_table 'queued_jobs', force: true do |t|
|
12
|
+
t.text 'job', null: false
|
13
|
+
t.integer 'status', default: 0, null: false
|
14
|
+
t.timestamps
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
|
5
|
+
class TransactionWithRetryTest < Minitest::Test
|
6
|
+
class CustomError < StandardError
|
7
|
+
end
|
8
|
+
|
9
|
+
def setup
|
10
|
+
@original_max_retries = OpenStaxTransactionRetry.max_retries
|
11
|
+
@original_wait_times = OpenStaxTransactionRetry.wait_times
|
12
|
+
@original_retry_on = OpenStaxTransactionRetry.retry_on
|
13
|
+
@original_before_retry = OpenStaxTransactionRetry.before_retry
|
14
|
+
end
|
15
|
+
|
16
|
+
def teardown
|
17
|
+
OpenStaxTransactionRetry.max_retries = @original_max_retries
|
18
|
+
OpenStaxTransactionRetry.wait_times = @original_wait_times
|
19
|
+
OpenStaxTransactionRetry.retry_on = @original_retry_on
|
20
|
+
OpenStaxTransactionRetry.before_retry = @original_before_retry
|
21
|
+
QueuedJob.delete_all
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_does_not_break_transaction
|
25
|
+
ActiveRecord::Base.transaction do
|
26
|
+
QueuedJob.create!(job: 'is fun!')
|
27
|
+
assert_equal(1, QueuedJob.count)
|
28
|
+
end
|
29
|
+
assert_equal(1, QueuedJob.count)
|
30
|
+
QueuedJob.first.destroy
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_does_not_break_transaction_rollback
|
34
|
+
ActiveRecord::Base.transaction do
|
35
|
+
QueuedJob.create!(job: 'gives money!')
|
36
|
+
raise ActiveRecord::Rollback
|
37
|
+
end
|
38
|
+
assert_equal(0, QueuedJob.count)
|
39
|
+
end
|
40
|
+
|
41
|
+
def test_retries_transaction_on_transaction_isolation_conflict
|
42
|
+
first_run = true
|
43
|
+
|
44
|
+
ActiveRecord::Base.transaction do
|
45
|
+
if first_run
|
46
|
+
first_run = false
|
47
|
+
message = 'Deadlock found when trying to get lock'
|
48
|
+
raise ActiveRecord::TransactionIsolationConflict, message
|
49
|
+
end
|
50
|
+
QueuedJob.create!(job: 'is cool!')
|
51
|
+
end
|
52
|
+
assert_equal(1, QueuedJob.count)
|
53
|
+
|
54
|
+
QueuedJob.first.destroy
|
55
|
+
end
|
56
|
+
|
57
|
+
def test_does_not_retry_on_unknown_error
|
58
|
+
first_run = true
|
59
|
+
|
60
|
+
assert_raises(CustomError) do
|
61
|
+
ActiveRecord::Base.transaction do
|
62
|
+
if first_run
|
63
|
+
first_run = false
|
64
|
+
raise CustomError, 'random error'
|
65
|
+
end
|
66
|
+
QueuedJob.create!(job: 'is cool!')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
assert_equal(0, QueuedJob.count)
|
70
|
+
end
|
71
|
+
|
72
|
+
def test_retries_on_custom_error
|
73
|
+
first_run = true
|
74
|
+
ActiveRecord::Base.transaction(retry_on: CustomError) do
|
75
|
+
if first_run
|
76
|
+
first_run = false
|
77
|
+
raise CustomError, 'random error'
|
78
|
+
end
|
79
|
+
QueuedJob.create!(job: 'is cool!')
|
80
|
+
end
|
81
|
+
assert_equal(1, QueuedJob.count)
|
82
|
+
QueuedJob.first.destroy
|
83
|
+
end
|
84
|
+
|
85
|
+
def test_retries_on_configured_retry_on
|
86
|
+
OpenStaxTransactionRetry.retry_on = CustomError
|
87
|
+
first_run = true
|
88
|
+
ActiveRecord::Base.transaction do
|
89
|
+
if first_run
|
90
|
+
first_run = false
|
91
|
+
raise CustomError, 'random error'
|
92
|
+
end
|
93
|
+
QueuedJob.create!(job: 'is cool!')
|
94
|
+
end
|
95
|
+
assert_equal(1, QueuedJob.count)
|
96
|
+
QueuedJob.first.destroy
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_retries_transaction_on_transaction_isolation_when_retry_on_set
|
100
|
+
OpenStaxTransactionRetry.retry_on = CustomError
|
101
|
+
first_run = true
|
102
|
+
ActiveRecord::Base.transaction do
|
103
|
+
if first_run
|
104
|
+
first_run = false
|
105
|
+
message = 'Deadlock found when trying to get lock'
|
106
|
+
raise ActiveRecord::TransactionIsolationConflict, message
|
107
|
+
end
|
108
|
+
QueuedJob.create!(job: 'is cool!')
|
109
|
+
end
|
110
|
+
assert_equal(1, QueuedJob.count)
|
111
|
+
QueuedJob.first.destroy
|
112
|
+
end
|
113
|
+
|
114
|
+
def test_does_not_retry_transaction_more_than_max_retries_times
|
115
|
+
OpenStaxTransactionRetry.max_retries = 1
|
116
|
+
run = 0
|
117
|
+
|
118
|
+
assert_raises(ActiveRecord::TransactionIsolationConflict) do
|
119
|
+
ActiveRecord::Base.transaction do
|
120
|
+
run += 1
|
121
|
+
message = 'Deadlock found when trying to get lock'
|
122
|
+
raise ActiveRecord::TransactionIsolationConflict, message
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
assert_equal(2, run) # normal run + one retry
|
127
|
+
|
128
|
+
OpenStaxTransactionRetry.max_retries = 3
|
129
|
+
|
130
|
+
run = 0
|
131
|
+
|
132
|
+
assert_raises(ActiveRecord::TransactionIsolationConflict) do
|
133
|
+
ActiveRecord::Base.transaction(max_retries: 1) do
|
134
|
+
run += 1
|
135
|
+
message = 'Deadlock found when trying to get lock'
|
136
|
+
raise ActiveRecord::TransactionIsolationConflict, message
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
assert_equal(2, run) # normal run + one retry
|
141
|
+
end
|
142
|
+
|
143
|
+
def test_does_not_retry_nested_transaction
|
144
|
+
first_try = true
|
145
|
+
|
146
|
+
ActiveRecord::Base.transaction do
|
147
|
+
assert_raises(ActiveRecord::TransactionIsolationConflict) do
|
148
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
149
|
+
if first_try
|
150
|
+
first_try = false
|
151
|
+
message = 'Deadlock found when trying to get lock'
|
152
|
+
raise ActiveRecord::TransactionIsolationConflict, message
|
153
|
+
end
|
154
|
+
QueuedJob.create!(job: 'is cool!')
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
assert_equal(0, QueuedJob.count)
|
160
|
+
end
|
161
|
+
|
162
|
+
def test_run_custom_lambda_before_retry
|
163
|
+
code_run = false
|
164
|
+
retry_id = nil
|
165
|
+
error_instance = nil
|
166
|
+
first_try = true
|
167
|
+
lambda_code = lambda do |retry_num, error|
|
168
|
+
code_run = true
|
169
|
+
retry_id = retry_num
|
170
|
+
error_instance = error
|
171
|
+
end
|
172
|
+
|
173
|
+
ActiveRecord::Base.transaction(before_retry: lambda_code) do
|
174
|
+
if first_try
|
175
|
+
first_try = false
|
176
|
+
raise ActiveRecord::TransactionIsolationConflict
|
177
|
+
end
|
178
|
+
QueuedJob.create!(job: 'is cool!')
|
179
|
+
end
|
180
|
+
assert_equal 1, QueuedJob.count
|
181
|
+
assert code_run
|
182
|
+
assert_equal 1, retry_id
|
183
|
+
assert_equal ActiveRecord::TransactionIsolationConflict, error_instance.class
|
184
|
+
end
|
185
|
+
|
186
|
+
def test_run_custom_global_lambda_before_retry
|
187
|
+
code_run = false
|
188
|
+
retry_id = nil
|
189
|
+
error_instance = nil
|
190
|
+
OpenStaxTransactionRetry.before_retry = lambda { |retry_num, error|
|
191
|
+
code_run = true
|
192
|
+
retry_id = retry_num
|
193
|
+
error_instance = error
|
194
|
+
}
|
195
|
+
first_try = true
|
196
|
+
|
197
|
+
ActiveRecord::Base.transaction do
|
198
|
+
if first_try
|
199
|
+
first_try = false
|
200
|
+
raise ActiveRecord::TransactionIsolationConflict
|
201
|
+
end
|
202
|
+
QueuedJob.create!(job: 'is cool!')
|
203
|
+
end
|
204
|
+
assert_equal 1, QueuedJob.count
|
205
|
+
assert code_run
|
206
|
+
assert_equal 1, retry_id
|
207
|
+
assert_equal ActiveRecord::TransactionIsolationConflict, error_instance.class
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Prepares application to be tested (requires files, connects to db, resets schema and data, applies patches, etc.)
|
4
|
+
|
5
|
+
# Initialize database
|
6
|
+
require 'db/all'
|
7
|
+
|
8
|
+
case ENV.fetch('db', 'sqlite3')
|
9
|
+
when 'mysql2'
|
10
|
+
OpenStaxTransactionRetry::Test::Db.connect_to_mysql2
|
11
|
+
when 'postgresql'
|
12
|
+
OpenStaxTransactionRetry::Test::Db.connect_to_postgresql
|
13
|
+
when 'sqlite3'
|
14
|
+
OpenStaxTransactionRetry::Test::Db.connect_to_sqlite3
|
15
|
+
else
|
16
|
+
raise "Unknown database: #{ENV.fetch('db', nil)}"
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'logger'
|
20
|
+
ActiveRecord::Base.logger = Logger.new(File.expand_path("#{File.dirname(__FILE__)}/log/test.log"))
|
21
|
+
|
22
|
+
OpenStaxTransactionRetry::Test::Migrations.run!
|
23
|
+
|
24
|
+
# Load the code that will be tested
|
25
|
+
require 'open_stax_transaction_retry'
|
26
|
+
|
27
|
+
OpenStaxTransactionRetry.apply_activerecord_patch
|
data/test/log/.gitkeep
ADDED
File without changes
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Ensure that LOAD_PATH is the same as when running "rake test"; normally rake takes care of that
|
4
|
+
$LOAD_PATH << File.expand_path('.', File.dirname(__FILE__))
|
5
|
+
$LOAD_PATH << File.expand_path('./lib', File.dirname(__FILE__))
|
6
|
+
$LOAD_PATH << File.expand_path('./test', File.dirname(__FILE__))
|
7
|
+
|
8
|
+
# Boot the app
|
9
|
+
require_relative 'library_setup'
|
10
|
+
|
11
|
+
# Fire the console
|
12
|
+
require 'pry'
|
13
|
+
|
14
|
+
binding.pry # rubocop:disable Lint/Debugger
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Load test coverage tool (must be loaded before any code)
|
4
|
+
# require 'simplecov'
|
5
|
+
# SimpleCov.start do
|
6
|
+
# add_filter '/test/'
|
7
|
+
# add_filter '/config/'
|
8
|
+
# end
|
9
|
+
|
10
|
+
# Load and initialize the application to be tested
|
11
|
+
require 'library_setup'
|
12
|
+
|
13
|
+
# Load test frameworks
|
14
|
+
require 'minitest/autorun'
|
data/test/test_runner.rb
ADDED
data/tests
ADDED
metadata
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: openstax_transaction_retry
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nathan Stitt
|
8
|
+
- Optimal Workshop
|
9
|
+
- Piotr 'Qertoip' Włodarek
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2023-08-28 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: activerecord
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - ">="
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '5.1'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '5.1'
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: transaction_isolation
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: 1.0.5
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: 1.0.5
|
43
|
+
description: Retries database transaction on deadlock and transaction serialization
|
44
|
+
errors. Supports MySQL, PostgreSQL and SQLite (as long as you are using new drivers
|
45
|
+
mysql2, pg, sqlite3).
|
46
|
+
email: []
|
47
|
+
executables: []
|
48
|
+
extensions: []
|
49
|
+
extra_rdoc_files: []
|
50
|
+
files:
|
51
|
+
- ".github/workflows/ci.yml"
|
52
|
+
- ".gitignore"
|
53
|
+
- ".rubocop.yml"
|
54
|
+
- ".ruby-version"
|
55
|
+
- ".semaphore/semaphore.yml"
|
56
|
+
- CHANGELOG.md
|
57
|
+
- Gemfile
|
58
|
+
- LICENSE
|
59
|
+
- README.md
|
60
|
+
- Rakefile
|
61
|
+
- d
|
62
|
+
- lib/open_stax_transaction_retry.rb
|
63
|
+
- lib/open_stax_transaction_retry/active_record/base.rb
|
64
|
+
- lib/open_stax_transaction_retry/version.rb
|
65
|
+
- open_stax_transaction_retry.gemspec
|
66
|
+
- test/db/all.rb
|
67
|
+
- test/db/db.rb
|
68
|
+
- test/db/migrations.rb
|
69
|
+
- test/db/queued_job.rb
|
70
|
+
- test/integration/active_record/base/transaction_with_retry_test.rb
|
71
|
+
- test/library_setup.rb
|
72
|
+
- test/log/.gitkeep
|
73
|
+
- test/test_console.rb
|
74
|
+
- test/test_helper.rb
|
75
|
+
- test/test_runner.rb
|
76
|
+
- tests
|
77
|
+
homepage: https://github.com/openstax/transaction_retry
|
78
|
+
licenses: []
|
79
|
+
metadata:
|
80
|
+
rubygems_mfa_required: 'true'
|
81
|
+
post_install_message:
|
82
|
+
rdoc_options: []
|
83
|
+
require_paths:
|
84
|
+
- lib
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '2.6'
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0'
|
95
|
+
requirements: []
|
96
|
+
rubygems_version: 3.4.7
|
97
|
+
signing_key:
|
98
|
+
specification_version: 4
|
99
|
+
summary: Retries database transaction on deadlock and transaction serialization errors.
|
100
|
+
Supports MySQL, PostgreSQL and SQLite.
|
101
|
+
test_files: []
|