Claude Media
Claude CodeでSidekiqのジョブを書く — 冪等性・リトライ・dead setをテストで確かめる

Claude CodeでSidekiqのジョブを書く — 冪等性・リトライ・dead setをテストで確かめる

Sidekiqのジョブは少なくとも1回実行される前提で書きます。Claude Codeに任せるときの指示、レビュー観点、Sidekiq::Testingで二重実行を確かめる手順をまとめます。

Sidekiqのジョブは「少なくとも1回」実行されます。Claude Codeにジョブを書かせるなら、この前提を指示とテストの両方に入れておくのが近道です。一見動くジョブでも、リトライで二重に課金や通知が走る書き方は珍しくありません。

この記事では返金ジョブを題材に、引数の決め方、冪等にする書き方、リトライを使い切ったときの処理、Sidekiq::Testing での検証までを順に組みます。

Sidekiqのジョブに求められる前提

Claude Codeに渡す指示は、Sidekiq側の約束事から逆算すると短く済みます。Sidekiq wikiのBest Practicesは、ジョブについて次の点を挙げています。

前提

ジョブを書く前に押さえるSidekiqの約束

  • 引数は小さく単純に

    引数はJSONとしてRedisに保存されます。ActiveRecordのオブジェクトではなく、IDのような単純な値を渡し、perform の中で引き直します。

  • 冪等かつトランザクション的に

    リトライにより、途中まで処理したジョブが何度も再実行されることがあります。完了済みのジョブでも再実行される可能性があります。

  • exactly onceの保証はない

    Redisが落ちるタイミング次第で、処理が終わったジョブが再実行されます。Sidekiqに「ちょうど1回」の保証はありません。

引数に使える型は、文字列・整数・浮動小数点数・真偽値・nil・配列・ハッシュです。Rubyのシンボル、名前付き引数、Date や Time はJSONの往復で壊れるため渡せません。ハッシュを渡すなら stringify_keys でキーを文字列にします。

Claude Codeに渡す前に、CLAUDE.mdとルールへ約束事を置く

毎回の指示で「冪等にして」と書く代わりに、ジョブ専用のルールを置きます。.claude/rules/ のMarkdownは、frontmatterの paths で対象ファイルを絞れます。パスに一致するファイルをClaudeが読む、書く、編集するときに読み込まれる仕組みです。

---
paths:
  - "app/jobs/**/*.rb"
---
 
# Sidekiqジョブのルール
 
- `perform` の引数はIDなどJSONで表せる単純な値だけにする。モデルやシンボルは渡さない
- ジョブは複数回実行されても結果が変わらないようにする。完了済みなら何もせず戻る
- 外部APIや決済を呼ぶ処理は、呼ぶ前に完了フラグを確認する
- `sidekiq_retries_exhausted` を定義し、使い切ったときの状態遷移を明示する
- テストは `spec/jobs` に置き、同じIDで2回 `perform` する例を必ず含める
- 確認コマンド: `bundle exec rspec spec/jobs`

CLAUDE.md本体には、全セッションで必要な事実だけを置きます。Claude Codeのベストプラクティスページは、テスト・型チェック・lintの実行コマンドや、単一ファイルのテストの回し方を書く例を挙げています。長い手順や一部のディレクトリにしか関係しない決まりは、ルールやSkillに移すのが向く扱いです。上のルールを app/jobs に限ったのはこの理由です。

CLAUDE.mdはコンテキストであって強制設定ではありません。守られているかはテストで確かめます。その検証のしかたは後半で扱います。

計画を立てさせてから書かせる

ジョブの設計は後から直すと影響が広いので、最初は編集なしで方針だけ出させます。plan modeはClaudeがファイルを読んで計画を示し、承認するまで編集しません。

claude --permission-mode plan

セッション中は Shift+Tab を押して、ステータスバーが ⏸ plan mode on になるまで切り替える方法もあります。指示は「何を壊したくないか」を先に書きます。

app/jobs に RefundChargeJob を追加したい。CardCharge の ID を受け取り、
決済を取り消してから利用者へ返金通知メールを送る。
Sidekiq はリトライで同じジョブを複数回実行するので、
二重に取り消さない・二重にメールしない設計案を先に出して。
実装はまだしないで。

