Nested ActiveRecord Transactions
Tags: ruby, rails, beginners, performance
Published: 2023-02-09 13:55:39

Nested ActiveRecord transactions are a common feature in many Ruby on Rails applications, providing a convenient way to handle complex data transactions and ensure the consistency of the data. However, when not used properly, nested transactions can lead to unexpected behaviors and data inconsistencies. This article will highlight some of the pitfalls of using nested ActiveRecord transactions and how to avoid them.

## Nested transactions can cause deadlocks

Deadlocks occur when two or more transactions are trying to access the same database resource simultaneously and each transaction is waiting for the other to complete, leading to a frozen state. In nested transactions, if one of the transactions fails, it will cause the entire transaction to be rolled back, but the other transactions may still be waiting for the resource, leading to a deadlock. To avoid this, it is recommended to avoid using too many nested transactions and to limit the number of transactions to a reasonable level.

```ruby
def create_user_and_post
  User.transaction do
    # create user
    user = User.create!(name: "Davide Santangelo")

    # create post
    Post.transaction do
      # create post
      post = user.posts.create!(title: "Hello World")

      # simulate failure
      raise ActiveRecord::Rollback if post.title == "Hello World"
    end

    # simulate failure
    raise ActiveRecord::Rollback if user.name == "Davide Santangelo"
  end
end
```

In this example, if the inner transaction fails, the outer transaction will also roll back, but the post will still be created, leading to a deadlock. To avoid this, it is recommended to avoid using too many nested transactions and to limit the number of transactions to a reasonable level.


## Nested transactions can cause inconsistencies

Nested transactions can lead to inconsistencies when the inner transactions are committed but the outer transactions are rolled back. For example, if an inner transaction updates a record and the outer transaction rolls back, the record will remain updated even though the entire transaction was supposed to be rolled back. To avoid this, it is recommended to use savepoints in the inner transactions, which will allow you to roll back to a specific point in the transaction, rather than rolling back the entire transaction.

```ruby
def update_user_and_post
  User.transaction do
    # find user
    user = User.find(1)

    # update user
    user.update!(name: "Davide Santangelo")

    Post.transaction do
      # find post
      post = user.posts.first

      # update post
      post.update!(title: "Hello Universe")

      # simulate failure
      raise ActiveRecord::Rollback if post.title == "Hello Universe"
    end

    # simulate failure
    raise ActiveRecord::Rollback if user.name == "Davide Santangelo"
  end
end
```

In this example, if the inner transaction fails, the post will still be updated, leading to an inconsistency. To avoid this, it is recommended to use savepoints in the inner transactions, which will allow you to roll back to a specific point in the transaction, rather than rolling back the entire transaction.

## Nested transactions can cause performance issues

Nested transactions can lead to performance issues, especially when there are many nested transactions, as each transaction requires a separate database connection. This can cause increased overhead and slower performance, especially when dealing with large amounts of data. To avoid this, it is recommended to use transactions sparingly and only when necessary.

```ruby
def update_user_and_post
  User.transaction do
    # find user
    user = User.find(1)

    # update user
    user.update!(name: "Davide Santangelo")

    ActiveRecord::Base.transaction(savepoint: true) do
      # find post
      post = user.posts.first

      # update post
      post.update!(title: "Hello Universe")

      # simulate failure
	  raise ActiveRecord::Rollback if post.title == "Hello Universe"
	end

	# simulate failure
	raise ActiveRecord::Rollback if user.name == "Davide Santangelo"
  end
end
```

By using savepoints, the update to the post will be rolled back, while the update to the user will remain, avoiding any inconsistencies.

## One last example

```ruby
class BankAccount < ApplicationRecord
  has_many :transactions

  def transfer(to, amount)
    transaction do
      self.balance -= amount
      self.save!

      transaction do
        to.balance += amount
        to.save!
      end
    end
  end
end
```

In this example, the transfer method transfers money from one bank account to another by performing two database operations: decreasing the balance of the current account and increasing the balance of the destination account. To ensure that both operations are atomic and can be rolled back if either fails, the method uses nested transactions.

The outer transaction block uses the transaction method provided by ActiveRecord, which opens a database transaction. If any exception is raised within the block, the transaction is automatically rolled back and the changes made within the block are discarded.

The inner transaction block performs the second database operation within the context of the outer transaction. This means that if an exception is raised within the inner transaction block, the entire transaction (both the outer and the inner transactions) will be rolled back, ensuring that the database remains in a consistent state.

Note that in this example, the save! method is used instead of the save method. The save! method raises an exception if the validation of the model fails, whereas the save method simply returns false in that case. By using save!, the method ensures that an exception is raised if either of the two save operations fail, triggering a rollback of the transaction.

## Conclusion

In conclusion, nested ActiveRecord transactions can be a powerful tool in ensuring the consistency of data in Ruby on Rails applications, but it is important to be aware of the potential pitfalls and to use them carefully. By understanding these pitfalls, you can avoid deadlocks and inconsistencies, and ensure that your transactions are executed smoothly and correctly.