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