Edited at

【翻訳】ActiveRecordにおける、ネストしたトランザクションの落とし穴


はじめに

この記事は以下の海外記事を原著者の許可を得た上で翻訳したものです。

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: falserequires_new: trueを毎回付けておくことをデフォルトの書き方にしておくのが良いでしょう。