module Activerecord::Transactionable::ClassMethods

Public Instance Methods

transaction_wrapper(object: nil, **args) { |outside_is_retry || is_retry| ... } click to toggle source
# File lib/activerecord/transactionable.rb, line 61
def transaction_wrapper(object: nil, **args)
  lock = args.delete(:lock)
  inside_args = extract_args(args, INSIDE_TRANSACTION_ERROR_HANDLERS)
  outside_args = extract_args(args, OUTSIDE_TRANSACTION_ERROR_HANDLERS)
  transaction_open = ActiveRecord::Base.connection.transaction_open?
  raise ArgumentError, "#{self} does not know how to handle arguments: #{args.keys.inspect}" unless args.keys.empty?
  if ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.detect { |error| inside_args.values.flatten.uniq.include?(error) }
    raise ArgumentError, "#{self} should not rescue #{ERRORS_TO_DISALLOW_INSIDE_TRANSACTION.inspect} inside a transaction: #{inside_args.keys.inspect}"
  end

  transaction_args = extract_args(args, TRANSACTION_METHOD_ARG_NAMES)
  if transaction_open
    if transaction_args[REQUIRES_NEW]
      logger.debug("[#{self}.transaction_wrapper] Will start a nested transaction.")
    else
      transaction_args[REQUIRES_NEW] = true
      logger.warn("[#{self}.transaction_wrapper] Opening a nested transaction. Setting require_new: true")
    end
  end
  error_handler_outside_transaction(object: object, transaction_open: transaction_open, **outside_args) do |outside_is_retry|
    run_inside_transaction_block(transaction_args: transaction_args, inside_args: inside_args, lock: lock, transaction_open: transaction_open, object: object) do |is_retry|
      # regardless of the retry being inside or outside the transaction, it is still a retry.
      yield outside_is_retry || is_retry
    end
  end
end

Private Instance Methods

error_handler_inside_transaction(object: nil, transaction_open:, **args) { |is_retry| ... } click to toggle source
# File lib/activerecord/transactionable.rb, line 125
def error_handler_inside_transaction(object: nil, transaction_open:, **args)
  rescued_errors = Array(args[:rescued_errors])
  prepared_errors = Array(args[:prepared_errors])
  retriable_errors = Array(args[:retriable_errors])
  reraisable_errors = Array(args[:reraisable_errors])
  num_retry_attempts = args[:num_retry_attempts] ? args[:num_retry_attempts].to_i : DEFAULT_NUM_RETRY_ATTEMPTS
  rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_INSIDE_TRANSACTION)
  prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_INSIDE)
  already_been_added_to_self, needing_added_to_self = rescued_errors.partition { |error_class| prepared_errors.include?(error_class) }
  local_context = INSIDE_CONTEXT
  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) do |is_retry|
    yield is_retry
  end
end
error_handler_outside_transaction(object: nil, transaction_open:, **args) { |is_retry| ... } click to toggle source
# File lib/activerecord/transactionable.rb, line 140
def error_handler_outside_transaction(object: nil, transaction_open:, **args)
  rescued_errors = Array(args[:outside_rescued_errors])
  prepared_errors = Array(args[:outside_prepared_errors])
  retriable_errors = Array(args[:outside_retriable_errors])
  reraisable_errors = Array(args[:outside_reraisable_errors])
  num_retry_attempts = args[:outside_num_retry_attempts] ? args[:outside_num_retry_attempts].to_i : DEFAULT_NUM_RETRY_ATTEMPTS
  rescued_errors.concat(DEFAULT_ERRORS_TO_HANDLE_OUTSIDE_TRANSACTION)
  prepared_errors.concat(DEFAULT_ERRORS_PREPARE_ON_SELF_OUTSIDE)
  already_been_added_to_self, needing_added_to_self = rescued_errors.partition { |error_class| prepared_errors.include?(error_class) }
  local_context = OUTSIDE_CONTEXT
  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) do |is_retry|
    yield is_retry
  end
end
extract_args(args, arg_names) click to toggle source

returns a hash of the arguments to the ActiveRecord::ConnectionAdapters::DatabaseStatements#transaction method

# File lib/activerecord/transactionable.rb, line 119
def extract_args(args, arg_names)
  arg_names.each_with_object({}) do |key, hash|
    hash[key] = args.delete(key)
  end
