🐷

Railsのコールバック実行順とトランザクションのタイミング: 実例と解説

2024/08/19に公開

こんにちは、M-Yamashitaです。

業務でコールバックがどの順で呼び出されるのか、トランザクションのタイミングがどこだったか忘れてしまい困ったことがありました。そのため、備忘録も兼ねてコールバックの実行順やトランザクションのタイミングを記事として残します。

この記事で伝えたいこと

  • DBの保存・更新メソッド実行時におけるコールバックの実行順
  • トランザクション開始・終了の実行タイミング
  • コールバック・トランザクションの実行コード

想定読者

この記事は、Ruby on Railsを使用している開発者で、コールバックやトランザクションの動作に興味がある方を対象としています。

環境

  • Ruby: 3.3.0
  • Ruby on Rails: 7.1.3.4
  • MySQL: 8.3.0

コールバック実行順とトランザクションのタイミング

オブジェクトの作成・更新でのコールバック一覧

コールバック一覧については、Railsガイドを参考にします。

https://railsguides.jp/active_record_callbacks.html

このオブジェクトの作成・更新でのコールバックをbeforeから順番に並べてまとめると、以下の通りになります。

before_validation
after_validation
before_create
before_save
before_update
around_create
around_save
around_update
after_commit / after_rollback
after_create
after_save
after_update

このコールバックの内、after_rollbackを除くコールバックを今回の確認対象とします。また、after_save_commitafter_update_commitについてはafter_commitとエイリアス関係なので、今回の対象コールバック一覧には含めません。

確認パターン

DBの保存・更新メソッドとしてcreate, create!, save, save!updateupdate!があります。
これらの組み合わせによってコールバックがどの順で呼び出されるのか調べるために、確認パターンを作ります。ただ、!の有無はコールバックに影響しないのでそのメソッドは今回のパターン作成から除きます。そのため保存メソッドのcreate!, save!のみ実行する場合と、それらのメソッドの実行後にsave!update!した時のパターンを考えます。

組み合わせたパターン一覧は以下の通りです。

前提 実行メソッド
なし create!
事前にcreate!メソッドを実行済み save!
事前にcreate!メソッドを実行済み update!
なし save!
事前にsave!メソッドを実行済み save!
事前にsave!メソッドを実行済み update!

確認に使ったコード

上記「オブジェクトの作成・更新でのコールバック一覧」項目で挙げた各コールバックを持ったモデルのコードは次のとおりです。コールバックが実行されたときにそのコールバック名を画面に表示するシンプルなコードとしています。

app/models/user.rb
class User < ApplicationRecord
  before_validation :puts_before_validation
  after_validation :puts_after_validation

  before_create :puts_before_create
  before_save :puts_before_save
  before_update :puts_before_update

  around_create :puts_around_create
  around_save :puts_around_save
  around_update :puts_around_update

  after_commit :puts_after_commit
  after_create  :puts_after_create
  after_update  :puts_after_update
  after_save  :puts_after_save

  def puts_before_validation
    puts 'before_validation'
  end

  def puts_after_validation
    puts 'after_validation'
  end

  def puts_before_create
    puts 'before_create'
  end

  def puts_before_save
    puts 'before_save'
  end

  def puts_before_update
    puts 'before_update'
  end

  def puts_around_create
    puts 'around_create'
    yield
  end

  def puts_around_save
    puts 'around_save'
    yield
  end

  def puts_around_update
    puts 'around_update'
    yield
  end

  def puts_after_commit
    puts 'after_commit'
  end

  def puts_after_create
    puts 'after_create'
  end

  def puts_after_save
    puts 'after_save'
  end

  def puts_after_update
    puts 'after_update'
  end
end

またそのUserモデルを使用し、Railsで簡単な画面を作ってボタンごとに各パターンを実行するようにしました。そのときの画面とcontrollerのコードは以下の通りです。

app/controllers/users_controller.rb
class UsersController < ApplicationController

  attr_accessor :name, :name2, :email

  def initialize
    @name = "test_user"
    @email = "example@exmaple.com"
    @name2 = "test_user2"
  end

  def create
    puts "---create!---"
    User.create!(name:, email:)

    redirect_to root_path
  end

  def create_save
    puts '---create!---'
    user = User.create!(name:, email:)
    puts '---save!---'
    user.name = name2
    user.save!

    redirect_to root_path
  end

  def create_update
    puts '---create!---'
    user = User.create!(name:, email:)
    puts '---update!---'
    user.update!(name: name2)

    redirect_to root_path
  end

  def save
    puts '---save!---'
    user = User.new(name:, email:)
    user.save!

    redirect_to root_path
  end

  def save_save
    puts '---save!---'
    user = User.new(name:, email:)
    user.save!
    puts '---save!---'
    user.name = name2
    user.save!

    redirect_to root_path
  end

  def save_update
    puts '---save!---'
    user = User.new(name:, email:)
    user.save!
    puts '---update!---'
    user.update!(name: name2)

    redirect_to root_path
  end
