Ruby on Rails

Rails Active Jobとは?perform_laterとSolid Queue・retry_onの使い方【Rails 8.1.4】

Rails Active Jobとは?perform_laterとSolid Queue・retry_onの使い方【Rails 8.1.4】

Active Jobは、Ruby on Railsで重い処理をバックグラウンドのジョブとして切り出すための共通APIです。ジョブの書き方はActive Jobで統一し、実際にキューへ保存して実行するのはSolid QueueやSidekiqといったバックエンドが受け持ちます。この記事では、2026年9月24日公開のRails 8.1.4と、Rails 8から既定バックエンドになったSolid Queue 1.7.0を手元で動かし、ジョブの作成とperform_laterでの投入、retry_onの待ち時間、トランザクションをロールバックしてもジョブが残る落とし穴、テストの書き方までを確かめた結果で整理します。

まとめ:Active Jobの要点(Rails 8.1.4時点)

  • Active Jobはジョブを書くためのAPIで、ジョブを保存・実行するのはバックエンド。Rails 8.0以降の新規アプリは、本番環境だけが:solid_queueに設定され、開発環境は:async、テスト環境は:testで動く。
  • ジョブはApplicationJobを継承して書き、perform_laterでキューへ積む。戻り値は受け取れないので、結果はDBや通知で扱う。
  • Solid Queueではretry_onを書かない限り失敗したジョブは再試行されない。実測でも例外を投げたジョブは1回で失敗扱いになった。
  • wait: :polynomially_longerの待ち時間は「実行回数の4乗+2秒」。既定の5回なら再試行の待ちは3秒・18秒・83秒・258秒(揺らぎ最大15%を加算)。
  • Rails 8の既定構成はキュー用DBが別のため、トランザクションをロールバックしてもジョブは残って実行される。enqueue_after_transaction_commitを有効にするか、after_commitから投入する。

Active Jobの役割と環境ごとの既定アダプタ

Webリクエストの中でメール送信や外部API連携を同期実行すると、その時間だけレスポンスが遅れます。Active Jobはこうした処理をジョブとして切り出し、後からワーカーに実行させる仕組みで、Rails 4.2で標準に入りました。

ジョブのAPIとバックエンドの分担

Active Jobが決めるのは、ジョブクラスの書き方(perform)、投入の書き方(perform_later)、リトライやコールバックの宣言方法です。ジョブをどこに保存し、どのプロセスが実行するかはキューアダプタ(バックエンド)の仕事です。アダプタはconfig.active_job.queue_adapterで切り替えられ、Active Jobの共通APIだけを使うジョブなら、通常は本体を書き換えずに切り替えられます。ただし、Solid Queue固有の同時実行制御などは移行先での見直しが必要です。

何も設定しないときのアダプタ

Rails 8.1.4のrailtieは、アダプタが未設定ならRails.env.test? ? :test : :asyncを入れます。rails newで作ったアプリを確認すると、queue_adapterを設定しているのはconfig/environments/production.rbの:solid_queueだけでした。つまり開発環境でperform_laterしたジョブはSolid Queueではなく:asyncで動きます。

:asyncは同期実行ではありません。Railsプロセス内のスレッドプールで非同期に実行しますが、キューはメモリ上にしか無く、サーバーを再起動すると未実行のジョブは消えます。本番で使うアダプタではない、というのがAsyncAdapterのAPIドキュメントの位置づけです。

アダプタ 保存先 実行 主な用途
:async メモリ プロセス内スレッド 開発環境の既定
:inline なし その場で同期 デバッグ
:test メモリ 実行しない(記録のみ) テスト環境の既定
:solid_queue RDB(キュー用DB) bin/jobs のワーカー Rails 8の本番既定
:sidekiq Redis sidekiq プロセス 高スループット

ApplicationJobによるジョブ定義とperform_laterによる投入

rails generate jobによる雛形の生成

