trx_ext 1.0.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.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ class CallbackPool
5
+ class << self
6
+ # @param :previous [nil, TrxExt::CallbackPool]
7
+ # @return [TrxExt::CallbackPool]
8
+ def add(previous: nil)
9
+ # It may happen when transaction is defined inside `on_complete` callback and, thus, when adding the
10
+ # `on_complete` callback for it, `previous` will point on the `TrxExt::CallbackPool` of the transaction's
11
+ # callback that is being executing right now. We should not continue such chain and allow the transaction to
12
+ # build its own chain.
13
+ # Example:
14
+ # trx do |c1|
15
+ # c1.on_complete do
16
+ # # When executing .add to define c2 TrxExt::CallbackPool - previous argument will contain
17
+ # # c1 TrxExt::CallbackPool that is already being executing. Assign nil to previous in this case.
18
+ # trx do |c2|
19
+ # c2.on_complete { }
20
+ # end
21
+ # end
22
+ # end
23
+ previous = nil if previous&.locked_for_execution?
24
+ inst = new
25
+ inst.previous = previous
26
+ inst
27
+ end
28
+ end
29
+
30
+ # Points on the previous instance in the single linked chain
31
+ attr_accessor :previous
32
+ attr_writer :locked_for_execution
33
+
34
+ def initialize
35
+ @callbacks = []
36
+ @locked_for_execution = false
37
+ end
38
+
39
+ # @return [Boolean] whether current instance is locked for the next {#exec_callbacks} action
40
+ def locked_for_execution?
41
+ @locked_for_execution
42
+ end
43
+
44
+ # @param blk [Proc]
45
+ # @return [void]
46
+ def on_complete(&blk)
47
+ @callbacks.push(blk)
48
+ end
49
+
50
+ # The chain of callbacks pool comes as follows:
51
+ # <#TrxExt::CallbackPool:0x03 @callbacks=[] previous=
52
+ # <#TrxExt::CallbackPool:0x02 @callbacks=[] previous=
53
+ # <#TrxExt::CallbackPool:0x01 @callbacks=[] previous=nil>
54
+ # >
55
+ # >
56
+ # The most inner instance - is the instance that was created first in stack call. The most top instance - is the
57
+ # instance that was created last in the stack call.
58
+ # Related example of how they are created during `trx` calls:
59
+ # trx do |c0x01|
60
+ # trx do |c0x02|
61
+ # trx do |c0x03|
62
+ # end
63
+ # end
64
+ # end
65
+ #
66
+ # At the end of execution of each `trx` - {#exec_callbacks_chain} will be called for each {TrxExt::CallbackPool}.
67
+ # But only <#TrxExt::CallbackPool:0x01> will really execute the callbacks of all pools in the chain - only it has
68
+ # rights to do this, because only it stands on the top of the call stack. This is ensured with
69
+ # `return unless previous.nil?` condition. At the moment of execution of {#exec_callbacks_chain} of top-stack
70
+ # instance - {ActiveRecord::Base.connection.current_callbacks_chain_link} points on the most inner, by call stack,
71
+ # {TrxExt::CallbackPool}. In the example above, it is <#TrxExt::CallbackPool:0x03>
72
+ #
73
+ # @param :connection [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter]
74
+ # @return [Boolean] whether callbacks was executed
75
+ def exec_callbacks_chain(connection:)
76
+ return false unless previous.nil?
77
+
78
+ current = connection.current_callbacks_chain_link
79
+ loop do
80
+ # It is important to keep it here to prevent potential
81
+ # `NoMethodError: undefined method `exec_callbacks' for nil:NilClass` exception when trying to execute callbacks
82
+ # for the transaction that raised an exception. In case of exception - current_callbacks_chain_link is set to
83
+ # nil. See {TrxExt::Retry.retry_until_serialized}. See {TrxExt::Transaction#transaction}.
84
+ # Example:
85
+ # trx { raise "trol" } # Should raise RuntimeError instead of NoMethodError
86
+ break if current.nil?
87
+
88
+ current.locked_for_execution = true
89
+ current.exec_callbacks
90
+ current = current.previous
91
+ end
92
+ # Can't use `ensure` here, because it will be triggered even if condition in first line is falsey. And we need
93
+ # to set connection#current_callbacks_chain_link to nil only in case of exception or in case of successful run of
94
+ # callbacks
95
+ connection.current_callbacks_chain_link = nil
96
+ true
97
+ rescue
98
+ connection.current_callbacks_chain_link = nil
99
+ raise
100
+ end
101
+
102
+ # @return [void]
103
+ def exec_callbacks
104
+ @callbacks.each(&:call)
105
+ nil
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ class Config
5
+ attr_accessor :unique_retries
6
+
7
+ def initialize
8
+ # Number of retries of unique constraint error before failing
9
+ @unique_retries = 5
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ module ObjectExt
5
+ # Wraps specified method in an +ActiveRecord+ transaction.
6
+ #
7
+ # @example
8
+ # class Tilapia < Symbology::Base
9
+ # wrap_in_trx def gnosis(number)
10
+ # introspect(number, string.numerology(:kabbalah).sum)
11
+ # end
12
+ # end
13
+ #
14
+ # order = Tilapia.new
15
+ # order.gnosis(93)
16
+ # # (0.6ms) BEGIN
17
+ # # Introspection Load (0.4ms) SELECT "introspections".* FROM "introspections" WHERE "introspections"."id" = $1 LIMIT 1 [["id", 93]]
18
+ # # (0.2ms) COMMIT
19
+ # # => 418
20
+ #
21
+ # @param method [Symbol] a name of the method
22
+ # @return [Symbol]
23
+ def wrap_in_trx(method)
24
+ module_to_prepend = Module.new do
25
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
26
+ def #{method}(...)
27
+ trx do
28
+ super
29
+ end
30
+ end
31
+ RUBY
32
+ end
33
+ prepend module_to_prepend
34
+ method
35
+ end
36
+
37
+ # A shorthand version of <tt>ActiveRecord::Base.transaction</tt>
38
+ def trx(...)
39
+ ActiveRecord::Base.transaction(...)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ class Railtie < Rails::Railtie
5
+ initializer 'trx_ext.setup_ar' do
6
+ TrxExt.integrate!
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ module Retry
5
+ class << self
6
+ # Wraps specified method in a +TrxExt::Retry.retry_until_serialized+ loop.
7
+ #
8
+ # @param klass [Class] class a method belongs to
9
+ # @param method [Symbol] instance method that needs to be wrapped into +TrxExt::Retry.retry_until_serialized+
10
+ def with_retry_until_serialized(klass, method)
11
+ module_to_prepend = Module.new do
12
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def #{method}(...)
14
+ ::TrxExt::Retry.retry_until_serialized do
15
+ super
16
+ end
17
+ end
18
+ RUBY
19
+ end
20
+ prepend module_to_prepend
21
+ method
22
+ end
23
+
24
+ # Retries block execution until serialization errors are no longer raised
25
+ def retry_until_serialized
26
+ retries_count = 0
27
+ begin
28
+ yield
29
+ rescue => error
30
+ error_classification = error_classification(error)
31
+ if error_classification == :record_not_unique
32
+ retries_count += 1
33
+ end
34
+ if retry_query?(error, retries_count)
35
+ TrxExt.log("Detected transaction rollback condition. Reason - #{error_classification}. Retrying...")
36
+ retry
37
+ else
38
+ raise error
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def error_classification(error)
46
+ case
47
+ when error.message.index('deadlock detected')
48
+ :deadlock
49
+ when error.message.index('could not serialize')
50
+ :serialization_error
51
+ when error.class == ActiveRecord::RecordNotUnique
52
+ :record_not_unique
53
+ end
54
+ end
55
+
56
+ def retry_query?(error, retryies_count)
57
+ classification = error_classification(error)
58
+ ActiveRecord::Base.connection.open_transactions == 0 &&
59
+ (%i(deadlock serialization_error).include?(classification) ||
60
+ classification == :record_not_unique && retryies_count < TrxExt.config.unique_retries)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ # Implements the feature that allows you to define callbacks that will be fired after SQL transaction is complete.
5
+ module Transaction
6
+ # See https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction
7
+ # for available params
8
+ def transaction(**kwargs, &blk)
9
+ pool = nil
10
+ TrxExt::Retry.retry_until_serialized do
11
+ super(**kwargs) do
12
+ pool = TrxExt::CallbackPool.add(previous: current_callbacks_chain_link)
13
+ self.current_callbacks_chain_link = pool
14
+ blk.call(pool)
15
+ end
16
+ rescue
17
+ self.current_callbacks_chain_link = nil
18
+ raise
19
+ end
20
+ ensure
21
+ pool.exec_callbacks_chain(connection: self)
22
+ end
23
+
24
+ # Returns the {TrxExt::CallbackPool} instance for the transaction that is being executed at the moment.
25
+ def current_callbacks_chain_link
26
+ @trx_callbacks_chain
27
+ end
28
+
29
+ # Set the {TrxExt::CallbackPool} instance for the transaction that is being executed at the moment.
30
+ def current_callbacks_chain_link=(val)
31
+ @trx_callbacks_chain = val
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrxExt
4
+ VERSION = "1.0.0"
5
+ SUPPORTED_AR_VERSIONS = %w(6.0.4.1 6.1.4.1).freeze
6
+ end
data/lib/trx_ext.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require_relative "trx_ext/callback_pool"
5
+ require_relative "trx_ext/object_ext"
6
+ require_relative "trx_ext/retry"
7
+ require_relative "trx_ext/transaction"
8
+ require_relative "trx_ext/config"
9
+ require_relative "trx_ext/version"
10
+
11
+ module TrxExt
12
+ class << self
13
+ attr_accessor :logger
14
+
15
+ # @return [void]
16
+ def integrate!
17
+ ActiveSupport.on_load(:active_record) do
18
+ require 'active_record/connection_adapters/postgresql_adapter'
19
+
20
+ # Allow to use #wrap_in_trx and #trx methods everywhere
21
+ Object.prepend(TrxExt::ObjectExt)
22
+
23
+ # Patch #transaction
24
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(TrxExt::Transaction)
25
+
26
+ # Single SELECT/UPDATE/DELETE queries should also be retried even if they are not a part of explicit transaction
27
+ TrxExt::Retry.with_retry_until_serialized(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter, :exec_query)
28
+ TrxExt::Retry.with_retry_until_serialized(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter, :exec_update)
29
+ TrxExt::Retry.with_retry_until_serialized(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter, :exec_delete)
30
+ end
31
+ end
32
+
33
+ # @return [void]
34
+ def log(msg)
35
+ return unless logger
36
+
37
+ logger.info(msg)
38
+ end
39
+
40
+ # @return [TrxExt::Config]
41
+ def config
42
+ @config ||= TrxExt::Config.new
43
+ end
44
+
45
+ def configure
46
+ yield config
47
+ end
48
+ end
49
+ end
50
+
51
+ if defined?(Rails::Railtie)
52
+ require_relative "trx_ext/railtie"
53
+ else
54
+ TrxExt.integrate!
55
+ end
data/log/.gitkeep ADDED
File without changes
data/trx_ext.gemspec ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/trx_ext/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "trx_ext"
7
+ spec.version = TrxExt::VERSION
8
+ spec.authors = ["Ivan Dzyzenko"]
9
+ spec.email = ["ivan.dzyzenko@gmail.com"]
10
+
11
+ spec.summary = "ActiveRecord's transaction extension"
12
+ spec.description = "Allow you to retry deadlocks, serialization errors, non-unique errors."
13
+ spec.homepage = "https://github.com/intale/trx_ext"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/intale/trx_ext/tree/v#{spec.version}"
21
+ spec.metadata["changelog_uri"] = "https://github.com/intale/trx_ext/blob/v#{spec.version}/CHANGELOG.md"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.add_dependency 'activerecord', '~> 6'
35
+ spec.add_dependency 'pg', '~> 1.2'
36
+ spec.add_development_dependency 'rspec', "~> 3.10"
37
+ spec.add_development_dependency 'timecop', "~> 0.9.4"
38
+ spec.add_development_dependency 'factory_bot', "~> 6.2"
39
+ spec.add_development_dependency 'fivemat', '~> 1.3'
40
+ spec.add_development_dependency 'rspec-its', '~> 1.3'
41
+ end
metadata ADDED
@@ -0,0 +1,167 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trx_ext
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Dzyzenko
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-12-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.10'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.10'
55
+ - !ruby/object:Gem::Dependency
56
+ name: timecop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.9.4
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.9.4
69
+ - !ruby/object:Gem::Dependency
70
+ name: factory_bot
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '6.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '6.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: fivemat
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec-its
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.3'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.3'
111
+ description: Allow you to retry deadlocks, serialization errors, non-unique errors.
112
+ email:
113
+ - ivan.dzyzenko@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".rspec"
119
+ - ".ruby-gemset"
120
+ - ".ruby-version"
121
+ - CHANGELOG.md
122
+ - CODE_OF_CONDUCT.md
123
+ - Gemfile
124
+ - LICENSE.txt
125
+ - README.md
126
+ - Rakefile
127
+ - bin/console
128
+ - bin/setup
129
+ - bin/test_all_ar_versions
130
+ - lib/trx_ext.rb
131
+ - lib/trx_ext/callback_pool.rb
132
+ - lib/trx_ext/config.rb
133
+ - lib/trx_ext/object_ext.rb
134
+ - lib/trx_ext/railtie.rb
135
+ - lib/trx_ext/retry.rb
136
+ - lib/trx_ext/transaction.rb
137
+ - lib/trx_ext/version.rb
138
+ - log/.gitkeep
139
+ - trx_ext.gemspec
140
+ homepage: https://github.com/intale/trx_ext
141
+ licenses:
142
+ - MIT
143
+ metadata:
144
+ allowed_push_host: https://rubygems.org
145
+ homepage_uri: https://github.com/intale/trx_ext
146
+ source_code_uri: https://github.com/intale/trx_ext/tree/v1.0.0
147
+ changelog_uri: https://github.com/intale/trx_ext/blob/v1.0.0/CHANGELOG.md
148
+ post_install_message:
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: 3.0.0
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 3.2.28
164
+ signing_key:
165
+ specification_version: 4
166
+ summary: ActiveRecord's transaction extension
167
+ test_files: []