バックグラウンド処理
リクエストの外で何かをしなければならないアプリケーション(レスポンスを返した後に通知を送る、1 時間ごとに古い行を掃除する、失敗した呼び出しを再試行する)は、それをフレームワークに登録します。フレームワークは、配信を行うプロセスの 1 本のバックグラウンドスレッドでそれを実行します:
# config/app.rb(または bin/<app>.rb が Cybertrain::Main.run の前に require するファイル)
Cybertrain::Scheduler.every(3600, "prune sessions") do
Cybertrain::DB.with { |c| c.execute("DELETE FROM sessions WHERE updated_at < datetime('now', '-30 days')") }
end
Cybertrain::Job.handle("welcome_mail") do |payload|
data = Cybertrain::Cast.hash(JSON.parse(payload))
WelcomeMail.deliver(data["email"].to_s) # 例外を出すと再試行される
end
# アクションの中で、ユーザーを保存した後に
Cybertrain::Job.enqueue("welcome_mail", JSON.generate({ "email" => @user.email }))
Cybertrain::Job.enqueue("reminder", @order.id.to_s, run_at: Time.now + 600)
- いつ実行されるか。 スレッドは、バイナリが配信を行うとき(
./<app>、./<app> server)、起動処理がデータベースに接続し、ポートをバインドし、SPINEL_WORKERSを設定した後に始まります。migrate、db ...、Main.commandのコマンドでは決して始まらず、Cybertrain::Test::Clientでも始まりません。タスクは開始からseconds秒後に初めて実行され、その後は各実行の開始からseconds秒後に実行されます。各実行は rescue で包まれており、[ERROR] Scheduled task "name" failed: ...をログに出してループは続きます。ブロックは引数を取りません(Spinel にはinstance_evalがありません)。Cybertrain::Scheduler.last_tickは最後のティックのエポック秒で、自前のヘルスチェックに使えます。 - ジョブ。
Job.enqueueはcybertrain_jobsテーブルに行を追加します。このテーブルはフレームワークが自分で作り(最初の使用時にCREATE TABLE IF NOT EXISTS)、db/schema.rbには含めません。ペイロードは String です:構造化されたデータは JSON でエンコードしてください。DB.transactionの中では、行は他のものと一緒にコミットまたはロールバックされます。ワーカーはjob_poll_interval秒(5)ごとにポーリングするスケジューラーのタスクで、期限の来た最も古いジョブを短いBEGIN IMMEDIATEトランザクションで確保し、そのハンドラーをトランザクションの外で実行して、doneにします。例外を出したハンドラーは 1 分、5 分、15 分、その後は 60 分後に再試行され、last_errorにはClass: message(最大 1000 文字)が入ります。これはjob_max_attempts(5)回の試行が失敗するまで続き、その後、行はfailedになって、調べられるように残ります。種類にハンドラーのないジョブはすぐにfailedになります。Job.enqueueは登録されていない種類を拒否します。クラッシュしたプロセスがrunningのまま残した行は、ワーカーの開始時にキューに戻されます。Job.state_count("failed")とJob.prune_done(before_epoch)は、ヘルスページや後片付けに役立ちます。 - データベースごとにワーカーは 1 つ。 配信を行うすべてのプロセスは、
CYBERTRAIN_WORKER=0(config.worker = false)でない限りスレッドを実行します:複数のプロセスが 1 つの SQLite ファイルを共有する場合は、1 つを除いてすべてで無効にしてください。そうしないと、各定期タスクがプロセスごとに 1 回実行されます。 - タスクは短く保つ。 タスクとハンドラーはその 1 本のスレッドで一度に 1 つずつ実行され、SQLite の呼び出しは戻るまで OS のワーカーを占有します:
SPINEL_WORKERS=1(デフォルト)では、長いクエリはその間すべてのリクエストを止めてしまいます。大きな作業は一度に済ませず、分割してください(1 回の実行で 1000 行ずつ削除する、項目ごとにジョブを 1 つキューに入れる)。 - テスト。
Cybertrain::Scheduler.run_due(now)はティックを 1 回、Cybertrain::Job.work_one(now)はジョブを 1 つ、自分で選んだエポック時刻で実行するので、テストがスリープすることはありません。
cron の構文も、ジョブの優先度や一意性も、deliver_later もありません:メールを送るジョブは自分で Cybertrain::Mailer を呼びます(メールを送る)。これが扱うのは「後で送る」と「N 秒ごとに実行する」です(docs/design.md D28)。
運用:バックアップ、VACUUM、ヘルスチェック
デプロイしたアプリケーションのデータは 1 つの SQLite ファイルなので、バイナリはそうしたアプリケーションに必要な 3 つの運用ツールを備えています。ファイルアップロードのあるアプリケーションは、そのバイト列もアップロードのルート(upload_root。デフォルトは storage/uploads/)の下に置きます:このディレクトリもデータベースと一緒にバックアップ・復元してください。そうしないと、復元したレコードが存在しないファイルを指すことになります(ファイルアップロードを追加する)。
バックアップ。 ./<app> db backup PATH は、サーバーを動かしたまま、設定されたデータベース(CYBERTRAIN_DATABASE)の一貫したコピーを書き出します:SQLite の VACUUM INTO(3.27 以降。バイナリが確認します)が 1 つのスナップショットを読み、WAL のもとでは、その間もサーバーの書き込みは続きます。コピーは PATH の隣の一時ファイルに書き込まれ、そのファイルはモード 0600 で排他的に作成され(ファイルにはパスワードのダイジェストとセッションのトークンが含まれます)、fsync された後にはじめて PATH にリネームされます。そのため、失敗したり中断したりしたコピーが本来の名前で不完全なファイルを残すことはありません(一時ファイルは削除されます)。既存の PATH は、--force を渡さない限り拒否されます。データベース自体が書き込み先になることは決してなく、:memory: のデータベースがコピー元になることもありません。
./blog db backup /srv/blog/backups/before-upgrade.sqlite3
./blog db backup /srv/blog/backups/ --keep 14 # blog-20261025T031500Z.sqlite3、新しい 14 個を残す
/ で終わる書き込み先(または既存のディレクトリ)には、UTC で日付の入った名前 <app>-<YYYYmmddTHHMMSSZ>.sqlite3 が付けられ、ディレクトリが存在しなければモード 0700 で作成されます。--keep N は、そのうえで、ちょうどその形の名前を持つファイルのうち N 個を超える古いものを削除し、そこにあるそれ以外のものには触れません。復元は、サーバーを止めた状態でバックアップをデータベースファイルの上にコピーすることです(そして、その隣にあるファイルの -wal と -shm を削除します)。
VACUUM。 行を削除すると、ファイルに空きページが残ります。./<app> db vacuum は VACUUM を実行してから PRAGMA wal_checkpoint(TRUNCATE) を実行するので、作り直されたページがデータベースファイルに届き、ファイルは新しいサイズに切り詰められ、WAL は空になります。前後のディスク上のサイズ(データベースと WAL の合計)を表示します。VACUUM はファイル全体を書き直し、その間は書き込みロックを保持するので、アプリケーションが静かなときに実行してください。読み手がいてチェックポイントが完了しない場合はその旨を伝え、WAL は後のチェックポイントで縮みます。
バックアップはサーバーの外で実行する。 SQLite の呼び出しは戻るまで OS のワーカーを占有するので、Scheduler のタスクで大きな VACUUM INTO を行うと、コピーにかかる間ずっとリクエスト(SPINEL_WORKERS=1 ではそのすべて)が止まります。代わりに cron や systemd のタイマーから、別のプロセスとしてコマンドを実行してください:
# アプリのユーザーの crontab:毎晩 03:15 に実行し、2 週間分を残す
15 3 * * * cd /srv/blog/current && ./blog db backup /srv/blog/backups/ --keep 14
自分でバックアップを取りたいアプリケーションは、SQL を自分のスレッドで実行するのではなく、定期タスクから自分自身のバイナリを子プロセスとして実行できます:system が子プロセスを待つのはスケジューラーのスレッドだけで、その間もリクエストは配信され続けます(Spinel には Process.wait がなく、それなしの Process.spawn はゾンビを残すので、system を引数のリストで使い、リクエストのデータを含む文字列では決して使わないでください)。health_worker_stale より長くかかるコピーの間は、終わるまでヘルスチェックが worker stale を返します。
# config/app.rb。自身のディレクトリ(dist/)から起動された、配信を行うバイナリで
Cybertrain::Scheduler.every(86_400, "nightly backup") do
system("./blog", "db", "backup", "storage/backups/", "--keep", "14")
end
ヘルスチェック。 ヘルスチェックが有効な間(デフォルトで有効です。空の config.health_path で無効になります)、配信を行うアプリケーションは、静的ファイル、セッション、CSRF、ルーターより前に GET /up(と HEAD)に応答します:コントローラーは関与せず、ログインの before_action がリダイレクトすることもなく、cookie も設定しません。SELECT count(*) FROM sqlite_master を実行し(これはファイルからスキーマのページを読む必要があります。素の SELECT 1 はファイルに触れません)、Cache-Control: no-store 付きの text/plain で応答します:
| ステータス | ボディ | いつ |
|---|---|---|
| 200 | ok |
クエリが成功した |
| 503 | database unavailable |
Cybertrain::DB::Error が出た(ファイルがない、または読めない、プールが閉じている) |
| 503 | worker stale |
バックグラウンドの Scheduler が動いていて、その最後のティックが health_worker_stale 秒(300)より古い:タスクが詰まっているか、あまりにも長く動いている |
ポーリングは他のリクエストと同じようにログに記録されます(2 行)。パスは config.health_path(CYBERTRAIN_HEALTH_PATH。デフォルトは /up。空の値でチェックが無効になり、そのパスは以前と同じく public/ とルーターに進みます)です。ロードバランサー、kamal-proxy、デプロイスクリプトがこれをポーリングできます。cybertrain new が書き出し、その Kamal の設定が今もポーリングしている public/up.html は、プロセスが HTTP に応答することを示すだけです。