end
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) { |re_try == false ? re_try : attempt| ... } click to toggle source
# File lib/activerecord/transactionable.rb, line 155
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)
  attempt = 0
  re_try = false
  begin
    attempt += 1
    # If the block we yield to here raises an error that is not caught below the `re_try = true` will not get hit.
    # If the error is rescued higher up, like where the transaction in active record
    #   rescues ActiveRecord::Rollback without re-raising, then transaction_wrapper will return nil
    # If the error is not rescued higher up the error will continue to bubble
    # If we were already inside a transaction, such that this one is nested,
    #   then the result of the yield is what we want to return, to preserve the innermost result
    # We pass the retry state along to yield so that the code implementing
    #   the transaction_wrapper can switch behavior on a retry
    #   (e.g. create => find)
    result = yield (re_try == false ? re_try : attempt)
    # When in the outside context we need to preserve the inside result so it bubbles up unmolested with the "meaningful" result of the transaction.
    if result.is_a?(Activerecord::Transactionable::Result)
      result # <= preserve the meaningful return value
    else
      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
    end
  rescue *reraisable_errors => error
    # This has highest precedence because raising is the most critical functionality of a raised error to keep
    #   if that is in the intended behavior, and this way a specific child of StandardError can be reraised while
    #   the parent can still be caught and added to self.errors
    # Also adds the error to the object if there is an object.
    transaction_error_logger(object: object, error: error, result: nil, attempt: attempt, add_to: nil, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context} re-raising!]")
    raise error
  rescue *retriable_errors => error
    # This will re-run the begin block above
    # WARNING: If the same error keeps getting thrown this would infinitely recurse!
    #          To avoid the infinite recursion, we track the retry state
    if attempt >= num_retry_attempts
      result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: error, attempt: attempt, type: 'retriable') # <= make the return value meaningful.  Meaning is: transaction failed after <attempt> attempts
      transaction_error_logger(object: object, error: error, result: result, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
      result
    else
      re_try = true
      # Not adding error to base when retrying, because otherwise the added error may
      #   prevent the subsequent save from working, in a catch-22
      transaction_error_logger(object: object, error: error, result: nil, attempt: attempt, add_to: nil, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
      retry
    end
  rescue *already_been_added_to_self => error
    # ActiveRecord::RecordInvalid, when done correctly, will have already added the error to object.
    result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: error, attempt: attempt, type: 'already_added') # <= make the return value meaningful.  Meaning is: transaction failed
    transaction_error_logger(object: nil, error: error, result: result, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
    result
  rescue *needing_added_to_self => error
    result = Activerecord::Transactionable::Result.new(false, context: local_context, transaction_open: transaction_open, error: error, attempt: attempt, type: 'needing_added') # <= make the return value meaningful.  Meaning is: transaction failed
    transaction_error_logger(object: object, error: error, result: result, additional_message: " [#{transaction_open ? 'nested ' : ''}#{local_context}]")
    result
  end
end
run_inside_transaction_block(transaction_args:, inside_args:, lock:, transaction_open:, object: nil) { |is_retry| ... } click to toggle source
# File lib/activerecord/transactionable.rb, line 90
def run_inside_transaction_block(transaction_args:, inside_args:, lock:, transaction_open:, object: nil)
  if object
    if lock
      # Note: with_lock will reload object!
      # Note: with_lock does not accept arguments like transaction does.
      object.with_lock do
        error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args) do |is_retry|
          yield is_retry
        end
      end
    else
      object.transaction(**transaction_args) do
        error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args) do |is_retry|
          yield is_retry
        end
      end
    end
  else
    raise ArgumentError, 'No object to lock!' if lock

    ActiveRecord::Base.transaction(**transaction_args) do
      error_handler_inside_transaction(object: object, transaction_open: transaction_open, **inside_args) do |is_retry|
        yield is_retry
      end
    end
  end
end
transaction_error_logger(object:, error:, result:, attempt: nil, add_to: :base, additional_message: nil, **opts) click to toggle source
# File lib/activerecord/transactionable.rb, line 210
def transaction_error_logger(object:, error:, result:, attempt: nil, add_to: :base, additional_message: nil, **opts)
  # Ruby arguments, like object, are passed by reference,
  #   so this update to errors will be available to the caller
  if object.nil?
    # when a transaction wraps a bunch of CRUD actions,
    #   the specific record that caused the ActiveRecord::RecordInvalid error may be out of scope
    # Ideally you would rewrite the caller to call transaction_wrapper on a single record (even if updates happen on other records)
    logger.error("[#{self}.transaction_wrapper] #{error.class}: #{error.message}#{additional_message}[#{attempt || (result && result.to_h[:attempt])}]")
  else
    logger.error("[#{self}.transaction_wrapper] On #{object.class} #{error.class}: #{error.message}#{additional_message}[#{attempt || (result && result.to_h[:attempt])}]")
    object.errors.add(add_to, error.message) unless add_to.nil?
  end
end