Ruby on Rails

Active Job Continuationsとは?Rails 8.1のstep・cursorでジョブを中断・再開する方法

Active Job Continuationsとは?Rails 8.1のstep・cursorでジョブを中断・再開する方法

Active Job Continuationsは、Rails 8.1(2025年10月22日リリース)でActive Jobに入った、長時間ジョブを途中で止めて続きから再開するための仕組みです。ジョブに ActiveJob::Continuable を include し、処理を step に分け、ループの位置を cursor に記録すると、デプロイなどでワーカーが止まる時点の進捗をジョブ自身が持ち越せます。この記事では、2026年9月24日時点の最新版activejob 8.1.4のソースを読み、テストアダプタで実際に動かした結果をもとに、書き方と導入時の判断材料をまとめます。仕様はRails 8.1.4のAPIドキュメントとv8.1.4タグのソースで確認しています。実行結果はRuby 4.0.6・activejob 8.1.4・minitestで、Railsアプリを使わずに確認したものです。

まとめ:Active Job Continuationsの要点

  • 使えるのはRails 8.1以降。include ActiveJob::Continuable を書き、perform の中で step を並べる
  • 中断できるのはチェックポイントだけ(advance!・set!・checkpoint!と、2つ目以降のstepの開始時)。処理の途中で強制的に止まるわけではない
  • 進捗はチェックポイントのたびに保存されるわけではない。中断して再投入される時点でジョブデータの continuation キーに書き込まれる。SIGKILL後の復旧可否はバックエンドと設定による。復旧しても今回の実行中に永続化されなかった進捗は失われる
  • 中断を検知できるのは、stopping? を実装したアダプタだけ。Solid Queue 1.2.0以降、Sidekiq、GoodJob 4.11.0以降、Shoryuken 7.0.0以降が該当し、Rails同梱のResque・Delayed Jobアダプタは対象外
  • cursorは整数・文字列・配列にする(8.1.4ではTimeやシンボルが文字列になって戻る)
  • チェックポイントの間隔は停止猶予(Solid Queue 5秒・Sidekiq 25秒・Kamal 30秒)より短くする
  • Resqueを使っている、または一定時間ごとに区切りたい場合は、Shopifyのjob-iterationのほうが合う

以下、書き方、内部動作、アダプタ対応、落とし穴、テスト、類似gemとの比較の順に説明します。

Active Job Continuationsが解決する問題:デプロイ時の長時間ジョブ中断

数十万件のCSVインポートや全ユーザーへの再計算のように、1回の実行に数十分かかるジョブは、途中でワーカーが再起動されると最初からやり直しになります。Rails 8.1の告知記事は、Kamalでデプロイするとジョブ実行用のコンテナに与えられる停止猶予が既定で30秒しかない点を例に挙げています。デプロイのたびに数十分の処理が振り出しに戻れば、1日に何度もデプロイするチームでは長いジョブが永遠に終わりません。

この機能は37signalsのDonal McBreen氏が主導し、Rails本体のPR #55127として2025年5月30日にマージされ、Rails 8.1.0で正式に入りました。それまでは、ジョブを細かく分割して連鎖的に投入するか、Shopifyのjob-iteration gemを入れるのが一般的な対処でした。Continuationsは、job-iterationと同じ考え方をActive Job標準のAPIとして取り込んだものと捉えると位置づけがわかりやすくなります。ジョブ基盤そのものの仕組みはRails Active Jobの基本とSolid Queueの設定で扱っています。

ActiveJob::Continuableの書き方:stepとcursor

stepの定義:ブロック形式とメソッド形式

step にはブロックかメソッド名を渡します。stepは書いた順に実行され、再開時には完了済みのstepが飛ばされます。メソッド形式の場合、引数は0個か1個(stepオブジェクト)に限られ、キーワード引数を取るメソッドを渡すと ArgumentError になります。

class ProcessImportJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(import_id)
    # step の外は再開のたびに毎回実行される
    @import = Import.find(import_id)

    step :validate do
      @import.validate!
    end

    step :process_records do |step|
      @import.records.find_each(start: step.cursor) do |record|
        record.process
        step.advance! from: record.id
      end
    end

    step :finalize
  end

  private

  def finalize
    @import.finalize!
  end
end

stepの外に書いたコードは、中断から再開するたびに毎回実行されます。テストで同じジョブを1回中断させて再開させたところ、step外のカウンタは2になりました。Import.find のような読み込みは置いてよい一方、メール送信のような副作用はstepの外に置かないでください。

cursorの進め方:advance!・set!・配列カーソル

cursorはstep内の位置を表す値で、既定は nil です。start: で初期値を指定できます。advance! は現在値に succ を呼んで1つ進め、from: を渡すとその値の次に進めます。IDが連番でないテーブルを回すときは advance! from: record.id を使います。任意の値を入れたいときは set! です。

step :process_nested_records, start: [0, 0] do |step|
  Account.find_each(start: step.cursor[0]) do |account|
    account.records.find_each(start: step.cursor[1]) do |record|
      record.process
      step.set! [account.id, record.id + 1]
    end
    step.set! [account.id + 1, 0]
  end
end