返ってきた計画では、次の3点を見ます。

  1. 引数がIDだけになっているか
  2. 「完了済み」を何で判定するか(DBの列か、外部サービスの冪等キーか)
  3. 失敗を繰り返したときの終着点が決まっているか

2が曖昧な計画は、実装に進めても二重実行を防げません。ここで列を追加する方針を決めておくと、後の差分が小さくなります。

冪等な返金ジョブの書き方

Best Practicesの例は、取引を取り消してからメールを送るジョブでした。メールのレンダリングが失敗すると、リトライのたびに void_transaction が再実行されます。ここで「すでに返金済みのケースを扱えるか」を問うています。

これを、完了フラグ(refunded_at、notified_at)で守る形にします。

class RefundChargeJob
  include Sidekiq::Job
  sidekiq_options retry: 8
 
  sidekiq_retries_exhausted do |job, _ex|
    RefundChargeJob.mark_failed(job["args"].first)
  end
 
  def self.mark_failed(card_charge_id)
    CardCharge.where(id: card_charge_id, refunded_at: nil)
              .update_all(refund_state: "failed")
  end
 
  def perform(card_charge_id)
    charge = CardCharge.find(card_charge_id)
 
    charge.with_lock do
      unless charge.refunded_at
        PaymentGateway.void(charge.gateway_id)
        charge.update!(refunded_at: Time.current)
      end
    end
 
    return if charge.notified_at
 
    Emailer.charge_refunded(charge).deliver_now
    charge.update!(notified_at: Time.current)
  end
end

読みどころは3つです。

  • 引数は card_charge_id だけで、オブジェクトは perform の中で引き直します
  • 取り消しは with_lock の中でフラグを確認してから行い、成功後に refunded_at を書きます。再実行しても PaymentGateway.void に二度目は届きません
  • メールは別のフラグで守ります。メール送信の失敗はリトライされますが、取り消しはやり直されません

完璧ではない点も残ります。メールを送った直後、notified_at を書く前にプロセスが落ちると、再実行で同じメールがもう一度出ます。ここを許容するか、メール側の冪等キーで防ぐかは業務の判断です。コードに任せず、設計の段階で決めておくところです。

なお、決済の呼び出しとフラグ更新も、トランザクションだけでは原子的になりません。PaymentGateway.void が成功し update! が失敗する窓が残るため、決済側が冪等キーを受け付けるならそれも併用します。

リトライを使い切ったとき、どこへ行くか

Sidekiqはエラーを自動で再試行します。ErrorHandlingページによれば、間隔は指数的に延び、リトライは25回、期間はおよそ20日です。同じページの別の箇所には「約21日」という書き方もあり、日数は目安です。25回を使い切ると、ジョブはDead setに移ります。

設定失敗が続いたときの動き
既定(retry 指定なし)失敗が続いたときの動き25回リトライし、Dead setへ移る
retry: 5失敗が続いたときの動き5回でDead setへ移る
retry: 0失敗が続いたときの動きリトライせず、すぐDead setへ移る
retry: false失敗が続いたときの動きリトライせず、破棄される
retry: 5, dead: false失敗が続いたときの動き5回リトライして、消える

Dead setは既定で10,000件または6か月が上限で、それを超えると消えます。手動の再実行はWeb UIの「Dead」タブから行います。「0以上のリトライを設定したジョブだけがDead setに入る」とも明記されています。

sidekiq_retries_exhausted は、ジョブクラスごとに定義するフックです。Dead setに移す直前に呼ばれ、ジョブのハッシュを受け取ります。:discard を返すと保存せず捨てられます。全ジョブ共通の通知なら、Sidekiq 5.1以降の death_handlers が使えます。

Claudeに書かせたジョブで特に見たいのが、このフックの中身です。フックが空のままだと、返金に失敗した利用者の状態は「処理中」のまま止まります。上の例では refund_state を failed にして、画面や運用側から拾えるようにしました。

Claude Codeへの実装指示

計画が固まったら、plan modeを抜けて実装させます。ここでは「何を満たせば完了か」を、実行できる形で渡します。

