概要
- 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の半月ぐらい先くらいのコード)
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のコード。
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番めの運用法が良さそうです。
- その挙動とうまくやり合うために、クリアに持続可能な運用をするアプローチを提案しました。
参考資料
- https://api.rubyonrails.org/v6.1.0/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions
- https://github.com/rails/rails/blob/main/activerecord/lib/active_record/transactions.rb
- https://techracho.bpsinc.jp/hachi8833/2018_03_16/53811
Comments