---
title: "Active Job Continuationsとは？Rails 8.1のstep・cursorでジョブを中断・再開する方法"
url: "https://www.issoh.co.jp/tech/details/8853/"
published: 2025-09-17
updated: 2026-10-02
categories: ["Ruby on Rails"]
publisher: "株式会社一創"
---

# 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ドキュメント](https://api.rubyonrails.org/v8.1.4/classes/ActiveJob/Continuation.html)と[v8.1.4タグのソース](https://github.com/rails/rails/blob/v8.1.4/activejob/lib/active%5Fjob/continuation.rb)で確認しています。実行結果は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の設定](/tech/details/3988/)で扱っています。

## 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の導入とリトライ設定](/tech/details/7843/)、[ShoryukenのSQSワーカー設定](/tech/details/3429/)を参照してください。

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

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

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

逆に、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を選びます。

## 関連記事

- [Rails Active Jobとは？非同期ジョブ処理の実装とSolid Queue・リトライ設定](/tech/details/3988/)
- [Sidekiqとは？Rails 8.1での導入・リトライ・Web UI設定【Sidekiq 8.1.7】](/tech/details/7843/)
- [Shoryuken（Ruby gem）とは？SQSワーカーの設定とActive Job連携・リトライ設計【7.0対応】](/tech/details/3429/)
- [Thrusterとは？Rails 8のHTTP/2プロキシ設定とKamal 2との役割分担](/tech/details/4298/)
- [ローリングアップデートとは？仕組みとKubernetes・ECSの設定値・採用判断を解説](/tech/details/16189/)

---

出典: [Active Job Continuationsとは？Rails 8.1のstep・cursorでジョブを中断・再開する方法](<https://www.issoh.co.jp/tech/details/8853/>)（株式会社一創）