ジョブはジェネレータで作ります。生成されるクラスはApplicationJobを継承し、処理本体をperformに書きます。ApplicationJobはアプリ全体のジョブの親クラスで、全ジョブ共通のretry_onやrescue_fromをここに書いておけます。ジェネレータの他のオプションはRailsジェネレータ(rails generate)の使い方にまとめています。

$ bin/rails generate job guests_cleanup
      create  app/jobs/guests_cleanup_job.rb
      create  test/jobs/guests_cleanup_job_test.rb

# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
  queue_as :default

  def perform(guest)
    guest.destroy
  end
end

perform_later・perform_now・set の使い分け

perform_laterはキューへ積んだ時点で戻り、呼び出し側はジョブの完了を待ちません。perform_nowはその場でperformを実行して戻り値を返します。実行時刻やキューを変えたいときはsetを挟みます。

GuestsCleanupJob.perform_later(guest)                                # キューに積んで即戻る
GuestsCleanupJob.perform_now(guest)                                  # その場で実行
GuestsCleanupJob.set(wait: 1.week).perform_later(guest)              # 1週間後以降に実行
GuestsCleanupJob.set(wait_until: Date.tomorrow.noon).perform_later(guest)
GuestsCleanupJob.set(queue: :low_priority, priority: 10).perform_later(guest)

wait_untilが保証するのは「その時刻より前には実行しない」ことだけです。Solid Queueの場合、予定時刻になったジョブを実行待ちへ移すディスパッチャは既定で1秒ごとにポーリングし、さらにワーカーが空いている必要があります。秒単位で時刻を守る用途には向きません。

引数に渡せる型とGlobalID

ジョブの引数はキューへ保存するためにシリアライズされます。文字列・数値・nil・真偽値・配列・ハッシュに加え、Rails 8.1.4はSymbol・Date・Time・DateTime・ActiveSupport::TimeWithZone・ActiveSupport::Duration・BigDecimal・Range、クラスやモジュールそのものを扱えます(arguments.rb)。Active Recordのモデルを渡すとGlobalID(gid://app/Guest/1のような文字列)で保存され、実行時にレコードを引き直します。

実行までにレコードが削除されていると、引き直しでActiveJob::DeserializationErrorになります。削除済みレコードが復元されない運用なら、後述のdiscard_onで破棄できます。ただし、この例外は復元処理中の別のエラーも包むため、原因を確認して破棄対象を決めます。独自クラスを渡したいときはActiveJob::Serializers::ObjectSerializerを継承したシリアライザをconfig.active_job.custom_serializersに登録します。

perform_all_laterによる一括投入とコールバックの制約

大量のジョブを積むときは、Rails 7.1で入ったActiveJob.perform_all_laterでまとめて投入できます。Solid Queueのように一括挿入に対応したアダプタでは、1件ずつperform_laterするより往復が減ります。

jobs = User.where(active: true).map { |user| WelcomeJob.new(user) }
ActiveJob.perform_all_later(jobs)

注意点が2つあります。手元のテストでは、perform_all_laterの戻り値はnil(Active Recordを読み込んだRails 8.1.4では常にnilを返す実装)で、before_enqueueに登録したコールバックは1回も呼ばれませんでした。投入時のコールバックでログや計測をしている場合、一括投入ではそれが抜けます。また、Solid Queueの同時実行制御(limits_concurrency)を設定したジョブも一括投入はできますが、solid_queue 1.7.0のREADMEによると制限を守るために内部で1件ずつ投入されるため、一括投入の利点はなくなります。

Solid Queueの設定とジョブ実行(Rails 8の既定)

Solid Queueは、ジョブをRDB(PostgreSQL・MySQL・SQLite)に保存するバックエンドです。Redisは不要です。PostgreSQLやMySQL 8のようにFOR UPDATE SKIP LOCKEDに対応したDBでは、この句で複数のワーカーがロック待ちせずに別々のジョブを取り出します。SQLiteはこの句に対応していないため使われず、書き込みはDB単位のロックで順番に処理されます。ここからは、Ruby 4.0.6・Rails 8.1.4・solid_queue 1.7.0・SQLiteで新規アプリを作り、本番環境(RAILS_ENV=production)でワーカーを動かした結果です。

生成されるファイルと bin/jobs の起動

rails newはGemfileにsolid_queueを入れ、bin/rails solid_queue:installが次のファイルを作ります。

  • config/queue.yml:ワーカーとディスパッチャの設定
  • config/recurring.yml:定期実行するジョブの定義
  • db/queue_schema.rb:キュー用DBのテーブル定義
  • bin/jobs:ワーカーを起動するコマンド
  • production.rbへの追記:queue_adapter = :solid_queueとsolid_queue.connects_to = { database: { writing: :queue } }

最後の行が重要で、Rails 8の既定構成ではジョブはアプリ本体とは別のキュー用DB(database.ymlのqueue)に保存されます。bin/rails db:prepareでキュー用DBを作り、bin/jobsでワーカーを起動します。Webサーバーと同じプロセスで動かしたい小規模な構成なら、config/puma.rbに生成済みのplugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]を使い、環境変数SOLID_QUEUE_IN_PUMAを設定します。ただし既定では別プロセスをforkします。同一プロセスで動かすには、さらにsolid_queue_mode :asyncの指定が必要です。

