ActiveRecordとdry-operationでバッチジョブをお手軽に管理してみる(1)

今回はどういう考えでどういうものをどう設計していったかの話と、いちばんの基本部分の DB スキーマと ActiveRecord の役割の話を整理する。
背景
以前から UI に関係しない定期的で時間の掛かるいわゆるバッチ処理についていろいろ思うところがあった。
- ActiveJob が難しい
- Rails の裏に回ってくれてリトライもできるけどそれだけ
- 失敗したらリトライすることはできるが、単純に一連の流れをリトライしてしまうと例えば二回処理してはいけない部分が二回処理される可能性もある
- というかそもそも queue ベースの処理って管理が難しい
- ActiveJob は queue ベースになっているようで queue のない adapter もアリだし、queue を前提にした管理機能がない(これは adapter に合わせて別個作り込むしかない)
- 個々のジョブが実際に完了したかどうかの把握はジョブの作り方次第
- ActiveJob は queue ベースになっているようで queue のない adapter もアリだし、queue を前提にした管理機能がない(これは adapter に合わせて別個作り込むしかない)
- これは言語を問わない面倒くさい問題
てことで、まぁ気づいてはいたんだけど、
- Rails も ActiveJob も関係なしにそもそもバッチジョブの成否の管理、長時間掛かって途中から再開できるようにする処理がそもそも面倒くさい
- じゃあいわゆるクラウドピタゴラスイッチかというと、これも厄介
- 代表的な FaaS は一度の実行で利用できるリソースの制限が厳しい
- バラしてパラレルに処理して結果整合性で解決することもできるが、実は FaaS 全体のリソース制限に引っかかったりする(サーバレスとはなんだったのか)
- そもそもクラウドピタゴラスイッチはローカルの開発環境で完全再現することは難しい
みたいな話に戻ってくる。
実はこの辺の問題意識は去年も扱っている。
Cloud Workflowsで長時間の処理の実行と成否を分かりやすく (2024-05-11) | あーありがち
ただ、Cloud Workflows は
- 実際の処理を Google Cloud で動かさないと旨みがない
- Google Cloud で動かす場合でもその内容に Google Cloud に依存する部分が少ない場合、単にローカルでテストしにくい仕組みになる
という問題がある。
実現したいこと
ここまでを踏まえて本当に自分の欲しいものはなんなのかを整理した。
- 失敗の可能性のあるバッチジョブの動作状況を記録(required)し、確認1できる
- 失敗した場合もどこまで成功したかが分かる(分解可能な作りになっている前提)
- 仮にプログラムのバグやメモリ容量など、失敗の原因を取り除くことができた場合、成功が確認できている次の工程から「再開」できる
- ローカルでもクラウドでもローコストに再現できる
調べると
- reidmorrison/rocketjob: Ruby’s missing background and batch processing system
- rails/mission_control-jobs: Dashboard and Active Job extensions to operate and troubleshoot background jobs
みたいなのは出てくるんだけど、MongoDB や Redis, Solid Queue 依存になり、「そういうことじゃないんだよな」という感想になるぐらいのことを実現したい、という要求になる。
実験と選定
ストレージはActiveRecordで
KVS をいろいろ調べたり試したりしたけど、現時点で ActiveRecord を使わないメリットより KVS を Ruby から使おうと思った時に gem がオリジナルの KVS に追従していないとか、けっきょく別のインフラが必要でコストが膨らむというデメリットの方が大きそうなので見送った。
ステートマシンライブラリを使わない
最初、上のリンク先でも触れている
GitHub - geekq/workflow: Ruby finite-state-machine-inspired API for modeling workflow
を使おうと考えていた。これなら ActiveRecord を利用した persistent layer もあるし、それ以外の storage との組み合わせも自分で作れば実現できるから。
しかし、結論としてはステートマシンライブラリの採用をやめた。というのも、
- やりたいことは途中でエラーになっても成功した部分以降で再開できる、まで
- まずは一直線に進むバッチジョブで上の課題を解消したいだけで、複雑な分岐などは(今のところ)必要ない
- そうなった時にステートマシンライブラリの複雑な状態遷移定義の機能が丸ごと不要
- どの状態からでも失敗は一気に失敗であり、ステップが増えるほど定義が面倒になるだけ
ということに気づいたから2。くり返すと機能としてはステートマシンなんだけど、複雑な定義が不要なのでステートマシンライブラリは不要という判断。ということで、
じゃあもうこれ、普通に ActiveRecord でモリモリ設計すればいいんじゃね?
という結論に至った。
バッチジョブをActiveRecordでどう表現するか
スキーマ
これは以下のようなスキーマで実現することにした。基本的な考え方は CI/CD と一緒。ただし、workflow 設定に複雑な機能は不要なので、中身はとてもシンプル。
- batch_jobs は update 可能、それ以外は create のみで考える
- batch_jobs の type は Single Table Inheritance で考える
- execution は単に実行のたびに新しく作成されるだけ
- event は定義にしたがったイベントが順次記録されるだけ
batch_job_events の中身はこんな感じになる。
| batch_job_execution_id | name | message | created_at |
|---|---|---|---|
| 1 | invoked | ||
| 1 | abc | ||
| 1 | def | ||
| 1 | completed |
- 成功裡に完了すれば completed まで記録される
- completed が記録されていなければ今まさに実行中か失敗で完了したもの
ただ、これだけだと失敗の理由を記録できないので、失敗記録用の errors を用意。error 発生時はただちにジョブが終了するので、execution : error = 1 : 1 になる。
※ 実際にはエラーの部分は必要になったらあとで考えればいいやと思っていたので、最初に決めたのは batch_jobs, batch_job_executions, batch_job_events の三つだけ。
改めて欲しい機能としては
- 現在どこまで進んでいるのかが分かる
- 最新の実行結果が成功で「完了」しているかが分かる
- このジョブが実行中か否か(後述)が分かる
この辺は基本的には上のスキーマに従って ActiveRecord で SQL を発行すれば分かる。
ただし、最後のジョブが今まさに実行中なのか、失敗して途中までで終わっているのかは判別できない。
また実行中か否かが分かるのかを ActiveRecord + SQLite でいろいろ試行錯誤していた3んだけど、SQLite はロックが全 DB 単位でしか取得できないので、
- 例えばジョブの実行前にロックを取得して長時間のジョブを実行するようなツクリにすると以降の処理がすべて待たされる
- 結果、「何も分からない」状態になる
ので、ここは別な方法を後で考えることにした。
ジョブの進行の定義と結果をどのように扱うか
ユーザーがこのライブラリを通じて行いたいこととしては、
- このジョブはどういうステップで完了まで進むのかを定義する
- 実際のジョブの実行内容を記述する
で、これらは別々な役割であり、ActiveRecord モデルの責務としては前者のみを考えていた(後者については後述)。schema 的には上の通りで問題ないと思う。あとは
- 結果をどう扱うか
で、ここはいろいろ悩んだが、
| クラス | 内容 |
|---|---|
| BatchJob | 進行の定義 |
| BatchJobExecution | 結果を持つ |
| BatchJobEvent | - |
これが素直なはず。4
- BatchJob を実行したら
- BatchJobExecution が返ってきてこれが結果を持っている
そんなイメージ。
1 については簡単。これはジョブの定義クラスを作って、そこに書けばよい。発想としてはステートマシンライブラリによくある DSL 的なものを参考にしつつ、クラスメソッドで Array が定義できれば十分だろう。そのために STI で利用する設計がよさそう。で、schema もそのようにしてある。
問題は 2 の実現方法で、そのままだと BatchJob に実行用のメソッドを用意して、そいつが BatchJobExecution にいろいろ値を詰め込んで返す。になる。これでも一応実現はできる(実際初期バージョンはそういう作りになっていた)。
ただ、ActiveRecord モデルの責務としてはやや過剰だろう。このあと触れる ActiveRecord 以外の何かに対してもいい具合に何かの仕事をしなければいけないとしたら、さすがに分離した方がよいと思う。
ActiveRecord 以外の部分は ActiveRecordとdry-operationでバッチジョブを管理してみる(2) (2025-08-24) | あーありがち へ。
表現は問わない ↩
保存こそしていなかったけど、実際にライブラリをいくつか試して状態の定義を書いてみてから、「んー、これ要らない」ってなったので、無駄な時間ではあるんだけど、無駄と分かったということが、仕様の明確化に繋がったわけで、やはりこういうのは大事なんだなぁと思った。また出来上がってからコードを LLM に突っ込んで設計を説明してもらったら Linear Workflow とか Sequential State Machine という言葉が出てきたが、こういう言葉を先に知っていたらそういう設計に寄せて考えていたのかもしれない。 ↩
ローコストに実現したかったので SQLite でも実現可能な方法が望ましい ↩
「結果」の詳細は上記の設計の時点では SQL schema としては持っていないが、これはおいおい考えればよいと思う。 ↩