Posted over 5 years ago. Visible to the public. Repeats. Linked content.

Nested ActiveRecord transaction pitfalls

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

tl;dr

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:

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

This is a safer default for working with custom transactions.
However, you need to be aware of the overall transaction nesting constellation, because creating proper sub-transactions can lead to unwanted triggering of after_commit callbacks even when transactions are rolled back.

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

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

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

Copy
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 Archive :

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:

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

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

Copy
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 Archive .

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

Does your version of Ruby on Rails still receive security updates?
Rails LTS provides security patches for unsupported versions of Ruby on Rails (2.3, 3.2, 4.2 and 5.2).

Owner of this card:

Avatar
Judith Roth
Last edit:
6 days ago
by Bruno Sedler
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Judith Roth to makandra dev
This website uses short-lived cookies to improve usability.
Accept or learn more