Railsで、うっかりトランザクションをネストしてロールバックされなくなったので、requires_newとjoinableを調べた時のメモを残します。
なお、調査時のソースコードが長くなってしまったことから、一部のみ記載しています。
もしよろしければ、Github上のソースコードも参照してください。
https://github.com/thinkAmi-sandbox/rails_transaction_app
目次
- 環境
- 調べ始めた経緯
- require_newとjoinableとActiveRecord::Rollbackの組み合わせを調査
- require_newとjoinableと非ActiveRecord::Rollbackの組み合わせを調査
- ソースコード
環境
- Rails 6.1.4
調べ始めた経緯
トランザクションなし
あるところに、こんな感じのViewがありました。
erbはこんな感じです。
<h1>登録フォーム</h1> <%= form_with(url: @post_path, method: :post, local: true) do |f| %> <% if flash[:message].present? %> <div><%= flash[:message] %></div> <% end %> <div class="field"> <%= f.label :name, '名称' %> <%= f.text_field :name, autofocus: true %> </div> <div class="actions"> <%= f.submit '送信' %> </div> <% end %>
当初、Viewに対応するコントローラでは、モデルの保存のみが実装されていました。
class HomeController < ApplicationController def new @post_path = home_create_path end def create fruit = Fruit.new(create_params) fruit.save! flash[:message] = '保存に成功しました' redirect_to home_new_path end # ... private def create_params params.permit('name') end end
[誤] トランザクションを作成したが、トランザクションの中で例外を捕捉していた
ある時、別のモデルや外部APIとトランザクションで更新することになりました。
Statusモデルと外部APIが増え、StatusとFruitをトランザクションで保存する必要が出てきました。
そこで、モデルに対しStatusとFruitをトランザクションで保存するクラスメソッド save_with_invalid_transaction
を作成しました。
また、トランザクションの挙動を確認するため、外部APIは常に失敗するようにメソッド save_by_api
を用意しました。
class Fruit < ApplicationRecord def self.save_with_invalid_transaction(params, type_name) ActiveRecord::Base.transaction do status = Status.find_by_key('fruit') status.name = 'success' status.save! fruit = Fruit.new(params) fruit.save! save_by_api true rescue StandardError status = Status.find_by_key('fruit') status.name = "error - #{type_name}" status.save! false end end private def self.save_by_api # 外部APIで保存エラーになるとする raise StandardError end end
動作確認をしたところ、トランザクションの中で例外を捕捉していたため、トランザクションをロールバックできず
- Fruitは入力値で登録済
- Statusはエラーで更新済
となりました。
トランザクション中にrescueするとロールバックしないので注意! - Qiita
トランザクションの外で例外を捕捉
上記のコードから例外を捕捉する場所をトランザクションの外に変えたメソッド save_with_valid_transaction
を
class Fruit < ApplicationRecord # ... def self.save_with_valid_transaction(params, type_name) ActiveRecord::Base.transaction do puts "現在のトランザクション数 : #{ActiveRecord::Base.connection.open_transactions}" status = Status.find_by_key('fruit') status.name = 'success' status.save! fruit = Fruit.new(params) fruit.save! save_by_api true end rescue StandardError status = Status.find_by_key('fruit') status.name = "error - #{type_name}" status.save! false end #... end
と用意してみたところ、トランザクションのロールバックができ、
- Fruitはロールバックされて何も登録されない
- Statusはエラーで登録
となりました。
また、参考までに現在のトランザクション数を出力するようにしましたが、今回は 1
でした。
mysql - How to tell if already within a database transaction in ruby on rails? - Stack Overflow
[誤] うっかりコントローラとモデルの両方でデフォルトのトランザクションを実装
上記の通り、モデルの save_with_valid_transaction
メソッドではトランザクションを使っています。
ただ、うっかりしていてモデルにトランザクションがあると気づかず、コントローラでもトランザクションを作ってしまいました。
その結果、コントローラとモデルでトランザクションがネストしてしまいました。
class HomeController < ApplicationController def new_with_nest_transaction @post_path = home_create_with_nest_transaction_path render 'home/new' end def create_with_nest_transaction is_success = false ActiveRecord::Base.transaction do is_success = Fruit.save_with_valid_transaction(create_params, 'nest') end if is_success flash[:message] = '保存に成功しました' else flash[:message] = '保存に失敗しました' end redirect_to home_new_with_nest_transaction_path end # ... end
ここで、コントローラ側・モデル側とも transaction はデフォルトのまま、引数は何も指定しませんでした。
デフォルトでは
requires_new == false
joinable == true
となることから、モデル側のトランザクションがコントローラ側のトランザクションに合流してしまいました。
そのため、トランザクションの内側で外部APIの例外が捕捉される形となり、
- Fruitは入力値で登録済
- Statusはエラーで更新済
と、DBにStatusがエラーなのに入力値が保存されているという状態でした。
また、ログにも
現在のトランザクション数 : 1
が出力され、トランザクションが合流していることがわかりました。
モデル側のトランザクションでrequires_newとjoinableを指定
うっかりネストして親のトランザクションに合流しないようにする方法を調べたところ、以下の記事がありました。
【翻訳】ActiveRecordにおける、ネストしたトランザクションの落とし穴 - Qiita
そこで、モデル側のトランザクションに
class HomeController < ApplicationController # ... def self.save_with_new_transaction(params, type_name) ActiveRecord::Base.transaction(requires_new: true, joinable: false) do # 引数追加 puts "現在のトランザクション数 : #{ActiveRecord::Base.connection.open_transactions}" status = Status.find_by_key('fruit') status.name = 'success' status.save! fruit = Fruit.new(params) fruit.save! save_by_api true end rescue StandardError status = Status.find_by_key('fruit') status.name = "error - #{type_name}" status.save! false end
としたところ、別々のトランザクションとみなされ、
- Fruitはロールバックされ、登録なし
- Statusエラーでコミット
となりました。
ログを見ても
現在のトランザクション数 : 2
と、トランザクション数が増え、別トランザクションになっていました。
require_newとjoinableとActiveRecord::Rollbackの組み合わせを調査
上記の通り対応はできました。
ただ、transactionのオプション require_new
とjoinable
それに例外を組み合わせた時、どのような挙動になるのかが気になりました。
そこでまずはロールバック用の例外 ActiveRecord::Rollback
を組み合わせた内容を調査することにしました。
調査方法
今まで参考にした記事より、
- require_new
- joinable
ように見えました。
また、ActiveRecordの実装を見ると
def transaction(requires_new: nil, isolation: nil, joinable: true) if !requires_new && current_transaction.joinable? if isolation raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction" end yield else transaction_manager.within_new_transaction(isolation: isolation, joinable: joinable) { yield } end rescue ActiveRecord::Rollback # rollbacks are silently swallowed end
そこで、
- 親トランザクション:
requires_new
とjoinable
はデフォルトrequires_new == false
・joinable == true
- 調査対象のトランザクション:
requires_new
とjoinable
を色々変えてみる - 子トランザクション:
requires_new
とjoinable
はデフォルト
と、親と子に挟まれたトランザクションを調査対象ととらえ、requires_new
と joinable
の組み合わせごとに結果を見てみました。
また、上記の組み合わせに対し、例外 ActiveRecord::Rollback
を
のいずれかで発生させてみて、各挙動を確認してみました。
今回、トランザクションおよび ActiveRecord::Rollback
を起こす処理はモデルに
class Fruit < ApplicationRecord # ... def self.rollback_by_parent(requires_new:, joinable:) puts "==== #{__method__}, requires_new #{requires_new}, joinable: #{joinable} ====>" puts "親のトランザクションの外 : #{ActiveRecord::Base.connection.open_transactions}" # すべて実行した後に、親でロールバック発生 ActiveRecord::Base.transaction do puts "親のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}" Fruit.create!(name: '親', description: "[親でロールバック] 親: requires_new=#{requires_new} joinable=#{joinable}") ActiveRecord::Base.transaction(requires_new: requires_new, joinable: joinable) do puts "自身のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}" Fruit.create!(name: '自身', description: "[親でロールバック] 自身: requires_new=#{requires_new} joinable=#{joinable}") ActiveRecord::Base.transaction do puts "子のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}" Fruit.create!(name: '子', description: "[親でロールバック] 子: requires_new=#{requires_new} joinable=#{joinable}") end end raise ActiveRecord::Rollback end end # ...
のような形で実装しました。
また、モデルに渡す requires_new
と joinable
の組み合わせ条件は、RSpecを使って
RSpec.describe Fruit, type: :model do describe '#rollback_by_xxx系 のトランザクションの設定とロールバック状況' do context 'requires_new: true, joinable: false' do context '親でロールバック' do it 'すべてロールバック' do Fruit.rollback_by_parent(requires_new: true, joinable: false) expect(Fruit.all.count).to eq 0 end end context '自身でロールバック' do it '親・自身・子はそれぞれ別トランザクションのため、自身とそのネストしたトランザクションの子はロールバックするが、親は残る' do Fruit.rollback_by_self(requires_new: true, joinable: false) expect(Fruit.all.count).to eq 1 expect(Fruit.find_by(name: '親')).to be_truthy end end context '子でロールバック' do it '親・自身・子はそれぞれ別トランザクションのため、子はロールバックするが、親・自身は残る' do Fruit.rollback_by_child(requires_new: true, joinable: false) expect(Fruit.all.count).to eq 2 expect(Fruit.find_by(name: '親')).to be_truthy expect(Fruit.find_by(name: '自身')).to be_truthy end end end
のようにモデル側に渡して検証しました。
ソースコードの全体は、以下のファイルになります。
- モデル側
- RSpec側
調査結果
transactionに渡す条件のうち、 requires_new: true, joinable: false
の場合が
- 親で ActiveRecord::Rollback
- 親・自身・子がすべてロールバック
- 自身で ActiveRecord::Rollback
- 子で ActiveRecord::Rollback
となり、一番すっきりしました。
一方、 requires_new: true, joinable: true
という、transactionに requires_new: true
だけ渡し、joinable
がデフォルトの場合は、
- 子で ActiveRecord::Rollback
となり、子のトランザクションが合流してきた時に意図しない挙動になりました。
また、他のケースでは ActiveRecord::Rollback
が握りつぶされてしまってロールバックできなくなる等、いずれも意図しない挙動となっていました。
require_newとjoinableと非ActiveRecord::Rollbackの組み合わせを調査
ActiveRecord::Rollback
は握りつぶされますが、それ以外の例外は握りつぶされません。
そのため、いずれの結果もすべてのトランザクションがロールバックしていました。
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_transaction_app