1. はじめに
別記事では、非同期ジョブキューを使ってタイムアウトを回避する方法を紹介しました。
しかし、バックグラウンド処理には新たな敵が待ち受けています。
「予期せぬエラー」 です。
外部APIの瞬断、一時的なネットワーク不良、リソース不足…。
長い処理を実行していると、コードが正しくても失敗することは避けられません。
今回は、そんなエラーが発生しても自動で立ち上がり、ユーザーに気づかれないように復旧する 「不死身のバックエンド」 の作り方を紹介します。
2. 直面した課題:エラー即死
当初の実装は単純でした。
「エラーが出たら status = 'FAILED' に更新して終了」。
しかし、これには問題がありました。
YouTube APIなどは時々 500 Server Error を返すことがあります。
たった1回のエラーで「失敗しました」と表示されると、ユーザーは「このアプリは壊れている」と感じてしまいます。
さらに、失敗したジョブがDBに残り続け、いつの間にかゴミだらけになっていました。
3. 解決策:3つの不死身ロジック
そこで、以下の3つの機能を持った「リカバリーシステム」を実装しました。
- 自動リトライ (Auto Retry): 3回までは無言でやり直す。
- エラー隠蔽 (Error Masking): リトライ中はユーザーに「失敗」を見せない。
- 自動お掃除 (Auto Cleanup): 古いジョブや完了したジョブを自動で消す。
4. 実装の詳細
4.1 データベース設計(ステータス管理)
まず、ジョブを管理するテーブルに retry_count カラムを追加します。
ALTER TABLE job_queue ADD COLUMN retry_count INT DEFAULT 0;
ステータスの定義も少し見直しました。
- PENDING: 待ち行列に入っている。
- PROCESSING: 現在実行中。
- FAILED: エラーで止まった(リトライ候補)。
- COMPLETED: 完了。
4.2 リカバリーサービスのロジック
バックグラウンドで常に動く「リカバリー担当(Recovery Service)」を作ります。
このサービスは、定期的に(例えば1分おきに)DBをパトロールし、倒れているジョブを見つけて助け起こします。
具体的なPython風疑似コードは以下のようになります。
async def recovery_patrol():
# 1. 失敗したジョブを探す
failed_jobs = await db.select("*").table("job_queue").eq("status", "FAILED").execute()
for job in failed_jobs:
if job['retry_count'] < 3:
# リトライ上限未満なら処理を実行
print(f"Rescuing job {job['id']}...")
# ステータスをPROCESSINGに戻し、リトライ回数を増やす
await db.table("job_queue").update({
"status": "PROCESSING",
"retry_count": job['retry_count'] + 1,
"error_message": None # エラーログは消して再挑戦
}).eq("id", job['id']).execute()
else:
# もう助からない(リトライ上限到達)
print(f"Giving up on job {job['id']}...")
# ここで初めてユーザーに「失敗」として扱う(あるいは削除する)
await db.table("job_queue").delete().eq("id", job['id']).execute()
4.3 エラー隠蔽(Error Masking)
ここがUX上の重要なポイントです。
フロントエンドへ状態を返すAPIでは、以下のような嘘をつきます。
if job['status'] == 'FAILED' and job['retry_count'] < 3:
# 実は失敗しているが、リトライ中なので「処理中」と伝える
return {"status": "PROCESSING", "message": "システム調整中..."}
else:
# 本当のステータスを返す
return {"status": job['status']}
これにより、ユーザーは裏でエラーが起きてリトライしていることに気づかず、「ちょっと時間がかかっているな」程度にしか感じません。
4.4 結果の同期(State Sync)
さらに、「同じ動画を分析したい人が複数いた場合」 の最適化も行いました。
Aさんが動画Xの分析をリクエストし、その処理中にBさんが同じ動画Xをリクエストした場合、Bさんに新しいジョブを作る必要はありません。
- 新規リクエスト時に、同じ
video_idで処理中のジョブがないか確認。 - あれば、そのジョブIDをBさんにも返す。
- AさんとBさんは同じジョブの完了を待つことになる(SSEで同時に通知を受け取る)。
これでサーバーのリソースを大幅に節約できます。
5. 運用効果
この「不死身システム」を入れてから、以下の効果がありました。
- エラーの予防: 一時的なAPIエラーで止まることを防止でき、再度リトライすることで一時的なエラーを回収する。
- DBがクリーン: 自動クリーンアップも入れているため(作成から20分以上経過したジョブは強制削除)、メンテナンスフリーで運用できています。
6. おわりに
個人開発では、完璧なコードを書くことよりも 「失敗しても勝手に直る仕組み」 を作ることの方が重要だとどこかで見たことがあったので、堅牢性というものを意識して作ってみました。
皆さんもぜひ、自分のアプリに「自動リトライ」を組み込んで、不死身のバックエンドを作ってみてください。