In this blog post, we’ll dive into database transactions, how they work behind the scenes, but mainly, we will focus on how to use them effectively within the Ruby on Rails framework.
What Are Database Transactions?
A database transaction is a mechanism that allows a group of operations to be executed as a single, atomic unit. This means that either all operations within the transaction succeed and are committed, or if any operation fails, all operations are rolled back, ensuring data consistency.
Consider a classic example: transferring $20 from Amanda to Daniel. This seemingly simple action involves two database operations:
- Withdrawing $20 from Amanda’s account.
- Depositing $20 into Daniel’s account.
If one of these operations fails (e.g., Amanda has insufficient funds, or Daniel’s account is invalid), the entire transfer must be canceled to prevent an inconsistent state. This is precisely what database transactions achieve. By wrapping both operations within a transaction, we guarantee that if one fails, both are reverted, maintaining data integrity and reliability.
Transactions adhere to the ACID principle:
- Atomicity: This is the "all or nothing" component. Either all operations within the transaction succeed and are persisted, or none of them are.
- Consistency: The database transitions from one valid, consistent state to another.
- Isolation: Concurrent transactions operate independently without interfering with each other.
- Durability: Once a transaction is committed, its changes persist even in the event of system failures.
Below is an example of a database transaction using raw SQL:
BEGIN TRANSACTION;
-- Withdraw from Amanda
UPDATE users
SET balance = balance - 100
WHERE name = 'Amanda';
-- Deposit into Daniel
UPDATE users
SET balance = balance + 100
WHERE name = 'Daniel';
COMMIT;
Using Transactions in Rails
Rails offers native support for transactions through ActiveRecord:
ActiveRecord::Base.transaction do
user.update!(balance: user.balance - 100)
recipient.update!(balance: recipient.balance + 100)
end
By wrapping your code within an ActiveRecord::Base.transaction
block, Rails automatically encapsulates all queries within a single database transaction. If any line within the block raises an exception, the entire set of operations will be automatically rolled back.
Keep Two Crucial Caveats in Mind
- Exceptions must be raised for rollback:
For a transaction to roll back correctly, an exception must be explicitly raised. Therefore, always use methods that raise exceptions on failure, such as update!
, save!
, and create!
.
Example (incorrect):
ActiveRecord::Base.transaction do
user.update(balance: user.balance - 100)
recipient.update(balance: nil) # This won't raise an exception
end
In this scenario, even if the second statement is invalid and won’t be executed, the first one will be executed and committed, leading to an inconsistent database state.
Corrected Example:
ActiveRecord::Base.transaction do
user.update!(balance: user.balance - 100)
recipient.update!(balance: nil) # This will raise an exception
end
When an exception is raised on the second statement, the first statement will also be rolled back, returning the database to its state before the transaction began.
- Handle exceptions other than
ActiveRecord::Rollback
:
If an exception other than ActiveRecord::Rollback
is raised, it will propagate up to your application. Be prepared to handle such exceptions using rescue
or similar mechanisms.
Nested Transactions and Savepoints
Rails supports nested transactions by internally utilizing database savepoints. This allows you to roll back only an inner transaction if needed, without affecting the outer one.
By default, when you use a transaction inside another, all database statements within the nested block become part of the parent transaction. This can lead to surprising behavior:
ActiveRecord::Base.transaction do
Post.create(title: 'first')
ActiveRecord::Base.transaction do
Post.create(title: 'second')
raise ActiveRecord::Rollback
end
end
In this case, both "first" and "second" posts are created. This happens because ActiveRecord::Rollback
is a special exception that doesn’t propagate to the outer transaction. Consequently, the outer transaction commits, including the "second" post.
Similarly, if we raise any other exception, neither post will be created, because the exception will propagate, and the entire transaction will be rolled back. So how can we ensure that only the first post will be created?
Using requires_new: true
To ensure that only the first post is created, you can pass the requires_new: true
option to the inner transaction:
ActiveRecord::Base.transaction do
Post.create(title: 'first')
ActiveRecord::Base.transaction(requires_new: true) do
Post.create(title: 'second')
raise ActiveRecord::Rollback
end
end
This approach instructs Rails to create a savepoint under the hood, allowing the "second" post to be rolled back while the "first" post is successfully created.
Handling Side Effects (Emails, APIs, etc.)
External operations like sending emails or making HTTP requests are not automatically rolled back if a database transaction fails. Therefore, these operations should only be triggered after the transaction has successfully committed.
Problematic Example:
ActiveRecord::Base.transaction do
user.update!(active: true)
UserMailer.welcome_email(user).deliver_now
raise ActiveRecord::Rollback
end
Here, even though the user’s state is rolled back, the welcome email is sent regardless.
Alternative Approach: Callbacks
Rails offers a solution: the .transaction
method yields an ActiveRecord::Transaction
object, allowing you to register callbacks:
ActiveRecord::Base.transaction do |transaction|
transaction.after_commit { puts "after commit!" }
transaction.after_rollback { puts "after rollback!" }
end
In this example:
"after commit!"
will only be printed if the transaction successfully commits."after rollback!"
will only be printed if the transaction is rolled back.
Using these callbacks guarantees that side effects run only if the transaction commits successfully. However, this approach can make your code harder to test and debug, and it implicitly ties your transaction to the model lifecycle, potentially creating tight coupling. As with all powerful tools, use them with caution.
Wrapping Up
Transactions are a powerful and essential tool for maintaining database integrity, and Rails provides an intuitive interface for working with them. However, it’s crucial to understand how they function under the hood, their limitations, and how to safely handle side effects to avoid unexpected behavior.
Happy hacking!
We want to work with you. Check out our Services page!