$ export RAILS_ENV=production
$ bin/rails db:prepare     # storage/production_queue.sqlite3 などが作られる
$ bin/jobs                 # スーパーバイザがワーカーとディスパッチャを起動する

queue.yml の既定値とキューの優先順位

生成されたqueue.ymlは、ディスパッチャが1秒間隔・500件ずつ、ワーカーが全キュー("*")・3スレッド・1プロセス(環境変数JOB_CONCURRENCYで変更)です。ワーカーのpolling_intervalはテンプレートでは1ですが、queue.ymlが無いときにSolid Queueが使う組み込みの既定値は0.1秒でした(solid_queue 1.7.0のconfiguration.rb)。ジョブの拾いが遅いと感じたら、まずここを見ます。

# config/queue.yml(生成直後)
default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "*"
      threads: 3
      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
      polling_interval: 1

キューの優先順位は、queues: [critical, default, low]のように並べた順が厳密に効きます。数値のpriorityは小さい値ほど優先されます(既定0)。キューを順序付きで指定した場合はキュー順が優先されますが、queues: "*"ではキューをまたいで数値優先度が比較されます。上位のキューが常に詰まっていると下位のキューは実行されないので、重いバッチは別ワーカーに分けるのが安全です。

limits_concurrencyによる同時実行数の制限

同じ顧客に対する同期処理を並行させたくない、といった場面ではSolid Queueのlimits_concurrencyを使います。keyが同じジョブはtoで指定した数までしか同時に実行されず、残りはブロックされて待ちます。

class ConcJob < ApplicationJob
  limits_concurrency to: 1, key: ->(account_id, _n) { account_id }, duration: 1.minute

  def perform(account_id, n)
    sleep 2
  end
end

[1, 1, 1, 2].each_with_index { |a, i| ConcJob.perform_later(a, i) }

4件を投入した直後は、実行待ち5件(他のジョブを含む)に対してブロック中が2件でした。ワーカーのログでは、account 1の3件は順番に実行され、前のジョブ終了から次の開始までの間隔は約1.3秒と約0.4秒でした。account 2の1件はaccount 1と並行して始まりました。toの既定は1、durationの既定は3分で、durationはロックが解放されない場合の保険です。期限を過ぎたジョブは解除候補となり、ディスパッチャの保守処理で確認されます。保守間隔は既定600秒で、期限直後の解除は保証されません。処理時間がこの期限を十分下回る値に設定してください。上限に当たったジョブを待たせず捨てたいときはon_conflict: :discardを指定します(既定は:block)。

recurring.ymlによる定期実行の設定

cronのような定期実行はconfig/recurring.ymlに書き、ディスパッチャと同じスーパーバイザ配下のスケジューラが投入します。scheduleは「every hour at minute 12」のような自然文をFugitで解釈します。生成直後のファイルには、終了済みジョブを1時間ごとに消すタスクが本番用に入っています。

