activerecord-transactionable 2.0.2 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- module Activerecord # Note lowercase "r" in Activerecord (different namespace than rails' module)
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
- DEFAULT_ERRORS_TO_HANDLE_INSIDE_TRANSACTION = []
19
- DEFAULT_ERRORS_PREPARE_ON_SELF_INSIDE = []
20
- DEFAULT_ERRORS_TO_HANDLE_OUTSIDE_TRANSACTION = [ ActiveRecord::RecordInvalid ]
21
- DEFAULT_ERRORS_PREPARE_ON_SELF_OUTSIDE = [ ActiveRecord::RecordInvalid ]
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
- ActiveRecord::RecordInvalid,
26
- ActiveRecord::StatementInvalid,
27
- ActiveRecord::RecordNotUnique
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
- :requires_new,
32
- :isolation,
33
- :joinable
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
- :rescued_errors,
38
- :prepared_errors,
39
- :retriable_errors,
40
- :reraisable_errors
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
- :outside_rescued_errors,
44
- :outside_prepared_errors,
45
- :outside_retriable_errors,
46
- :outside_reraisable_errors
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".freeze
49
- OUTSIDE_CONTEXT = "outside".freeze
54
+ INSIDE_CONTEXT = "inside"
55
+ OUTSIDE_CONTEXT = "outside"
50
56
 
51
- def transaction_wrapper(**args)
52
- self.class.transaction_wrapper(object: self, **args) do |is_retry|
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
- raise ArgumentError, "#{self} does not know how to handle arguments: #{args.keys.inspect}" unless args.keys.empty?
64
- if ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.detect { |error| inside_args.values.flatten.uniq.include?(error) }
65
- raise ArgumentError, "#{self} should not rescue #{ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.inspect} inside a transaction: #{inside_args.keys.inspect}"
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
- unless transaction_args[REQUIRES_NEW]
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, **outside_args) do |outside_is_retry|
77
- run_inside_transaction_block(transaction_args: transaction_args, inside_args: inside_args, lock: lock, transaction_open: transaction_open, object: object) do |is_retry|
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
- # Note: with_lock will reload object!
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) do |is_retry|
93
- yield is_retry
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) do |is_retry|
99
- yield is_retry
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) do |is_retry|
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, transaction_open:, **args)
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 {|error_class| prepared_errors.include?(error_class)}
128
- local_context = INSIDE_CONTEXT
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, transaction_open:, **args)
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 {|error_class| prepared_errors.include?(error_class)}
142
- local_context = OUTSIDE_CONTEXT
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
- # If the block we yield to here raises an error that is not caught below the `true` will not get hit.
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 => error
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: error, add_to: nil, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} re-raising!]")
173
- raise error
174
- rescue *retriable_errors => error
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 re_try
179
- transaction_error_logger(object: object, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} 2nd attempt]")
180
- Activerecord::Transactionable::Result.new(false) # <= make the return value meaningful. Meaning is: transaction failed after two attempts
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: error, add_to: nil, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} 1st attempt]")
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 => error
208
+ rescue *already_been_added_to_self => e
189
209
  # ActiveRecord::RecordInvalid, when done correctly, will have already added the error to object.
190
- transaction_error_logger(object: nil, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
191
- Activerecord::Transactionable::Result.new(false) # <= make the return value meaningful. Meaning is: transaction failed
192
- rescue *needing_added_to_self => error
193
- transaction_error_logger(object: object, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
194
- Activerecord::Transactionable::Result.new(false) # <= make the return value meaningful. Meaning is: transaction failed
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