Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Solid Queue 徹底解説:Ruby on Rails に最適な実績あるバックグラウンドジョブソリューション

本シリーズの前回の記事では、バックグラウンドジョブを処理するシステムが必要な場合、Solid Queue が優れた選択肢であることを確認しました。すべてのジョブをデータベースに保存することで外部依存を最小限に抑えられる——つまり Redis が不要!——にもかかわらず、驚くほど高いパフォーマンスを発揮します。

しかし、本番環境で使えるバックグラウンドジョブシステムにとって、パフォーマンスが高いだけでは十分ではありません。Rails 開発者は長年の経験の中で、多くの機能を当たり前に期待するようになりました。単にジョブをキューに登録してバックグラウンドで実行したいだけでなく、将来の日時を指定したスケジュール実行や、定期的な繰り返し実行、さらには同時実行数の制御まで求められることがあります。もっと多くの機能が欲しいのです!

驚くことに、Solid Queue はこれらすべての機能を標準で備えています。この記事では Solid Queue をさらに深く掘り下げ、それがどのように実現されているのかを学んでいきましょう!

Solid Queue でのジョブのスケジューリング

まずは簡単なおさらいです。Solid Queue はジョブデータをデータベースのみに保存します。その動作はすべて、何らかのデータベーステーブルに支えられています。ジョブのスケジューリング——つまり、将来の特定の時刻に実行するようジョブを指定すること——も例外ではありません。スケジュールされたジョブはすべて solid_queue_scheduled_executions テーブルに格納されます。


このテーブルは solid_queue_ready_executions テーブルとほぼ同一です。唯一の違いは scheduled_at カラムが追加されている点で、このカラムによってスケジュールされたジョブがいつ実行されるべきかがわかります。実際にジョブをスケジュールしたときに何が起こるかを見て確認してみましょう。



ここには特に驚きはありません。Solid Queue は solid_queue_scheduled_executions テーブルに新しい行を追加し、予想どおりのデータが格納されます。しかし、こうしたレコードの存在から、どうやって正しい時刻にジョブを実行へと導くのでしょうか?

そのためには、solid_queue_scheduled_executions テーブルを継続的にポーリングするプロセスが必要です。このプロセスは「Dispatcher(ディスパッチャー)」と呼ばれ、スケジュールされたジョブを時間どおりに実行する役割を担っています。Dispatcher は Solid Queue の起動とともに開始されるため、追加の設定は不要です。ただし必要であれば、特定の設定を使って Solid Queue を起動し、Dispatcher プロセスだけを起動することも可能です。


Dispatcher プロセスがどのように監督されているのか気になる方のために説明すると、それは名前のとおり Supervisor の責務です。Supervisor は Solid Queue 内で動作しているすべてのプロセス——ワーカープロセスや Dispatcher を含む——を追跡管理しています。

それでは、Dispatcher は実際にどのように動作しているのでしょうか? Dispatcher はループ内で呼び出される poll メソッドを定義しており、これによって継続的にスケジュール済みジョブをチェックします。ポーリングのコードは複数のクラスやモジュールに分散していますが、大きく簡略化すると次のような形になります。


「実行可能な状態」にあるスケジュール済み実行を取得するクエリは非常にシンプルです。


つまり、scheduled_at が過去になっているスケジュール済みジョブは、ディスパッチ可能な状態ということです。本シリーズ第1回で扱ったように、Solid Queue がジョブをディスパッチすると、ReadyExecution レコードを作成し、対応する ScheduledExecution レコードを削除します。作成された ReadyExecution レコードは通常のワーカープロセスに拾われ、該当するジョブが実行されます。

ここまでは順調ですね。スケジュールされたジョブはそれほど複雑ではありません! 次はもう少し複雑なものを見てみましょう。定期タスクです。

定期タスク

定期タスクは、バックグラウンドジョブプロセッサーにおいて昔から要望の多かった機能です。簡単に言えば、決まったスケジュールで繰り返し実行されるバックグラウンドジョブのことです。「5分ごと」「毎日正午」といったスケジュールを定義できる点で、Cron ジョブに似ています。

Solid Queue では、config/recurring.yml ファイルを使って定期ジョブを設定します。たとえば、毎日正午に CleanupData ジョブを実行したい場合は、次のように記述します。


Solid Queue はスケジュール式の解析に Fugit を使用しています。そのため、「毎日正午」のような人間が読みやすい形式のスケジュール表現が許容されています。定期タスクを使用する際は、実行するジョブのクラスとジョブ引数を定義します。詳細については充実した SolidQueue の定期タスク ReadMe を参照してください。ここでは仕組みを学ぶのが目的なので、内部実装を覗いてみましょう。

