activerecord-transactionable 2.0.1 → 2.0.5

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