Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

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

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

jnchito
SIer、社内SEを経て、ソニックガーデンに合流したプログラマ。 「プロを目指す人のためのRuby入門」の著者。 http://gihyo.jp/book/2017/978-4-7741-9397-7 および「Everyday Rails - RSpecによるRailsテスト入門」の翻訳者。 https://leanpub.com/everydayrailsrspec-jp
https://blog.jnito.com/
sonicgarden
「お客様に無駄遣いをさせない受託開発」と「習慣を変えるソフトウェアのサービス」に取り組んでいるソフトウェア企業
http://www.sonicgarden.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away