Rollback Rails transaction and rescue error to display it

good:

This is fine

record = MyModel.last
error_for_user = nil

begin
  ActiveRecord::Base.transaction do
    # ...
    record.save!
  end
rescue ActiveRecord::RecordInvalid => e
  # do something with exception here
  error_for_user = "Sorry your transaction failed. Reason: #{e}"
end

puts error_for_user || "Success"

source, source2, source3

This is ok as well, but pls realize StandardError is base for many errors that may happen not related to valid record

record = MyModel.last
error_for_user = nil

begin
  ActiveRecord::Base.transaction do
    # ...
    record.save!
  end
rescue StandardError => e
  # do something with exception here
  error_for_user = "Sorry your transaction failed. Reason: #{e}"
end

puts error_for_user || "Success"

So much better would be if you define your own error classes and rescue those like this:

MyErrors = Class.new(StandardError)
MySpecificError = Class.new(MySpecificError)

record = MyModel.last
error_for_user = nil

begin
  ActiveRecord::Base.transaction do
    # ...
    record.save!
    raise MySpecificError if record.has_some_issue?
  end
rescue ActiveRecord::RecordInvalid, MyErrors => e
  # do something with exception here
  error_for_user = "Sorry your transaction failed. Reason: #{e}"
end

puts error_for_user || "Success"

wrong:

Following code is wrong!

record = MyModel.last
error_for_user = nil


ActiveRecord::Base.transaction do
  begin
    # ...
    record.save!
  rescue ActiveRecord::StatementInvalid => e # DON'T DO THIS !
    error_for_user = "Sorry your transaction failed. Reason: #{e}"
  end
end

puts error_for_user || "Success"

Why is it wrong ? According to Active Record Transactions docs:

one should not catch ActiveRecord::StatementInvalid exceptions inside a transaction block. ActiveRecord::StatementInvalid exceptions indicate that an error occurred at the database level

Following code is also wrong!

record = MyModel.last
error_for_user = nil


ActiveRecord::Base.transaction do
  begin
    # ...
    record.save!
  rescue StandardError => e # DON'T DO THIS !
    error_for_user = "Sorry your transaction failed. Reason: #{e}"
  end
end

puts error_for_user || "Success"

Because ActiveRecord::StatementInvalid < ActiveRecord::ActiveRecordError < StandardError Therefore to rescue StandardError you rescue any children classes including ActiveRecord::StatementInvalid same reason as described before

Triggering rollback manually / Abort transaction

this is fine:

def add_bonus(tomas)
  ActiveRecord::Base.transaction do
    raise ActiveRecord::Rollback if john.is_not_cool?
    tomas.update!(money: tomas.money + 100)
  end
end


begin
  add_bonus(tomas)
rescue ActiveRecord::Rollback => e
  puts "Sorry your transaction failed. Reason: #{e}"
end

source, source2

Different aliases

there is no difference between #transaction, MyModel.transaction and ActiveRecord::Base.transaction. All 3 examples are the same:

my_model = MyModel.last

my_model.transaction do
  # ...
  my_model.save!
end

MyModel.transaction do
  # ...
  my_model.save!
end

ActiveRecord::Base.transaction do
  # ...
  my_model.save!
end

Want better explanation ? Good guide is this article: https://medium.com/@kristenrogers.kr75/rails-transactions-the-complete-guide-7b5c00c604fc

Avoid nested transactions

Nested transactions are possible but really hard to get right (docs).

For example:

User.count # => 0

ActiveRecord::Base.transaction do
  User.create!(name: "Foo")
  ActiveRecord::Base.transaction do
    User.create!(name: "Bar")
    raise ActiveRecord::Rollback
  end
end

User.count # => 2

…that means you end up with 2 Users

Special thank to Reddit user Linupe for contributing this example

My recommendation is to avoid them.

If you are ever in situation you need to use method with transaction inside another method with transaction rewrite your code so that the transaction is optional. Example:

def partial_update_user!(user:, name:, own_transaction: true)
  user.name = name
  user.initial = name.to_s[0]

  persist_logic = ->(u){ u.save! } # lambda

  if own_transaction
    ActiveRecord::Base.transaction do
      persist_logic.call(user)
    end
  else
    persist_logic.call(user)
  end
end


def full_user_update!(user:, name:, email:)
  email_identity = user.email_identity
  email_identity.email= email

  ActiveRecord::Base.transaction do
    partial_update_user!(user: user, name: name, own_transaction: false)
    email_identity.save!

    raise ActiveRecord::Rollback if user.is_not_cool?
  end
end


user = User.last

partial_update_user!(user: user, name: 'Tomas') # executed with 1 transaction

full_user_update!(user: user, name: 'Tomas', email: '[email protected]') # executed with 1 transaction

Avoid transaction to take lot of time

Once you do a transaction block you are creating DB transaction => you hold the DB

Try to move any non DB update/create/delete code calls outside transaction block

bad:

def some_method_that_takes_lot_of_time
  report_result = 'some long running result' # e.g. download and process CSV
  sleep 1
  report_result
end

ActiveRecord::Base.transaction do
  user  = User.find 123
  report_result = some_method_that_takes_lot_of_time
  user.performance = report_result
  user.save!
end

good

def some_method_that_takes_lot_of_time
  report_result = 'some long running result' # e.g. download and process CSV
  sleep 1
  report_result
end

user  = User.find 123
report_result = some_method_that_takes_lot_of_time
user.performance = report_result

ActiveRecord::Base.transaction do
  user.save!
end

Transactions on a single record

There is no reason to do

ActiveRecord::Base.transaction do
  user.save!
end

as:

Both #save and #destroy come wrapped in a transaction that ensures that whatever you do in validations or callbacks will happen under its protected cover source

That means when dealing with single record update you are fine with just

user.save!

Where Rails transaction blocks are needed is when you are creating/updating/deleting mulitple records and fail of one of them needs to rever all. Like:

ActiveRecord::Base.transaction do
  order = Order.create!
  user.latest_order.destroy! if user.latest_order
  user.latest_order = order
  user.save!
end

Trough out this article you may have notice I was using transactions on a single record

ActiveRecord::Base.transaction do
  user.save!
end

That’s because I don’t have time creating exapmles that would be both clear and follow this rule. I’m expeting you understand what Rails transactions are for and I just want to show you some interesting pitfalls.

Other pitfals

This is not a comprehencive guide of “what can go wrong” with Rails transactions. I just covered some examples me and my collegues encounter in past couple of years.

Please read trought Active Record Transactions for more examples

sources:

Discussion

https://www.reddit.com/r/ruby/comments/k09ccr/rollback_rails_transaction_and_rescue_error_and/