Database transactions are a crucial mechanism for maintaining data integrity in the face of unexpected errors or failures. They ensure that multiple related operations are treated as a single, indivisible unit of work.
While transactions are very common and often implicit in many Rails operations, in some cases mixing them with asynchronous actions might lead to unexpected results.
Let's consider a practical example to illustrate potential issues, with a simple model and an asynchronous job (in our case, a Sidekiq job):
class User < ApplicationRecord
after_save :do_something_asynchronous
private
def do_something_asynchronous
SyncUser.perform_later(user: self)
end
end
class SyncUser < ApplicationJob
def perform(user_id:)
user = User.find(user_id)
# Some business logic on the user...
puts "ASYNC action on user with ID: #{user_id}"
end
end
In normal circumstances, this code works as expected:
# Rails console
user = User.new(name: 'John DOE')
user.save
# => true
# Sidekiq server
## Performing SyncUserJob [...] from Sidekiq [...] with arguments: {:user_id=>1}
## ASYNC action on user with name: John DOE
## Performed SyncUserJob [...] in 37.25ms
However, let’s see what happens if a wrapping transaction prevents my save call to be committed immediately to the DB:
# Rails console
User.transaction do
user = User.new(name: 'John DOE')
user.save
# We wait for 5 sec to simulate a (very) long transaction
sleep 5
end
# Sidekiq server
# Performing SyncUserJob [...] from Sidekiq [...] with arguments: {:user_id=>2}
# Discarded SyncUserJob due to a ActiveRecord::RecordNotFound.
# Performed SyncUserJob [...] in 37.25ms
We can see here that the job was performed in Sidekiq with an ActiveRecord::RecordNotFound error.
This is due to the fact that, at the time of executing the job’s logic (in the Sidekiq server’s runtime), the DB operation hasn’t been committed yet (in the Rails app’s runtime), meaning the object does not exist in the DB.
Non-atomic interactions in transactions
Non-atomic interactions occur when asynchronous actions are triggered within a transaction that hasn't been committed yet. This situation can lead to race conditions on the executed async job due to the following reasons:
- The transaction can potentially rollback, causing data inconsistencies
- Even in successful operations, the transaction might take longer than expected to commit due to additional tasks being executed, leading to unexpected behaviours
In a Rails environment, a common source of implicit non-atomic interactions is ActiveRecord's native after_save callback, which wraps its content in a transaction and runs side actions regardless of the transaction's outcome.
While identifying them manually can sometimes be cumbersome, fortunately a great tool has been developed for this exact purpose...
The Isolator gem
is a great tool to help with such concerns. It is recommended for use in both test and development environments, preferably in a ‘whiny’ mode that raises errors when detecting dangerous operations. While it can be plugged into staging environments, extra careful consideration should be given to this approach.
SOCIAL SHARE CARD GENERATOR