注意喚起の記事になります。タイトルが結論です。
既にこの問題に言及している記事はいくつかあるのですが*1、私は気付かずに踏んでしまったので、タイトルで「おっと、うちは大丈夫かな」と思ってもらえるようにこの記事を書いています。
joinable
とは何か
問題として挙げているjoinable
オプションですが、これはネストしたトランザクションの挙動に影響を与えます。少しややこしいので、サンプルコードを見せながら説明します。
# frozen_string_literal: true require "bundler/inline" gemfile(true) do source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } gem "activerecord", "7.1.3" gem "sqlite3" end require "active_record" require "minitest/autorun" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveRecord::Schema.define do create_table :users, force: true do |t| t.string :username end end class User < ActiveRecord::Base; end class TransactionTest < Minitest::Test # @see https://api.rubyonrails.org/v7.1.3/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions def test_nested_transaction User.transaction do User.create(username: 'Kotori') User.transaction do User.create(username: 'Nemu') raise ActiveRecord::Rollback end end assert_equal 1, User.count end end
そのまま実行できるように色々書いていますが、重要なのは以下の部分なので、抜粋して解説します。
User.transaction do User.create(username: 'Kotori') User.transaction do User.create(username: 'Nemu') raise ActiveRecord::Rollback end end assert_equal 1, User.count
2つのトランザクションがあり、2つ目のネストしたトランザクションはActiveRecord::Rollback
をraise
しています。1つ目のトランザクションはロールバックされないはずなので、最終的な期待値はユーザーが1人作成されることのはずです。実行してみましょう。
% ruby transaction_test.rb ...<snip>... # Running: D, [2024-02-21T18:40:48.274835 #50505] DEBUG -- : TRANSACTION (0.0ms) begin transaction D, [2024-02-21T18:40:48.278697 #50505] DEBUG -- : User Create (0.1ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Kotori"]] D, [2024-02-21T18:40:48.278926 #50505] DEBUG -- : User Create (0.0ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Nemu"]] D, [2024-02-21T18:40:48.279027 #50505] DEBUG -- : TRANSACTION (0.0ms) commit transaction D, [2024-02-21T18:40:48.282091 #50505] DEBUG -- : User Count (0.0ms) SELECT COUNT(*) FROM "users" F Finished in 0.008239s, 121.3740 runs/s, 121.3740 assertions/s. 1) Failure: TransactionTest#test_nested_transaction [transaction_test.rb:39]: Expected: 1 Actual: 2 1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
失敗してしまいました。なんとロールバックできておらず、2人のユーザーが作成されてしまっているようです。
しかし、この挙動はRailsのドキュメントに"surprising behavior"として説明されています*2。デフォルトでは、ネストしたトランザクションは親のトランザクションに合流して、1つにまとめられます。一方でActiveRecord::Rollback
はネストしたtransaction
ブロック内でrescue
されてしまうために、合流した親トランザクションでROLLBACK
が発行されず、このような現象が起きます。クエリログを見ると、確かにトランザクションが1つしか無いこと、ROLLBACK
が発行されていないことに気付くかと思います。
この問題の対応策は、ドキュメントにもある通り、requires_new: true
を設定することです。
User.transaction do User.create(username: 'Kotori') - User.transaction do + User.transaction(requires_new: true) do User.create(username: 'Nemu') raise ActiveRecord::Rollback end end
# Running: D, [2024-02-21T18:56:10.515604 #50709] DEBUG -- : TRANSACTION (0.0ms) begin transaction D, [2024-02-21T18:56:10.518255 #50709] DEBUG -- : User Create (0.0ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Kotori"]] D, [2024-02-21T18:56:10.518437 #50709] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1 D, [2024-02-21T18:56:10.518483 #50709] DEBUG -- : User Create (0.1ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Nemu"]] D, [2024-02-21T18:56:10.518542 #50709] DEBUG -- : TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1 D, [2024-02-21T18:56:10.518641 #50709] DEBUG -- : TRANSACTION (0.0ms) commit transaction D, [2024-02-21T18:56:10.521321 #50709] DEBUG -- : User Count (0.0ms) SELECT COUNT(*) FROM "users" . Finished in 0.006369s, 157.0105 runs/s, 157.0105 assertions/s. 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
今度はちゃんとテストが通りました。クエリログでもネストした分のトランザクションが開始していること、ROLLBACK
が発行されていることが確認できます。
ここからが本題ですが、joinable
はrequires_new
に近い振る舞いをします。異なる点としては、requires_new
はネストしたトランザクションに渡すのに対し、joinable
は親トランザクションに渡します。以下の挙動を見るとわかりやすいでしょう。
-User.transaction do +User.transaction(joinable: false) do User.create(username: 'Kotori') User.transaction do User.create(username: 'Nemu') raise ActiveRecord::Rollback end end
# Running: D, [2024-02-21T19:04:50.308538 #50760] DEBUG -- : TRANSACTION (0.0ms) begin transaction D, [2024-02-21T19:04:50.311271 #50760] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1 D, [2024-02-21T19:04:50.311337 #50760] DEBUG -- : User Create (0.1ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Kotori"]] D, [2024-02-21T19:04:50.311416 #50760] DEBUG -- : TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 D, [2024-02-21T19:04:50.311564 #50760] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1 D, [2024-02-21T19:04:50.311608 #50760] DEBUG -- : User Create (0.1ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Nemu"]] D, [2024-02-21T19:04:50.311662 #50760] DEBUG -- : TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1 D, [2024-02-21T19:04:50.311738 #50760] DEBUG -- : TRANSACTION (0.0ms) commit transaction D, [2024-02-21T19:04:50.314488 #50760] DEBUG -- : User Count (0.0ms) SELECT COUNT(*) FROM "users" . Finished in 0.006643s, 150.5344 runs/s, 150.5344 assertions/s. 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
ネストしたトランザクションにrequires_new: true
を渡さなくても、やりたいことが実現できました。このように
- ネストしたトランザクションが呼び出される場所はわからないが、親トランザクションに合流してほしくない
- Gemなど外部のコードでトランザクションが開始するため、
requires_new
を渡せない
といった場合に、joinable: false
を渡す習慣がありました。
なぜ使ってはいけないのか
一見便利に見えるjoinable
オプションですが、使ってはいけない理由として、after_commit
コールバックがコミット前に呼ばれる問題があります。以下のようなテストを見てみましょう。
$queue = [] class User < ActiveRecord::Base after_commit -> { $queue << self.id } end class AfterCommitTest < Minitest::Test def test_after_commit User.transaction(joinable: false) do User.create(username: 'Kotori') raise ActiveRecord::Rollback end assert_equal 0, User.count assert_equal 0, $queue.size end end
トランザクションをロールバックしているため、ユーザーは作成されず、after_commit
によるエンキューも行われないことを期待しています。実行してみましょう。
# Running: D, [2024-02-21T19:35:46.057812 #51315] DEBUG -- : TRANSACTION (0.0ms) begin transaction D, [2024-02-21T19:35:46.060269 #51315] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1 D, [2024-02-21T19:35:46.060345 #51315] DEBUG -- : User Create (0.1ms) INSERT INTO "users" ("username") VALUES (?) RETURNING "id" [["username", "Kotori"]] D, [2024-02-21T19:35:46.060423 #51315] DEBUG -- : TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 D, [2024-02-21T19:35:46.060519 #51315] DEBUG -- : TRANSACTION (0.0ms) rollback transaction D, [2024-02-21T19:35:46.064906 #51315] DEBUG -- : User Count (0.0ms) SELECT COUNT(*) FROM "users" F Finished in 0.007895s, 126.6624 runs/s, 253.3249 assertions/s. 1) Failure: AfterCommitTest#test_after_commit [after_commit_test.rb:40]: Expected: 0 Actual: 1 1 runs, 2 assertions, 1 failures, 0 errors, 0 skips
コミット前にafter_commit
が呼び出されているため、ユーザーが作成されていないにも関わらず、エンキューされてしまいました。これは予想もできないトラブルを引き起こしかねません。
もしかしたら「これはバグではないか?」と思われるかもしれません。しかし、Rails開発チームはjoinable
はプライベートAPIであり、アプリケーション開発者が利用することを想定していないとの認識を示しています*3*4。
将来的にプライベートAPIで無くなる可能性は確かにあるかもしれません。しかし、少なくともv7.1.3の時点では、このような問題があり、想定していない使い方であるという前提を踏まえると、利用するのは避けたほうが良さそうです。大人しくrequires_new
を使いましょう。
厄介な点としては、joinable
オプションが公開APIでないものとして、ドキュメントから完全に消えたのはv7.1からであることが挙げられます*5。v7.0では説明こそないものの、メソッドシグネチャにjoinable
が明記されています。おそらくこれを見た一部の開発者が公開APIだと勘違いし、かつ思わぬ副作用に気付かないまま、利用が広がってしまったのでしょう。皆様のRailsアプリケーションでもjoinable: false
が指定されているコードがないか、今一度grepしてみることをおすすめします。
過ちを繰り返さないために
この記事を読んだ方は、今後joinable: false
を使うことはない(もしくは、覚悟を持って使う)と思います。とはいえ、知らないチームメイトが意図せず使ってしまうことはあるかもしれません。コードレビューで弾くのにも限界があります。どうすれば良いのでしょうか?
そんな心配をしている皆様は、ぜひRuboCopをご検討ください。筆者が現在出しているこのプルリクエストがマージされると、rubocop-railsを使っていれば誰でもこの問題を機械的に検知できます。マージされるのを待てない!という場合にはお手元のリポジトリのカスタムCopとして追加してもらえればすぐにご利用できます。
We Are Hiring!
SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!
*1:例えば【翻訳】ActiveRecordにおける、ネストしたトランザクションの落とし穴 #Rails - Qiitaなど
*2:https://api.rubyonrails.org/v7.1.3/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions
*3:https://github.com/rails/rails/issues/39912#issuecomment-665483779
*4:https://github.com/rails/rails/issues/46182#issuecomment-1265966330