Rails の enqueue_after_transaction_commit を深掘りする
Active Job で利用できる enqueue_after_transaction_commit オプションについて調べていたところ、意外と奥が深く、それなりの時間調べたのでわかりやすくまとめてみます。
検証環境
- rails 8.1.2
- ruby 3.4.8
enqueue_after_transaction_commit とは
DBのトランザクション内からジョブをキューに追加するときに、キューの追加処理をトランザクションのコミット後に遅らせるかどうか、という設定値です。
設定値として true, false のどちらかを指定します。
true: ジョブのキュー追加をトランザクションコミット後に遅らせるfalse: ジョブは即座にキューに追加される
なお Rails 7.2 ではシンボル値(:never, :always, :default)で指定する形式でしたが、Rails 8.0 で非推奨となり、8.1 で完全に削除されました。現在は boolean のみです。
# 設定例
class UserNotificationJob < ApplicationJob
self.enqueue_after_transaction_commit = true
end
有効化すると何が嬉しいのか
大きく2つの関連する問題があります。
- トランザクションのコミット前にジョブが動いちゃう問題
- トランザクション内でエラーが起こったときにジョブだけ実行されちゃう問題
順番に見ていきます。
トランザクションのコミット前にジョブが動いちゃう問題
しばしば、以下のようなコードが予期せぬエラーを発生させていました。
# ユーザの新規登録処理
ApplicationRecord.transaction do
# まずユーザをDBに登録
user = User.create!(name: 'Bob')
# その後、登録完了メールを送る
UserNotificationJob.perform_later(user)
end
このコードは一見すると特に問題が無いように見えますが、ユーザの登録処理がコミットされる前にジョブが動いてしまうケースがあり、その場合においてジョブはエラーとなり、ユーザ登録は完了したのにメール送信ができていない、という問題につながります。
よくある対策としては UserNotificationJob を transaction ブロックの外に出してユーザ登録が完了してからジョブをキューに積むというものでした。
user = ApplicationRecord.transaction do
User.create!(name: 'Bob')
end
UserNotificationJob.perform_later(user)
enqueue_after_transaction_commit を有効化すると、ジョブのキュー追加をトランザクションのコミット後に遅らせるため、最初のコードでもエラーが起こりません。
# enqueue_after_transaction_commit: true
ApplicationRecord.transaction do
user = User.create!(name: 'Bob')
# ジョブのキュー追加がトランザクションコミット後に行われる
UserNotificationJob.perform_later(user)
end
トランザクション内でエラーが起こったときにジョブだけ実行されちゃう問題
トランザクション内においてジョブのエンキュー後の何かしらの処理が失敗した場合、メール送信はしたけどデータは作られていなかった のような問題が起こります。
ApplicationRecord.transaction do
user = User.create!(name: 'Bob')
UserNotificationJob.perform_later(user)
# 何か関連する別の処理が失敗して例外を投げる
some_process!
end
enqueue_after_transaction_commit を有効化すると、ジョブのエンキューはコミット後まで遅延されます。トランザクションがロールバックされた場合はジョブが破棄されるため、この問題も回避できます。
デフォルト値が頻繁に変わっている点に注意
ここまでの内容を理解すると、true にしておくのが無難に思いました。
前述の問題になりがちだったコードは「うまく動くケース」もあるので、気をつけるだけでは完全な対策は難しいです。フレームワークレベルで対策をしてくれることで、細かいことを気にせず安全な実装ができるようになります。
ただし Rails のデフォルト値は以下のように指定できる値も含めてコロコロ変わっている現状のようなので、使う際はバージョン間の差異に気をつけたほうが良さそうです。
- Rails 7.2:
:default(アダプターに委任。クラス定義上は:neverだがload_defaults "7.2"で上書き) - Rails 8.0 ~ 8.1:
false - Rails 8.2:
true
また、ジョブクラス単位で設定を上書きすることもできます。
class UserNotificationJob < ApplicationJob
self.enqueue_after_transaction_commit = true
end
SolidQueue を使う場合はどうするのがいいか
Rails8 から ActiveJob のバックエンドのデフォルトとなった SolidQueue は、Sidekiq などと異なりジョブキューもDBで管理します。
SolidQueue はデフォルトではアプリケーションとは別のDBでジョブを使います。設定次第で1つのDBにまとめることもできますが、enqueue_after_transaction_commit とどう組み合わせて使うのが良いかを考えてみました。
別DB管理の場合
アプリケーションとジョブが別DB管理の場合、enqueue_after_transaction_commit は有効化しておくのが良いでしょう。
なぜなら Redis など別でジョブを管理している状態と同じであるため、トランザクションのコミット前にジョブが動いちゃう問題 が起こり得ます。
無効化する場合は、これまでのようにアプリケーションの実装時に注意が必要になります。
同一DB管理の場合
アプリケーションとジョブが同一DB管理の場合、全てが1つのトランザクションで行われるため、有効化しなくても前述の問題が起こりません。一般的なトランザクション分離レベルにおいて、コミットされるまではワーカーからも参照ができないため、問題になりづらいでしょう。
ただし SolidQueue のドキュメントには、将来的に別DBに移動したりバックエンドを切り替えたときに問題が起こるので注意を促しています。
そのようなことを踏まえると、同一DBの場合であっても enqueue_after_transaction_commit は有効化しておくのが良さそうですね。
h3pei
フリーランスのソフトウェアエンジニア。Ruby / Rails アプリケーションの開発が得意領域。設計・実装・運用まで含めてプロダクト開発が好きです。
Questal という目標達成コミュニティサービスを開発しました。仲間と一緒に目標達成に取り組みたい方はぜひご利用ください。