transaction_retry 1.0.1

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