Five things you did not know about Rails transactions

Transactions in Rails are an integral part of the framework, even if you don’t use them explicitly. Sometimes the way they work may be surprising, especially if you are not experienced in this topic.

This article is a set of interesting and less-known features of the transactions. After reading it, you should be more confident in using this feature that is a great tool when used correctly.

You can easily take down your database with not properly designed transaction

When you wrap the code inside the transaction block, the database connection is reserved unless the code in the block is fully executed. This behavior is expected and not surprising, but it can easily affect your whole application when misused.

Imagine that you are calling some external API and using response to create some records in the database:

ActiveRecord::Base.transaction do
  attributes = SomeService.call_api
  user = User.create!(attributes[:user])
  user.memberships.create!(attributes[:membership])
end

At first sight, such code looks correct but imagine that the API request can take a lot of time. The database connection would be blocked unless the response is returned and records are created. If you experience colossal traffic, you can quickly run out of the database connections and take down the application for many users.

There are two types of transactions

The standard transaction is called “real transaction” which means that the set of SQL queries is performed and can be easily rollbacked when needed. Because most database engines don’t support nested transactions, Rails is faking this behavior.

To replicate the nested transactions feature, Rails is using savepoints transactions. Savepoint is like a checkpoint in the game, but it saves the state of the database and gives you the ability to go back to the previous state when needed.

What might be surprising is that not every nested transaction uses savepoints. This behavior creates a lot of confusion among developers and leads to many errors in the development if you don’t read the documentation carefully.

Nested transactions and their weird behavior

Let’s consider the following code:

ActiveRecord::Base.transaction do
  Article.create!(title: 'How to use Ruby')
  ActiveRecord::Base.transaction do
    Article.create!(title: 'How to use Rails')
    raise ActiveRecord::Rollback
  end
end

Guess how many articles would be created after executing the above code. 0 or 1? The answer is 2.

If we want to rollback the nested transaction we have to pass the requires_new: true option that will give us a real sub-transaction:

ActiveRecord::Base.transaction do
  Article.create!(title: 'How to use Ruby')
  ActiveRecord::Base.transaction(requires_new: true) do
    Article.create!(title: 'How to use Rails')
    raise ActiveRecord::Rollback
  end
end

Personally, I think it should be the default behavior for transactions, but it’s not, so we have to keep it in mind to pass the extra option.

The transaction has its lifecycle

The transaction is not just a simple block which goal is to provide data integrity in the database. Much more is happening under the hood of the transaction mechanism in Rails. The framework provides a few different states to describe the current transaction state.

There are two types of states regarding transactions: those related to committing phase and those related to the rollback process. In addition, each group has other states that describe nested or single transactions.

Commit phase

When the transaction is committed, it can either have the state of fully_commited when there is a single transaction or committed when a nested transaction is used. Thanks to these states, Rails knows when the whole transaction is finished and when it’s just a part of the process, and there is still a parent transaction to include.

Rollback phase

The behavior is similar to the commit when it comes to the rollback. The transaction we want to revert can either have the state of fully_rollbacked or rollbacked depending on the transaction type. The transaction can be fully rollbacked only when the transaction is single or all child transactions are already rollbacked.

You are using transactions everywhere in your application

Even if you are not calling ActiveRecord::Base.transaction (or any other form of the transaction block) directly, Rails is using transactions under the hood.

Every time you call #save, #destroy, or #update on the model instance, the call is wrapped into a transaction, and you have the model’s callbacks at your disposal. Those callbacks are before_commit, after_commit, and after_rollback.

The reason for that behavior is straightforward: Rails wants to ensure that everything that you do in validations or callbacks will be safe, and the code will succeed along with the database query. Thus, such an approach helps to keep the database integrity and avoid some hard to debug bugs.