Railsのトランザクション

ネストしたトランザクションについて
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")!
でエラーになった場合は全てロールバックされる

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

この問題を解決するために読んだ記事とその表記
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
を渡す方法が考えられます。そして何かが失敗すると、データベースはサブトランザクションの冒頭までロールバックし、親トランザクションはロールバックしません。これを上述のコード例に追加すると以下のようになります。
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
を使用する前提で記述されている

すべてRDBMSがネストしたトランザクションをサポートしているわけではありません。そのため、Railsはしれっとネストしたトランザクションを無視して、他のトランザクションを再利用することがあります。 しかし、ネストしたトランザクションの中で発行されたActiveRecord::Rollbackは、そのトランザクションブロックの中では捕捉されますが、外側のトランザクションでは無視されます。そして、ロールバックされることはありません!
この思いがけない振る舞いを避けるため、各トランザクションが適切にネストするよう、次のようにRailsに対して明示的に指示を出さなくてはなりません。
ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
# inner code
end
class Country < ActiveRecord::Base
after_save :do_something
def do_something
raise ActiveRecord::Rollback
end
end
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"
これが落とし穴です。一見、ネストしているようには見えませんが、実際はネストしているのです。update!メソッドは独自のトランザクションを開始します。このトランザクションは自前で実装したトランザクションの内部でネストしていることになります。したがって、Railsは内側のトランザクションを「なかったことに」して、自前のトランザクション処理だけを使うのです。しかし、内側のトランザクションで発生させた例外はそこで捕捉され、外側のトランザクションには通知されません。ボカーン!
joinable: falseはこのトランザクションの内部でネストしているトランザクションを無視しない(ゆえに、自前のトランザクションに合流しない)ことを意味しています。この場合は真の意味でネストしたトランザクションが利用されます。もしくは、RDBMSがネストしたトランザクションをサポートしていない場合は、セーブポイント(SAVEPOINT)を利用してネストしたトランザクションがシミュレートされます(MySQLとPostgresはこちらに該当します)。
もし、自前のトランザクションが他のトランザクション(つまり、我々が制御できないトランザクション)の内部で呼び出されていた場合は、ActiveRecord::Base.transaction(requires_new: true)を使うことで、真の(またはシミュレートされた)ネストしたトランザクションを使うように強制できます。こうすれば、親のトランザクションに合流することを防止することができます。
ActiveRecord::Rollback
を使用する前提で記述されている

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
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が出力され、トランザクションが合流していることがわかりました。

そこで、モデル側のトランザクションに
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
で表記している。

公式ドキュメント
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
でキャッチできなかったのが気になる…