production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12
  nightly_report:
    class: NightlyReportJob
    queue: low
    schedule: at 3am every day

retry_onとdiscard_onによる再試行・破棄の設定

Solid Queueの再試行の既定とretry_onの必要性

Sidekiqは独自の仕組みで失敗したジョブを既定で25回再試行しますが、Solid Queueにはその仕組みがありません。例外を投げるだけのジョブ(retry_onなし)を流すと、1回実行されてsolid_queue_failed_executionsに1行入り、それきりでした。再試行させたい例外は、ジョブかApplicationJobにretry_onで明示します。

class RetryJob < ApplicationJob
  retry_on ArgumentError, wait: 2.seconds, attempts: 3

  def perform
    raise ArgumentError, "always"
  end
end

このジョブは3回実行され、間隔はそれぞれ3.9秒と3.7秒でした。待ち時間2秒に、揺らぎ(最大15%)と、ディスパッチャ・ワーカーのポーリング間隔(どちらも1秒)が上乗せされた値です。attemptsを使い切ると例外がSolid Queueまで伝わり、ジョブは失敗済みとして残ります。失敗したジョブの一覧・再実行・破棄は、Rails公式の管理画面Mission Control Jobs(2026年9月公開の1.3.1)で行えます。

:polynomially_longer の待ち時間

retry_onの既定はwait: 3.seconds・attempts: 5です。wait: :polynomially_longerにすると、Rails 8.1.4のdetermine_delayは「実行回数の4乗+揺らぎ+2秒」を返します。旧名の:exponentially_longerは計算が指数でなかったため改名されたもので、ソースのコメントにも「以前は誤ってexponentialと呼んでいた」とあります。

揺らぎはrand × 実行回数の4乗 × 0.15で、待ちを最大15%延ばす方向にだけ働きます(config.load_defaultsが6.1以上ならretry_jitterは0.15)。揺らぎを0にして実際に計算した値は次のとおりです。

失敗した実行回数 次の再試行までの待ち 累計
1回目 3秒 3秒
2回目 18秒 21秒
3回目 83秒 104秒
4回目 258秒 362秒(約6分)
5回目 再試行しない(attempts: 5) –

既定の5回だと、最初の失敗から6分ほどで諦めます。外部APIの数十分の障害に耐えさせたいならattemptsを増やします。attempts: 10なら揺らぎを除いた待ちの合計は15,351秒(約4.3時間)で、増やすほど1回あたりの待ちが急に伸びる点に注意してください。成功するまで続けたい場合はattempts: :unlimitedを指定できますが、直らない例外でキューを占有し続けるので、対象の例外を絞って使います。

class RemoteSyncJob < ApplicationJob
  retry_on Net::OpenTimeout, Faraday::ServerError, wait: :polynomially_longer, attempts: 10
  retry_on RateLimitedError, wait: ->(executions) { executions * 60 }   # 独自の待ち時間
  discard_on ActiveJob::DeserializationError

  def perform(record)
    ExternalApi.sync(record)
  end
end

Rails 8.1ではretry_onとdiscard_onにreport: trueが追加され、再試行や破棄の前にRails.errorのエラーレポーターへ通知できるようになりました。

discard_on・after_discard と冪等性

再試行しても結果が変わらない失敗はdiscard_onで破棄します。破棄したことを記録したい場合は、Rails 7.1で入ったafter_discardを使うと、retry_onの上限到達とdiscard_onのどちらで捨てられたときも呼ばれます。

class WelcomeJob < ApplicationJob
  discard_on ActiveJob::DeserializationError
  after_discard do |job, error|
    Rails.logger.warn("discarded #{job.class} #{job.job_id}: #{error.class}")
  end

  def perform(user)
    UserMailer.welcome(user).deliver_now
  end
end

再試行がある以上、同じジョブが2回実行されても結果が壊れないこと(冪等性)が前提になります。DBへの二重登録は一意制約で防げますが、メール送信と送信済みフラグの更新は一体ではありません。送信直後の障害では重複送信が起こり得るため、配信サービスの冪等性キーや重複許容方針も検討します。なおretry_onとdiscard_onは内部的にrescue_fromなので、後に書いた宣言が先に照合されます。親クラスの例外を広く拾う宣言は上に、具体的な例外の宣言は下に書きます。

