Rails has for a long time had a feature of
silently committing a transaction
if return
is called from within it.
It’s done this way to not leave an open
transaction on the connection.
That same feature can also be used to return early from a transaction if some condition is not met.
def destroy_post_if_invalid
Post.transaction do
post = Post.find_by(id: id)
return if post.valid?
post.destroy
end
end
This behavior of committing transactions silently can bring surprises when used in combination with the Ruby’s timeout method.
In the following example, the transaction will commit even if it doesn’t finish in 1 second.
Timeout.timeout(1) do
Post.transaction do
# do some heavy lifting
# post.time_consuming_task
# simulate something slow
sleep 3
end
end
It works this way because timeout
uses throw
to exit the transaction block when the klass
argument is not provided.
# uses throw and catch to exit the block
>> Timeout.timeout(1) do
sleep 2
end
Timeout::Error: execution expired
# will raise ArgumentError
>> Timeout.timeout(1, ArgumentError) do
sleep 2
end
ArgumentError: execution expired
There is no backward-compatible solution to the problem, as stated by the author of the PR which deprecates this surprising behavior without replacement.
Unfortunately, the ensure block can’t distinguish between a block exited with return, break or throw, so we can’t fix the problem with throw as used in Timeout.timeout
We will now start seeing the following deprecation warning in Rails 6.1.
DEPRECATION WARNING: Using `return`, `break` or `throw` to exit a transaction block is
deprecated without replacement. If the `throw` came from
`Timeout.timeout(duration)`, pass an exception class as a second
argument so it doesn't use `throw` to abort its block. This results
in the transaction being committed, but in the next release of Rails
it will raise and rollback.
The solution going forward is to use if/unless
condition
inside of transactions rather than return
and using an
exception class with the timeout
method.
def destroy_post_if_invalid
Post.transaction do
post = Post.find_by(id: id)
unless post.valid? do
post.destroy
end
end
end