ActiveJob の polynomially_longer 戦略のリトライ時間の目安表を作成した
こんにちは、masaki です。
今回は ActiveJob
の retry_on
のオプション attempts
を指定し、リトライの待機時間 wait
に :polynomially_longer
( Rails 7.0 以下では wait: :exponentially_longer
)を設定したときのリトライ開始時間の目安表を作成した話をします。
この内容を見て「結果だけ知りたい」という人は「リトライ回数ごとの開始時間の目安表」を見てください。
また、「ActiveJob
の retry_on
のオプション attempts
の挙動」について詳しく解説した前回の記事も併せてご覧になっていただくと、より理解が深まるのでぜひご覧ください。
リトライ待機時間の戦略
ActiveJob
の retry_on
の待機時間は2つの戦略があります。
- Fixed Backoff --- 固定値で増加させる
- Polynomial Backoff --- 多項式的に増加させる
ActiveJob
の retry_on
を使用したときのデフォルトのリトライ待機時間は3秒の固定値、つまり Fixed Backoff です。
wait
に polynomially_longer
を指定したときに、リトライ待機時間を Polynomial Backoff にできます。
この場合、リトライ待機時間の増加間隔は ((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2
という式で導き出されます。( ActiveJob::Exceptions::ClassMethods#retry_on (Ruby on Rails 7.1.3) より)
リトライの間隔が短すぎてサーバーに負荷をかけ過ぎたり、長すぎて処理の完了が不必要に遅れたり、ということを避けたいときに :polynomially_longer
を使うとよいでしょう。
なぜ polynomially_longer のリトライ時間目安表を作ったか
ソーシャル PLUS では attempts
と組み合わせて wait: :polynomially_longer
を定義することが多く、attempts
が n 回のときの最後のジョブの実行開始時間はどれぐらい後になるの?と思ったことがきっかけでした。
前述したとおり polynomially_longer
の増加間隔は ((executions**4) + (Kernel.rand * (executions**4) * jitter)) + 2
という式で導き出されますが、「最後のジョブがどれぐらい後に実行されるか」というのがぱっと分かりづらいな、と思いました。
あらかじめ目安表を用意しておき「attempts を何回に設定するのがよいか」の議論を円滑に進めることを目的に作成しました。
リトライ回数ごとの開始時間の目安表
というわけで目安表を作成しましたが、注意点があります。
polynomially_longer
によるリトライ時間の増加間隔は、「Kernel.rand
によるランダム値」や「jitter
値」や「ジョブ自体の実行時間」によっても左右されます。
あくまでも目安とするための表なので、「Kernel.rand
によるランダム値」を 0 ~ 1 の中間の 0.5 とし、「jitter
値」をデフォルトの 0.15、「ジョブの実行時間」を 0 で計算しました。
実行回数 | 実行開始時間 | 次の実行までの待ち時間 |
---|---|---|
1 | 0s (0s) | 3s (3s) |
2 | 3s (3s) | 18s (18s) |
3 | 21s (21s) | 1m 23s (83s) |
4 | 1m 45s (105s) | 4m 19s (259s) |
5 | 6m 5s (365s) | 10m 28s (628s) |
6 | 16m 33s (993s) | 21m 39s (1299s) |
7 | 38m 13s (2293s) | 40m 5s (2405s) |
8 | 1h 18m 18s (4698s) | 1h 8m 20s (4100s) |
9 | 2h 26m 38s (8798s) | 1h 49m 25s (6565s) |
10 | 4h 16m 4s (15364s) | 2h 46m 45s (10005s) |
11 | 7h 2m 49s (25369s) | 4h 4m 6s (14646s) |
12 | 11h 6m 55s (40015s) | 5h 45m 41s (20741s) |
13 | 16h 52m 37s (60757s) | 7h 56m 6s (28566s) |
14 | 1d 48m 44s (89324s) | 10h 40m 22s (38422s) |
15 | 1d 11h 29m 6s (127746s) | 14h 3m 51s (50631s) |
16 | 2d 1h 32m 58s (178378s) | 18h 12m 22s (65542s) |
17 | 2d 19h 45m 20s (243920s) | 23h 12m 8s (83528s) |
18 | 3d 18h 57m 28s (327448s) | 1d 5h 9m 43s (104983s) |
19 | 5d 7m 12s (432432s) | 1d 12h 12m 8s (130328s) |
20 | 6d 12h 19m 21s (562761s) | 1d 20h 26m 48s (160008s) |
21 | 8d 8h 46m 9s (722769s) | 2d 6h 1m 29s (194489s) |
22 | 10d 14h 47m 38s (917258s) | 2d 17h 4m 24s (234264s) |
23 | 13d 7h 52m 2s (1151522s) | 3d 5h 44m 9s (279849s) |
24 | 16d 13h 36m 12s (1431372s) | 3d 20h 9m 45s (331785s) |
25 | 20d 9h 45m 58s (1763158s) | 4d 12h 30m 34s (390634s) |
上記の目安表は以下のコードで出力しています。
「Kernel.rand
によるランダム値」や「jitter
値」や「ジョブ自体の実行時間」を調整したい場合はコードの定数を変更することで調整できます。
RAND_VALUE = 0.5 # rand の平均値
JITTER = 0.15 # デフォルトから変更するときは設定する
JOB_EXECUTION_DURATION = 0 # ジョブの実行時間を含めるときは設定する
ONE_DAY_IN_SECONDS = 24 * 60 * 60
def format_duration(duration_in_seconds)
days = duration_in_seconds / ONE_DAY_IN_SECONDS
time = Time.at(duration_in_seconds).utc
hours = time.hour
minutes = time.min
seconds = time.sec
duration_parts = []
duration_parts << "#{days.to_i}d" if days.to_i.positive?
duration_parts << "#{hours}h" if hours.positive?
duration_parts << "#{minutes}m" if minutes.positive? || hours.positive?
duration_parts << "#{seconds}s"
duration_parts.join(' ')
end
def calculate_wait_duration(execution_count)
((execution_count**4) + (RAND_VALUE * (execution_count * 4) * JITTER)) + 2
end
current_time = 0
puts '| 実行回数 | 実行開始時間 | 次の実行までの待ち時間 |'
puts '|----------|--------------|------------------------|'
(1..25).each do |execution_count|
wait_duration = calculate_wait_duration(execution_count)
puts "| #{execution_count.to_s.rjust(8)} |"\
" #{format_duration(current_time).ljust(12)} (#{current_time.to_i}s) |"\
" #{format_duration(wait_duration).ljust(21)} (#{wait_duration.to_i}s) |"
current_time += wait_duration + JOB_EXECUTION_DURATION
end
目安表の精度を検証する
作成した表が目安として使えそうか、ActiveJob を使ったサンプルコードを実行して検証してみました。
- 検証コード
class TestJob < ApplicationJob
class TestError < StandardError; end
retry_on TestError, wait: :polynomially_longer, attempts: 5 do
Rails.logger.info '=== Finished retry_on TestError ==='
end
def perform
$start_time ||= Time.current
$times ||= 0
$times += 1
Rails.logger.info "=== Performed TestJob #{$times} times, #{Time.current - $start_time}s past ==="
raise TestError
end
end
- 検証結果
1回目
実行回数 | 実行開始時間 |
---|---|
1 | 1.2083e-05s |
2 | 9.642510004s |
3 | 29.704695541s |
4 | 116.601339123s |
5 | 404.646322215s |
2回目
実行回数 | 実行開始時間 |
---|---|
1 | 4.975e-05s |
2 | 7.568235087s |
3 | 29.37972325s |
4 | 124.17764946s |
5 | 409.579631009s |
3回目
実行回数 | 実行開始時間 |
---|---|
1 | 3.2292e-05s |
2 | 5.317226336s |
3 | 25.957281721s |
4 | 129.279960129s |
5 | 395.130410127s |
検証結果と目安表に多少ずれはありますが、目安表を参考として使うのはよさそうです。
Polynomial Backoff と Exponential Backoff
polynomially_longer
は、Rails 7.0 以前は exponentially_longer
という名前だった、と冒頭で話しましたが、exponentially_longer
(Exponential Backoff) についてどういう違いがあるかを参考として記載しておきたいと思います。
Exponential Backoff は、リトライの待機時間を「指数関数的に増加させる」戦略です。
実際は「多項式的に増加させる」という動作であるのに exponentially_longer
という名前になっているのは混乱を生むため、Rails 7.1 から polynomially_longer
に名前が変わることになりました。
Fixed Backoff は一定のペースで再試行間隔が増加、Exponential Backoff は非常に速く増加、Polynomial Backoff ではより穏やか、という特徴があります。
以下のグラフを見ていただくと、各戦略ごとの待ち時間の増加率の特徴がよく分かるのではと思います。
まとめ
この記事を通じて、Ruby on Rails の ActiveJob
で retry_on
オプションを使用する際の polynomially_longer
戦略のリトライ時間の目安表や検証について説明しました。
本記事の目安表がジョブのリトライ間隔を適切に設定するための一助となれば幸いです。
Discussion