'Rollback entire transaction within nested transaction
I want a nested transaction to fail the parent transaction.
Lets say I have the following model
class Task < ApplicationRecord
def change_status(status, performed_by)
ActiveRecord::Base.transaction do
update!(status: status)
task_log.create!(status: status, performed_by: performed_by)
end
end
end
I always want the update
and task_log
creation to be a transaction that performs together, or not at all.
And lets say if I have a controller that allows me to update multiple tasks
class TaskController < ApplicationController
def close_tasks
tasks = Task.where(id: params[:_json])
ActiveRecord::Base.transaction do
tasks.find_each do |t|
t.change_status(:close, current_user)
end
end
end
end
I want it so that if any of the change_status
fails, that the entire request gets rolled back, from the Parent level transaction.
However, this isn't the expected behavior in Rails, referring to the documentation on Nested Transactions
They give two examples.
User.transaction do
User.create(username: 'Kotori')
User.transaction do
User.create(username: 'Nemu')
raise ActiveRecord::Rollback
end
end
Which will create both Users
"Kotori" and "Nemu", since the Parent never see's the raise
Then the following example:
User.transaction do
User.create(username: 'Kotori')
User.transaction(requires_new: true) do
User.create(username: 'Nemu')
raise ActiveRecord::Rollback
end
end
Which only creates only "Kotori", because only the nested transaction failed.
So how can I make Rails understand if there is a failure in a Nested Transaction, to fail the Parent Transaction. Continuing from the example above, I want it so that neither "Kotori" and "Nemu" are created.
Solution 1:[1]
You can make sure the transactions are not joinable
User.transaction(joinable:false) do
User.create(username: 'Kotori')
User.transaction(requires_new: true, joinable: false) do
User.create(username: 'Nemu') and raise ActiveRecord::Rollback
end
end
This will result in something akin to:
SQL (12.3ms) SAVE TRANSACTION active_record_1
SQL (11.7ms) SAVE TRANSACTION active_record_2
SQL (11.1ms) ROLLBACK TRANSACTION active_record_2
SQL (13.6ms) SAVE TRANSACTION active_record_2
SQL (10.7ms) SAVE TRANSACTION active_record_3
SQL (11.2ms) ROLLBACK TRANSACTION active_record_3
SQL (11.7ms) ROLLBACK TRANSACTION active_record_2
Where as your current example results in
SQL (12.3ms) SAVE TRANSACTION active_record_1
SQL (13.9ms) SAVE TRANSACTION active_record_2
SQL (28.8ms) ROLLBACK TRANSACTION active_record_2
While requires_new: true
creates a "new" transaction (generally via a save point) the rollback only applies to that transaction. When that transaction rolls back it simply discards the transaction and utilizes the save point.
By using requires_new: true, joinable: false
rails will create save points for these new transactions to emulate the concept of a true nested transaction and when the roll back is called it will rollback all the transactions.
You can think of it this way:
requires_new: true
keeps this transaction from joining its parentjoinable: false
means the parent transaction cannot be joined by its children
When using both you can ensure that any transaction is never discarded and that ROLLBACK anywhere will result in ROLLBACK everywhere.
Solution 2:[2]
It seems that rails docs are not straightforward or easy to understand in this section.
Transactions are meant to silently fail if an ActiveRecord::Rollback is raised inside the block, but if any other error is raised, the transactions will be rollbacked and the exception will be passed on.
Looking into the rails docs first example:
User.transaction do
User.create(username: 'Kotori')
User.transaction do
User.create(username: 'Nemu')
raise ActiveRecord::Rollback
end
end
An ActiveRecord::Rollback is being raised, and therefore the inner transaction will be rollbacked and there will be no Nemu user. After the rollback is done, the outer transaction will continue without an issue because of the type of exception that we are using (ActiveRecord::Rollback). As I wrote before, ActiveRecord::Rollback exceptions will be intentionally rescued and swallowed without any consequences, and the parent transaction won't detect the exception.
If we take the same example, but we raise a different exception:
User.transaction do
User.create(username: 'Kotori')
User.transaction do
User.create(username: 'Nemu')
raise ArgumentError
end
end
This will work as expected. The transactions are nested and joined correctly in only one connection (this is default behaviour), therefore, Nemu and Kotori won't be created. It also doesn't matter where the error is raised, if it is raised in the parent or child transactions it will still rollback all statements.
The documentation also gives just a bit of information about the 2 options that we can pass to the transaction method: joinable and requires_new.
This options help us treat nested transactions as individual database connections, but can also help to avoid dependancy between parent-childs transactions when intentionally raising an ActiveRecord::Rollback
exception. Each option is intended to be used depending on the nested hierarchy level of the transaction.
joinable: default true. Allows us to tell the outer transaction if we want the inner transaction to be joined within the same connection. If this value is set to false and the inner transaction raises a rollback exception it wont affect the outer transaction.
requires_new: default nil. Allows us to tell the inner transaction if we want it run in a different connection. If this value is set to true and a rollback exception is raised, it wont affect the parent transaction.
So, this two options are meant to be used to run transactions in individual database connections depending on the nested hierarchy you can control.
Extra: it seems that the rails team is already working towards updating this functionality https://github.com/rails/rails/pull/44518
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 |