定期タスクは RecurringTask モデルとして表現され、対応する solid_queue_recurring_tasks テーブルに支えられています。テーブルのカラムは設定ファイルで利用可能なフィールドに対応しています。


Solid Queue を起動すると、定期タスクの設定ファイルに従って定期タスクのレコードが作成されます。適切なタイミングでジョブを作成するために、今度もまた新しいプロセスが必要になります——今回の主役は「Scheduler(スケジューラー)」です。Scheduler は既に紹介した Dispatcher の兄弟分にあたります。動作の仕組みもほぼ同じで、Solid Queue の起動時に新しいプロセスが立ち上がり、無限ループを実行します。Scheduler と Dispatcher の違いは、そのループ内で何が行われるかという点です。Dispatcher が solid_queue_scheduled_executions テーブルをクエリするのに対し、Scheduler は solid_queue_recurring_tasks をクエリし、適切なタイミングでジョブをスケジュールします。では、Scheduler は一体どうやって「適切なタイミング」を判断し、適切なジョブをスケジュールしているのでしょうか?

この問いに答えるには、実装を詳しく調べる必要があります。Scheduler クラスは新しい RecurringSchedule オブジェクトを作成し、そこに schedule メソッドを定義しています。このメソッドは各定期タスクに対して繰り返し呼び出されます。簡略化したバージョンは以下のとおりです。


このコードを紐解いていきましょう。Solid Queue は concurrent-ruby ライブラリの Concurrent::ScheduledTask を使って新しいスレッドを生成します。このスレッドは、定期タスクのスケジュールが示す時刻に実行されるよう予約されます。スレッドが実行されると、まず再帰的にもう一つのスレッドを生成して次回の定期タスクをスケジュールします。その後、「現在の」スケジュール済みジョブをエンキューします。

理解を深めるために、シンプルな定期タスクの例を見てみましょう。


8時30分に Solid Queue を起動した場合、schedule メソッド内の変数には次のような値が割り当てられます。厳密な値ではありません。大幅に簡略化している点にご注意ください。

この場合、バックグラウンドスレッドは30分後、つまり9時00分に実行されるよう予約されます。その時刻が来るとバックグラウンドスレッドが実行され、thread_task.enqueue(at: 9:00) が呼ばれて CleanupData のインスタンスが実行待ちのキューに入ります。同時に、thread_schedule.schedule を通じて自分自身を再帰的に呼び出します。このときはすでに9時00分なので、変数の値は変わっています。

こうして、バックグラウンドスレッドは10時00分に再度実行されるようスケジュールされ、このサイクルが続いていきます。「スケジューリング用のスレッドが死んだらどうなるのか?」と疑問に思うかもしれません。再デプロイやシステムクラッシュなどでスレッドが中断されれば、スケジュールは崩れてしまうのではないでしょうか? 幸い、答えはノーです。Cron スケジュールは静的なものだからです。「毎時」という式は、Solid Queue がいつ起動されたかにかかわらず、常に10時00分、11時00分、12時00分…と解決されます。スケジューリングスレッドに何らかの中断が発生しても、これは変わりません。

他にも知っておくべき実装の詳細があります。第一に、定期タスクの次回実行を実行前にスケジュールするこのパターンは、GoodJob に着想を得ています。第二に、RecurringTask.enqueue は皆さんが想像するような新しい Job レコードや ReadyExecution レコードを作成しません。代わりに、さらに別のレコード、つまり RecurringExecution を作成します。


このレコードは、定期ジョブが重複して実行されるのを防ぐことだけを目的としたものです。そのために task_keyrun_at に対してユニーク制約付きのインデックスが張られています。同じ時刻・同じジョブに対する先行する RecurringExecution が存在しない場合にのみ、RecurringTask はキューに入れられます。


注意深い読者なら、このコード断片が Solid Queue の制約を示していることに気づいたでしょう。つまり、cron 形式のタスク実行のバックエンドとして Solid Queue を使用していない場合——そういう使い方もできるのですが——Solid Queue は定期ジョブが一度だけエンキューされることを保証できません。そのような状況に置かれた場合は、この点に留意してください。

「Scheduler プロセスが死んだらどうなるのか?」と疑問に思う方もいるでしょう。たとえばデプロイ中などにプロセスが強制終了された場合、スレッドで管理されている繰り返し処理が壊れてしまうのではないでしょうか? 幸い、この答えもノーです。

