Rails has deprecated using return, break and throw to exit a transaction


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