トランザクションとジョブ投入のずれ

Rails 8でSolid Queueを使うときに最も気づきにくいのがここです。モデルの保存とジョブの投入を同じトランザクションに入れても、Rails 8の既定構成ではジョブはキュー用DBという別の接続に書かれるため、アプリ側のトランザクションに巻き込まれません。

class TxJob < ApplicationJob
  def perform(label) = Rails.logger.info("performed #{label}")
end

class TxAfterCommitJob < TxJob
  self.enqueue_after_transaction_commit = true
end

ActiveRecord::Base.transaction do
  TxJob.perform_later("default(false)")
  TxAfterCommitJob.perform_later("after_commit(true)")
  raise ActiveRecord::Rollback
end

このコードを実行すると、ロールバックしたにもかかわらずTxJobはキュー用DBに残り、ワーカーを起動すると実行されました。enqueue_after_transaction_commit = trueを付けたTxAfterCommitJobはキューに積まれていません。保存に失敗した注文に確認メールが飛ぶ、削除されたはずのレコードでジョブがDeserializationErrorになる、といった不具合はこれが原因です。

この設定は版ごとに変わってきました。Rails 7.2では:always・:never・:default(アダプタに任せる)の3値でしたが、Rails 8.0で真偽値になり既定はfalse、アプリ全体に効かせるconfig.active_job.enqueue_after_transaction_commitも非推奨になりました(v8.1.4のenqueuing.rb)。Rails 8.1.4ではジョブクラスごとに指定します。モデルの保存に続けてジョブを投入するクラスにはApplicationJobなどでtrueを設定するか、モデルのafter_commitコールバックからperform_laterします。trueにした場合、perform_laterはコミットまで実際の投入を遅らせ、Rails 8.1からはbefore_enqueueなどのコールバックもコミット後に回ります。呼び出し直後に、ジョブがキューに入ったかどうかは判断できません。トランザクションコールバック側の変更はActiveRecord 7.2の変更点で実行して確かめています。

ActiveJob::TestHelperによる投入・実行のテスト

テスト環境のアダプタは:testで、perform_laterしても実行せず「積まれた」記録だけを残します。そのため、テストは「正しく積まれたか」と「実行した結果どうなるか」を分けて書きます。ActiveJob::TestCase(またはテストクラスにinclude ActiveJob::TestHelper)で使える主なアサーションは次の3つです。

require "test_helper"

# この節で使う、メール送信を行わないテスト専用のジョブ定義
class WelcomeJob < ApplicationJob
  queue_as :mailers
  def perform(name) = "hello #{name}"
end

class WelcomeJobTest < ActiveJob::TestCase
  test "キュー名と引数つきで投入される" do
    assert_enqueued_with(job: WelcomeJob, args: ["taro"], queue: "mailers") do
      WelcomeJob.perform_later("taro")
    end
  end

  test "perform_enqueued_jobs のブロック内で投入したジョブは実行される" do
    perform_enqueued_jobs do
      WelcomeJob.perform_later("hanako")
    end
    assert_performed_jobs 1
    assert_enqueued_jobs 0
  end
end

このテストファイルをそのまま手元のRails 8.1.4アプリで実行し、2件・5アサーションとも通りました。perform_enqueued_jobsはブロックを渡すとブロック内で積まれたジョブを実行し、ブロック無しで呼ぶとそれまでに積まれたジョブをまとめて実行します。RSpecではrspec-railsのhave_enqueued_jobマッチャを使います。

expect { WelcomeJob.perform_later("taro") }
  .to have_enqueued_job(WelcomeJob).with("taro").on_queue("mailers")

コントローラーからジョブが積まれることを確かめるなら、リクエストを投げてhave_enqueued_jobを当てるのが手早い方法です。書き方はrequest specとは?Rails 8・RSpec 8でのリクエストスペックの書き方を参照してください。