計画のとおり RefundChargeJob と spec/jobs/refund_charge_job_spec.rb を実装して。
spec は同じ ID で 2 回 perform しても PaymentGateway.void が 1 回しか
呼ばれないことと、メール送信が失敗した後の再実行で二重に取り消さない
ことを含めて。最後に bundle exec rspec spec/jobs を実行し、出力を見せて。

ベストプラクティスページは、Claudeに合否が返ってくるチェックを渡すと、変更・実行・出力の読み取り・修正の反復を自走できると説明しています。テストファイルもそのチェックの一つです。完了の報告だけでなく、実行したコマンドとその出力を求めるよう勧めています。

テストの書き方は、既存のspecの流儀に合わせるよう伝えます。common-workflowsの「Work with tests」の節では、Claudeが既存のテストファイルを見て、使用中のフレームワークやアサーションの形式に揃えると説明しています。RSpecの回し方そのものはClaude CodeでRSpecを回し、失敗だけを絞って直させるにまとめています。

Sidekiq::Testingで二重実行を確かめる

ここからがこの記事の核心です。Sidekiqのテスト用ハーネスには3つのモードがあります。

モード動き
fake動き投入したジョブをプロセス内の jobs 配列に溜める(sidekiq/testing を読み込むと自動で有効)
inline動き投入と同時にその場で実行する
disable動き通常どおりRedisへ投入する

sidekiq/testing を require すると fake! が呼ばれ、ジョブはRedisに入りません。本番コードから読み込まないよう注意書きがあります。Sidekiq 8.1.1以降には Sidekiq.testing!(:fake) という新しいAPIもあり、require が不要です。使っているバージョンに合わせて選びます。

まずは perform を直接呼ぶ形で、冪等性を確かめます。

require "sidekiq/testing"
 
RSpec.describe RefundChargeJob do
  let(:charge) { create(:card_charge, gateway_id: "ch_1") }
 
  before do
    allow(PaymentGateway).to receive(:void)
    allow(Emailer).to receive_message_chain(:charge_refunded, :deliver_now)
  end
 
  after { Sidekiq::Worker.clear_all }
 
  it "同じIDで2回実行しても取り消しは1回だけ" do
    2.times { described_class.new.perform(charge.id) }
 
    expect(PaymentGateway).to have_received(:void).once
  end
 
  it "メール送信が失敗した後に再実行しても二重に取り消さない" do
    allow(Emailer).to receive(:charge_refunded).and_raise(Net::ReadTimeout)
    expect { described_class.new.perform(charge.id) }.to raise_error(Net::ReadTimeout)
 
    allow(Emailer).to receive_message_chain(:charge_refunded, :deliver_now)
    described_class.new.perform(charge.id)
 
    expect(PaymentGateway).to have_received(:void).once
    expect(charge.reload.notified_at).not_to be_nil
  end
end

Testingページは、ジョブ単体のテストでは HardWorker.new.perform(1, 2) のように、普通のRubyオブジェクトとして呼べばよいと説明しています。2本目はリトライを手で再現したものです。1回目の失敗の後、同じジョブをもう一度呼んでいます。

次に、キュー経由の挙動です。引数の形と、同じジョブが2度投入されたときの実行結果を見ます。

it "引数はIDだけを積む" do
  described_class.perform_async(charge.id)
 
  expect(described_class.jobs.size).to eq(1)
  expect(described_class.jobs.last["args"]).to eq([charge.id])
end
 
it "同じジョブを2度投入して drain しても取り消しは1回" do
  2.times { described_class.perform_async(charge.id) }
  described_class.drain
 
  expect(PaymentGateway).to have_received(:void).once
end

drain は溜まったジョブをすべて実行します。全クラス分なら Sidekiq::Worker.drain_all です。テスト間にジョブが残らないよう、clear_all を before か after で呼ぶ例がTestingページにあります。

使い切ったあとの処理も確かめます。フック本体は小さなメソッドに寄せてあるので、単体で呼べます。

it "リトライを使い切ったら返金状態を failed にする" do
  described_class.mark_failed(charge.id)
 
  expect(charge.reload.refund_state).to eq("failed")
end
 
it "すでに返金済みなら failed にしない" do
  charge.update!(refunded_at: Time.current)
  described_class.mark_failed(charge.id)
 
  expect(charge.reload.refund_state).not_to eq("failed")
