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

# 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