activerecord-transactionable 2.0.1 → 2.0.5
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 +5 -5
- data/.github/FUNDING.yml +12 -0
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/eol.yml +37 -0
- data/.github/workflows/style.yml +34 -0
- data/.github/workflows/supported.yml +57 -0
- data/.github/workflows/unsupported.yml +40 -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 +42 -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 +104 -79
- metadata +211 -39
- 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
|
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,51 +64,58 @@ 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
|
-
|
78
|
-
|
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|
|
91
|
+
# regardless of the retry being inside or outside the transaction, it is still a retry.
|
92
|
+
yield outside_is_retry || is_retry
|
79
93
|
end
|
80
94
|
end
|
81
95
|
end
|
82
96
|
|
83
97
|
private
|
84
98
|
|
85
|
-
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)
|
86
100
|
if object
|
87
101
|
if lock
|
88
|
-
#
|
102
|
+
# NOTE: with_lock will reload object!
|
89
103
|
# Note: with_lock does not accept arguments like transaction does.
|
90
104
|
object.with_lock do
|
91
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args
|
92
|
-
|
93
|
-
end
|
105
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args,
|
106
|
+
&block)
|
94
107
|
end
|
95
108
|
else
|
96
109
|
object.transaction(**transaction_args) do
|
97
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args
|
98
|
-
|
99
|
-
end
|
110
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args,
|
111
|
+
&block)
|
100
112
|
end
|
101
113
|
end
|
102
114
|
else
|
103
115
|
raise ArgumentError, "No object to lock!" if lock
|
116
|
+
|
104
117
|
ActiveRecord::Base.transaction(**transaction_args) do
|
105
|
-
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args)
|
106
|
-
yield is_retry
|
107
|
-
end
|
118
|
+
error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args, &block)
|
108
119
|
end
|
109
120
|
end
|
110
121
|
end
|
@@ -116,38 +127,44 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
116
127
|
end
|
117
128
|
end
|
118
129
|
|
119
|
-
def error_handler_inside_transaction(object: nil,
|
130
|
+
def error_handler_inside_transaction(transaction_open:, object: nil, **args, &block)
|
120
131
|
rescued_errors = Array(args[:rescued_errors])
|
121
132
|
prepared_errors = Array(args[:prepared_errors])
|
122
133
|
retriable_errors = Array(args[:retriable_errors])
|
123
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
|
124
136
|
rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_INSIDE_TRANSACTION)
|
125
137
|
prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_INSIDE)
|
126
|
-
already_been_added_to_self, needing_added_to_self = rescued_errors.partition
|
127
|
-
|
128
|
-
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|
|
129
|
-
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)
|
130
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)
|
131
144
|
end
|
132
145
|
|
133
|
-
def error_handler_outside_transaction(object: nil,
|
146
|
+
def error_handler_outside_transaction(transaction_open:, object: nil, **args, &block)
|
134
147
|
rescued_errors = Array(args[:outside_rescued_errors])
|
135
148
|
prepared_errors = Array(args[:outside_prepared_errors])
|
136
149
|
retriable_errors = Array(args[:outside_retriable_errors])
|
137
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
|
138
152
|
rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_OUTSIDE_TRANSACTION)
|
139
153
|
prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_OUTSIDE)
|
140
|
-
already_been_added_to_self, needing_added_to_self = rescued_errors.partition
|
141
|
-
|
142
|
-
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|
|
143
|
-
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)
|
144
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)
|
145
160
|
end
|
146
161
|
|
147
|
-
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
|
148
164
|
re_try = false
|
149
165
|
begin
|
150
|
-
|
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.
|
151
168
|
# If the error is rescued higher up, like where the transaction in active record
|
152
169
|
# rescues ActiveRecord::Rollback without re-raising, then transaction_wrapper will return nil
|
153
170
|
# If the error is not rescued higher up the error will continue to bubble
|
@@ -156,54 +173,62 @@ module Activerecord # Note lowercase "r" in Activerecord (different namespace th
|
|
156
173
|
# We pass the retry state along to yield so that the code implementing
|
157
174
|
# the transaction_wrapper can switch behavior on a retry
|
158
175
|
# (e.g. create => find)
|
159
|
-
result = yield re_try
|
176
|
+
result = yield (re_try == false ? re_try : attempt)
|
160
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.
|
161
178
|
if result.is_a?(Activerecord::Transactionable::Result)
|
162
179
|
result # <= preserve the meaningful return value
|
163
180
|
else
|
164
|
-
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
|
165
182
|
end
|
166
|
-
rescue *reraisable_errors =>
|
183
|
+
rescue *reraisable_errors => e
|
167
184
|
# This has highest precedence because raising is the most critical functionality of a raised error to keep
|
168
185
|
# if that is in the intended behavior, and this way a specific child of StandardError can be reraised while
|
169
186
|
# the parent can still be caught and added to self.errors
|
170
187
|
# Also adds the error to the object if there is an object.
|
171
|
-
transaction_error_logger(object: object, error:
|
172
|
-
|
173
|
-
|
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
|
174
192
|
# This will re-run the begin block above
|
175
193
|
# WARNING: If the same error keeps getting thrown this would infinitely recurse!
|
176
194
|
# To avoid the infinite recursion, we track the retry state
|
177
|
-
if
|
178
|
-
|
179
|
-
|
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
|
180
200
|
else
|
181
201
|
re_try = true
|
182
202
|
# Not adding error to base when retrying, because otherwise the added error may
|
183
203
|
# prevent the subsequent save from working, in a catch-22
|
184
|
-
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}]")
|
185
206
|
retry
|
186
207
|
end
|
187
|
-
rescue *already_been_added_to_self =>
|
208
|
+
rescue *already_been_added_to_self => e
|
188
209
|
# ActiveRecord::RecordInvalid, when done correctly, will have already added the error to object.
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
194
219
|
end
|
195
220
|
end
|
196
221
|
|
197
|
-
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)
|
198
223
|
# Ruby arguments, like object, are passed by reference,
|
199
224
|
# so this update to errors will be available to the caller
|
200
225
|
if object.nil?
|
201
226
|
# when a transaction wraps a bunch of CRUD actions,
|
202
227
|
# the specific record that caused the ActiveRecord::RecordInvalid error may be out of scope
|
203
228
|
# Ideally you would rewrite the caller to call transaction_wrapper on a single record (even if updates happen on other records)
|
204
|
-
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])}]")
|
205
230
|
else
|
206
|
-
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])}]")
|
207
232
|
object.errors.add(add_to, error.message) unless add_to.nil?
|
208
233
|
end
|
209
234
|
end
|