Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

AfterCommitEverywhereからRails 7.2で追加されたActiveRecord.after_all_transactions_commitへの移行

こんにちは、Eightの開発を担当している倉田です。

Eightではこれまで、トランザクションのコミット後に処理を実行するためにafter_commit_everywhereからRails 7.2で新たに追加されたActiveRecord.after_all_transactions_commitへ移行しました。

本記事では、移行を完了させた方法を紹介するとともに、移行を進める中で直面した、両者が混在する環境における技術的な問題点についても掘り下げます。

背景

これまでEightでは、トランザクションがコミットされた後に実行したい処理を実現するために、after_commit_everywhereというgemを利用してきました。

しかし、Rails 7.2ではトランザクションのコミット後に処理を実行するためのActiveRecord.after_all_transactions_commitが新たに追加され、同等のことをRails標準の機能で実現できるようになりました。これにより外部gemへの依存を減らせるため、メンテナンス性向上を目的として移行を行いました。

Rails 7.2の新機能

ApplicationRecord.transaction do
  user.update!(name: 'New Name')
  ActiveRecord.after_all_transactions_commit do
    # トランザクションコミット後に実行される
    NotificationJob.perform_later(user.id)
  end
end

after_commit_everywhere

after_commit_everywhereは、ActiveRecordのコールバック外でもトランザクションコミット後の処理を実行できるようにするgemです。Eightでは、コントローラーやジョブなど、コミット後の処理を実行する必要があるケースで広く使われていました。

ApplicationRecord.transaction do
  user.update!(name: 'New Name')
  AfterCommitEverywhere.after_commit do
    NotificationJob.perform_later(user.id)
  end
end

定義順と実行順が異なる問題

Eightでは、ActiveRecord.after_all_transactions_commitとafter_commit_everywhereを同時に利用していました。

その結果、同一トランザクション内で両者を併用すると、処理の定義順と実行順が一致しないという問題が発生しました。

以下にサンプルコードを示します。

ApplicationRecord.transaction do
  user.update!(name: 'New Name')
  ActiveRecord.after_all_transactions_commit { puts '1番目に実行される' }
  AfterCommitEverywhere.after_commit { puts '2番目に実行される' }
end

定義順に従い、以下の順序で実行されることを期待します:

TRANSACTION BEGIN
INSERT INTO ...
TRANSACTION COMMIT
1番目に実行される  # 最初に定義したActiveRecord.after_all_transactions_commit
2番目に実行される  # 次に定義したAfterCommitEverywhere.after_commit

しかし、実際には実行順序が逆転します:

TRANSACTION (1.5ms)  BEGIN
ReservedVirtualCardMailTarget Create (3.1ms)  INSERT INTO `reserved_virtual_card_mail_targets` ...
TRANSACTION (5.6ms)  COMMIT
2番目に実行される  # AfterCommitEverywhere.after_commitが先に実行される
1番目に実行される  # ActiveRecord.after_all_transactions_commitが後に実行される

この逆転は、例えば以下のようなケースで不具合を引き起こす可能性があります。

class NotificationJob < ApplicationJob
  def perform(user_id)
    user = User.active.find_by(id: user_id)
    return if user.blank?
    
    # 本来実行したい処理
  end
end

ApplicationRecord.transaction do
  user = User.create(status: :inactive)
  user.status = :active

  # ユーザの保存
  ActiveRecord.after_all_transactions_commit do
    user.save!
  end

  # 通知Jobをエンキューする
  AfterCommitEverywhere.after_commit do
    NotificationJob.perform_later(user.id)
  end
end

ApplicationRecord.transaction内では「通知Jobのエンキュー」→「ユーザの保存」という順で実行されてしまい、ユーザが存在しない状態でJobが実行されるため、意図した挙動になりません。

なぜ実行順序が異なるのか

この問題は、両者が異なるキューにコールバックを登録することに起因します。

after_commit_everywhereがトランザクションそのものに紐づくafter_commitコールバックを利用しているのに対し、ActiveRecord.after_all_transactions_commitはActiveRecordが内部で管理するキューに処理を登録します。

一方、同一ライブラリ内で複数回定義する場合は同じキューに順序通り追加されるため、定義順が保たれます。

# 同じキューに追加されるため、定義順が保たれる
ActiveRecord.after_all_transactions_commit { puts '1' }
ActiveRecord.after_all_transactions_commit { puts '2' }
# 出力: 1, 2

AfterCommitEverywhere.after_commit { puts '1' }
AfterCommitEverywhere.after_commit { puts '2' }
# 出力: 1, 2

移行手順

移行手順は以下のように進めました。

1. RuboCopのカスタムCopを作成

移行期間中に新たなActiveRecord.after_all_transactions_commitが追加されるのを防ぐため、RuboCopのカスタムCopを作成しました。

# lib/rubo_cop/avoid_after_all_transactions_commit.rb
module RuboCop
  class AvoidAfterAllTransactionsCommit < Base
    MSG = 'ActiveRecord.after_all_transactions_commitの使用は非推奨です。' \
      'AfterCommitEverywhereを使用してください。'

    def_node_matcher : after_all_transactions_commit?, <<~PATTERN
      (send (const nil? :ActiveRecord) :after_all_transactions_commit ...) 
    PATTERN

    def on_send(node)
      return unless after_all_transactions_commit?(node)
    
      add_offense(node)
    end
  end
end

.rubocop.ymlに追加:

AvoidAfterAllTransactionsCommit:
  Enabled: true

これにより、新規コードでActiveRecord.after_all_transactions_commitを使用するとCIで検出されるようになり、新たな混在を防ぐことから始めました。

2. より多く使われていたafter_commit_everywhereに統一
Eightではこれまでafter_commit_everywhereを利用しており、利用箇所が多く存在していました。
定義順と実行順が異なる問題を解消するため、処理を一度after_commit_everywhereに統一していきました。

3. E2Eテストで既存機能が正しく動作することを確認
after_commit_everywhereに統一した状態でE2Eテストを実行して、定義順と実行順が異なる問題により、意図しない挙動となっている機能がないかを確認しました。特にトランザクション内で両方の処理を併用している部分を重点的に確認しました。この調査では、AIを活用することで効率的に洗い出しを行いました。

4. ActiveRecord.after_all_transactions_commitへ置換
最終的に、AfterCommitEverywhere.after_commitをActiveRecord.after_all_transactions_commitに置換し、Rails標準機能への移行を完了することができました。これにより不要になったafter_commit_everywhereのgemを削除することができました!

まとめ

Rails 7.2への移行に伴い、after_commit_everywhereからActiveRecord.after_all_transactions_commitへの移行を行いました。

Rails標準の機能に統一したことで挙動の理解がしやすくなり、実装のばらつきも減ったことで、保守性の向上につながったと感じています。このようにEightでは、開発者体験を向上させるための取り組みを積極的に進めています。

そして、一緒にプロダクトを発展させていきたいメンバーも募集しています!
media.sansan-engineering.com

© Sansan, Inc.