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.
@@ -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
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
- 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
77
- run_inside_transaction_block(transaction_args: transaction_args, inside_args: inside_args, lock: lock, transaction_open: transaction_open, object: object) do |is_retry|
78
- yield 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|
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
- # Note: with_lock will reload object!
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) do |is_retry|
92
- yield is_retry
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) do |is_retry|
98
- yield is_retry
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) do |is_retry|
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, transaction_open:, **args)
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 {|error_class| prepared_errors.include?(error_class)}
127
- local_context = INSIDE_CONTEXT
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, transaction_open:, **args)
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 {|error_class| prepared_errors.include?(error_class)}
141
- local_context = OUTSIDE_CONTEXT
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
- # 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.
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 => error
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: error, add_to: nil, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} re-raising!]")
172
- raise error
173
- 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
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 re_try
178
- transaction_error_logger(object: object, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} 2nd attempt]")
179
- 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
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: 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}]")
185
206
  retry
186
207
  end
187
- rescue *already_been_added_to_self => error
208
+ rescue *already_been_added_to_self => e
188
209
  # ActiveRecord::RecordInvalid, when done correctly, will have already added the error to object.
189
- transaction_error_logger(object: nil, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
190
- Activerecord::Transactionable::Result.new(false) # <= make the return value meaningful. Meaning is: transaction failed
191
- rescue *needing_added_to_self => error
192
- transaction_error_logger(object: object, error: error, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
193
- 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
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