同時実行制御

最後に取り上げるのは、Solid Queue のもう一つの機能、同時実行制御です。特定の種類のジョブが同時に実行できる数を制限したい場面は少なくありません。Solid Queue では limits_concurrency を使ってこれを実現できます。


この例では、ユーザーごとに MyJob のインスタンスは最大1つまでしか同時実行できないよう Solid Queue に指示しています。設定内容を詳しく見ていきましょう。

  • to: 同時に実行を許可するジョブの最大数。
  • key: どのジョブ群をまとめて制限対象にするかを指定する必須の引数。上記の例では、同じ User ID を持つジョブが単一の同時実行に制限されます。key には任意のジョブ引数を使用できますが、文字列やシンボルといった固定値も許容されます。
  • duration: ジョブがエンキューされた後、Solid Queue が同時実行を保証できる最大時間。ジョブの実行がこの時間を超えると、同時実行制御は適用されなくなり、ジョブが重複して実行される可能性があります。理由は後ほど説明します!
  • group: このオプションを使うと、異なるジョブクラス間で同時実行を制限できます。

さらに詳しく知りたい方は、同時実行制御のドキュメントを参照してください。同時実行制御は、Solid Queue の中でも断トツで洗練された機能です。定期タスクですでに頭が混乱していた方には、この機能の仕組みを学べば確実にさらに混乱するでしょう。

基本から始めましょう。他の Solid Queue の機能と同様に、同時実行制御もさまざまなモデルとそれに対応するデータベーステーブルによって支えられています。特に把握しておくべき2つのモデルが、SemaphoreBlockedExecution です。


まず Semaphore から見ていきます。名前のとおり、これはカウンティングセマフォパターンの実装です。Solid Queue は limits_concurrency を持つジョブをエンキューする際、まず同時実行キーに基づいてセマフォロックの取得を試みます。この同時実行キーは、limits_concurrency に渡された引数——ジョブクラス、キー、そして指定されていればグループ名——に基づいて生成されます。セマフォが利用可能であればジョブはエンキューされ、利用不可の場合は代わりに BlockedExecution レコードが作成されます。

セマフォには複数の同時実行ジョブをサポートするための value があります。これはセマフォの残り容量と考えるとわかりやすいでしょう。セマフォの取得はこの値をデクリメントすること、解放はインクリメントすることを意味します。値がゼロになると、セマフォは利用不可とみなされます。シンプルなジョブでロック機構がどのように働くか、具体例を見てみましょう。


このジョブを連続して何度もエンキューしようとしたとき、何が起こるか確認してみます。

  1. MyJob の最初のインスタンスがエンキューされます。まだセマフォは存在しないため、新しく作成されます。初期値は limit - 1 です。上限が3なので、セマフォの初期値は2になります。
  2. MyJob の2番目のインスタンスがエンキューされます。Solid Queue はこのジョブのロック取得を試みます。値が2でゼロより大きいため、ジョブはエンキュー可能です。セマフォの値は1に減ります。
  3. 3番目のジョブインスタンスがエンキューされます。先ほどと同じ手順が繰り返されます。セマフォの値はゼロになります。
  4. MyJob の4番目のインスタンスがエンキューされます。値がゼロになったため、セマフォの取得に失敗します。このジョブのために BlockedExecution レコードが作成されます。
  5. 最初のジョブインスタンスが完了します。完了時にセマフォを解放するため、セマフォの値は再び1になります。
  6. 完了した最初のジョブインスタンスは、ブロックされているジョブを解放するメソッドも呼び出します。
  7. 4番目の MyJob インスタンスが解放され、改めてロックの取得を試みます。セマフォの値は1なので、ロックは取得可能で、ブロックされていたジョブがキューに入ります。セマフォの値はゼロになります。

ジョブ完了時にセマフォを解放するコードは非常にシンプルです。


まだ触れていない詳細が一つあります。なぜセマフォに有効期限があるのか、そして limits_concurrency を使う際に duration を設定する必要があるのはなぜでしょうか?

ジョブがセマフォを解放せずにクラッシュした場合——たとえばそのジョブを処理していたワーカーが死んだ場合——何が起こるか考えてみましょう。セマフォをクリーンアップする仕組みがないと、そのジョブが保持していたロックは永遠に保持され続けます。最悪の場合、他のジョブが永久に処理されない事態に陥ります。

