Vhoo.aiは、テキストや参考画像からAI画像・動画を作成するサービスです。この記事では、Vhoo.aiの生成処理で使っているクレジット台帳を題材に、失敗した非同期ジョブの返却処理を何度実行しても残高を増やしすぎない設計を説明します。運営側による技術解説で、AIの執筆支援を利用し、記述をプロジェクトの実装と照合しています。
生成ジョブと残高を別々に考えない
Vhoo.aiでは、画像・動画の生成完了を外部サービスへ問い合わせます。利用者がページを開き直したり、複数の問い合わせが重なったりするため、「失敗を見つけたら残高に加算する」だけでは同じジョブのクレジットを二重に返すおそれがあります。必要なのは、処理が一度しか呼ばれないことではなく、同じ業務操作の再実行を識別できることです。
Vhoo.aiのクレジット処理は、生成ジョブに対応する参照IDを消費と返却の両方に使います。アプリ内のジョブIDは、外部サービスが発行するタスクIDとは別です。外部サービスへの送信前からアプリ内IDを持つことで、まだ外部タスクIDを取得していない段階でも、どの生成操作に対する消費なのかを記録できます。
タスク作成と消費を同じバッチに入れる
Vhoo.aiでは、ジョブのレコード作成、付与済みクレジットの残高更新、消費履歴の追加を、同じD1バッチに含めます。タスク作成だけが成功して課金されない、または課金だけが成功して対応するタスクがない、という中間状態を減らすためです。外部の生成APIへの送信は、このデータベース処理の後に行います。D1の公式ドキュメントには、バッチ内の文が失敗した場合に処理全体をロールバックする動作が説明されています。
Vhoo.aiの台帳では、残高が負にならないことをデータベースの制約でも確認します。アプリ側で残高を読んでから更新するだけでは、その間に別の生成処理が同じ残高を使えるからです。消費履歴のIDも操作とクレジット付与単位から決定的に作り、一意性制約とバッチのロールバックを組み合わせます。事前の重複チェックだけに並行実行の正しさを任せないことがポイントです。
返却量は現在の料金ではなく、元の消費履歴から求める
Vhoo.aiの返却処理は、同じ参照IDに紐づく消費履歴を読み、実際に消費した量を返却対象にします。たとえば、説明用に「1つの生成が付与Aから30、付与Bから20を消費した」とすると、返却対象は合計50です。この数字は説明用で、Vhoo.aiの特定テンプレートの料金ではありません。ジョブの開始後に料金設定が変わっても、返却額を最新の料金表から再計算しない設計です。
Vhoo.aiの返却履歴は、元の操作とクレジット付与単位に対応する決定的なIDを持ちます。既存の返却履歴を読み、未処理の分だけをバッチに含めます。同時に2つの返却処理が進んでも、一意性制約によって重複した履歴の追加が失敗した場合はバッチ全体が取り消されます。その後に履歴を読み直し、先行処理の完了を確認して結果を返します。
Vhoo.aiのクレジット台帳には有効期限もあるため、返却は常に元の残高へ足すだけでは済みません。現在の実装では、有効な付与への返却と、期限切れなどで補填用の付与が必要な場合を分けています。返却履歴が存在することと、利用者が今使える残高は別の概念として扱う必要があります。
再試行と新規生成を区別する
Vhoo.aiの設計で重要なのは、「同じジョブの失敗確認を再試行すること」と「利用者がもう一度生成すること」を区別する点です。前者は同じ参照IDの返却を再確認します。後者は新しい生成ジョブとして扱います。台帳の冪等性が保証する範囲は同じ業務操作の重複処理であり、別々に作成された生成ジョブを自動的に1つへまとめるものではありません。
Vhoo.aiと同様の非同期生成サービスで確認したいケースは、同一ジョブへの重複した失敗通知、返却処理の同時実行、複数の付与にまたがる消費、消費後の有効期限切れ、返却前の料金変更です。これらは設計から導いたテスト項目であり、本記事の作成時に新たな負荷試験を実施したという意味ではありません。
この設計を使っているサービス
Vhoo.aiでは、人物写真2枚から物語を作るAI Zombie Trendテンプレートなどの画像・動画生成機能を提供しています。テンプレートの入力や演出が異なっても、生成ジョブに紐づく消費と返却を追跡するという台帳の役割は共通です。