1449 文字
7 分
ActiveRecordでネストされたトランザクションのRollback方法

概要#

  • 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の半月ぐらい先くらいのコード)

https://github.com/rails/rails/blob/e9d997a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L313

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のコード。

https://github.com/rails/rails/blob/e9d997a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L313

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
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番めの運用法が良さそうです。
  • その挙動とうまくやり合うために、クリアに持続可能な運用をするアプローチを提案しました。

参考資料#

ActiveRecordでネストされたトランザクションのRollback方法
https://blog.teraren.com/posts/activerecord-transaction-nest/
作者
Yuki Matsukura
公開日
2021-02-01
ライセンス
CC BY-NC-SA 4.0

コメント