activerecord-transactionable 2.0.2 → 3.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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +12 -0
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/style.yml +34 -0
- data/.github/workflows/supported.yml +57 -0
- data/.github/workflows/unsupported.yml +37 -0
- data/.gitignore +2 -1
- data/.rspec +1 -0
- data/.rubocop.yml +121 -0
- data/.rubocop_todo.yml +138 -0
- data/.simplecov +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +26 -0
- data/Gemfile +4 -3
- data/LICENSE +21 -0
- data/README.md +152 -40
- data/Rakefile +22 -3
- data/SECURITY.md +14 -0
- data/activerecord-transactionable.gemspec +37 -15
- data/bin/console +1 -0
- data/lib/activerecord/transactionable/result.rb +32 -4
- data/lib/activerecord/transactionable/version.rb +3 -1
- data/lib/activerecord/transactionable.rb +102 -78
- metadata +211 -40
- data/.coveralls.yml +0 -1
- data/.travis.yml +0 -20
- data/Appraisals +0 -34
- data/gemfiles/rails_4_0.gemfile +0 -9
- data/gemfiles/rails_4_1.gemfile +0 -9
- data/gemfiles/rails_4_2.gemfile +0 -9
- data/gemfiles/rails_5_0.gemfile +0 -9
- data/gemfiles/rails_5_1.gemfile +0 -9
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "active_model"
|
|
2
4
|
require "active_record"
|
|
3
5
|
# apparently needed for Rails 4.0 compatibility with rspec, when
|
|
@@ -8,50 +10,52 @@ require "active_record/validations"
|
|
|
8
10
|
require "activerecord/transactionable/version"
|
|
9
11
|
require "activerecord/transactionable/result"
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
# Note lowercase "r" in Activerecord (different namespace than rails' module)
|
|
14
|
+
module Activerecord
|
|
12
15
|
# SRP: Provides an example of correct behavior for wrapping transactions.
|
|
13
16
|
# NOTE: Rails' transactions are per-database connection, not per-model, nor per-instance,
|
|
14
17
|
# see: http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
|
|
15
18
|
module Transactionable
|
|
16
19
|
extend ActiveSupport::Concern
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
DEFAULT_NUM_RETRY_ATTEMPTS = 2
|
|
22
|
+
DEFAULT_ERRORS_TO_HANDLE_INSIDE_TRANSACTION = [].freeze
|
|
23
|
+
DEFAULT_ERRORS_PREPARE_ON_SELF_INSIDE = [].freeze
|
|
24
|
+
DEFAULT_ERRORS_TO_HANDLE_OUTSIDE_TRANSACTION = [ActiveRecord::RecordInvalid].freeze
|
|
25
|
+
DEFAULT_ERRORS_PREPARE_ON_SELF_OUTSIDE = [ActiveRecord::RecordInvalid].freeze
|
|
22
26
|
# These errors (and possibly others) will invalidate the transaction (on PostgreSQL and possibly other databases).
|
|
23
27
|
# This means that if you did rescue them inside a transaction (or a nested transaction) all subsequent queries would fail.
|
|
24
28
|
ERRORS_TO_DISALLOW_INSIDE_TRANSACTION = [
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
ActiveRecord::RecordInvalid,
|
|
30
|
+
ActiveRecord::StatementInvalid,
|
|
31
|
+
ActiveRecord::RecordNotUnique
|
|
28
32
|
].freeze
|
|
29
33
|
# http://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction
|
|
30
|
-
TRANSACTION_METHOD_ARG_NAMES = [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
TRANSACTION_METHOD_ARG_NAMES = %i[
|
|
35
|
+
requires_new
|
|
36
|
+
isolation
|
|
37
|
+
joinable
|
|
34
38
|
].freeze
|
|
35
39
|
REQUIRES_NEW = TRANSACTION_METHOD_ARG_NAMES[0]
|
|
36
|
-
INSIDE_TRANSACTION_ERROR_HANDLERS = [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
INSIDE_TRANSACTION_ERROR_HANDLERS = %i[
|
|
41
|
+
rescued_errors
|
|
42
|
+
prepared_errors
|
|
43
|
+
retriable_errors
|
|
44
|
+
reraisable_errors
|
|
45
|
+
num_retry_attempts
|
|
41
46
|
].freeze
|
|
42
|
-
OUTSIDE_TRANSACTION_ERROR_HANDLERS = [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
OUTSIDE_TRANSACTION_ERROR_HANDLERS = %i[
|
|
48
|
+
outside_rescued_errors
|
|
49
|
+
outside_prepared_errors
|
|
50
|
+
outside_retriable_errors
|
|
51
|
+
outside_reraisable_errors
|
|
52
|
+
outside_num_retry_attempts
|
|
47
53
|
].freeze
|
|
48
|
-
INSIDE_CONTEXT = "inside"
|
|
49
|
-
OUTSIDE_CONTEXT = "outside"
|
|
54
|
+
INSIDE_CONTEXT = "inside"
|
|
55
|
+
OUTSIDE_CONTEXT = "outside"
|
|
50
56
|
|
|
51
|
-
def transaction_wrapper(**args)
|
|
52
|
-
self.class.transaction_wrapper(object: self, **args)
|
|
53
|
-
yield is_retry
|
|
54
|
-
end
|
|
57
|
+
def transaction_wrapper(**args, &block)
|
|
58
|
+
self.class.transaction_wrapper(object: self, **args, &block)
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
module ClassMethods
|
|
@@ -60,21 +64,30 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
|
60
64
|
inside_args = extract_args(args, INSIDE_TRANSACTION_ERROR_HANDLERS)
|
|
61
65
|
outside_args = extract_args(args, OUTSIDE_TRANSACTION_ERROR_HANDLERS)
|
|
62
66
|
transaction_open = ActiveRecord::Base.connection.transaction_open?
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
unless args.keys.empty?
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"#{self} does not know how to handle arguments: #{args.keys.inspect}"
|
|
66
70
|
end
|
|
71
|
+
if ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.detect do |error|
|
|
72
|
+
inside_args.values.flatten.uniq.include?(error)
|
|
73
|
+
end
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"#{self} should not rescue #{ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.inspect} inside a transaction: #{inside_args.keys.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
67
78
|
transaction_args = extract_args(args, TRANSACTION_METHOD_ARG_NAMES)
|
|
68
79
|
if transaction_open
|
|
69
|
-
|
|
80
|
+
if transaction_args[REQUIRES_NEW]
|
|
81
|
+
logger.debug("[#{self}.transaction_wrapper] Will start a nested transaction.")
|
|
82
|
+
else
|
|
70
83
|
transaction_args[REQUIRES_NEW] = true
|
|
71
84
|
logger.warn("[#{self}.transaction_wrapper] Opening a nested transaction. Setting require_new: true")
|
|
72
|
-
else
|
|
73
|
-
logger.debug("[#{self}.transaction_wrapper] Will start a nested transaction.")
|
|
74
85
|
end
|
|
75
86
|
end
|
|
76
|
-
error_handler_outside_transaction(object: object, transaction_open: transaction_open,
|
|
77
|
-
|
|
87
|
+
error_handler_outside_transaction(object: object, transaction_open: transaction_open,
|
|
88
|
+
**outside_args) do |outside_is_retry|
|
|
89
|
+
run_inside_transaction_block(transaction_args: transaction_args, inside_args: inside_args, lock: lock,
|
|
90
|
+
transaction_open: transaction_open, object: object) do |is_retry|
|
|
78
91
|
# regardless of the retry being inside or outside the transaction, it is still a retry.
|
|
79
92
|
yield outside_is_retry || is_retry
|
|
80
93
|
end
|
|
@@ -83,29 +96,26 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
|
83
96
|
|
|
84
97
|
private
|
|
85
98
|
|
|
86
|
-
def run_inside_transaction_block(transaction_args:, inside_args:, lock:, transaction_open:, object: nil)
|
|
99
|
+
def run_inside_transaction_block(transaction_args:, inside_args:, lock:, transaction_open:, object: nil, &block)
|
|
87
100
|
if object
|
|
88
101
|
if lock
|
|
89
|
-
#
|
|
102
|
+
# NOTE: with_lock will reload object!
|
|
90
103
|
# Note: with_lock does not accept arguments like transaction does.
|
|
91
104
|
object.with_lock do
|
|
92
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args
|
|
93
|
-
|
|
94
|
-
end
|
|
105
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args,
|
|
106
|
+
&block)
|
|
95
107
|
end
|
|
96
108
|
else
|
|
97
109
|
object.transaction(**transaction_args) do
|
|
98
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args
|
|
99
|
-
|
|
100
|
-
end
|
|
110
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args,
|
|
111
|
+
&block)
|
|
101
112
|
end
|
|
102
113
|
end
|
|
103
114
|
else
|
|
104
115
|
raise ArgumentError, "No object to lock!" if lock
|
|
116
|
+
|
|
105
117
|
ActiveRecord::Base.transaction(**transaction_args) do
|
|
106
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args)
|
|
107
|
-
yield is_retry
|
|
108
|
-
end
|
|
118
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args, &block)
|
|
109
119
|
end
|
|
110
120
|
end
|
|
111
121
|
end
|
|
@@ -117,38 +127,44 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
|
117
127
|
end
|
|
118
128
|
end
|
|
119
129
|
|
|
120
|
-
def error_handler_inside_transaction(object: nil,
|
|
130
|
+
def error_handler_inside_transaction(transaction_open:, object: nil, **args, &block)
|
|
121
131
|
rescued_errors = Array(args[:rescued_errors])
|
|
122
132
|
prepared_errors = Array(args[:prepared_errors])
|
|
123
133
|
retriable_errors = Array(args[:retriable_errors])
|
|
124
134
|
reraisable_errors = Array(args[:reraisable_errors])
|
|
135
|
+
num_retry_attempts = args[:num_retry_attempts] ? args[:num_retry_attempts].to_i : DEFAULT_NUM_RETRY_ATTEMPTS
|
|
125
136
|
rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_INSIDE_TRANSACTION)
|
|
126
137
|
prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_INSIDE)
|
|
127
|
-
already_been_added_to_self, needing_added_to_self = rescued_errors.partition
|
|
128
|
-
|
|
129
|
-
run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors, already_been_added_to_self, needing_added_to_self) do |is_retry|
|
|
130
|
-
yield is_retry
|
|
138
|
+
already_been_added_to_self, needing_added_to_self = rescued_errors.partition do |error_class|
|
|
139
|
+
prepared_errors.include?(error_class)
|
|
131
140
|
end
|
|
141
|
+
local_context = INSIDE_CONTEXT
|
|
142
|
+
run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors,
|
|
143
|
+
already_been_added_to_self, needing_added_to_self, num_retry_attempts, &block)
|
|
132
144
|
end
|
|
133
145
|
|
|
134
|
-
def error_handler_outside_transaction(object: nil,
|
|
146
|
+
def error_handler_outside_transaction(transaction_open:, object: nil, **args, &block)
|
|
135
147
|
rescued_errors = Array(args[:outside_rescued_errors])
|
|
136
148
|
prepared_errors = Array(args[:outside_prepared_errors])
|
|
137
149
|
retriable_errors = Array(args[:outside_retriable_errors])
|
|
138
150
|
reraisable_errors = Array(args[:outside_reraisable_errors])
|
|
151
|
+
num_retry_attempts = args[:outside_num_retry_attempts] ? args[:outside_num_retry_attempts].to_i : DEFAULT_NUM_RETRY_ATTEMPTS
|
|
139
152
|
rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_OUTSIDE_TRANSACTION)
|
|
140
153
|
prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_OUTSIDE)
|
|
141
|
-
already_been_added_to_self, needing_added_to_self = rescued_errors.partition
|
|
142
|
-
|
|
143
|
-
run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors, already_been_added_to_self, needing_added_to_self) do |is_retry|
|
|
144
|
-
yield is_retry
|
|
154
|
+
already_been_added_to_self, needing_added_to_self = rescued_errors.partition do |error_class|
|
|
155
|
+
prepared_errors.include?(error_class)
|
|
145
156
|
end
|
|
157
|
+
local_context = OUTSIDE_CONTEXT
|
|
158
|
+
run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors,
|
|
159
|
+
already_been_added_to_self, needing_added_to_self, num_retry_attempts, &block)
|
|
146
160
|
end
|
|
147
161
|
|
|
148
|
-
def run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors, already_been_added_to_self, needing_added_to_self)
|
|
162
|
+
def run_block_with_retry(object, local_context, transaction_open, retriable_errors, reraisable_errors, already_been_added_to_self, needing_added_to_self, num_retry_attempts)
|
|
163
|
+
attempt = 0
|
|
149
164
|
re_try = false
|
|
150
165
|
begin
|
|
151
|
-
|
|
166
|
+
attempt += 1
|
|
167
|
+
# If the block we yield to here raises an error that is not caught below the `re_try = true` will not get hit.
|
|
152
168
|
# If the error is rescued higher up, like where the transaction in active record
|
|
153
169
|
# rescues ActiveRecord::Rollback without re-raising, then transaction_wrapper will return nil
|
|
154
170
|
# If the error is not rescued higher up the error will continue to bubble
|
|
@@ -157,54 +173,62 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
|
157
173
|
# We pass the retry state along to yield so that the code implementing
|
|
158
174
|
# the transaction_wrapper can switch behavior on a retry
|
|
159
175
|
# (e.g. create => find)
|
|
160
|
-
result = yield re_try
|
|
176
|
+
result = yield (re_try == false ? re_try : attempt)
|
|
161
177
|
# When in the outside context we need to preserve the inside result so it bubbles up unmolested with the "meaningful" result of the transaction.
|
|
162
178
|
if result.is_a?(Activerecord::Transactionable::Result)
|
|
163
179
|
result # <= preserve the meaningful return value
|
|
164
180
|
else
|
|
165
|
-
Activerecord::Transactionable::Result.new(true) # <= make the return value meaningful. Meaning: transaction succeeded, no errors raised
|
|
181
|
+
Activerecord::Transactionable::Result.new(true, context: local_context, attempt: attempt, transaction_open: transaction_open) # <= make the return value meaningful. Meaning: transaction succeeded, no errors raised
|
|
166
182
|
end
|
|
167
|
-
rescue *reraisable_errors =>
|
|
183
|
+
rescue *reraisable_errors => e
|
|
168
184
|
# This has highest precedence because raising is the most critical functionality of a raised error to keep
|
|
169
185
|
# if that is in the intended behavior, and this way a specific child of StandardError can be reraised while
|
|
170
186
|
# the parent can still be caught and added to self.errors
|
|
171
187
|
# Also adds the error to the object if there is an object.
|
|
172
|
-
transaction_error_logger(object: object, error:
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
transaction_error_logger(object: object, error: e, result: nil, attempt: attempt, add_to: nil,
|
|
189
|
+
additional_message: " [#{transaction_open ? "nested " : ""}#{local_context} re-raising!]")
|
|
190
|
+
raise e
|
|
191
|
+
rescue *retriable_errors => e
|
|
175
192
|
# This will re-run the begin block above
|
|
176
193
|
# WARNING: If the same error keeps getting thrown this would infinitely recurse!
|
|
177
194
|
# To avoid the infinite recursion, we track the retry state
|
|
178
|
-
if
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
if attempt >= num_retry_attempts
|
|
196
|
+
result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: e, attempt: attempt, type: "retriable") # <= make the return value meaningful. Meaning is: transaction failed after <attempt> attempts
|
|
197
|
+
transaction_error_logger(object: object, error: e, result: result,
|
|
198
|
+
additional_message: " [#{transaction_open ? "nested " : ""}#{local_context}]")
|
|
199
|
+
result
|
|
181
200
|
else
|
|
182
201
|
re_try = true
|
|
183
202
|
# Not adding error to base when retrying, because otherwise the added error may
|
|
184
203
|
# prevent the subsequent save from working, in a catch-22
|
|
185
|
-
transaction_error_logger(object: object, error:
|
|
204
|
+
transaction_error_logger(object: object, error: e, result: nil, attempt: attempt, add_to: nil,
|
|
205
|
+
additional_message: " [#{transaction_open ? "nested " : ""}#{local_context}]")
|
|
186
206
|
retry
|
|
187
207
|
end
|
|
188
|
-
rescue *already_been_added_to_self =>
|
|
208
|
+
rescue *already_been_added_to_self => e
|
|
189
209
|
# ActiveRecord::RecordInvalid, when done correctly, will have already added the error to object.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
210
|
+
result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: e, attempt: attempt, type: "already_added") # <= make the return value meaningful. Meaning is: transaction failed
|
|
211
|
+
transaction_error_logger(object: nil, error: e, result: result,
|
|
212
|
+
additional_message: " [#{transaction_open ? "nested " : ""}#{local_context}]")
|
|
213
|
+
result
|
|
214
|
+
rescue *needing_added_to_self => e
|
|
215
|
+
result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: e, attempt: attempt, type: "needing_added") # <= make the return value meaningful. Meaning is: transaction failed
|
|
216
|
+
transaction_error_logger(object: object, error: e, result: result,
|
|
217
|
+
additional_message: " [#{transaction_open ? "nested " : ""}#{local_context}]")
|
|
218
|
+
result
|
|
195
219
|
end
|
|
196
220
|
end
|
|
197
221
|
|
|
198
|
-
def transaction_error_logger(object:, error:, add_to: :base, additional_message: nil)
|
|
222
|
+
def transaction_error_logger(object:, error:, result:, attempt: nil, add_to: :base, additional_message: nil, **_opts)
|
|
199
223
|
# Ruby arguments, like object, are passed by reference,
|
|
200
224
|
# so this update to errors will be available to the caller
|
|
201
225
|
if object.nil?
|
|
202
226
|
# when a transaction wraps a bunch of CRUD actions,
|
|
203
227
|
# the specific record that caused the ActiveRecord::RecordInvalid error may be out of scope
|
|
204
228
|
# Ideally you would rewrite the caller to call transaction_wrapper on a single record (even if updates happen on other records)
|
|
205
|
-
logger.error("[#{self}.transaction_wrapper] #{error.class}: #{error.message}#{additional_message}")
|
|
229
|
+
logger.error("[#{self}.transaction_wrapper] #{error.class}: #{error.message}#{additional_message}[#{attempt || (result && result.to_h[:attempt])}]")
|
|
206
230
|
else
|
|
207
|
-
logger.error("[#{self}.transaction_wrapper] On #{object.class} #{error.class}: #{error.message}#{additional_message}")
|
|
231
|
+
logger.error("[#{self}.transaction_wrapper] On #{object.class} #{error.class}: #{error.message}#{additional_message}[#{attempt || (result && result.to_h[:attempt])}]")
|
|
208
232
|
object.errors.add(add_to, error.message) unless add_to.nil?
|
|
209
233
|
end
|
|
210
234
|
end
|