Posted about 4 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.


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.

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> # => "Afghanistan" my-project> Country.first.update!(name: 'Afghanistan will not change') # => ROLLBACK my-project> # => "Afghanistan"

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

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

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

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

A good default is to always use both, joinable: false and requires_new: true when working with custom transactions.

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.

Once an application no longer requires constant development, it needs periodic maintenance for stable and secure operation. makandra offers monthly maintenance contracts that let you focus on your business while we make sure the lights stay on.

Owner of this card:

Judith Roth
Last edit:
2 months ago
by Tobias Kraze
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