何番煎じかわからないけどRailsのトランザクション処理について

この記事は [trocco® Advent Calendar 2023](https://qiita.com/advent-calendar/2023/trocco)

21日目の記事になります。

こんにちは。trocco開発メンバーのいとうです。最近一気に寒くなりましたね。

この記事では最近ハマっていたRailsのトランザクション処理について書こうと思います。（何番煎じだよ）

先に結論だけ書くと、よくわからず使っているという私みたいな人は公式をちゃんと読み解いて、コード読んだり
実際に手を動かして書いてみると理解できると思います。

なので、私の記事は読まなくていいので、APIリファレンス読んでない人は読んでおくと良いです。
- [ActiveRecord::Transactions::ClassMethods](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html)

# 最初にまとめ

- `ActiveRecord::Base.transaction` ブロックはネストされても1つのトランザクションとして動作する。
- ネストした内部で `ActiveRecord::Rollback` が起きても伝搬されない（=ロールバックされない
- ネストした内部で他の例外が発生した場合は大元のtransactionブロックまで到達すればロールバックされる
- ネストした内部のtransactionブロックでトランザクション処理が動かないわけでは無い。

# ネストした内部で `ActiveRecord::Rollback` が起きても伝搬されない

Railsフレームワークにおいて、transactionブロックをネストした形での記述は可能で、
その場合、全てのtransactionブロックが、親のトランザクションの一部となります。

参考のコードは公式そのままです。

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

# 例外通知こない

# User.all.count ==> 2
```

公式でも書いてあるとおり、Kotori、Nemuのユーザは作成されます。

私は最初「え、なんで？」と思ったのですがその後に記載されているとおり、
`ActiveRecord::Rollback` は一番近いtransactionブロックでキャプチャされてしまい、親には見えていないためとのことです。

# ネストした内部で他の例外が発生した場合は大元のtransactionブロックまで到達すればロールバックされる

あくまで `ActiveRecord::Rollback` が親まで伝搬されないということなので
別の例外であれば親まで伝搬され、ロールバックされます。

```ruby
User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    # raise ActiveRecord::Rollback
    raise StandardError("ロールバックしたい")
  end
end

# 例外通知来る

# User.all.count ==> 0
```

これなら直感的にわかりますね。
ネストされたブロックでの `ActiveRecord::Rollback` の扱いが特殊なんですね。

# ネストした内部のtransactionブロックが動かないわけでは無い

私が業務でハマったのはこれで、見ている箇所ではtransactionブロックにしているのに
ロールバック処理されていないことがわかり調査を始めました。

RSpecのメソッドの単体テストではロールバックできているのにな...と。

この原因はtransactionブロックでネストされた `ActiveRecord::Rollback` の動作と同じで、ネストしたtransactionブロックの中で例外を握り潰していたことで、親のブロックまで到達せずロールバックされていないことがわかりました。

```ruby
User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    # ...
  rescue StandardError => e
    logger.error("うまく処理できなかった")
  end
end

# User.all.count ==> 2
```

こんだけスッキリ読めれば「それはそうだよね」でしかないのですが
実務のコードはまぁそんなことはなくて、
今読んでいるところが大元のtransactionブロックかと思いきや、
さらに上位の呼び出し元でtransactionブロックを使っていたことがわかり、特定に時間がかかったということです。

あえて擬似コードに落とし込むとしたらこんな感じですかね。
（これでもまぁシンプルではありますが）

```ruby
# user_creation.rb

class UserCreation

  def run()
    User.transaction do
      User.create(username: 'Nemu')
      Item.transaction do
        Item.create('pencil case')
        Item.create('pencil') # --> error
      end
    end
  end
end
```

```ruby
# root_executor.rb

ActiveRecord::Base.transaction do  # おっとここから transaction ブロックはじまってるじゃん。
  UserCreation.new.run()
  rescue StandardError => e
    logger.error("作成に失敗")
  end
end
```

user_creation.rbまでしかみてなかったので、なんで単体テストではロールバックもできるのに実際にウェブサービスとして動かしてみるとデータが残ってしまうのだ？という状態でした。

# さいごに

最後まで読んでいただきありがとうございました。
troccoはユーザの利用する画面をRuby On Rails、
今回は書けませんでしたが、バックエンドで embulk(Java実装のOSS)のプラグインはもちろん
最近だとGoやC++のコードがあることに気づいてとてもわくわくしています。
Rustも業務で使ってみたいですね。

troccoの開発に興味ある人はぜひ連絡ください。

# 参考

- [ActiveRecord::Transactions::ClassMethods](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html)