🎃

ActiveJob の polynomially_longer 戦略のリトライ時間の目安表を作成した

2024/03/13に公開

こんにちは、masaki です。
今回は ActiveJobretry_on のオプション attempts を指定し、リトライの待機時間 wait:polynomially_longer( Rails 7.0 以下では wait: :exponentially_longer)を設定したときのリトライ開始時間の目安表を作成した話をします。
この内容を見て「結果だけ知りたい」という人は「リトライ回数ごとの開始時間の目安表」を見てください。
また、「ActiveJobretry_on のオプション attempts の挙動」について詳しく解説した前回の記事も併せてご覧になっていただくと、より理解が深まるのでぜひご覧ください。

https://zenn.dev/socialplus/articles/1e5bfa2619ec55

リトライ待機時間の戦略

ActiveJobretry_on の待機時間は2つの戦略があります。

  • Fixed Backoff --- 固定値で増加させる
  • Polynomial Backoff --- 多項式的に増加させる

ActiveJobretry_on を使用したときのデフォルトのリトライ待機時間は3秒の固定値、つまり Fixed Backoff です。
waitpolynomially_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 に名前が変わることになりました。

https://github.com/rails/rails/pull/49292

Fixed Backoff は一定のペースで再試行間隔が増加、Exponential Backoff は非常に速く増加、Polynomial Backoff ではより穏やか、という特徴があります。
以下のグラフを見ていただくと、各戦略ごとの待ち時間の増加率の特徴がよく分かるのではと思います。

まとめ

この記事を通じて、Ruby on Rails の ActiveJobretry_on オプションを使用する際の polynomially_longer 戦略のリトライ時間の目安表や検証について説明しました。
本記事の目安表がジョブのリトライ間隔を適切に設定するための一助となれば幸いです。

SocialPLUS Tech Blog

Discussion