end

加えてトランザクションがどのタイミングで実行されたかわかるように、クエリを表示するようにしておきます。

config/environments/test.rb
require "active_support/core_ext/integer/time"

Rails.application.configure do
# ・・・略・・・
  config.logger = ActiveSupport::Logger.new(STDOUT)
# ・・・略・・・
end

結果

確認パターンに沿ってそれぞれボタンを押して確認しました。
各パターンにおけるコールバックの実行順番とトランザクションのBEGINCOMMITの実行タイミングは以下の通りです。

create!のみ

---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.1ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (0.9ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:19:35.354712', '2024-08-16 03:19:35.354712')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.8ms)  COMMIT
  ↳ app/controllers/users_controller.rb:4:in `create'
after_commit
Redirected to http://localhost:3000/

create! → save!

---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (1.5ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:23:52.754989', '2024-08-16 03:23:52.754989')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.4ms)  COMMIT
  ↳ app/controllers/users_controller.rb:11:in `create_save'
after_commit
---save!---
before_validation
after_validation
before_save
around_save
before_update
around_update
  TRANSACTION (0.1ms)  BEGIN
  ↳ app/models/user.rb:47:in `puts_around_update'
  User Update (0.4ms)  UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:23:52.758843' WHERE `users`.`id` = 2
  ↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
  TRANSACTION (0.6ms)  COMMIT
  ↳ app/controllers/users_controller.rb:14:in `create_save'
after_commit
Redirected to http://localhost:3000/

create! → update!

---create!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (1.1ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:24:35.517345', '2024-08-16 03:24:35.517345')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.6ms)  COMMIT
  ↳ app/controllers/users_controller.rb:21:in `create_update'
after_commit
---update!---
before_validation
after_validation
before_save
around_save
before_update
around_update
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:47:in `puts_around_update'
  User Update (0.6ms)  UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:24:35.521125' WHERE `users`.`id` = 3
  ↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
  TRANSACTION (0.5ms)  COMMIT
  ↳ app/controllers/users_controller.rb:23:in `create_update'
after_commit
Redirected to http://localhost:3000/

save!のみ

---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.3ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (1.3ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:24:54.344819', '2024-08-16 03:24:54.344819')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.6ms)  COMMIT
  ↳ app/controllers/users_controller.rb:31:in `save'
after_commit
Redirected to http://localhost:3000/

save! → save!

---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (1.1ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:25:07.233447', '2024-08-16 03:25:07.233447')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.4ms)  COMMIT
  ↳ app/controllers/users_controller.rb:39:in `save_save'
after_commit
---save!---
before_validation
after_validation
before_save
around_save
before_update
around_update
  TRANSACTION (0.1ms)  BEGIN
  ↳ app/models/user.rb:47:in `puts_around_update'
  User Update (0.5ms)  UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:25:07.236525' WHERE `users`.`id` = 5
  ↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
  TRANSACTION (0.4ms)  COMMIT
  ↳ app/controllers/users_controller.rb:42:in `save_save'
after_commit
Redirected to http://localhost:3000/

save! → update!

---save!---
before_validation
after_validation
before_save
around_save
before_create
around_create
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:37:in `puts_around_create'
  User Create (1.7ms)  INSERT INTO `users` (`name`, `email`, `created_at`, `updated_at`) VALUES ('test_user', 'example@example.com', '2024-08-16 03:25:24.064263', '2024-08-16 03:25:24.064263')
  ↳ app/models/user.rb:37:in `puts_around_create'
after_create
after_save
  TRANSACTION (0.5ms)  COMMIT
  ↳ app/controllers/users_controller.rb:50:in `save_update'
after_commit
---update!---
before_validation
after_validation
before_save
around_save
before_update
around_update
  TRANSACTION (0.2ms)  BEGIN
  ↳ app/models/user.rb:47:in `puts_around_update'
  User Update (0.4ms)  UPDATE `users` SET `users`.`name` = 'test_user2', `users`.`updated_at` = '2024-08-16 03:25:24.068754' WHERE `users`.`id` = 6
  ↳ app/models/user.rb:47:in `puts_around_update'
after_update
after_save
  TRANSACTION (0.5ms)  COMMIT
  ↳ app/controllers/users_controller.rb:52:in `save_update'
after_commit
Redirected to http://localhost:3000/

コールバックの実行コード

validationの場合

レコード作成・保存の場合、以下save!メソッド内のperform_validationsメソッド内で実行されます。

activerecord/lib/active_record/validations.rb
def save!(**options)
  perform_validations(options) ? super : raise_validation_error
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/validations.rb#L54

このメソッドを辿っていくと、以下run_validations!メソッドが呼び出され、そこで動的に作られた_run_validation_callbacksメソッドを呼び出し、before_validationafter_validationが実行されます。

activemodel/lib/active_model/validations/callbacks.rb
def run_validations!
  _run_validation_callbacks { super }
end

https://github.com/rails/rails/blob/v7.1.3.4/activemodel/lib/active_model/validations/callbacks.rb#L114

saveの場合

以下save!メソッド内のcreate_or_updateメソッド内で実行されます。

activerecord/lib/active_record/persistence.rb
def save!(**options, &block)
  create_or_update(**options, &block) || raise(RecordNotSaved.new("Failed to save the record", self))
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/persistence.rb#L750

このメソッドを辿った先にあるActiveRecord::Callbacksモジュールのcreate_or_updateメソッドが呼び出されます。ここでも同様に動的に作成された_run_save_callbacksメソッドでコールバック処理を呼び出します。
なお、before_create, around_createのコールバックはクエリ実行前に呼び出されるようですが、after_createはクエリ実行後の呼び出しとなるようです。

activerecord/lib/active_record/callbacks.rb
def create_or_update(**)
  _run_save_callbacks { super }
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/callbacks.rb#L440

create、updateの場合

上記save!メソッドとほぼ同じ呼び出し場所となります。save!メソッドを持っている以下create!メソッドでコールバックが実行されます。

activerecord/lib/active_record/persistence.rb
def create!(attributes = nil, &block)
  if attributes.is_a?(Array)
    attributes.collect { |attr| create!(attr, &block) }
  else
    object = new(attributes, &block)
    object.save!
    object
  end
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/persistence.rb#L50

save!メソッドの先にあるActiveRecord::Callbacksモジュールの_create_recordで、動的に作成された_run_save_callbacksメソッドでコールバック処理を呼び出します。
なお、before_createaround_createはクエリ実行前、after_createはクエリ実行後に呼び出されるようです。

activerecord/lib/active_record/callbacks.rb
def _create_record
  _run_create_callbacks { super }
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/callbacks.rb#L444

updateに関してもcreateとほぼ同様であり、_update_recordメソッド内でコールバックを呼んでいます。

activerecord/lib/active_record/callbacks.rb
def _update_record
  _run_update_callbacks { record_update_timestamps { super } }
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/callbacks.rb#L448

commitの場合

後述するトランザクションCOMMITのコードを実行後に、以下のcommitted!メソッド内にある_run_commit_callbacksメソッドでコールバックが実行されます。

activerecord/lib/active_record/transactions.rb
def committed!(should_run_callbacks: true) # :nodoc:
  @_start_transaction_state = nil
  if should_run_callbacks
    @_committed_already_called = true
    _run_commit_callbacks
  end
ensure
  @_committed_already_called = @_trigger_update_callback = @_trigger_destroy_callback = false
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/transactions.rb#L328

トランザクションの実行コード

BEGINについて

Rails 6から、トランザクションを貼った後のクエリ未実行ということを避けるためにトランザクションの遅延対応が入っています。
https://github.com/rails/rails/pull/32647

この結果、クエリ実行直前でBEGINが実行されるようです。コードとしてはクエリ実行の処理を持つraw_executeメソッドにおいて、with_raw_connectionメソッドの先にあるbegin_db_transactionメソッドでBEGINを実行しています。

activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb
def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
  log(sql, name, async: async) do
    with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
      sync_timezone_changes(conn)
      result = conn.query(sql)
      verified!
      handle_warnings(sql)
      result
    end
  end
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb#L96

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
def begin_db_transaction # :nodoc:
  internal_execute("BEGIN", "TRANSACTION", allow_retry: true, materialize_transactions: false)
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L236

COMMITについて

COMMITについては、save!や``destroy!メソッドで使用されているwith_transaction_returning_status`メソッドで実行しています。

activerecord/lib/active_record/transactions.rb
def save!(**) # :nodoc:
  with_transaction_returning_status { super }
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/transactions.rb#L312

このメソッド内部にあるtransactionメソッドを辿った先で、commit_db_transactionメソッドによりCOMMITを実行しています。

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
def commit_db_transaction # :nodoc:
  internal_execute("COMMIT", "TRANSACTION", allow_retry: false, materialize_transactions: true)
end

https://github.com/rails/rails/blob/v7.1.3.4/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L245

おわりに

この記事では、コールバックやトランザクションの順番と実行タイミングについてまとめました。その点について再確認できたことに加えて、調べている過程でトランザクションの遅延対応について新しく発見できたことも良かったと感じています。

この記事が誰かのお役に立てれば幸いです。

Money Forward Developers

Discussion