Search code examples
ruby-on-railsactiverecordruby-on-rails-5

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

  • 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.