ActiveRecordでネストされたトランザクションのRollback方法

Ruby on Rails

概要

  • ActiveRecordでトランザクションを入れ子にするケースがあり、ActiveRecordのトランザクションの処理を追ってみるとなかなか興味深かったので文章にまとめておきます。
  • おそらく、この内容を知っておかないと排他制御を考慮したクラス設計、実装ができないと思う。

問題

以下のようなコードがあったときに、1つ目のセンテンスである’Kotori’と’Nemu’が作られます。(一見、一切コミットされないように思えるかもしれませんが)

User.transaction do
  User.create(username: 'Kotori')
  User.transaction(requires_new: true) do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end
  • こんなコードは普通は書きませんが、クラスが別れていたり、実装するレイヤーが分かれている場合に不用意に発行されてしまう可能性があります。
    • 例えば、振替というビジネスロジックに対して残高の加算や減算の処理。

原因

理由

ActiveRecordのtransactionはActiveRecord::Rollbackをraiseしないために起きます。

該当のコードはこちら。(現時点のmainブランチの最新です。v6.1.1の半月ぐらい先くらいのコード)

rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb at e9d997a33cf43a03b60677cadb2cf396da47dcdb · rails/rails
Ruby on Rails. Contribute to rails/rails development by creating an account on GitHub.
      def transaction(requires_new: nil, isolation: nil, joinable: true)
        if !requires_new && current_transaction.joinable?
          if isolation
            raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
          end
          yield
        else
          transaction_manager.within_new_transaction(isolation: isolation, joinable: joinable) { yield }
        end
      rescue ActiveRecord::Rollback
        # rollbacks are silently swallowed
      end

1回目のtransaction呼び出しは、transaction_manager.within_new_transactionが実行されますが、2回めのtransaction呼び出しは普通にyieldされるだけになります。(transaction開始時にオプションを指定していない場合)

よって、beginが発行されません。また、そのyieldで発行されたRollbackは、rescue ActiveRecord::Rollbackによって捕捉されて中身は何もしていません。

参考までに、within_new_transactionのコード。

rails/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb at e9d997a33cf43a03b60677cadb2cf396da47dcdb · rails/rails
Ruby on Rails. Contribute to rails/rails development by creating an account on GitHub.
      def within_new_transaction(isolation: nil, joinable: true)
        @connection.lock.synchronize do
          transaction = begin_transaction(isolation: isolation, joinable: joinable)
          ret = yield
          completed = true
          ret
        rescue Exception => error
          if transaction
            rollback_transaction
            after_failure_actions(transaction, error)
          end
          raise
        ensure
          if !error && transaction
            if Thread.current.status == "aborting"
              rollback_transaction
            else
              if !completed && transaction.written
                ActiveSupport::Deprecation.warn(<<~EOW)
                  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 rollback.
                EOW
              end
              begin
                commit_transaction
              rescue Exception
                rollback_transaction(transaction) unless transaction.state.completed?
                raise
              end
            end
          end
        end
      end

ネストされたブロックだけロールバックしたい場合

まず、回答としてはできます。

前提として殆どのデータベースはネストされたトランザクションをサポートしていません。MS-SQLだけのようです。

MySQLなどのRDBではsavepointを使ってエミュレートしてエミュレートしています。よって、細かい例外条件や細かい注意事項が出てきます。

やり方は、savepointを使うためにネストされたtransaction呼び出し時に、requires_new: trueを追加します。これによって、savepointが発行されます。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction(requires_new: true) do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

上記のコードを実行すると、Kotoriだけが保存されます。

アプローチ

1. requires_new: trueを全部に付ける

では、全部requires_new: trueをつければ、なんとなくプログラマーの意図通りに動いてくれるだろうからそれで良いのかもしれません。これはこれで、1つの解ではあるかもしれません。しかしながら、RDBMSごとに実装が異なったり、savepointを使って、部分的にrollbackされたときのロック範囲について考えながらコードレビューするのは大変かと思います。静的チェックもできないですし。

2. DB transactionを1つにまとめる

transactionブロックが分散してしまうのであれば、transactionを1箇所で管理するとtransactionのnestを発行させないようにできます。

前提として、ビジネスロジックレイヤーを整理しておく必要があります。なぜなら、どのレイヤー(ビジネスロジック、ユーティリティクラス、ORMなど)でDBのtransactionを使うかを明確にする必要があり、けっこう大変かと思います。

class TransactionScript
  def self.process(&block)
    ActiveRecord::Base.transaction do
      yield
    mary.deposit(100)
  end

TransactionScript.process do
  subtract
  add
end

この方法では、静的チェックも可能です。デメリットとしては、transactionを使うときには呼び出しコードをブロックで実行しなければいけないので、インデントが深くなるのとコード量が増えます。

3. raise ActiveRecord::Rollbackしない

一見、このルールを作ったらコーディングの自由度が減るから嫌うかと思いますが、現場ではraise ActiveRecord::Rollbackを書く必要は無いと思います。

サンプルコードで、ネストされたブロックでraise ActiveRecord::Rollbackをしていますが、サンプルでしかありえない書き方かと思います。本来ならば独自の例外をraiseするはずなので全体が失敗するようなコードになります。

例えば以下のようになります。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    raise MyOriginalError
  end
end

これならば全体で失敗しますし、外側のブロックから見たら全体で失敗すべきなので問題ないです。

そもそも、raise ActiveRecord::Rollbackを発行する際というのは、そのtransactionブロックの中で何かしら問題が起きて、その問題を上位に伝搬せずに対処するという時にしか使わないはずです。そのようなケースが思いつかないです。

このアプローチならば、コードの静的チェックも可能になり、運用しやすいです。

まとめ

  • ActiveRecordのtransactionを使ったときのネストの挙動を追ってみました。
  • 3番めの運用法が良さそうです。
  • その挙動とうまくやり合うために、クリアに持続可能な運用をするアプローチを提案しました。

参考資料

Comments

タイトルとURLをコピーしました