Solid Queue以外のバックエンドを選ぶ条件

新規アプリはSolid Queueから始め、次のどれかに当たったら乗り換えを検討する、という順番で判断して構いません。

  • ジョブ数が多く、DBのポーリング負荷が気になる:Redisを使うSidekiq(最新は8.1.7)が候補です。ただしRails 8.1でActive Jobに組み込まれていたsidekiqアダプタは非推奨になり、Rails 8.2で削除されます。Sidekiq 7.3.3以降が同梱するアダプタを使うので、config.active_job.queue_adapter = :sidekiqの書き方はそのままで、Sidekiqのバージョンを上げておきます。
  • AWSでSQSにキューを任せたい:SQSをバックエンドにするShoryukenがあります。
  • Delayed Job・Resqueを使い続けている:どちらもActive Job経由で書いていればジョブ本体はそのまま移せます。新規に選ぶ理由は、DBで動くならSolid Queue、Redisで動くならSidekiqがある今は乏しいでしょう。

Active Job自体を使わないほうがよい場面もあります。ユーザーの操作に対して即座に結果を返す処理(決済の確定など)は、キューの遅延が入るので同期で書きます。数時間かかるジョブは、終了猶予内に完了できずワーカーが強制終了すると、Solid Queueでは失敗扱いになる場合があります。通常のジョブを再実行すると処理の先頭から始まります。Rails 8.1のActiveJob::Continuableで区切りごとに再開できるようにする方法はActive Job Continuationsとは?で扱っています。

よくある質問

Active JobとSidekiqの違いは何ですか?

Active Jobはジョブの書き方を揃えるRailsのAPIで、Sidekiqはジョブを保存・実行するバックエンドです。Active Jobで書いたジョブを、アダプタに:sidekiqを指定してSidekiqで動かす、という組み合わせになります。

開発環境でperform_laterしたジョブがbin/jobsを起動しなくても実行されるのはなぜですか?

rails newで作ったアプリは本番環境にだけ:solid_queueを設定しており、開発環境は既定の:asyncで動くためです。:asyncはRailsプロセス内のスレッドで実行します。開発環境でもSolid Queueを使うなら、development.rbにアダプタとキュー用DBの接続を書き、bin/jobsを起動します。

perform_laterした処理の戻り値は受け取れますか?

受け取れません。perform_laterが返すのは通常ジョブのインスタンスで、処理結果ではありません。投入がコールバックで中止された場合などはfalseになり、投入エラーが例外として伝わる場合もあります。結果はジョブの中でDBに保存したり、Action Cableなどで通知したりして扱います。

retry_onの上限に達したジョブはどうなりますか?

例外がバックエンドまで伝わり、Solid Queueでは失敗済みジョブとしてsolid_queue_failed_executionsに残ります。after_discardで記録するか、Mission Control Jobsの画面から再実行・破棄します。

Solid QueueはRedisなしで本番運用できますか?

できます。Rails 8.0以降の既定構成はRedisを使わず、PostgreSQL・MySQL・SQLiteのキュー用DBにジョブを保存します。スループットが頭打ちになった段階でSidekiqを検討すれば足ります。

関連記事

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

一創の公式 SNS

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

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

ほか 1 件の記事からもリンクされています。

資料請求

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

  1. 2026.10.29 テックブログ ソニー生命の不正アクセス:38.5万件の対象確認と窓口、停止中の手続き
  2. 2026.10.28 テックブログ リンツ不正アクセス:314万件の対象確認とパスワード強制リセット後の対処
  3. 2026.01.30 コラム 税務調査は個人と法人で何が違う?担当部署・件数・調べられる点と追徴税額を比較【令和6事務年度】
  4. 2026.10.27 テックブログ Joshin(ジョーシン)不正アクセス:最大600万件の対象とサブスク初期パスワードの対処
  5. 2026.10.31 テックブログ 無印良品の情報漏洩:MUJIアプリ最大2,726万件の対象確認となりすましメール対策

RELATED POSTS 関連記事

目次