旧ブログ(ISSEN)から移行しました

【Rails】Solid Queueの仕組みとリトライ処理の実装方法

【Rails】Solid Queueの仕組みとリトライ処理の実装方法

塩見直樹
塩見直樹16分で読めます
はてなブックマーク

はじめに

こんにちは、株式会社TOKOSのナオキです!

アプリを運用していると、時間のかかる処理や定期的に実行したい処理を、裏側で走らせたい場面が出てきます。
こうした処理は、外部APIの一時的な不調やネットワークエラーなどで失敗してしまうことが多々あります。
そのような失敗に備えて、「失敗したらもう一度実行する」というリトライ処理を用意する必要があります。
本記事ではRailsのSolid Queueについて初学者にも分かりやすく解説し、リトライ処理の実装方法まで紹介していきます。

対象読者

  • Rails初学者の方
  • Solid Queueの仕組みを理解したい方
  • リトライ処理を実装したい方

この記事で扱う内容

  • Solid Queueの仕組みの解説
  • Solid Queueの導入方法
  • ジョブを作成して処理を実行する方法
  • リトライ処理の実装方法

Solid Queueとは?

Solid QueueはActive Jobのキューイングシステムで、Rails 8.0からはこれがデフォルトとして採用されています。
特徴としては、ジョブの管理に通常のデータベースをそのまま使う点です。
SidekiqなどほかのキューイングシステムはRedisという専用の置き場所を別に用意する必要があります。
一方Solid Queueは、アプリがすでに使っているデータベースをジョブの置き場所として活用します。
そのため新しく専用の置き場所を用意せずに済むため、導入コストを削減できます。

ナオキ
ナオキ

キューとは、データの先入れ先出し(FIFO)のことだね!
例えるならレジに並ぶ人の列で、先に並んだ人が先にレジを通るイメージです!
また、対になる概念として、スタックもあります!
スタックはデータの後入れ先出し(LIFO)のことで、例えるなら積み上げられたお皿を上から取り出すイメージです!

Solid Queue README(翻訳)

概要 MITライセンスに基づいて翻訳・公開いたします。 英語記事: solid_queue/README.md

techracho.bpsinc.jp

Solid Queueを使用する理由

Solid Queueを使用する理由としては、以下のようなものがあります。

  • 導入・運用コストが低い
  • Rails標準のキューイングシステム
  • 利用可能なデータベースが多い
  • 機能が豊富

導入・運用コストが低い

Solid Queueは、ジョブの保存先にアプリがすでに使っているデータベースをそのまま使えます。
これまでRailsのデファクトスタンダードであったSidekiqなどはRedisというサービスを別に用意・運用する必要がありましたが、Solid Queueにはそれが不要です。
インフラの構成要素が1つ減るため、セットアップや保守がシンプルになり、運用コストを抑えられます。

Rails標準のキューイングシステム

Solid QueueはRails 8.0以降の新規アプリケーションでは、Active Jobが利用する標準のキューイングシステムとして採用されています。
Rails本体の開発に合わせてメンテナンスされていくため、将来の互換性やサポートの面でも安心感があります。

利用可能なデータベースが多い

Solid QueueはMySQL、MariaDB、PostgreSQL、SQLiteといった一般的なRDB(リレーショナルデータベース)で利用できます。
Solid Queueでは、FOR UPDATE SKIP LOCKEDというSQLの仕組みを使い、複数のジョブ処理(ワーカー)が互いを待たずにジョブを取り出せるよう工夫されています。
FOR UPDATE SKIP LOCKEDは、複数のワーカーが同じ行を取り合わないようロックされた行をスキップして次の行を読むSQLの構文です。

機能が豊富

シンプルさを重視しつつ、実用上必要な機能はひととおり揃っています。

  • 遅延ジョブ:指定した時刻にジョブを実行する
  • 繰り返しジョブ:cronのような定期スケジューリング
  • 同時実行制御:同時に実行できるジョブ数を種類や引数で制限する
  • 優先度の指定:ジョブごとの数値優先度や、キュー順による優先度づけ
  • キューの一時停止
  • 一括エンキュー(Active Jobのperform_all_laterに対応)

なお、リトライやエラーハンドリング、ジョブの破棄といった処理は Solid Queue自身ではなくActive Jobが受け持ちます。 Solid Queueはその裏でジョブを蓄えて処理する役割に徹しています。

Solid Queueの機能については以下の記事で詳しく解説されていますので、ぜひご覧ください。

Solid Queue in Rails 8: Setup Notes and Trade-offs | Nikita Sinenko

