はじめに
題名の「深掘る」は「ネスト」と掛けています。実際の題名は「Railsのネストしたtransactionにおける requires_new の挙動を検証する」とかの方が近いです。
さて、Ruby on RailsのORマッパーであるActive Recordには、データベースのトランザクションを制御する transaction メソッドがあります。
# 各種RDBMSでtransactionがBEGIN ~ COMMITされる ActiveRecord::Base.transaction do user = User.create!(user_params) item = ActiveRecord::Base.transaction do Item.create!(item_params) end end
この transaction メソッドをネストさせた際、どのような例外でどのようにロールバックされるのか。よくわかっていなかったので、実際にRailsを動かして検証してみました。
前提: requires_new オプションについて
Railsでネストしたトランザクションを発行する際、重要になるのが requires_new: true オプションです。これが指定されているかどうかで、Active Recordが「既存のトランザクションに合流する」か「SAVEPOINTを作成するか」が切り替わります。
# requires_new: true を渡す例(デフォルトは false) ActiveRecord::Base.transaction do user = User.create!(user_params) item = ActiveRecord::Base.transaction(requires_new: true) do Item.create!(item_params) end end
検証環境
検証に使用したバージョンは以下の通りです。
1. 正常にコミットされる場合
まずは、例外が発生しない基本パターンのログを確認します。
requires_new: false (デフォルト)
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... Item Create (0.0ms) INSERT INTO "items" ... TRANSACTION (2.3ms) COMMIT TRANSACTION
この場合、ネストしたトランザクションは親のトランザクションに合流します。発行されるSQLを見ても、BEGIN と COMMIT は1回ずつであり、単一のトランザクションとして処理されています。
requires_new: true
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... TRANSACTION (0.0ms) SAVEPOINT active_record_1 Item Create (0.1ms) INSERT INTO "items" ... TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 TRANSACTION (0.1ms) COMMIT TRANSACTION
こちらは SAVEPOINT が発行されています。RDBレベルで「ここまでの状態を保存しておく」という目印を打っている状態です。最終的にエラーがなければ RELEASE SAVEPOINT され、親と一緒にコミットされます。
2. ActiveRecord::Rollback が発生した場合
ここからが本題です。Rails特有の例外である ActiveRecord::Rollback を投げた際の挙動を見ていきます。
requires_new: false (デフォルト)
注意:このパターンではロールバックされません。
ActiveRecord::Base.transaction do user = User.create!(user_params) ActiveRecord::Base.transaction do Item.create!(item_params) raise ActiveRecord::Rollback # 子でロールバックを試みる end end
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... Item Create (0.0ms) INSERT INTO "items" ... TRANSACTION (1.7ms) COMMIT TRANSACTION
全ての変更がコミットされます。 理由は、ネストしたブロックが親トランザクションに吸収されているためです。ActiveRecord::Rollback はそのブロック内では捕捉されますが、親まで例外が伝播しないため、親は何事もなかったかのようにコミットを完了させます。
requires_new: true
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... TRANSACTION (0.0ms) SAVEPOINT active_record_1 Item Create (0.0ms) INSERT INTO "items" ... TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1 TRANSACTION (1.3ms) COMMIT TRANSACTION
SAVEPOINT までロールバック(ROLLBACK TO SAVEPOINT)されました。結果として、Userのみ保存され、Itemは保存されません。 子トランザクションの結果だけを破棄したい場合に有効な手段です。
3. StandardError(一般的な例外)が発生した場合
ActiveRecord::Rollback 以外の例外が発生した場合は、挙動が異なります。
requires_new: false (デフォルト)
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... Item Create (0.0ms) INSERT INTO "items" ... TRANSACTION (0.0ms) ROLLBACK TRANSACTION
例外が捕捉されずに親まで伝播するため、トランザクション全体がロールバックされます。
requires_new: true
TRANSACTION (0.0ms) BEGIN immediate TRANSACTION User Create (0.2ms) INSERT INTO "users" ... TRANSACTION (0.0ms) SAVEPOINT active_record_1 Item Create (0.0ms) INSERT INTO "items" ... TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1 TRANSACTION (0.0ms) ROLLBACK TRANSACTION
まず SAVEPOINT まで巻き戻り、その後例外が親に伝わることで親もロールバックします。結果として、全ての変更が破棄されます。
まとめ
検証結果を比較表にまとめました。
| 発生した例外 | requires_new: false (デフォルト) |
requires_new: true |
|---|---|---|
ActiveRecord::Rollback |
全てコミットされる (要注意) | 子のみロールバック、親はコミット |
StandardError |
全てロールバック | 全てロールバック |
requires_new: false の状態で「子だけをロールバックさせる」ことはできません。また、ActiveRecord::Rollback は「親に例外を伝播させずに、そのブロックだけをなかったことにする」という特殊な挙動をします。
トランザクションを中断したい場合は、安易に ActiveRecord::Rollback を使うのではなく、適切なカスタム例外を定義して、呼び出し元で制御する方が意図しないコミットを防げるため安全だと感じました。