SmartHR Tech Blog

SmartHR 開発者ブログ

ActiveRecord::Base.transaction(joinable: false)を使ってはいけない

注意喚起の記事になります。タイトルが結論です。

既にこの問題に言及している記事はいくつかあるのですが*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::Rollbackraiseしています。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が発行されていることが確認できます。

ここからが本題ですが、joinablerequires_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 を作りあげていく仲間を募集中です!

少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp