追記(2023/10/13)
この記事で説明されている、Rails 6.1から導入された振る舞いの変更は、Rails 7.1にマージされた以下のプルリクで元に戻りました。
Rails 6.1でreturnやbreakやthrowによるトランザクション終了が非推奨化(翻訳)
Railsには長年の間、トランザクションをひそかにコミットする機能が入っていました(fc83920)。トランザクションの内部でreturn
を呼び出すと、コネクション上のトランザクションを開きっぱなしにしない形でreturn
が実行されます。
この機能は、何らかの条件が満たされない場合にトランザクションから早々に抜け出すのにも使えます。
def destroy_post_if_invalid
Post.transaction do
post = Post.find_by(id: id)
return if post.valid?
post.destroy
end
end
トランザクションが裏でコミットされるこの振る舞いは、Rubyのtimeout
メソッドと組み合わせたときに驚かされることがあります。
以下の例では、トランザクションが1秒以内に終了しなかった場合にもコミットされてしまいます。
Timeout.timeout(1) do
Post.transaction do
# 何か重たい処理を行う
# post.time_consuming_task
# ここで何らかの遅い動作をシミュレートする
sleep 3
end
end
このように動作するのは、klass
引数が渡されない場合にtimeout
がthrow
でトランザクションブロックを終了するためです。
# throwとcatchでブロックを終了する
>> Timeout.timeout(1) do
sleep 2
end
Timeout::Error: execution expired
# ArgumentErrorをraiseする
>> Timeout.timeout(1, ArgumentError) do
sleep 2
end
ArgumentError: execution expired
このプルリク#29333の作者は、この驚きの振る舞いを非推奨化するときに代替手段を設けなかったので、この問題に関する後方互換のソリューションはありません。
残念ながら、ensureブロックではブロックの終了がreturnで行われたのか、breakで行われたのか、throwで行われたのかを区別できません。このため、
Timeout.timeout
で行われているようにthrow
でこの問題を修正することができません。
ここで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.
先に進めるソリューションは、以下のようにトランザクション内部でreturn
ではなくif
やunless
による条件を利用するか、timeout
メソッドを持つ何らかの例外クラスを用いることです。
def destroy_post_if_invalid
Post.transaction do
post = Post.find_by(id: id)
unless post.valid? do
post.destroy
end
end
end
概要
元サイトの許諾を得て翻訳・公開いたします。