Install Solid Queue in Rails 8, configure recurring.yml and concurrency controls, decide between Puma and a separate jobs process, and understand the Redis t...

nsinenko.com

Solid Queueの導入方法

Rails 8の新規アプリの場合

Rails 8で新しくアプリを作成した場合、Solid Queueはデフォルトで設定されているため、特別な導入作業は必要ありません。

既存アプリ(Rails 7.1以降)に導入する場合

Rails 8より前のバージョンや、既存のアプリに後から導入する場合は、以下の手順で追加できます。

ターミナル
# gemを追加する
$ bundle add solid_queue
# インストールコマンドを実行する
$ rails solid_queue:install

このコマンドによって、本番環境でActive Jobが利用するキューイングシステムとしてSolid Queueが設定されます。
また、Solid Queueを使用するために必要な以下のファイルが自動で追加されます。

  • 設定ファイルconfig/queue.ymlconfig/recurring.ymlが作られる
  • スキーマ定義ファイルdb/queue_schema.rbが作られる
  • Solid Queueを起動するための実行ファイルbin/jobsが作られる

データベースの設定

次に、config/database.ymlにキュー用データベースの設定を追加します。

SQLiteを使う場合は次のようになります。

config/database.yml
production:
  primary:
    <<: *default
    database: storage/production.sqlite3
  queue:
    <<: *default
    database: storage/production_queue.sqlite3
    migrations_paths: db/queue_migrate

MySQLやPostgreSQLを使う場合は、それぞれの接続情報に合わせてqueueの設定を記述します。

config/database.yml
production:
  primary: &primary_production
    <<: *default
    database: app_production
    username: app
    password: <%= ENV["APP_DATABASE_PASSWORD"] %>
  queue:
    <<: *primary_production
    database: app_production_queue
    migrations_paths: db/queue_migrate

設定を追加したら、本番環境でdb:prepareを実行し、データベースの作成とスキーマの読み込みを行います。

ターミナル
$ rails db:prepare

追加されるテーブルについて

rails db:prepare(またはdb:migrate)を実行すると、キュー用のデータベースに11個のテーブルが作られます。
中心となるのはsolid_queue_jobsで、すべてのジョブの情報がここに記録されます。
ほかのテーブルの多くはジョブの「状態」を表すもので、ジョブは処理の進行状況に応じてこれらのテーブルの間を移動していきます。

テーブル名役割
solid_queue_jobsすべてのジョブの本体を保存する中心的なテーブル。
ジョブのキュー名、クラス名、優先度、実行予定時刻、完了時刻などの情報を持つ。
solid_queue_ready_executionsすぐに実行できる状態になったジョブを保持する。
solid_queue_scheduled_executions将来の時刻に実行するようスケジュールされたジョブを保持する。
solid_queue_claimed_executions現在処理中のジョブを保持する。
solid_queue_failed_executions失敗したジョブを記録する。エラー内容も保存されるため、後から原因を確認したり、再実行・破棄したりできる。
solid_queue_blocked_executions同時実行制御によって、現在実行できず待機させられているジョブを保持する。
solid_queue_recurring_executions繰り返しジョブ(定期実行)が実際に実行された記録を保持する。
solid_queue_recurring_tasks繰り返しジョブの定義(どのジョブを、どんなスケジュールで動かすか)を保持する。
solid_queue_pauses一時停止中のジョブを保持する。
solid_queue_processes動作中のプロセスを記録する。
solid_queue_semaphores同時実行制御のための「鍵」を管理する。

Solid Queueを構成するプロセス

Solid Queueでは、役割の異なる4つのプロセスが動いています。
ジョブが登録されてから完了するまでの流れの中で、これらがそれぞれの作業を担います。

プロセス役割
ワーカー実行できる状態になったジョブを取り出して、実際に処理する役割。
solid_queue_ready_executionsからジョブを拾って実行する。
ディスパッチャ将来実行するようスケジュールされたジョブを見張り、実行時刻が来たものを「実行できる状態」へ送り出す役割。
solid_queue_scheduled_executionsからsolid_queue_ready_executionsへジョブを移す。
スケジューラ繰り返しジョブ(定期実行)を管理する役割。
設定したスケジュールの時刻になると、対応するジョブをキューに登録する。
スーパーバイザ上記のワーカーやディスパッチャを取りまとめる管理役。
設定に従ってそれらを起動・停止し、各プロセスが正常に動いているかを監視する。
ナオキ
ナオキ

分かりやすく言うと、以下のようにプロセスが連携してジョブを処理していきます!

  1. スケジューラが繰り返しジョブを登録
  2. ディスパッチャが「そろそろ実行していいよ」とジョブを準備
  3. ワーカーが実際にそのジョブを処理
  4. スーパーバイザがプロセスを管理

