既定の deliver_later は ApplicationJob の retry_on を継承しない

Rails のアプリでは、ジョブの共通設定を ApplicationJob にまとめて書けます。

class ApplicationJob < ActiveJob::Base
  retry_on StandardError, wait: :polynomially_longer, attempts: 5

  discard_on ActiveJob::DeserializationError do |_job, error|
    raise error unless error.cause.is_a?(ActiveRecord::RecordNotFound)
  end

  self.enqueue_after_transaction_commit = true
end

アプリが定義したジョブはこのクラスを継承するので、StandardError のサブクラスを最大5回まで実行します。 失敗の扱いも、enqueue のタイミングも、ApplicationJob を継承しているジョブには届きます。

メールも Active Job のキューに積まれます。

UserMailer.welcome(user).deliver_later

そのため、同じ再試行が効いていると考えてしまいますが、既定の deliver_later では効きません。

deliver_later が使うジョブクラス

Rails 8.1.3.1 で deliver_later が既定で積むのは ActionMailer::MailDeliveryJob です。 このクラスは ActiveJob::Base を直接継承しています1

# actionmailer/lib/action_mailer/mail_delivery_job.rb
class MailDeliveryJob < ActiveJob::Base # :nodoc:

そのため ApplicationJob に書いた retry_ondiscard_on も、既定のメール配信ジョブには適用されません。

届かないのは再試行の設定だけではありません。 このアプリは enqueue_after_transaction_commit = trueApplicationJob に書いていますが、既定の配信ジョブはこれを受け取りません。 Active Job 側の既定値は false なので、メールの配信ジョブだけはトランザクションのコミットを待たずに enqueue されます。

Solid Queue で配信に失敗したときの見え方

このアプリでは、バックグラウンドジョブに Solid Queue を使い、例外を Sentry に送っています。 その構成では、メールの配信が失敗しても、画面にはエラーが出ません。 ユーザーの操作はすでに成功していて、失敗するのはそのあとに動くジョブだからです。

とはいえ、失敗が消えるわけではありません。 ジョブが上げた例外は Sentry に届きます。 Solid Queue は失敗したジョブを残すので、mission_control-jobs のダッシュボードからも追えます。

行われないのは、この配信ジョブからの自動再送です。 SMTP 側が一時的に詰まっただけで、もう一度試せば通ったはずのメールでも、そのままでは相手に届きません。 記録を見た人が手で送り直すか、別の再送経路を用意する必要があります。

配信ジョブを差し替えて再試行に対応する

ActionMailer::MailDeliveryJob を継承したサブクラスを作り、設定で差し替えます。

class ApplicationMailerDeliveryJob < ActionMailer::MailDeliveryJob
  self.enqueue_after_transaction_commit = true

  retry_on Net::OpenTimeout, Net::ReadTimeout, Net::SMTPServerBusy,
           IOError, SocketError, Timeout::Error, Errno::ECONNRESET, Errno::ETIMEDOUT,
           wait: :polynomially_longer, attempts: 5

  discard_on ActiveJob::DeserializationError do |_job, error|
    raise error unless error.cause.is_a?(ActiveRecord::RecordNotFound)
  end
end
config.action_mailer.delivery_job = 'ApplicationMailerDeliveryJob'

config.action_mailer.delivery_job は「非同期でメッセージを配信するジョブ」を指定する設定で、既定値が ActionMailer::MailDeliveryJob です。 フレームワークが差し替えを想定して用意している設定なので、内部のクラスに手を入れる必要はありません。

配信ジョブで再試行する例外

配信ジョブには、Action Mailer の配信処理を残したまま、メール向けの再試行だけを足します。 ApplicationJob を直接継承するジョブも作れますが、その場合は ActionMailer::MailDeliveryJob が持つ配信処理を自分で引き受けることになります。 ここでは Action Mailer の配信契約を再利用するため、ActionMailer::MailDeliveryJob のサブクラスに必要な設定だけを置きます。

そのうえで、ApplicationJob と同じ再試行設定は使いません。 冒頭に挙げた ApplicationJobretry_onStandardError を対象にしていて、通常のアプリケーション例外を広く再試行するからです。 この設定は、Delayed Job で失敗したジョブを最大5回実行していた運用を引き継いだものです。 どの失敗が一時的で、どの失敗が恒久的かを判断して決めた設定ではありません。

メールには、再試行してよい失敗と、すぐ失敗として記録したい失敗があります。 宛先が存在しないような恒久的な失敗は、何度試しても結果が変わりません。 それでも StandardError を広く対象にすると、最大5回の実行が終わるまで失敗の記録が遅れます。

遅れの幅も小さくありません。 wait: :polynomially_longer のバックオフは executions ** 4 + 2 秒に、retry_jitter のぶんを上乗せした長さです2。 待機は最短で 3 秒、18 秒、83 秒、258 秒と伸び、5回目の実行に到達するまでの合計は 6 分から 7 分になります。

そこで配信ジョブでは、再試行する例外を列挙します。 接続のタイムアウト、ソケットのエラー、SMTP のビジー応答を中心に、運用上もう一度送る対象にしたい例外だけを含めます。 Net::SMTPFatalError のような 5xx 系は最初の1回で失敗させ、すぐ記録に残します。

