Railsで、うっかりトランザクションをネストしたところロールバックされなくなったため、requires_newとjoinableを調べてみた

Railsで、うっかりトランザクションをネストしてロールバックされなくなったので、requires_newとjoinableを調べた時のメモを残します。

なお、調査時のソースコードが長くなってしまったことから、一部のみ記載しています。

もしよろしければ、Github上のソースコードも参照してください。
https://github.com/thinkAmi-sandbox/rails_transaction_app

 
目次

 

環境

 

調べ始めた経緯

トランザクションなし

あるところに、こんな感じの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

と用意してみたところ、トランザクションロールバックができ、

となりました。

 
また、参考までに現在のトランザクション数を出力するようにしましたが、今回は 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

としたところ、別々のトランザクションとみなされ、

となりました。

ログを見ても

現在のトランザクション数 : 2

と、トランザクション数が増え、別トランザクションになっていました。

 

require_newとjoinableとActiveRecord::Rollbackの組み合わせを調査

上記の通り対応はできました。

ただ、transactionのオプション require_newjoinable それに例外を組み合わせた時、どのような挙動になるのかが気になりました。

そこでまずはロールバック用の例外 ActiveRecord::Rollback を組み合わせた内容を調査することにしました。

 

調査方法

今まで参考にした記事より、

ように見えました。

また、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

となっていました。
https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L313

 
そこで、

と、親と子に挟まれたトランザクションを調査対象ととらえ、requires_newjoinable の組み合わせごとに結果を見てみました。

 
また、上記の組み合わせに対し、例外 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_newjoinable の組み合わせ条件は、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

のようにモデル側に渡して検証しました。

 
ソースコードの全体は、以下のファイルになります。

 

調査結果

transactionに渡す条件のうち、 requires_new: true, joinable: false の場合が

となり、一番すっきりしました。

 
一方、 requires_new: true, joinable: true という、transactionに requires_new: true だけ渡し、joinable がデフォルトの場合は、

となり、子のトランザクションが合流してきた時に意図しない挙動になりました。

 
また、他のケースでは ActiveRecord::Rollback が握りつぶされてしまってロールバックできなくなる等、いずれも意図しない挙動となっていました。

 

require_newとjoinableと非ActiveRecord::Rollbackの組み合わせを調査

ActiveRecord::Rollbackは握りつぶされますが、それ以外の例外は握りつぶされません。

そのため、いずれの結果もすべてのトランザクションロールバックしていました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_transaction_app