222
145

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2019-06-12

🙅‍♂️この記事の内容は実際のコードに適用しないでください!!

(2022-10-5追記)
この記事の本文でトランザクションに joinable: false というオプションを付けることが推奨されていますが、 joinable: false は内部APIなので指定してはいけない、というのがRails開発チームの見解のようです。

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

222
145
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
222
145

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?