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.
@@ -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