transaction_retry 1.0.1

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.
@@ -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,22 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in kontolib.gemspec
4
+ gemspec
5
+
6
+ group :test do
7
+ # Use the gem instead of a dated version bundled with Ruby
8
+ gem 'minitest', '2.8.1'
9
+
10
+ gem 'simplecov', :require => false
11
+
12
+ gem 'mysql2'
13
+ gem 'pg'
14
+ gem 'sqlite3'
15
+ end
16
+
17
+ group :development do
18
+ gem 'rake'
19
+ # enhance irb
20
+ gem 'awesome_print', :require => false
21
+ gem 'pry', :require => false
22
+ 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.
@@ -0,0 +1,73 @@
1
+ # transaction_retry
2
+
3
+ Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL, and SQLite.
4
+
5
+ ## Example
6
+
7
+ The gem works automatically by rescuing ActiveRecord::TransactionIsolationConflict and retrying the transaction.
8
+
9
+ ## Installation
10
+
11
+ Add this to your Gemfile:
12
+
13
+ gem 'transaction_retry'
14
+
15
+ Then run:
16
+
17
+ bundle
18
+
19
+ __With Rails it works out of the box__.
20
+
21
+ If you have a standalone ActiveRecord-based project you'll need to call:
22
+
23
+ TransactionRetry.apply_activerecord_patch # after connecting to the database
24
+
25
+ __after__ connecting to the database.
26
+
27
+ ## Configuration
28
+
29
+ You can optionally configure transaction_retry gem in your config/initializers/transaction_retry.rb (or anywhere else):
30
+
31
+ TransactionRetry.max_retries = 3
32
+ TransactionRetry.wait_times = [0, 1, 2, 4, 8, 16, 32] # seconds to sleep after retry n
33
+
34
+ ## Features
35
+
36
+ * Supports MySQL, PostgreSQL, and SQLite (as long as you are using new drivers mysql2, pg, sqlite3).
37
+ * Exponential sleep times between retries (0, 1, 2, 4 seconds).
38
+ * Logs every retry as a warning.
39
+ * Intentionally does not retry nested transactions.
40
+ * Configurable number of retries and sleep time between them.
41
+ * Use it in your Rails application or a standalone ActiveRecord-based project.
42
+
43
+ ## Requirements
44
+
45
+ * ruby 1.9.2
46
+ * activerecord 3.0.11+
47
+
48
+ ## Running tests
49
+
50
+ Run tests on the selected database (mysql2 by default):
51
+
52
+ db=mysql2 bundle exec rake test
53
+ db=postgresql bundle exec rake test
54
+ db=sqlite3 bundle exec rake test
55
+
56
+ Run tests on all supported databases:
57
+
58
+ ./tests
59
+
60
+ Database configuration is hardcoded in test/db/db.rb; feel free to improve this and submit a pull request.
61
+
62
+ ## How intrusive is this gem?
63
+
64
+ You should be very suspicious about any gem that monkey patches your stock Ruby on Rails framework.
65
+
66
+ This gem is carefully written to not be more intrusive than it needs to be:
67
+
68
+ * wraps ActiveRecord::Base#transaction class method using alias_method to add new behaviour
69
+ * introduces two new private class methods in ActiveRecord::Base (with names that should never collide)
70
+
71
+ ## License
72
+
73
+ Released under the MIT license. Copyright (C) 2012 Piotr 'Qertoip' Włodarek.
@@ -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,41 @@
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
+ end
@@ -0,0 +1,58 @@
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
+ begin
23
+ transaction_without_retry(*objects, &block)
24
+ rescue ::ActiveRecord::TransactionIsolationConflict
25
+ raise if retry_count >= TransactionRetry.max_retries
26
+ raise if tr_in_nested_transaction?
27
+
28
+ retry_count += 1
29
+ postfix = { 1 => 'st', 2 => 'nd', 3 => 'rd' }[retry_count] || 'th'
30
+ logger.warn "Transaction isolation conflict detected. Retrying for the #{retry_count}-#{postfix} time..." if logger
31
+ tr_exponential_pause( retry_count )
32
+ retry
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Sleep 0, 1, 2, 4, ... seconds up to the TransactionRetry.max_retries.
39
+ # Cap the sleep time at 32 seconds.
40
+ # An ugly tr_ prefix is used to minimize the risk of method clash in the future.
41
+ def tr_exponential_pause( count )
42
+ seconds = TransactionRetry.wait_times[count-1] || 32
43
+ sleep( seconds ) if seconds > 0
44
+ end
45
+
46
+ # Returns true if we are in the nested transaction (the one with :requires_new => true).
47
+ # Returns false otherwise.
48
+ # An ugly tr_ prefix is used to minimize the risk of method clash in the future.
49
+ def tr_in_nested_transaction?
50
+ connection.open_transactions != 0
51
+ end
52
+
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ ActiveRecord::Base.send( :include, TransactionRetry::ActiveRecord::Base )
@@ -0,0 +1,3 @@
1
+ module TransactionRetry
2
+ VERSION = "1.0.1"
3
+ end
@@ -0,0 +1,4 @@
1
+ require 'active_record'
2
+ require_relative 'db'
3
+ require_relative 'migrations'
4
+ require_relative 'queued_job'
@@ -0,0 +1,35 @@
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 => "transaction_retry_test",
11
+ :user => 'root',
12
+ :password => ''
13
+ )
14
+ end
15
+
16
+ def self.connect_to_postgresql
17
+ ::ActiveRecord::Base.establish_connection(
18
+ :adapter => "postgresql",
19
+ :database => "transaction_retry_test",
20
+ :user => 'qertoip',
21
+ :password => 'test123'
22
+ )
23
+ end
24
+
25
+ def self.connect_to_sqlite3
26
+ ActiveRecord::Base.establish_connection(
27
+ :adapter => "sqlite3",
28
+ :database => ":memory:",
29
+ :verbosity => "silent"
30
+ )
31
+ end
32
+
33
+ end
34
+ end
35
+ 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,87 @@
1
+ # -*- encoding : utf-8 -*-
2
+
3
+ require 'test_helper'
4
+
5
+ class TransactionWithRetryTest < MiniTest::Unit::TestCase
6
+
7
+ def setup
8
+ @original_max_retries = TransactionRetry.max_retries
9
+ @original_wait_times = TransactionRetry.wait_times
10
+ end
11
+
12
+ def teardown
13
+ TransactionRetry.max_retries = @original_max_retries
14
+ TransactionRetry.wait_times = @original_wait_times
15
+ QueuedJob.delete_all
16
+ end
17
+
18
+ def test_does_not_break_transaction
19
+ ActiveRecord::Base.transaction do
20
+ QueuedJob.create!( :job => 'is fun!' )
21
+ assert_equal( 1, QueuedJob.count )
22
+ end
23
+ assert_equal( 1, QueuedJob.count )
24
+ QueuedJob.first.destroy
25
+ end
26
+
27
+ def test_does_not_break_transaction_rollback
28
+ ActiveRecord::Base.transaction do
29
+ QueuedJob.create!( :job => 'gives money!' )
30
+ raise ActiveRecord::Rollback
31
+ end
32
+ assert_equal( 0, QueuedJob.count )
33
+ end
34
+
35
+ def test_retries_transaction_on_transaction_isolation_conflict
36
+ first_run = true
37
+
38
+ ActiveRecord::Base.transaction do
39
+ if first_run
40
+ first_run = false
41
+ message = "Deadlock found when trying to get lock"
42
+ raise ActiveRecord::TransactionIsolationConflict.new( ActiveRecord::StatementInvalid.new( message ), message )
43
+ end
44
+ QueuedJob.create!( :job => 'is cool!' )
45
+ end
46
+ assert_equal( 1, QueuedJob.count )
47
+
48
+ QueuedJob.first.destroy
49
+ end
50
+
51
+ def test_does_not_retry_transaction_more_than_max_retries_times
52
+ TransactionRetry.max_retries = 1
53
+ run = 0
54
+
55
+ assert_raises( ActiveRecord::TransactionIsolationConflict ) do
56
+ ActiveRecord::Base.transaction do
57
+ run += 1
58
+ message = "Deadlock found when trying to get lock"
59
+ raise ActiveRecord::TransactionIsolationConflict.new( ActiveRecord::StatementInvalid.new( message ), message )
60
+ end
61
+ end
62
+
63
+ assert_equal( 2, run ) # normal run + one retry
64
+ end
65
+
66
+ def test_does_not_retry_nested_transaction
67
+ first_try = true
68
+
69
+ ActiveRecord::Base.transaction do
70
+
71
+ assert_raises( ActiveRecord::TransactionIsolationConflict ) do
72
+ ActiveRecord::Base.transaction( :requires_new => true ) do
73
+ if first_try
74
+ first_try = false
75
+ message = "Deadlock found when trying to get lock"
76
+ raise ActiveRecord::TransactionIsolationConflict.new( ActiveRecord::StatementInvalid.new( message ), message )
77
+ end
78
+ QueuedJob.create!( :job => 'is cool!' )
79
+ end
80
+ end
81
+
82
+ end
83
+
84
+ assert_equal( 0, QueuedJob.count )
85
+ end
86
+
87
+ 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
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,22 @@
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"
7
+ s.version = TransactionRetry::VERSION
8
+ s.authors = ["Piotr 'Qertoip' Włodarek"]
9
+ s.email = ["qertoip@gmail.com"]
10
+ s.homepage = "https://github.com/qertoip/transaction_retry"
11
+ s.summary = %q{Retries database transaction on deadlock and transaction serialization errors. Supports MySQL, PostgreSQL and SQLite.}
12
+ s.description = %q{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).}
13
+ s.required_ruby_version = '>= 1.9.2'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.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", ">= 3.0.11"
21
+ s.add_runtime_dependency "transaction_isolation", ">= 1.0.2"
22
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: transaction_retry
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Piotr 'Qertoip' Włodarek
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-07 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: &16526380 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: 3.0.11
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *16526380
25
+ - !ruby/object:Gem::Dependency
26
+ name: transaction_isolation
27
+ requirement: &16525880 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *16525880
36
+ description: Retries database transaction on deadlock and transaction serialization
37
+ errors. Supports MySQL, PostgreSQL and SQLite (as long as you are using new drivers
38
+ mysql2, pg, sqlite3).
39
+ email:
40
+ - qertoip@gmail.com
41
+ executables: []
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - .gitignore
46
+ - Gemfile
47
+ - LICENSE
48
+ - README.md
49
+ - Rakefile
50
+ - d
51
+ - lib/transaction_retry.rb
52
+ - lib/transaction_retry/active_record/base.rb
53
+ - lib/transaction_retry/version.rb
54
+ - test/db/all.rb
55
+ - test/db/db.rb
56
+ - test/db/migrations.rb
57
+ - test/db/queued_job.rb
58
+ - test/integration/active_record/base/transaction_with_retry_test.rb
59
+ - test/library_setup.rb
60
+ - test/log/.gitkeep
61
+ - test/test_console.rb
62
+ - test/test_helper.rb
63
+ - test/test_runner.rb
64
+ - tests
65
+ - transaction_retry.gemspec
66
+ homepage: https://github.com/qertoip/transaction_retry
67
+ licenses: []
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: 1.9.2
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubyforge_project:
86
+ rubygems_version: 1.8.15
87
+ signing_key:
88
+ specification_version: 3
89
+ summary: Retries database transaction on deadlock and transaction serialization errors.
90
+ Supports MySQL, PostgreSQL and SQLite.
91
+ test_files: []