ジョブのライフサイクル

ここまでに紹介したテーブルとプロセスが、実際にどう連携するのかを、1つのジョブが処理されるまでの流れを簡単に説明します。

  1. ジョブの登録
    ジョブが追加されると、まずsolid_queue_jobsにジョブの情報が記録されます。

  2. 実行待ちへの振り分け
    すぐに実行するジョブはsolid_queue_ready_executionsへ、将来実行するジョブはsolid_queue_scheduled_executionsへ振り分けられます。
    スケジュールされたジョブは、実行時刻が来るとディスパッチャによってsolid_queue_ready_executionsへ移されsolid_queue_scheduled_executionsから削除されます。

  3. ワーカーによる実行
    ワーカーがsolid_queue_ready_executionsからジョブを取り出し、solid_queue_claimed_executionsに記録してから処理を始めます。その際solid_queue_ready_executionsから該当ジョブが削除されます。
    この記録があることで、ほかのワーカーが同じジョブを二重に処理してしまうのを防いでいます。

  4. 完了または失敗
    ジョブが成功すると、該当ジョブはsolid_queue_claimed_executionsから削除され、solid_queue_jobsには完了時刻が記録されます。
    失敗した場合は、エラー内容とともにsolid_queue_failed_executionsに記録され、後から確認・再実行できる状態になります。

ジョブを作成して処理を実行

ここまでで、Solid Queueの仕組みと導入方法を見てきました。
ここからは、実際にジョブを作ってバックグラウンドで処理を動かす流れを、具体的なコードとともに見ていきましょう。

簡単な処理を実行してみる

まずは動きを確かめるため、メッセージをログに出力するだけのシンプルなジョブを作ってみます。
ジョブはrails generate jobコマンドで作成できます。

ターミナル
$ rails generate job SampleJob

このコマンドを実行すると、app/jobs/sample_job.rbが作られます。
中身を以下のように書き換えてみましょう。

app/jobs/sample_job.rb
class SampleJob < ApplicationJob
  queue_as :default
 
  def perform(name)
    Rails.logger.info("SampleJobを開始します:#{name}")
    sleep(5) # 時間のかかる処理を再現
    Rails.logger.info("SampleJobが完了しました:#{name}")
  end
end

performメソッドの中に、実際に実行したい処理を書きます。
ここでは、わかりやすいようにsleep(5)で「時間のかかる処理」を再現しています。

作成したジョブは、perform_laterで実行を依頼(エンキュー)します。 Railsコンソールから試してみましょう。

ターミナル
$ rails console
Railsコンソール
SampleJob.perform_later("ナオキ")

perform_laterを呼ぶと、ジョブは即座に実行されるのではなく、一旦キューに積まれます。
具体的には、データベースのsolid_queue_jobsにジョブの情報がレコードとして記録され、すぐに実行できるものはsolid_queue_ready_executionsへ移されます。
そして、ワーカーがそれを拾って処理します。

ナオキ
ナオキ

perform_laterが「あとでやっておいてね」とお願いするイメージです!
すぐ実行したい場合はperform_nowを使うと、キューに積まれずその場で処理されます!

ただし、このままではジョブはキューに積まれるだけで処理されません。
ジョブを実際に処理するには、ワーカーを起動しておく必要があります。
別のターミナルを開いて、以下のコマンドを実行しましょう。

ターミナル
$ bin/jobs

ワーカーが起動した状態でperform_laterを実行すると、しばらくしてログに以下のような出力が表示されます。

ログ
SampleJobを開始します:ナオキ
SampleJobが完了しました:ナオキ

これで、バックグラウンドでジョブが処理されたことを確認できました。

mission_control-jobsでジョブの状態を確認する

コンソールやログでもジョブの状態は確認できますが、ジョブの数が増えてくると、画面上で一覧を見られたほうが便利です。
そこで役立つのがmission_control-jobsです。
mission_control-jobsは、Active Jobのジョブを管理するためのダッシュボードを提供するgemです。
キューに溜まっているジョブや、失敗したジョブをブラウザ上で確認でき、失敗したジョブの再実行や破棄もボタン操作で行えます。

導入方法

Gemfileに以下を追加します。

Gemfile
gem "mission_control-jobs"

そしてbundle installを実行します。

ターミナル
$ bundle install

ルーティングの設定

次に、ダッシュボードにアクセスするためのルーティングをconfig/routes.rbに追加します。

config/routes.rb
Rails.application.routes.draw do
  # ...
  mount MissionControl::Jobs::Engine, at: "/jobs"
