Open7

Railsのトランザクション

philosophynotephilosophynote

ネストしたトランザクションについて

model.rb

ActiveRecord::Base.transaction do
  User.find_by(name: "Taro").destroy!
  ActiveRecord::Base.transaction do
    User.create(name: "Hanako")!
   User.create(name: "Jiro")!
  end
end

結論から言うと、User.create(name: "Jiro")!でエラーになった場合は全てロールバックされる

philosophynotephilosophynote

https://teratail.com/questions/qnxazsn4dlbbg5

実際に試してみた所、after_save { raise ActiveRecord::Rollback }
じゃなくてafter_save { raise ActiveRecord::RecordInvalid},after_save { raise }
のように、ActiveRecord::Rollback以外をraiseすればちゃんと外側のtransactionもROLLBACKするみたいですね。なので、ActiveRecord::Rollbackだけ例外って理解しておけばいいのかな

philosophynotephilosophynote

この問題を解決するために読んだ記事とその表記

https://techracho.bpsinc.jp/hachi8833/2020_11_30/101160#5

model.rb

User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

上のコードは”Kotori”と”Nemu”を両方とも作成します。その理由は、ネストしたブロック内では ActiveRecord::Rollback例外がROLLBACKを発行しないからです。これらの例外はトランザクションブロック内でキャプチャされるので、親ブロックからは例外が見えず、実際のトランザクションがコミットされます。

ネステッドトランザクションでROLLBACKされるようにするために、実際のサブトランザクションにrequires_new: trueを渡す方法が考えられます。そして何かが失敗すると、データベースはサブトランザクションの冒頭までロールバックし、親トランザクションはロールバックしません。これを上述のコード例に追加すると以下のようになります。

model.rb

User.transaction do
  User.create(username: 'Kotori')
  User.transaction(requires_new: true) do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

raise ActiveRecord::Rollbackを使用する前提で記述されている

philosophynotephilosophynote

https://qiita.com/jnchito/items/930575c18679a5dbe1a0

すべてRDBMSがネストしたトランザクションをサポートしているわけではありません。そのため、Railsはしれっとネストしたトランザクションを無視して、他のトランザクションを再利用することがあります。 しかし、ネストしたトランザクションの中で発行されたActiveRecord::Rollbackは、そのトランザクションブロックの中では捕捉されますが、外側のトランザクションでは無視されます。そして、ロールバックされることはありません!

この思いがけない振る舞いを避けるため、各トランザクションが適切にネストするよう、次のようにRailsに対して明示的に指示を出さなくてはなりません。

model.rb

ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
  # inner code
end

model.rb

class Country < ActiveRecord::Base

  after_save :do_something

  def do_something
    raise ActiveRecord::Rollback
  end

end

model.rb

my-project> Country.first.name
# => "Afghanistan"
my-project> Country.first.update!(name: 'Afghanistan will not change')
# => ROLLBACK
my-project> Country.first.name
# => "Afghanistan"

model.rb

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"

これが落とし穴です。一見、ネストしているようには見えませんが、実際はネストしているのです。update!メソッドは独自のトランザクションを開始します。このトランザクションは自前で実装したトランザクションの内部でネストしていることになります。したがって、Railsは内側のトランザクションを「なかったことに」して、自前のトランザクション処理だけを使うのです。しかし、内側のトランザクションで発生させた例外はそこで捕捉され、外側のトランザクションには通知されません。ボカーン!

joinable: falseはこのトランザクションの内部でネストしているトランザクションを無視しない(ゆえに、自前のトランザクションに合流しない)ことを意味しています。この場合は真の意味でネストしたトランザクションが利用されます。もしくは、RDBMSがネストしたトランザクションをサポートしていない場合は、セーブポイント(SAVEPOINT)を利用してネストしたトランザクションがシミュレートされます(MySQLとPostgresはこちらに該当します)。

もし、自前のトランザクションが他のトランザクション(つまり、我々が制御できないトランザクション)の内部で呼び出されていた場合は、ActiveRecord::Base.transaction(requires_new: true)を使うことで、真の(またはシミュレートされた)ネストしたトランザクションを使うように強制できます。こうすれば、親のトランザクションに合流することを防止することができます。

ActiveRecord::Rollbackを使用する前提で記述されている

philosophynotephilosophynote

https://thinkami.hatenablog.com/entry/2021/06/30/014210

model.rb

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

controller.rb

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

上記の通り、モデルの save_with_valid_transaction メソッドではトランザクションを使っています。
ただ、うっかりしていてモデルにトランザクションがあると気づかず、コントローラでもトランザクション.
を作ってしまいました。
その結果、コントローラとモデルでトランザクションがネストしてしまいました。
ここで、コントローラ側・モデル側とも transaction はデフォルトのまま、引数は何も指定しませんでした。
デフォルトではrequires_new == false,joinable == trueとなることから、モデル側のトランザクションがコントローラ側のトランザクションに合流してしまいました。
そのため、トランザクションの内側で外部APIの例外が捕捉される形となり、Fruitは入力値で登録済
Statusはエラーで更新済と、DBにStatusがエラーなのに入力値が保存されているという状態でした。
また、ログにも現在のトランザクション数 : 1が出力され、トランザクションが合流していることがわかりました。

philosophynotephilosophynote

そこで、モデル側のトランザクションに

model.rb

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と、トランザクション数が増え、別トランザクションになっていました。

このエラーはStandardErrorで表記している。

philosophynotephilosophynote

公式ドキュメント

https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions

transaction calls can be nested. By default, this makes all database statements in the nested transaction block become part of the parent transaction.

デフォルトでは、これはネストされたトランザクションブロック内のすべてのデータベース文が親トランザクションの一部になるようにします。

create!update!といった例外を発行するメソッドであれば、例外を発行して、親トランザクション内で例外をキャッチできるから問題はないように思われる。

親は実行したいが、子はロールバックしたい場合はオプションで調整すればいい

基本的に例外を発行するメソッドを使用できるので大した問題にはならないとは思うが、StandardErrorでキャッチできなかったのが気になる…