end

ここで検証しているのは、フックが呼ばれた後の状態遷移です。フックがSidekiqから実際に呼ばれること、そして本物のDead setに入ることは、fake モードのテストでは確かめていません。Testingページに、SidekiqのAPI(Sidekiq::ScheduledSet.new.each など)にはテストモードがなく、常にRedisに当たるとあります。Dead setの挙動を見たい場合は Sidekiq::Testing.disable! で実Redisを使うか、ステージングでWeb UIの「Dead」タブを開いて確認します。

レビューで見る観点の一覧

Claudeが書いたジョブのレビューは、次の表の順に見ます。/code-review のように、会話の経緯を知らない別のコンテキストで差分を見る方法もあります。

観点見る場所危ない例
引数見る場所perform_async の呼び出し側危ない例モデル、シンボル、Time を渡している
冪等性見る場所perform の冒頭危ない例完了フラグの確認なしに外部APIを呼ぶ
副作用の順序見る場所外部呼び出しとDB更新の間危ない例外部呼び出しの成功後に更新が落ちる窓がある
リトライ設定見る場所sidekiq_options危ない例既定の25回のまま、通知ジョブで数日かけて再送する
使い切ったとき見る場所sidekiq_retries_exhausted危ない例未定義で、状態が「処理中」のまま残る
テスト見る場所spec/jobs危ない例1回しか perform していない

レビューを頼むときは、観点を絞って「スタイルは対象外」と添えます。ベストプラクティスページも、問題を探せと言われたレビュアーは必ず何かを見つけるため、指摘をすべて直すと不要な抽象化が増えると注意しています。

プロセスがクラッシュした場合、実行中だったジョブは失われるとErrorHandlingページに書かれています。Sidekiq Proの信頼性の高いキュー機能がその対策として案内されています。標準のSidekiqで失うと困る処理は、冪等な再投入の仕組みを別に用意します。

よくあるつまずき

落とし穴

モデルのオブジェクトをそのまま引数に渡す

ActiveRecordのオブジェクトを perform_async に渡すと、to_s 相当の文字列になって届きます。キューが詰まる間にレコードが変わる問題もあり、IDだけを渡して perform で引き直す、がBest Practicesの整理です。

落とし穴

リトライ間隔を短くして負荷を上げる

sidekiq_retry_in で間隔を計算できます。ブロックの戻り値は秒数、:kill(Dead setへ)、:discard(捨てる)、nil(既定の間隔)のどれかです。例外ごとの出し分けを書かせる前に、期待する再試行の回数と最終状態を決めておくと、Claudeの実装が揺れません。

落とし穴

テストの前提がfakeかinlineか混ざる

inline! を before で呼びっぱなしにすると、他のテストにモードが残ります。Testingページはブロック付きの inline! do ... end を勧めています。ブロックは現在のスレッドだけに効き、他スレッド(Capybaraなど)には fake が見えます。

Python側のタスクキューならCeleryタスクをpytestで検証する手順が同じ型の検証を扱います。冪等性を「完了を示す状態を確かめてから副作用に進む」と捉える点は、Claude CodeでAnsibleのPlaybookを書く手順の冪等性の検証とも共通します。Railsアプリ全体の組み立てはClaude CodeでRailsアプリを開発する手順から辿れます。

まとめ

ジョブの品質は「何度実行されても同じ結果になるか」で決まります。その確認は、指示文の工夫よりも、二重に perform するテストを完了条件に置くほうが確実です。ルールファイルで約束事を渡し、plan modeで完了判定の方式を先に決め、Sidekiq::Testing で二重実行と失敗後の再実行を回し、使い切ったときの状態遷移まで書かせる。この順序なら、Claudeの実装が揺れても、テストが赤になって差分が見えます。Dead setへの実際の移動だけは、実Redisのあるステージングで一度確かめておくと安心です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. code.claude.com/docs/en/best-practices
  3. code.claude.com/docs/en/memory
  4. github.com/sidekiq/sidekiq/wiki/Best-Practices
  5. github.com/sidekiq/sidekiq/wiki/Error-Handling
  6. github.com/sidekiq/sidekiq/wiki/Testing
この記事を共有:XはてブLinkedIn