Railsの`transaction`メソッドの使い方と内部挙動を解説（ネストトランザクション）

# Railsの`transaction`メソッドの使い方と内部挙動を解説（ネストトランザクション）

本記事では、ActiveRecordの`transaction`メソッドの引数や内部動作について詳しく解説します。（断片的に調べた内容をまとめたものです。ミスがあるかもしれません、、、）
序盤で動作のロジック的な部分を解説し、終盤で実際のコードを用いてRspecで検証を行います。
そのため、先にコードを見たい方は[rspecで検証](#rspecで検証)までスキップしてください。

## まとめ

1. **既存トランザクションへの「参加」:** デフォルト動作では外部トランザクションに参加する。
2. **新しいトランザクションの作成:** 必要に応じて`requires_new: true`を指定する。
3. **`ActiveRecord::Rollback`の扱い:** 外部トランザクションには影響しない。

## 以下の場合にネストトランザクションを使う際は注意が必要

`require_new: true`を指定するかの考慮が必要

- `rescue`を使用する場合
- `ActiveRecord::Rollback`を使用する場合

## メソッドの定義

以下が`transaction`メソッドの定義です。

```ruby
def transaction(requires_new: nil, isolation: nil, joinable: true, &block)
```
https://github.com/rails/rails/blob/31aec233a17a44cb4666aa074243f0961798ed46/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L351-L352

**主な引数の意味:**

- **`requires_new`**: 新しいトランザクションを強制的に作成するかどうか（デフォルト: `nil`）。
- **`isolation`**: トランザクションの分離レベルを指定（デフォルト: `nil`）。指定可能なレベルは以下の通り。
  - `:read_uncommitted`
  - `:read_committed`
  - `:repeatable_read`
  - `:serializable`
- **`joinable`**: 外部トランザクションに「参加」できるかどうかを指定（デフォルト: `true`）。ただし、この引数を明示的に指定するのは非推奨です。

**この記事では主に`requires_new`に焦点を当てて解説します。**

## デフォルトの動作

`ActiveRecord::Base.transaction`を使用すると、デフォルトで外部トランザクションに「参加」する形で処理が実行されます。

つまり下記のコードでは、BトランザクションはAトランザクションに「参加」するため、1つのトランザクションとして扱われます。

1つのトランザクションとして扱われるため、ロールバックを行うかの判断は外部トランザクションに委ねられます。（外部トランザクションがロールバックされると、内部トランザクションもロールバックされる）

```ruby
ActiveRecord::Base.transaction do # Aトランザクション
  ActiveRecord::Base.transaction do # Bトランザクション
    # BはAに「参加」する形で実行される
  end
end
```

この「参加」する仕組みは、以下のコードで管理されています。

```ruby
if !requires_new && current_transaction.joinable?
  if isolation
    raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
  end
  yield current_transaction.user_transaction
```
https://github.com/rails/rails/blob/31aec233a17a44cb4666aa074243f0961798ed46/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L353-L357

`current_transaction.joinable?`は基本的には`true`を返すため、`requires_new`が指定されていない場合は外部トランザクションに「参加」する形で処理が実行されます。
今回でいうとAトランザクションが`current_transaction (current_transaction.user_transaction)`で、Bトランザクションが`yield`の部分です

`yield current_transaction.user_transaction` は `yeild`

### Tips: 一番外側のトランザクションについて

ちなみに最初に実行されるトランザクションの親のトランザクションは`NULL_TRANSACTION`という形で表現されています

```ruby
ActiveRecord::Base.transaction do # <-- 親のトランザクションはNULL_TRANSACTION
end
```

```ruby
def current_transaction
  @stack.last || NULL_TRANSACTION # <-- @stackにネストトランザクションが積まれる
end
```
https://github.com/rails/rails/blob/31aec233a17a44cb4666aa074243f0961798ed46/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L661-L663

```ruby
def joinable?; false; end
```
https://github.com/rails/rails/blob/31aec233a17a44cb4666aa074243f0961798ed46/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L114

`NULL_TRANSACTION`は`joinable?`が`false`なので、`NULL_TRANSACTION`に「参加」することはできません。

## `requires_new: true`を指定した場合

`requires_new: true`を指定すると、必ず新しいトランザクションが作成されます。
この動作は、内部的に`within_new_transaction`メソッドを呼び出すことで実現されています。

```ruby
within_new_transaction(isolation: isolation, joinable: joinable, &block)
```

以下のコードのように新しいトランザクションを強制的に作成することで、外部トランザクションの影響を受けない動作が可能です。

```ruby
ActiveRecord::Base.transaction do # 外部トランザクションA
  ActiveRecord::Base.transaction(requires_new: true) do # 新しいトランザクションB
    # BはAに影響されない独立したトランザクション
  end
end
```

## `ActiveRecord::Rollback`の扱い

`ActiveRecord::Rollback`はトランザクション内でのロールバックを明示するために使用されますが、外部トランザクションでは検知されません。
ちなみに以下の例だと`require_new: true`を指定していない（つまり外部トランザクションに「参加」している）ため、内部トランザクションでRollbackが検知されてもRollbackされません。

```ruby
ActiveRecord::Base.transaction do # <-- ActiveRecord::Rollbackは検知されない
  ActiveRecord::Base.transaction do # <-- ActiveRecord::Rollbackは検知される
    raise ActiveRecord::Rollback
  end
end
```

## rspecで検証

```ruby:transaction_behavior_test.rb
#!/usr/bin/env ruby

require 'bundler/setup'
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'activerecord', require: 'active_record'
  gem 'mysql2'
  gem 'rspec', require: %w[rspec/autorun]
end

# MySQL 設定
config = {
  adapter: 'mysql2',
  database: 'transaction_test',
  host: '127.0.0.1',
  username: 'root',
  password: ''
}

# データベース接続と初期化
ActiveRecord::Base.establish_connection(config.merge(database: nil))
ActiveRecord::Base.connection.create_database(config[:database]) rescue nil
ActiveRecord::Base.establish_connection(config)
ActiveRecord::Base.connection.create_table(:users, force: true) { |t| t.string :name, null: false }

# RSpec 設定
RSpec.configure { |config| config.default_formatter = 'documentation' }

# モデル定義
class User < ActiveRecord::Base; end

# RSpec テスト
RSpec.describe 'ネストトランザクションにおける挙動' do
  before { User.delete_all }

  describe 'トランザクションのロールバックの挙動' do
    context '外部トランザクションでエラーが発生した場合' do
      it 'すべての変更がロールバックされる' do
        expect {
          ActiveRecord::Base.transaction do
            User.create!(name: "outer-forward")
            ActiveRecord::Base.transaction do
              User.create!(name: "inner")
            end
            raise ActiveRecord::Rollback
          end
        }.not_to change { User.count }

        expect(User.count).to eq(0)
      end
    end

    context '内部トランザクションでエラーが発生した場合' do
      context 'デフォルト設定の場合' do
        it 'StandardError発生時にすべての変更がロールバックされる (rescueなし)' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              ActiveRecord::Base.transaction do
                User.create!(name: "inner")
                raise StandardError, 'エラーが発生しました'
              end
              User.create!(name: "outer-backward")
            end
          }.to raise_error(StandardError)

          expect(User.count).to eq(0)
        end

        it 'StandardError発生時にrescueするとすべてのデータが保存される' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              begin
                ActiveRecord::Base.transaction do
                  User.create!(name: "inner")
                  raise StandardError, 'エラーが発生しました'
                end
              rescue StandardError
              end
              User.create!(name: "outer-backward")
            end
          }.to change { User.count }.by(3)

          expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward', 'inner'])
        end

        it 'ActiveRecord::Rollbackではすべてのデータが保存される' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              ActiveRecord::Base.transaction do
                User.create!(name: "inner")
                raise ActiveRecord::Rollback
              end
              User.create!(name: "outer-backward")
            end
          }.to change { User.count }.by(3)

          expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward', 'inner'])
        end
      end

      context 'requires_new: true を使用した場合' do
        it 'StandardError発生時に全体がロールバックされる (rescueなし)' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              ActiveRecord::Base.transaction(requires_new: true) do
                User.create!(name: "inner")
                raise StandardError, 'エラーが発生しました'
              end
              User.create!(name: "outer-backward")
            end
          }.to raise_error(StandardError)

          expect(User.count).to eq(0)
        end

        it 'StandardError発生時にrescueすると外部トランザクションのデータのみ保存される' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              begin
                ActiveRecord::Base.transaction(requires_new: true) do
                  User.create!(name: "inner")
                  raise StandardError, 'エラーが発生しました'
                end
              rescue StandardError
              end
              User.create!(name: "outer-backward")
            end
          }.to change { User.count }.by(2)

          expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward'])
        end

        it 'ActiveRecord::Rollbackでは外部トランザクションのデータのみ保存される' do
          expect {
            ActiveRecord::Base.transaction do
              User.create!(name: "outer-forward")
              ActiveRecord::Base.transaction(requires_new: true) do
                User.create!(name: "inner")
                raise ActiveRecord::Rollback
              end
              User.create!(name: "outer-backward")
            end
          }.to change { User.count }.by(2)

          expect(User.pluck(:name)).to match_array(['outer-forward', 'outer-backward'])
        end
      end
    end
  end
end
```

実行結果は以下の通り。

```bash
$ docker run --rm -p 3306:3306 --env MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:8
$ ruby transaction_behavior_test.rb

ネストトランザクションにおける挙動
  トランザクションのロールバックの挙動
    外部トランザクションでエラーが発生した場合
      すべての変更がロールバックされる
    内部トランザクションでエラーが発生した場合
      デフォルト設定の場合
        StandardError発生時にすべての変更がロールバックされる (rescueなし)
        StandardError発生時にrescueするとすべてのデータが保存される
        ActiveRecord::Rollbackではすべてのデータが保存される
      requires_new: true を使用した場合
        StandardError発生時に全体がロールバックされる (rescueなし)
        StandardError発生時にrescueすると外部トランザクションのデータのみ保存される
        ActiveRecord::Rollbackでは外部トランザクションのデータのみ保存される

Finished in 0.08909 seconds (files took 0.27644 seconds to load)
7 examples, 0 failures
```