0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

まとめ

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

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

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

  • rescueを使用する場合
  • ActiveRecord::Rollbackを使用する場合

メソッドの定義

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

def transaction(requires_new: nil, isolation: nil, joinable: true, &block)

主な引数の意味:

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

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

デフォルトの動作

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

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

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

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

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

if !requires_new && current_transaction.joinable?
  if isolation
    raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
  end
  yield current_transaction.user_transaction

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

yield current_transaction.user_transactionyeild

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

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

ActiveRecord::Base.transaction do # <-- 親のトランザクションはNULL_TRANSACTION
end
def current_transaction
  @stack.last || NULL_TRANSACTION # <-- @stackにネストトランザクションが積まれる
end

def joinable?; false; end

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

requires_new: trueを指定した場合

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

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

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

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されません。

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

rspecで検証

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

実行結果は以下の通り。

$ 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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?