ただし、ネットワークエラーなら常に再試行してよいわけではありません。 たとえばメッセージを書き込んだあと、応答を読むところで Net::ReadTimeout や接続断が起きると、クライアント側では SMTP サーバーが受理済みか判断できません。 その状態で再試行すると、同じメールが二度届く可能性があります。

このアプリでは、一般のジョブは StandardError を広く再試行し、メールの配信は例外を限定しています。 メールの配信では、「届かない遅れ」と「二重に届く事故」の両方を考えて、再送してよい失敗だけを選ぶ必要があります。 この判断は、Delayed Job から Solid Queue へ移行するときにも問題になりました。

discard_on は cause で分岐する

discard_on ActiveJob::DeserializationError とだけ書くと、あらゆる deserialize 失敗を失敗として伝播させずに破棄します。 捨ててよいのは、参照先のレコードがすでに削除されている場合だけです。

そこで、冒頭の ApplicationJob と同じブロック付きの形を配信ジョブにも置いています。 DeserializationError は元の例外を cause に包んでいるので、そこを見て分岐します。 RecordNotFound なら破棄し、それ以外は再 raise して失敗させます。

握りつぶす範囲を広げると、原因が分からないままメールが送られなくなります。

spec の perform_enqueued_jobs(only:) への影響

メール配信ジョブを差し替えると、perform_enqueued_jobs(only:) を使っている spec にも影響します。 only:ActionMailer::MailDeliveryJob を指定していると、差し替え後の ApplicationMailerDeliveryJob は対象から外れるからです。

ジョブ自体は enqueue されます。 しかし Rails 8.1.3.1 の test adapter は only: のクラスを完全一致で見るため、perform_enqueued_jobs はそのジョブを perform しません。 ApplicationMailerDeliveryJobActionMailer::MailDeliveryJob のサブクラスであっても、ここでは一致扱いになりません。

- perform_enqueued_jobs(only: ActionMailer::MailDeliveryJob)
+ perform_enqueued_jobs(only: ApplicationMailerDeliveryJob)

今回の変更では、only: で配信ジョブを絞り込んでいる spec が 9 ファイル 38 箇所ありました。 ここを旧クラス名のままにしておくと、メール送信を期待している spec が配信ジョブを perform できなくなります。

この種の spec があると、差し替え後にクラス名を直し忘れたとき、該当する spec が失敗してくれます。 逆に、enqueue されるジョブクラスを確かめる spec がなければ、既存のテストだけでは差し替え漏れに気付きにくくなります。

retry_on の実行回数を検証する

retry_onwait を伴って再 enqueue するので、そのまま perform_enqueued_jobs を呼んでも次の試行は走りません。 未来の時刻を渡して順に実行させます。

allow_any_instance_of(Mail::Message)
  .to receive(:deliver)
  .and_raise(Net::OpenTimeout)

TestMailer.test_email.deliver_later

4.times { perform_enqueued_jobs(at: 1.year.from_now) }

expect { perform_enqueued_jobs(at: 1.year.from_now) }
  .to raise_error(Net::OpenTimeout)

deliver_later は enqueue するだけなので、実行はすべて perform_enqueued_jobs の側で起きます。 ループの4回と最後の1回で計5回実行され、5回目で再試行を使い切って元の例外が上がります。

差し替えそのものは、enqueue されるクラスを見れば確かめられます。

expect { TestMailer.test_email.deliver_later }
  .to have_enqueued_job(ApplicationMailerDeliveryJob)

この assertion は、deliver_later が積むクラスの確認です。 再試行の対象や回数は、例外を発生させる振る舞いのテストで別に確かめます。

名前から想像した継承関係を確かめる

今回の見落としは、ApplicationJob という名前から「アプリのジョブはすべてここを通る」と考えたことに起因します。 実際には、フレームワークが内部で積むジョブはアプリの基底クラスを通らないことがあります。

基底クラスに設定を足したときは、それが実際に enqueue されるクラスに届いているかを一度確かめておきます。 ジョブの失敗そのものは Sentry にもダッシュボードにも出ますが、設定不達そのものは専用のエラーとして報告されないからです。 確かめ方は上の have_enqueued_job の1行から始められます。

既定クラスを設定で差し替えるときは、あわせてそのクラス名を直接書いている箇所を探します。 テストの絞り込み、ジョブ名でのフィルタ、監視の条件などが該当します。

参考リンク

Footnotes

  1. 引用は actionmailer 8.1.3.1 のものです。 :nodoc: が付いているため、このクラスは API ドキュメントに現れません。 継承関係を確かめようとして最初に開く場所に、答えが載っていないことになります。

  2. retry_jitter の既定値は 0.15 で、config.load_defaults の 6.1 以降で有効になります。 上乗せは Kernel.rand * delay * jitter で、delay+ 2 する前の executions ** 4 です。 待機は上の秒数を下限に、その delay の 15% までのぶんだけ伸びます。

CONTACT

フレイズに相談してみる

開発からデザインまで、構想段階からでも大丈夫です。
まずはお気軽にご相談ください。

[email protected]