Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 6.1でreturnやbreakやthrowによるトランザクション終了が非推奨化(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。


  • 2020/04/24: 初版公開
  • 2023/10/13: 追記

追記(2023/10/13)

この記事で説明されている、Rails 6.1から導入された振る舞いの変更は、Rails 7.1にマージされた以下のプルリクで元に戻りました。

参考: Active Record commit transaction on return, break and throw by casperisfine · Pull Request #48600 · rails/rails

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引数が渡されない場合にtimeoutthrowでトランザクションブロックを終了するためです。

# 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ではなくifunlessによる条件を利用するか、timeoutメソッドを持つ何らかの例外クラスを用いることです。

def destroy_post_if_invalid
  Post.transaction do
    post = Post.find_by(id: id)

    unless post.valid? do
      post.destroy
    end
  end
end

関連記事

Rails: Active Recordメソッドのパフォーマンス改善とN+1問題の克服(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。