'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 parent
  • joinable: 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