Nested ActiveRecord transaction pitfalls

Updated . Posted . Visible to the public. Repeats.

When working with custom transactions and use ActiveRecord::Rollback you may encounter unexpected behaviour if you try to roll back your changes.

tl;dr

When using nested transactions, ActiveRecord::Rollback might not do what you expect, since it will only roll back the inner, but not the outer transaction.

You can fix this behavior by using transaction(joinable: false) but this leads to a bunch of different problems.

When you don't need an explicit ActiveRecord::Rollback, don't worry about any of this and just use a plan transaction do.

If you need ActiveRecord::Rollback, read on.

The basic problem

Not all databases support nested transactions. Therefore, Rails will sometimes silently ignore a nested transaction and simply reuse the other transaction. However, a ActiveRecord::Rollback within the nested transaction will be caught by the block of the nested transaction. Therefore it will be ignored by the outer transaction, and not cause a roll back!
To avoid this unexpected behaviour, you have to explicitly tell rails for each transaction to indeed use proper nesting:

ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
  # inner code
end

This is a safer default for working with custom transactions.

Warning

However, you need to be aware of the overall transaction nesting constellation, because creating proper sub-transactions can lead to unwanted or too early triggering of after_commit callbacks:
Unwanted because the inner after_commit callback is still executed if the outer transaction is rolled back.
Too early because the inner after_commit callback is executed before the overall commit is persisted to the database.

In detail

Take for example the following (simplified) model with an after_save callback that wants to roll back the transaction (in real-life this would do meaningful things and only roll back under certain conditions):

class Country < ActiveRecord::Base

  after_save :do_something

  def do_something
    raise ActiveRecord::Rollback
  end
  
end

With a callback like this the model should never be persisted in the database when we call save, right?
Ok, let's see what happens:

my-project> Country.first.name
# => "Afghanistan"
my-project> Country.first.update!(name: 'Afghanistan will not change')
# => ROLLBACK
my-project> Country.first.name
# => "Afghanistan"

Ok, that worked as expected. What about updating the record inside a transaction?

my-project> Country.first.name
# => "Afghanistan"
my-project> ActiveRecord::Base.transaction { Country.first.update!(name: 'Afghanistan will not change') }
# => COMMIT
my-project> Country.first.name
# => "Afghanistan will not change"

Wait. What happened to the rollback in the after_save-callback?
From the Rails docs Show archive.org snapshot :

Transaction calls can be nested. By default, this makes all database statements in the nested transaction block become part of the parent transaction.
The ActiveRecord::Rollback exception in the nested block does not issue a ROLLBACK. Since these exceptions are captured in transaction blocks, the parent block does not see it and the real transaction is committed.

The pitfall here is: It doesn't look like nesting, but it is. update! opens its own transaction, which is nested within our custom transaction. Hence rails will "discard" the inner transaction and only use our custom transaction. But the rollback that we raised in the inner transaction is catched there and never shows up at the outer transaction. Bäm :(

To avoid this unlucky behaviour we have to explicitly tell Rails not to reuse transactions:

my-project> Country.first.name
# => "Afghanistan will not change"
my-project> ActiveRecord::Base.transaction(joinable: false) { Country.first.update!(name: 'Afghanistan') }
# => ROLLBACK
my-project> Country.first.name
# => "Afghanistan will not change"

joinable: false means transactions nested within this transaction will not be discarded (and therefore not be joined to the custom transaction). A real nested transaction will be used, or, if the DBMS does not support nested transaction, this behaviour will be simulated with Savepoints (this is done for MySQL and Postgres).

If a custom transaction lives inside another transaction, which we can not control, we can use ActiveRecord::Base.transaction(requires_new: true) to force a real (or simulated) nested transaction and avoid joining with the parent transaction.

Note that all exceptions other than ActiveRecord::Rollback will also trigger rollbacks, but not suffer from this problem. So it might be a better choice to simply avoid ActiveRecord::Rollback. However the issue does not go away, since some ActiveRecord features (e.g. autosave: true, dependent: destroy ) will themselves trigger ActiveRecord::Rollback if things go wrong.

The problem with joinable: false

If you use joinable: false, you have to beware of another pitfall, though. If you have nested transactions with the inner one being a custom transaction that uses joinable: false, the inner transactions after_commit callbacks will be called, even though the transaction itself is rolled back!

Let's change the example above to the following:

class Country < ActiveRecord::Base

  after_commit :do_something

  def do_something
    puts 'Callback is triggered'
  end
  
  def save
    ActiveRecord::Base.transaction(joinable: false) do
       # do stuff
       super  
    end
  end
  
  def self.write_temporary_to_db(record, attributes)
    ActiveRecord::Base.transaction do
      record.attributes = attributes
      record.save

      raise ActiveRecord::Rollback
    end
  end
  
end

Now we would expect, that the after_commit callback should only run once changes are committed to the database, right?

country = Country.first
country.name
# => "Afghanistan"

Country.write_temporary_to_db(country, { name: "Afghanistan will not change" })
# => Callback is triggered
# => ROLLBACK

country.name
# => "Afghanistan"

When we use joinable: false the transactions are not merged. Unfortunately that means that the sub-transaction may be regarded as "finished" and after_commit callbacks will run although its surrounding transaction may be rolled back. This seems to be the desired behavior by rails Show archive.org snapshot .

This behavior also happens in some cases where joinable: false is set on the outer transaction, depending on which transaction is rolled back.
Here is a little matrix describing in which transaction combinations after_commit callbacks of inner transactions are called.

joinable: false on outer joinable: false on inner joinable: false on both joinable: false on neither
rollback on inner after_commit not triggered after_commit triggered after_commit triggered after_commit not triggered
rollback on outer after_commit triggered after_commit triggered after_commit triggered after_commit not triggered

Summary

When you're using nested transactions, you seem to be stuck between a rock and a hard place. Without joinable:false you may miss rollbacks on inner transactions. With joinable: false, after_commit callbacks may be triggered unwanted. In either case you need to be aware of what your choice does and you may have to mitigate the side effects some other way.

The problem still exists with Rails 7.0

Last edit
Tobias Kraze
License
Source code in this card is licensed under the MIT License.
Posted to makandra dev (2016-11-02 17:28)