そのような状況を避けるため、セマフォにはジョブ定義で指定された duration に対応する有効期限が設けられています。セマフォが期限切れになると——新しいジョブがエンキューされていない場合に発生します——セマフォは破棄されます。その担当プロセスはもうおなじみ、あの Dispatcher です。Dispatcher は ConcurrencyMaintenance クラスをインスタンス化し、次の2つの処理を行います。

  • 第一に、期限切れのセマフォをすべて削除します。
  • 第二に、ブロックされているジョブがあるかどうかを確認し、あれば解放します。

ジョブは一件ずつ解放されるため、同時実行の上限は引き続き守られます。しかし、ジョブの実行時間が指定した duration を超えた場合どうなるか考えてみてください。この場合、ジョブはまだ実行中であるにもかかわらず、セマフォはクリーンアップされてしまいます。そこで別のジョブがエンキューされると、両者の実行が重なってしまうのです。

AppSignal による Rails 向け Solid Queue の監視

ここまで見てきたように、Solid Queue は多くのことができます。しかし、これだけ多くの稼働部分があると、監視が極めて重要になります。幸い、AppSignal は Solid Queue を組み込みでサポートしており、ジョブの実行時間、スループット、失敗率に関するダッシュボードがすぐに使える状態で提供されています。Rails アプリケーションに AppSignal をインストールするだけで準備完了です。

AppSignal は Solid Queue の使用を自動的に検出し、エラー率やスループットといった重要なメトリクスのグラフを含む Active Job ダッシュボードを作成します。

Solid Queue 徹底解説:Ruby on Rails に最適な実績あるバックグラウンドジョブソリューション

実行が遅い、エラーが多すぎるなど、問題のあるジョブを見つけたら、ステータスと担当者を割り当てて効率的に解決しましょう。

Solid Queue 徹底解説:Ruby on Rails に最適な実績あるバックグラウンドジョブソリューション

もちろん、問題の有無を確認するために一日中ダッシュボードを眺めているわけにはいきません。そんなときこそ AppSignal Alerts の出番です。失敗率やジョブ実行時間といったジョブのメトリクスに対するアラートを新規作成すれば、あとはお任せです。

Solid Queue 徹底解説:Ruby on Rails に最適な実績あるバックグラウンドジョブソリューション

Solid Queue は、手間をかけずにアプリケーションへ強力なジョブ処理を追加できる素晴らしいツールです。監視に関しては、AppSignal が同じ役割を果たします!

まとめ

今回は、Solid Queue の高度な機能について幅広く掘り下げてきました。スケジュールされたジョブから複雑な依存関係のチェーンまで、各機能は第1回で解説した堅牢な基盤の上に成り立っています。見てきたとおり、ジョブ処理バックエンドの構築は決して容易ではありません。しかし Solid Queue のソースコードとその動作原理を深く掘り下げることで、そこに潜む課題への理解と、ある種の敬意を得ることができました。

いずれにせよ、優れたデータベース設計とプロセス協調のおかげで、Solid Queue は Rails エコシステムにとって素晴らしい追加要素となっています。外部依存なしという核心的な約束を守りながら、開発者に必要なツールを提供してくれるのです。

それでは、Happy Coding!

  1. RailsでTailwind CSSを使う方法|導入から実践的なスタイリングまで徹底解説

    CSSは魔法のような存在ですが、同時に時間のかかる作業でもあります。美しく、機能的で、アクセシブルなサイトは使っていて心地よいものですが、自分でCSSを一から書くのは骨の折れる仕事です。近年はBootstrapをはじめとする多くのCSSフレームワークが登場し、その中でもTailwind CSSは特に注目を集めています。 RailsにはTailwindが標準搭載されていませんが、この記事では新しいRuby on RailsプロジェクトにTailwind CSSを追加する方法を解説します。これにより、デザイン実装にかかる時間を大幅に節約できるでしょう。さらに、Tailwindのユーティリティクラス

  2. Rubyで学ぶ実用数学:モジュロ演算子、進数変換、ビットマスクの基礎

    優れたプログラマーになるためには、数学の知識が必要なのでしょうか? 答えは「場合によります」。 もし日々CRUDアプリの作成だけを行うのであれば、数学の知識はほとんど必要ないかもしれません。しかし、もっと面白いことに挑戦したい方——例えばコーディングチャレンジに取り組んだり、プログラミング面接に備えたりしたい方にとっては、いくつかの基本的な概念を身につけておくことが大きな助けになります。 この記事で学べる内容: モジュロ演算子(%) 記数法(数体系) ビットマスキング これらの概念をRubyでどう活用するかを解説する、実践的なガイドです。それでは始めましょう! Rubyのモジュロ演算子(%