freeeの開発情報ポータルサイト

ActiveJob の retry_on を使いこなす

こんにちは! 大阪で freee販売を開発しております、bucyou (ぶちょー) というモノでございます。入社1年にしてようやく DevelopersHub デビューです! Rails での開発や、設計面の話題をお送りしたいと思います。今日は ActiveJob についてです。

ActiveJob 使っていますか? Rails で非同期な処理を組み立てるときには非常に便利なツールです。ここでは、ActiveJob のリトライ機構についてまとめます。なお、Rails 7ベースでの情報を記載していきます。

一時的なエラーに立ち向かう

例えば HTTPリクエストを行って、外部のコンテンツを取得するといった非同期処理があるとします。

このような処理は、ネットワークの都合 (接続元の問題かもしれませんし、接続先の問題かもしれません) によって、エラーになってしまうことは、まれにあります。 そもそもリクエスト方法が誤っているといった、400, 404 エラーなのであれば、「恒久的なエラー」として扱い、処理を中断すれば良いのですが、ネットワークの一時的な不調のような、「一時的なエラー」について処理を中断してしまうと、Jobの成功率が不安定な要素によって変動してしまいます。

ありがたいことに、ActiveJob にはエラーが発生した場合に、再実行するための機構が存在します!

再実行を行うに当たっての大前提

これは、非同期処理一般に言える大前提ですが、再実行を行うに当たって「冪等性」を考える必要があります。

再実行が発生したとして、以下のような状況になることは避けたい場合が多いです。

  • 中途半端なデータが作られてしまう
  • 2回処理が実行されてしまうことにより、2件の同じ内容のデータが意図せず登録されてしまう

このため、再実行が必要な非同期処理を行うときは同じパラメータが渡されたのであれば、何度実行してもそのタイミングにおいては同じ結果となることを保証しておく必要があります。

ActiveJob における再実行

ActiveJob の再実行機構について見てみましょう

class FetchItemJob < ActiveJob::Base


  retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 10 do |job, error|
    # ここで最終的に失敗し続けたときに行う処理がかける
  end

  # 別の例外における対応も書くことができる
  retry_on Timeout::Error, wait: 10.seconds, attempts: 10

  def perform(*args)
  end
end

ActiveJob::Base には、retry_on という特異メソッドが用意されていて、これを呼び出しておくことで perform 実行中に発生した特定の例外を補足し、特定時間後に再実行してくれます。

引数を見ておきましょう。

  • *exceptions: 例外を複数指定することができる可変引数です。上記の例では Net::OpenTimeout だけを指定していますが、Net::OpenTimeout, Timeout::Error のようにまとめて例外を指定することもできます。
  • wait: リトライの待ち時間です。時間か、時間を生成する Proc を指定できます。これは面白い仕様もあるので、後述します。デフォルトは3秒です。
  • attempts: 最大実行回数です。この回数に達してもなおエラーが起きる場合はエラーとして処理します。デフォルトは5回です。:unlimited にすると永遠に繰り返す事ができます。危険なのでよっぽどの要件が無い限りはこれを指定するのはやめましょう。
  • queue: 再実行時のキューです。再実行のときだけキューを変えたいときなどはこちらを指定できます。
  • priority: 再実行時の優先度です。再実行のときだけ優先度を上げたり下げたりしたいときはこちらを指定できます。
  • jitter: 待ち時間にブレを与えるための引数です。これも待ち時間の話題で後述します。この機構は Rails 6.1 から用意されたものです。

待ち時間の議論

ものにはよりますが、再実行における待ち時間が一定である状況は好ましくない場合はあります。例えば以下のような状況です。

  • 同じJobが同一タイミングで2回発生している状況でどちらも再実行状態になると、再実行も同じタイミングになる。競合状態などを引き起こしたり、結局どちらもエラーとなってしまう状況が続いたりしてしまう。
  • 最初の一時的なエラーは「たまたま」発生したものなので、即座に再実行したい。それでもエラーが起きるならちょっと待ちたい。さらにエラーが起きるなら更に待っておきたい。

それぞれの解決法については、「待ち時間をバラす」ことと、「待ち時間を実行回数に合わせて伸ばす」ことです。

「待ち時間をバラす」点については、jitter という値がこれを担っています。ActiveJob のデフォルトでは待ち時間は以下のように計算されます。jitter 自体のデフォルトは 0.15 です。

wait + (rand * wait * jitter)

つまり、5秒の待ち時間を設定したとき、待ち時間の15% が最大加算されるという挙動になります。つまり、5.0秒〜5.75秒の間で待ち時間が発生します。 jitter を2にすると、+200% が最大値になるので、5.0秒〜15.0秒の待ち時間になることになり、だいぶ振れ幅が大きくなります。

「待ち時間を実行回数に合わせて伸ばす」点については、2つの解決策があります。wait には Proc を渡すことができ、引数には「実行回数」が渡されるという性質を持っています。 このため、wait: ->(execution) { execution * 3.seconds } のように指定すると、3秒, 6秒, 9秒... のような形で待ち時間が長くなっていきます。 さらに wait には、最初から便利なアルゴリズムが組み込まれています。:exponentially_longer という値を指定すると、以下のような計算式で待ち時間を計算します。

((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2

これは、3秒+jitter、18秒+jitter、83秒+jitter、のように実行回数の4乗を2秒に加算するという式になっています。

いろいろな選択肢がありますが、待ち時間をどのように設定するかについては、

  • 成功・失敗の結果をフィードバックするためにどれくらいの時間まで待つことができるか (リトライを含めた最大の時間を見積しておくこと)
  • 一時的なエラーの特性

のような状況に応じて設定しておくべきでしょう。

ところで、:exponentially_longer については、つい2週間ほど前に :polynomially_longer という名前に変更されるPRがマージされました。次のバージョンアップ以降、:exponentially_longer を使用していると警告が表示されるでしょう。 exponentially というのは、指数関数を意味する言葉です。2 ** executions という計算式あれば指数関数になりますので正しいです。Google Cloud のドキュメント 上でも利用されている用語になります。 一方で、Rails に現状実装されている式は、executions ** 4 をベースにしているため、底を増加させていき指数は変動させないという式になっています。これは、exponentially ではありません。ということで、誤解を防ぐために、多項式を意味する polynomially という言葉に置き換えられました。

リトライ回数が増えてくると、意味が違うだけで顕著に待ち時間が変わるので、特に exponentially の意味を知っている人にとっては、要注意な実装であると考えられそうです。

まとめ

処理が行われる確率が低かったり、エラーの結果が利用者にフィードバックされない状況になるのは寂しいものです。 ActiveJob を使うときは、リトライ設計や、エラーが起きたときのフィードバック方法などについてもしっかり話し合っておきましょう。

参考