🙅♂️この記事の内容は実際のコードに適用しないでください!!
(2022-10-5追記)
この記事の本文でトランザクションに joinable: false
というオプションを付けることが推奨されていますが、 joinable: false
は内部APIなので指定してはいけない、というのがRails開発チームの見解のようです。
- https://github.com/rails/rails/issues/39912#issuecomment-665483779
- https://github.com/rails/rails/issues/46182#issuecomment-1266550987
joinable: false
を付けるとコミット実行前にafter_create_commit
コールバックが呼ばれるなど(参考)、思いがけない別の問題を引き起こすことがあります。
というわけで、この記事の内容は参考情報程度に留めておき、実際のコードに適用するのは避けた方が良いと思います。
はじめに
この記事は以下の海外記事を原著者の許可を得た上で翻訳したものです。
Nested ActiveRecord transaction pitfalls - makandra dev
この記事では、Railsの(厳密にはActiveRecordの)ネストしたトランザクションのややこしい考え方をわかりやすく説明してくれているのと同時に、よくある落とし穴と、その落とし穴の対処方法も説明してくれています。
それでは以下が翻訳です。
ActiveRecordにおける、ネストしたトランザクションの落とし穴
自前のトランザクション処理を書いていて、ロールバックを実行しようとした場合、あなたは思いがけない振る舞いに遭遇するかもしれません。
tl;dr(最初に結論)
すべてRDBMSがネストしたトランザクションをサポートしているわけではありません。そのため、Railsはしれっとネストしたトランザクションを無視して、他のトランザクションを再利用することがあります。 しかし、ネストしたトランザクションの中で発行されたActiveRecord::Rollback
は、そのトランザクションブロックの中では捕捉されますが、外側のトランザクションでは無視されます。そして、ロールバックされることはありません!
この思いがけない振る舞いを避けるため、各トランザクションが適切にネストするよう、次のようにRailsに対して明示的に指示を出さなくてはなりません。
ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
# inner code
end
自前のトランザクション処理を書く場合は、この書き方をデフォルトにする方が安全です。
詳細
たとえば、以下のようなシンプルなモデルを例にして考えてみましょう。このモデルはafter_save
コールバックでトランザクションをロールバックしようとします。(現実のアプリケーションではもう少し意味のある実装になるでしょうし、ロールバックも特定の条件に限定されるはずです)
class Country < ActiveRecord::Base
after_save :do_something
def do_something
raise ActiveRecord::Rollback
end
end
こんなコールバックが実装されていると、save
メソッドを呼んでも絶対にデータは保存されないはずですよね?
オーケー、実際に試してみましょう。
my-project> Country.first.name
# => "Afghanistan"
my-project> Country.first.update!(name: 'Afghanistan will not change')
# => ROLLBACK
my-project> Country.first.name
# => "Afghanistan"
はい、これは予想通りです。では、別のトランザクションの内部でレコードを更新する場合はどうでしょうか?
my-project> Country.first.name
# => "Afghanistan"
my-project> ActiveRecord::Base.transaction { Country.first.update!(name: 'Afghanistan will not change') }
# => COMMIT
my-project> Country.first.name
# => "Afghanistan will not change"
あれ?ちょっと待ってください。after_save
コールバックで実行したロールバックに何が起きたんでしょうか?
Railsのドキュメントには、こう書いてあります。
トランザクションはネストして呼び出すことができます。デフォルトでは、ネストしたトランザクションブロックの内部で発行されたSQLは、親のトランザクションの一部になります。
ネストしたトランザクションの内部でActiveRecord::Rollback
例外が発生しても、 ロールバックされません。 例外はトランザクションブロックの内部で捕捉されるため、親のブロックはその例外を検知できず、トランザクション全体としてはコミットされます。
これが落とし穴です。一見、ネストしているようには見えませんが、実際はネストしているのです。update!
メソッドは独自のトランザクションを開始します。このトランザクションは自前で実装したトランザクションの内部でネストしていることになります。したがって、Railsは内側のトランザクションを「なかったことに」して、自前のトランザクション処理だけを使うのです。しかし、内側のトランザクションで発生させた例外はそこで捕捉され、外側のトランザクションには通知されません。ボカーン!☹️
この不運な振る舞いを避けるには、Railsに対してトランザクションを再利用しないよう、明示的に伝える必要があります。
my-project> Country.first.name
# => "Afghanistan will not change"
my-project> ActiveRecord::Base.transaction(joinable: false) { Country.first.update!(name: 'Afghanistan') }
# => ROLLBACK
my-project> Country.first.name
# => "Afghanistan will not change"
joinable: false
はこのトランザクションの内部でネストしているトランザクションを無視しない(ゆえに、自前のトランザクションに合流しない)ことを意味しています。この場合は真の意味でネストしたトランザクションが利用されます。もしくは、RDBMSがネストしたトランザクションをサポートしていない場合は、セーブポイント(SAVEPOINT)を利用してネストしたトランザクションがシミュレートされます(MySQLとPostgresはこちらに該当します)。
もし、自前のトランザクションが他のトランザクション(つまり、我々が制御できないトランザクション)の内部で呼び出されていた場合は、ActiveRecord::Base.transaction(requires_new: true)
を使うことで、真の(またはシミュレートされた)ネストしたトランザクションを使うように強制できます。こうすれば、親のトランザクションに合流することを防止することができます。
自前のトランザクションを書くときは、joinable: false
とrequires_new: true
を毎回付けておくことをデフォルトの書き方にしておくのが良いでしょう。