end

これで、/jobsにアクセスするとダッシュボードが表示されるようになります。

認証の設定

mission_control-jobsは、デフォルトでHTTPベーシック認証が有効になっており、認証情報を設定しないとアクセスできないようになっています。
以下のコマンドでユーザー名とパスワードを設定しましょう。

ターミナル
$ rails mission_control:jobs:authentication:configure

設定が完了したら、サーバーを起動して http://localhost:3000/jobs にアクセスしてみましょう。
以下のようにダッシュボードが表示され、キューの一覧や各ジョブの状態を画面上で確認できます。

ナオキ
ナオキ

失敗したジョブが一覧で見られて、ボタン1つで再実行できるのがとても便利です!

リトライ処理の実装方法

ここからが本題のリトライ処理です。
冒頭でも触れたとおり、Solid Queue自身はリトライの仕組みを持っておらず、リトライやエラーハンドリングはActive Jobが受け持ちます。
そのため、リトライ処理はActive Jobretry_onを使って実装します。

retry_onの基本

retry_onは、指定した例外が発生したときにジョブを自動でリトライさせるための仕組みです。
先ほどのSampleJobを、わざと失敗してリトライする形に書き換えてみましょう。

app/jobs/sample_job.rb
class SampleJob < ApplicationJob
  queue_as :default
 
  retry_on StandardError, wait: 5.seconds, attempts: 3
 
  def perform(name)
    Rails.logger.info("SampleJobを開始します:#{name}")
    raise StandardError, "エラーが発生しました"
  end
end

retry_onには、主に次のような設定を渡せます。

  • 第1引数:リトライの対象とする例外クラス(ここではStandardError
  • wait:リトライまでの待機時間。5.secondsのように秒単位で指定する
  • attempts:実行する最大回数(初回 + リトライを含めた合計回数)

上記の例では、ジョブが失敗すると5秒待ってから再実行され、それを最大3回まで繰り返します。
3回試しても失敗した場合は、最終的に失敗ジョブとしてsolid_queue_failed_executionsに記録されます。

動作を確認する

ワーカーを起動した状態で、コンソールから先ほどのジョブを実行してみましょう。

Railsコンソール
SampleJob.perform_later("ナオキ")

ログを見ると、ジョブが失敗するたびに、5秒間隔で繰り返し実行される様子が確認できます。
さらに先ほど導入したmission_control-jobsのダッシュボードを開くと、リトライ待ちのジョブや最終的に失敗したジョブの状態を画面上で確認できます。

リトライしきれなかったジョブの扱い

attemptsで指定した回数を試しても成功しなかったジョブは、失敗ジョブとして記録され、自動では再実行されません。
こうしたジョブは、mission_control-jobsのダッシュボードから内容を確認し、原因を直したうえで手動にて再実行できます。
また、コンソールから再実行・破棄も行えます。

Railsコンソール
# 失敗した実行を取得する
failed_execution = SolidQueue::FailedExecution.find(対象のID)
 
# エラー内容を確認する
failed_execution.error
 
# 再実行する
failed_execution.retry
 
# 破棄する
failed_execution.discard
ナオキ
ナオキ

「自動リトライで一時的なエラーには備えつつ、それでもダメなものは失敗ジョブとして記録して後から対応する」という流れだね!

特定の例外は破棄したいとき

エラーの中には、何度リトライしても成功する見込みのないものもあります(例:対象のレコードがすでに削除されている場合など)。
そういった例外は、リトライせずに破棄するdiscard_onを使用します。

app/jobs/sample_job.rb
class SampleJob < ApplicationJob
  queue_as :default
 
  retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
  discard_on ActiveJob::DeserializationError
 
  def perform(name)
    # ...
  end
end

このようにretry_ondiscard_onを組み合わせることで「一時的なエラーはリトライし、回復の見込みがないエラーは破棄する」といった状況に応じたエラーハンドリングが実現できます。

まとめ

本記事では、Solid Queueの仕組みとリトライ処理の実装方法を紹介しました。
Solid Queueは、ジョブの管理に通常のデータベースをそのまま使うことができるため、導入コストが低く、Rails標準のキューイングシステムとして非常に便利です。
また、リトライ処理を実装することで、ジョブの失敗に備えることができます。
ぜひSolid Queueを使って、ジョブの管理やリトライ処理を実装してみてください!

この記事を書いた人

塩見直樹
塩見直樹

TOKOSのバックエンドエンジニア。パフォーマンス最適化と可読性の向上を目指しています。アニメ好きで、特にHUNTER×HUNTERが好きです。