cursorの型には注意が要ります。8.1.4ではcursorがActive Jobの引数用シリアライザを通らず、そのままジョブデータに入ります。ジョブデータをJSONで保存するバックエンドを想定して往復させると、Timeのcursorは文字列「2026-10-01 00:00:00 UTC」に、シンボルは文字列に変わって戻りました。整数・文字列・それらの配列にしておけば型は変わりません。テストアダプタはJSONを経由しないので、この差はテストでは見つかりません。Rails本体ではcursorを引数用シリアライザに通す変更(PR #58045)が2026年8月5日にmainへマージされていますが、8.1.4の変更履歴には含まれていません。

succ を持たない値で advance! を呼ぶと UnadvanceableCursorError になります。実際に start: 1.5 で試すと「Cursor class ‘Float’ does not implement ‘succ’」で止まりました。

checkpoint!による中断地点とisolated: trueによる実行分離

cursorを更新する必要はないものの、中断できる地点は欲しいという場合は step.checkpoint! を呼びます。レコードを順に削除するループのように、処理済みのものが対象から自然に消える場合に向いています。

1つのstepが長すぎて内部にチェックポイントを置けない場合は、step :slow_step, isolated: true を指定します。同じ実行内で先行する処理が進んでいれば、そのstepの前で再投入し、進捗を保存してから実行します。後続のstepも別の実行へ分離されます。ただし、その実行で最初のstepなら開始前の再投入はなく、重い処理自体の強制終了を防ぐ機能でもありません。

中断と再開の内部動作(activejob 8.1.4のソースで確認)

チェックポイントでstopping?を確認する仕組み

チェックポイントでは queue_adapter.stopping? を呼び、trueなら ActiveJob::Continuation::Interrupt を投げます。この例外は StandardError ではなく Exception を継承しているので、アプリ側の rescue => e には捕まりません。自動のチェックポイントは、1回の実行で最初のstepを除く各stepの開始時と、set!・advance!・checkpoint! の呼び出し時に置かれます。最後のstepの後にはチェックポイントがありません。

ソースのコメントにも明記されているとおり、アダプタが停止中になってもジョブは即座には止まりません。次のチェックポイントに到達するか、プロセス自体が終了するまで走り続けます。

進捗の保存タイミング:continuationキーに書かれるのは再投入時

中断されたジョブは retry_job で投入し直され、その際のシリアライズで進捗が continuation キーに入ります。5件を処理するジョブをcursor=3の地点で中断させると、再投入されたジョブデータは次のようになりました。

after interrupt: [:prepare, 1, 2]
serialized continuation: {"completed" => ["prepare"], "current" => ["process", 3]} resumptions=0 executions=1
after resume: [:prepare, 1, 2, 3, 4, 5, :finalize] setup_runs=2

cursorを3へ進めた直後のチェックポイントで止まるため、2件目まで処理済み、3件目は再開後に処理されます。重複も欠落も起きていません。

ここで押さえておくべきなのは、チェックポイントを通過するたびに進捗がどこかへ保存されるわけではないという点です。進捗はジョブオブジェクトのメモリ上にあり、ジョブデータへ書かれるのは中断またはエラーで再投入されるときだけです。SIGKILLやOOMで終了した場合、今回の実行中に永続化されなかった進捗は失われます。ジョブ自体が戻るかどうかもバックエンド次第です。Solid Queueは、ハートビートが途絶えたプロセスのジョブを ProcessPrunedError で失敗扱いにし、自動では再試行しません(READMEは、ジョブ自体がプロセスを落としている可能性を理由に挙げています)。retry_on も効かないため、Mission Control – Jobsなどで外から再投入します。Sidekiqの標準fetchでも、プロセスが異常終了すると実行中のジョブは失われることがあります。比較として、Sidekiq::IterableJobは反復の境目で前回保存から5秒以上経過したかを確認し、経過していればRedisへ状態を書き出します。ただし、1回の処理が長ければ保存間隔も5秒を超えます。

エラー時の自動再開と設定項目

stepを1つ完了するか、cursorを進めた後に StandardError が起きた場合、ジョブはその時点の進捗を持って自動で再投入されます。進捗が無いまま失敗した場合は、例外がそのままキューバックエンドへ渡ります。cursor=2で例外を出すジョブで試すと、{"completed" => [], "current" => ["a", 2]} で再投入され、次の実行で2から最後まで処理しました。最初の1件目で失敗させた場合は、そのまま例外になりました。

クラス属性 既定値 意味
max_resumptions nil(無制限) 再開回数の上限。超えると ResumeLimitError
resume_options { wait: 5.seconds } 再投入時に retry_job へ渡す
resume_errors_after_advancing true 進捗後のエラーを再開扱いにする

max_resumptions = 1 にして中断を続けると、2回目に「Job was resumed a maximum of 1 times」で ResumeLimitError になりました。既定では無制限なので、毎回少しだけ進んでは失敗するジョブは延々と再投入されます。外部APIを叩くジョブでは上限を設定しておくべきです。なお、エラーからの再開時に exception_executions が記録されない不具合が8.1.4で修正されています。8.1.3.1以前を使っているなら、8.1.4への更新でエラーからの再開時のexception_executionsの記録不具合が修正されます。

キューアダプタの対応状況と停止猶予

中断を検知できるかどうかは、アダプタが stopping? を実装しているかで決まります。Rails 8.1.4に同梱されたアダプタのうち、停止フラグを立てるのはSidekiqとテスト用だけです。Resque・Delayed Job・Backburner・queue_classic・Sneakersのアダプタは上書きしていないため常にfalseを返し、stepを書いても、アダプタの停止状態を検知した中断は行われません。

バックエンド 対応版 停止を検知する契機 既定の停止猶予
Solid Queue 1.2.0以降 ワーカーの停止フック 5秒(shutdown_timeout)
Sidekiq gem同梱・Rails同梱とも quietイベント 25秒(timeout)
GoodJob 4.11.0以降 スレッドの停止 設定による
Shoryuken 7.0.0以降 ランチャーの停止 設定による
Resque・Delayed Job 非対応 なし –

Sidekiqは停止時にまずquietを発火し、そのあと timeout の期限まで実行中のジョブを待ちます。TSTPシグナルでquietだけを送った場合も停止フラグが立つため、デプロイ前にquietを送る運用ならその時点でジョブは次のチェックポイントで抜けます。各バックエンドの設定はSidekiqの導入とリトライ設定、ShoryukenのSQSワーカー設定を参照してください。

停止猶予を基準にしたチェックポイント間隔の設計

停止猶予を超えるとプロセスは強制終了され、前の節のとおり進捗はジョブデータに残りません。そのため、チェックポイントからチェックポイントまでの最長時間を、使っている基盤の停止猶予より短くすることが設計の中心になります。Solid Queueの既定5秒はかなり短く、1件に数秒かかる外部API呼び出しを1件ごとの advance! で回すと、猶予内に次のチェックポイントへ届かないことがあります。この場合は SolidQueue.shutdown_timeout を延ばすか、1件の処理時間そのものを短くします。

Kamalでデプロイしている場合はコンテナ側の停止猶予(既定30秒)も効くため、ワーカー側の猶予を延ばしてもそれより長くはなりません。Kubernetesでも terminationGracePeriodSeconds が上限になります。停止猶予の考え方はローリングアップデートの仕組みと設定値、KamalとRails 8の構成はThrusterとKamal 2の役割分担で扱っています。

逆に、1件が数ミリ秒で終わる処理で毎回 advance! を呼んでも、チェックポイント自体は stopping? の確認だけなので重くはありません。永続化は中断時の1回だけだからです。迷ったら1件ごとにcursorを進めるのが基本になります。

Active Job Continuationsの落とし穴

step名・順序の変更による再開状態の不整合

再開時には、保存された完了済みstepの並びと、コード上のstepの並びが照合されます。中断中のジョブが残っている状態でstepの名前や順番を変えたコードをデプロイすると、「Step ‘x’ found, expected to see ‘y’」のような InvalidStepError で失敗します。このエラーは自動再開の対象外です。stepを組み替えるときは新しいジョブクラスを作り、旧クラスのキューが空になってから削除します。step名はシンボルで書く必要があり、同じ名前のstepを2回書いたり、stepの中にstepを入れ子にしたりするのもエラーです。

トランザクション内のチェックポイントはCheckpointError

Active Recordを読み込んだRailsアプリでは、データベーストランザクションが開いている間にチェックポイントへ到達すると CheckpointError(「Cannot checkpoint job with open transactions」)になります。transaction do ... end の中で advance! を呼ぶ書き方はできません。トランザクションは1件分の処理だけを囲み、advance! はその外に出します。

最後に永続化された進捗以降の重複実行リスク

エラーや強制終了で再開した場合、最後に保存された位置から処理し直すため、その間の処理は2回走る可能性があります。record.process を「処理済みなら何もしない」形にしておく(処理済みフラグの更新と本体を同じトランザクションに入れる、外部APIには冪等キーを渡す、など)のが前提です。Continuationsは処理の重複を防ぐ仕組みではなく、やり直しの範囲を狭める仕組みです。

テストの書き方:interrupt_job_during_stepとinterrupt_job_after_step

ActiveJob::Continuation::TestHelper を include すると、指定したstepやcursorの位置で中断させるヘルパーが使えます。どちらもテストアダプタが必要です。

class ProcessImportJobTest < ActiveSupport::TestCase
  include ActiveJob::Continuation::TestHelper

  test "cursor 3 で中断して再投入され、再開後にキューが空になる" do
    import = imports(:five_records) # 関連レコードのIDを1〜5に明示したフィクスチャ
    assert_equal [1, 2, 3, 4, 5], import.records.order(:id).pluck(:id)
    ProcessImportJob.perform_later(import.id)

    interrupt_job_during_step(ProcessImportJob, :process_records, cursor: 3) do
      perform_enqueued_jobs
    end
    assert_enqueued_jobs 1, only: ProcessImportJob
    assert_equal ["process_records", 3], enqueued_jobs.last.fetch("continuation").fetch("current")

    perform_enqueued_jobs
    assert_enqueued_jobs 0, only: ProcessImportJob
    assert_performed_jobs 2, only: ProcessImportJob
  end
end

interrupt_job_after_step(ProcessImportJob, :validate) はstep完了直後に中断させます。最後のstepの後にはチェックポイントが無いので、最後のstepを指定しても中断しません。Railsアプリの外(素のactivejob gem)でこのヘルパーを使うと、内部で使う with が未定義で NoMethodError になりました。require "active_support/all" を先に読み込めば動きます。

job-iteration・Sidekiq::IterableJobとの違いと選び方

同じ目的のgemは以前からあります。Shopifyのjob-iterationは2017年5月から同社の本番で使われており、最新は1.15.1(2026年9月18日)です。Sidekiqは7.3.0で、Active Jobを介さない Sidekiq::IterableJob を追加しました。

観点 Active Job Continuations job-iteration Sidekiq::IterableJob
提供 Rails 8.1標準 gem(Rails 7.1+) Sidekiq 7.3.0+
単位 複数のstep+cursor 1つの列挙 1つの列挙
進捗の保存 中断・再投入時 中断・再投入時 反復境界で5秒以上経過時など
時間で区切る設定 なし max_job_runtime max_iteration_runtime
Resque・Delayed Job 非対応 対応 –
CSV・入れ子の列挙 自分で書く enumerator_builderあり CSVヘルパーあり・入れ子は自作

判断は次のとおりです。Rails 8.1以降でSolid QueueかSidekiqを使い、検証→本処理→後処理のような段階のあるジョブを書くなら、Continuationsを選びます。追加gemが不要で、段階ごとの完了をstepでそのまま表現できるからです。一方、Resqueを使っている、デプロイと無関係に「5分たったら一度キューへ戻して他のジョブに譲る」動きが欲しい、という場合はjob-iterationのほうが合います。Continuationsには経過時間で区切る設定が無いためです。Active Jobを使わずSidekiqを直接使っていて、実行中にも進捗を保存したいならIterableJobが候補になります。ただし保存は反復の境目で行われ、強制終了後にジョブ自体を復旧する仕組みも別途必要です。

job-iterationで動いている既存ジョブを急いで書き換える必要はありません。新規ジョブからContinuationsで書き始め、job-iterationの依存を外したくなった時点で移すのが現実的です。

よくある質問

Active Job ContinuationsはRailsのどのバージョンから使えますか?

Rails 8.1.0(2025年10月22日)からです。Rails 8.0以前のactivejobには ActiveJob::Continuable がありません。2026年10月時点の最新は8.1.4で、エラー後の再開に関する不具合修正が入っています。

Resqueでも途中から再開できますか?

Rails同梱のResqueアダプタは stopping? を実装していないため、デプロイ時に中断されることはありません。ただし進捗を持ったまま例外が起きた場合の自動再開は、アダプタに関係なく動きます。デプロイ時の中断が必要ならjob-iterationを検討してください。

ワーカーがSIGKILLで落ちた場合も続きから再開されますか?

されません。進捗がジョブデータに書かれるのは中断またはエラーで再投入されるときだけなので、その実行中に進めた分は失われます。ジョブ自体が戻るかどうかもバックエンドと設定によります(Solid Queueは失敗扱いにし、自動では再試行しません)。チェックポイントの間隔を停止猶予より短くしてください。

cursorにはどんな値を使えますか?

8.1系では整数・文字列・それらの配列にしておくのが安全です。cursorはそのままジョブデータに入るため、TimeやシンボルはJSONで保存されると文字列になって戻ります。advance! を使う場合は succ を持つ値(整数など)が必要で、それ以外は set! で更新します。

job-iterationとActive Job Continuationsはどちらを使うべきですか?

Rails 8.1以降でSolid QueueかSidekiqを使っているならContinuationsで足ります。ResqueやDelayed Jobを使っている場合や、一定時間ごとにジョブを区切りたい場合はjob-iterationを選びます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ ノジマ不正アクセスで約700万件漏えい:対象確認・窓口と乗っ取り注文を止める実装
  2. 2026.10.29 テックブログ ソニー生命の情報漏洩38.5万件|ログインできない理由、再開状況と窓口の電話番号
  3. 2026.11.03 テックブログ クラブツーリズム不正アクセス:約432万件の対象確認と偽メールの見分け方・流出検知の設計
  4. 2026.11.04 テックブログ CVE-2026-119977:WordPress更新中のRCEと安全な更新手順
  5. 2026.11.02 テックブログ アララの不正アクセス:メール約7,700万件の持ち出しと契約企業が行うAPIキー再設定・代替送信・連名報告

RELATED POSTS 関連記事

目次