openstax_transaction_retry 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|