こんにちは、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