Sidekiq のリトライ処理についてまとめた


業務で Sidekiq を使う時、ジョブの中でやりたいことが Slack へ通知を送ることだったのですが、
API を使うと当然ネットワークのエラーなど開発者にはどうにも出来ないエラーが起こり得ます。
そんな時のために Sidekiq のリトライ処理について調査したのでまとめておこうと思います。

前提

  • ruby 2.6.3
  • rails 6.0.3
  • sidekiq 6.4.1

目次

  1. Worker を生成する
  2. リトライ処理を書く時に気をつけること
  3. リトライ処理を書く

Worker を生成する

まずは Sidekiq の Worker を作成します。

$ rails generate sidekiq:job SlackNotify

ジョブでやりたいことは Slack へ通知を送ることとします。

class SlackNotifyWorker
  include Sidekiq::Job

  def perform(*args)
    # Do something
  end
end

リトライ処理を書く時に気をつけること

リトライ処理を書く前に考えるべきことは、ジョブが冪等になっているかどうかです。
ジョブをリトライした時に副作用が生じないようにジョブを設計しなければいけません。
例えば、複数の Slack チャンネルに通知したい場合を考えます。

def perform(channels)
  channels.each do |channel|
    SlackNotifier.call(channel)
  end
end

これだと1個目のチャンネルへの通知は成功したけど、2個目のチャンネルへの通知に失敗して
しまった際ジョブをリトライした時に、再度1個目のチャンネルに通知が飛んでしまうと言う
副作用が発生してしまいます。この様な副作用が発生しないように、1つのチャンネルに通知する
ジョブを実装しなければいけません。

def perform(channel)
  SlackNotifier.call(channel)
end

このジョブでは副作用は発生しません。ジョブを実装する際は冪等性に気を付けましょう。

リトライ処理を書く

Sidekiq はジョブの実行に失敗した時、デフォルトで25回リトライします。
sidekiq.yml の中で max_retries を指定することでデフォルト値を変更することも
出来ますし、sidekiq_options を使ってジョブごとに指定する事も出来ます。

:max_retries: 5

私はジョブを実装する時は他の開発者のためにも明示的にリトライ回数を指定しておいた方が
良いと思っています。

class SlackNotifyWorker
  include Sidekiq::Job

  sidekiq_options retry: 5

  def perform(channel)
    SlackNotifier.call(channel)
  end
end

またリトライ回数だけでなく、リトライに失敗した時に実行される処理も定義出来ます。
私は最初ジョブを実装した時、下記の様に例外処理を書いていたのですが、sidekiq_options
みたいにリトライに失敗した時の処理を定義出来たらなーと思い Wiki をちゃんと読んでみると
sidekiq_retries_exhausted というメソッドがありました。

class SlackNotifyWorker
  include Sidekiq::Job

  sidekiq_options retry: 5

  def perform(channel)
    SlackNotifier.call(channel)
  rescue StandardError => e
    # Do something
  end
end

こんな感じで書けます。

class SlackNotifyWorker
  include Sidekiq::Job

  sidekiq_options retry: 5

  sidekiq_retries_exhausted do |msg, ex|
    ExceptionNotifier.call(msg, ex)
  end

  def perform(channel)
    SlackNotifier.call(channel)
  end
end

Sidekiqのコードを読んでみると、定義されたブロックは retries_exhausted というメソッドの
中で呼び出される様です。引数に msgexception を渡してますね。

def retries_exhausted(jobinst, msg, exception)
  begin
    block = jobinst&.sidekiq_retries_exhausted_block
    block&.call(msg, exception) # ←ココ
  rescue => e
    handle_exception(e, {context: "Error calling retries_exhausted", job: msg})
  end

  send_to_morgue(msg) unless msg["dead"] == false

  Sidekiq.death_handlers.each do |handler|
    handler.call(msg, exception)
  rescue => e
    handle_exception(e, {context: "Error calling death handler", job: msg})
  end
end

渡された msg の中身は

# msg
{
  "retry"=>5,
  "queue"=>"article_notifier",
  "args"=>["invalid_channel"],
  "class"=>"SlackNotifyWorker",
  "jid"=>"ab88ef1f049ed91d5731ff18",
  "created_at"=>1649118311.218128,
  "enqueued_at"=>1649118311.2181807,
  "error_message"=>"channel_not_found",
  "error_class"=>"Slack::Web::Api::Errors::ChannelNotFound",
  "failed_at"=>1649118311.6029007,
  "retry_count"=>5
}

こんな風になっていました。 exceptionmsg[’error_class’] でも確認出来るSlack::Web::Api::Errors::ChannelNotFound です。

ちなみにリトライの間隔も指定することが出来ます。デフォルトでは

(count**4) + 15 + (rand(10) * (count + 1))

となっているのですが、 sidekiq_retry_in を利用して等間隔でリトライさせる事も出来ます。

class SlackNotifyWorker
  include Sidekiq::Job

  sidekiq_options retry: 5

  sidekiq_retries_exhausted do |msg, ex|
    ExceptionNotifier.call(msg, ex)
  end

  sidekiq_retry_in do |count, ex|
    10
  end

  def perform(channel)
    SlackNotifier.call(channel)
  end
end

count にはリトライした回数が、 ex には例外クラスが入っています。
以上でリトライ処理の実装が完了となります。

参考資料

Sidekiq Wiki
Rails: Active Jobスタイルガイド(翻訳)