バックエンド

返金後回収リトライのためのべき等クレジット台帳

失敗したジョブのクレジットは返金されます。その後、再試行が成功します。二重課金せずにそれらを回収するには、べき等の台帳とアトミックなガードが必要です。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

従量課金制システムでは、ジョブが失敗するとクレジットが返金されます。厄介なのは、その同じジョブが後で再試行によって復旧し、成功する場合です。この場合、返金は間違いだったことになり、二重に課金することなくクレジットを回収しなければなりません。この解決策は、逆操作のペアからなる追記専用台帳です。そこでは、すべての返金とすべての回収が、現在の状態によって保護された単一の条件付き書き込みとなります。この記事では、台帳の形状、二重返金と二重課金の両方をブロックするアトミックなガード、そして再試行キューが無限にループすることなくそれにデータを供給する方法について説明します。

「返金後のリカバリー」問題

従量課金制システムでは、ビデオのレンダリング、文字起こしの実行、画像の生成といったユーザーのアクションはクレジットを消費します。一般的な設計では、前払いで課金し、ジョブを実行し、失敗した場合には返金します。これにより、ユーザーは受け取ることのなかった作業に対して支払うことがなくなります。その返金が、問題の始まりです。

分散ジョブは、きれいに失敗するわけではありません。あるワーカーが30秒でタイムアウトし、ジョブを失敗としてマークし、40クレジットを返金します。しかし、その作業を行っていたGPUは実際には32秒で処理を完了し、タイムアウトが発生した2秒後に結果がストレージに格納されます。ジョブは失敗していません。リカバリーしたのです。台帳には、存在し、配信される作業に対する返金が記録されることになります。

ナイーブな実装は、これをさらに悪化させます。失敗処理と成功処理が、状態を共有しない2つの独立したコードパスである場合、2つのバグのうちの1つが発生します。同じジョブに対して2つの失敗シグナル(タイムアウトとその後のエラーコールバック)がそれぞれ返金を行い、ユーザーは40クレジットのジョブに対して80クレジットを受け取ることになります。あるいは、返金の後にリカバリーが続き、さらに2回目のリカバリーシグナルがそれぞれ再請求を行うため、ユーザーは1つのジョブに対して2回課金されます。どちらも、正確性が最も重要である領域における正当性の欠如です。

根本的な原因は、ほとんどの分散システムのバグの背後にあるものと同じです。つまり、状態を読み取り、次に状態を書き込むという操作を2つの別々のステップとして行うことであり、その間には、別のワーカーが同じ古い読み取りデータに基づいて動作できてしまう隙間が存在します。

すべての変更を逆操作のペアとしてモデル化する

最初の原則は、以前の台帳エントリを決して変更したり削除したりしないことです。台帳は追記専用です。課金、返金、そして再請求が行われたジョブは、3つの不変の行を生成します。

  • charge: -40
  • refund: +40
  • reclaim: -40

ユーザーの残高は、上書きする可変のカウンターではなく、行の合計です。各エントリは job_idtypeamountcreated_at を保持しているため、すべてのクレジットの完全な履歴が監査可能です。サポートがユーザーの残高の理由を尋ねたときは、これらの行を再生します。

返金と再請求は互いに逆の操作であり、これにより復旧が表現可能になります。再請求は、最初に返金が行われ、かつまだ再請求されていない場合にのみ意味を持ちます。その条件は、アプリケーションコードで if を使ってチェックするものではありません。それは書き込み自体のガードです。

二重返金を防ぐアトミックなガード

二重返金バグは、古典的なチェック時間対使用時間 (time-of-check-to-time-of-use) の競合状態です。ワーカーAが is_refunded = false を読み取り、返金を決定します。それが書き込む前に、ワーカーBが同じ is_refunded = false を読み取り、同じく返金を決定します。両方が書き込みを行います。ユーザーには二重に入金されます。

このギャップを埋めることは、チェックと書き込みが1つの不可分な操作でなければならないことを意味します。条件付き更新を備えたドキュメントストアでは、それはフィルターがチェック、更新が書き込みとなる単一の UpdateOne 操作です。フラグはジョブドキュメント上に存在し、それを false から true に反転させたワーカーのみが、台帳の行を追加することを許可されます。

// Refund only if this job has not already been refunded.
// The filter and the $set are one atomic operation, so two concurrent
// failure signals cannot both win.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
	bson.M{"_id": jobID, "is_refunded": false},
	bson.M{"$set": bson.M{
		"is_refunded": true,
		"refunded_at": now,
	}},
)
if err != nil {
	return err
}
if res.ModifiedCount == 1 {
	// We won the guard. Append the ledger row exactly once.
	appendLedger(ctx, jobID, "refund", cost, now)
}

ModifiedCountが1で返ってきたワーカーがガードを獲得し、返金を追加します。他のすべてのワーカーでは、フラグが設定されるとフィルターが一致しなくなるため、ModifiedCountは0で返され、何も行いません。データベースが条件付き書き込みをシリアライズしてくれるため、どれほどの並行性があっても2つの返金が生成されることはありません。

ジョブが復旧したときのクレジットの再請求

復旧ではミラーガードを使用します。すでに返金されたジョブが後で成功した場合、クレジットを再請求します。条件は、ジョブが返金済み (is_refunded = true) であり、まだ再請求されていない (is_reclaimed = false) ことです。そして、この場合もチェックと書き込みは1つのオペレーションです。

// Reclaim only if the job was refunded and not yet reclaimed.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
	bson.M{"_id": jobID, "is_refunded": true, "is_reclaimed": false},
	bson.M{"$set": bson.M{
		"is_reclaimed": true,
		"reclaimed_at": now,
	}},
)
if err != nil {
	return err
}
if res.ModifiedCount == 1 {
	appendLedger(ctx, jobID, "reclaim", cost, now)
}

この仕組みの優れた点は、通常の成功パスで起こることです。最初の試行で成功したジョブは払い戻されなかったため、is_refundedfalse のままであり、フィルターは一致に失敗し、ModifiedCount は 0 となり、回収行は書き込まれません。これはまさに正しい動作です。何も払い戻されなかったため、回収するものは何もありません。成功ハンドラーは同じ回収呼び出しを無条件に実行し、ガードがそれを適用するかどうかを決定します。成功パスは、払い戻しが発生したかどうか、また重複した配信によって1回実行されるか5回実行されるかに関わらず、べき等です。

回収額は、リトライが報告するものではなく、ジョブに記録された cost から取得されることに注意してください。金額を元の請求額に固定することで、回復したジョブは払い戻されたものと全く同じ額を回収し、決して異なる数値になることはありません。

従量課金ジョブの状態遷移

2つのブール値フラグis_refundedis_reclaimedは、小さなステートマシンを定義します。それを通るすべての有効なパスは台帳のバランスを保ち、上記のすべてのガードはこのマシンの1つのエッジです。

状態 is_refunded is_reclaimed 正味の台帳効果 有効な次の遷移
課金済み、実行中 false false -cost 成功(留まる)、または失敗(返金)
最初の試行で成功 false false -cost 終端
失敗、返金済み true false 0 回復(再請求)、または再試行
回復、再請求済み true true -cost 終端

2つの終端状態はどちらも正味の効果が-costで確定しますが、これは正しいです。ユーザーは作業に対して正確に一度支払いました。返金されたが再請求されていない状態は0で確定し、これは純粋に失敗して決して配信されなかった作業に対して正しいです。配信後に-2倍のコストまたは0に達するパスはありません。なぜなら、各エッジは1つのライターしか反転できないフラグによってゲートされているためです。

バックオフを伴う失敗したワークの再キューイング

返却されたジョブは、必ずしもデッドになるわけではありません。失敗が一時的な依存関係(ドロップしたGPUノード、タイムアウトしたストレージコールなど)によるものであった場合、ジョブはグレイブヤード(墓場)ではなく、リトライキューに戻されます。RPush を使用してRedisリストの末尾にプッシュすることで、シンプルなFIFOリトライチャネルが提供されます。そして、各再キューイングはインクリメントされた試行回数とnot-beforeタイムスタンプを保持するため、壊れた依存関係がタイトなループで繰り返し叩かれることはありません。

// Re-queue a failed job with exponential backoff, capped.
attempt := job.Attempts + 1
backoff := time.Duration(
	math.Min(
		float64(baseDelay)*math.Pow(2, float64(attempt)),
		float64(maxDelay),
	),
)
job.Attempts = attempt
job.NotBefore = time.Now().Add(backoff)
payload, _ := json.Marshal(job)
rdb.RPush(ctx, "jobs:retry", payload)

コンシューマーは、NotBefore が未来の時刻であるジョブをスキップし、何も処理せずに再キューイングします。そのため、個別の遅延キューメカニズムなしで遅延が守られます。maxDelay を上限とする指数関数的な増加により、最初のリトライは迅速に行われますが、永続的に失敗する依存関係は、待機中のリソースそのものを消費してしまうビジーループではなく、低速なポーリングにバックオフします。

デッドレターガードが無限リトライを停止させる

バックオフだけでは、決して成功しないジョブを停止させることはできません。上限がなければ、恒久的に壊れているジョブは返金、再キュー、失敗、返金のサイクルを永久に繰り返し、返金ガードはべき等であるため金銭的な損害は発生しませんが、ワーカーを無駄にし、キューを煩雑にします。試行回数が上限も兼ねています。

// Terminal guard: past the cap, dead-letter instead of retrying.
if job.Attempts >= maxAttempts {
	rdb.RPush(ctx, "jobs:dead-letter", payload)
	return
}

デッドレターリストは、自動リトライチャネルではなく、人による確認のための一時保管場所です。タイマーによって消費されることはありません。同様に重要なこととして、同じis_reclaimedおよびis_refundedフラグが、重複配信に対するべき等キーとして機能します。リトライキューからジョブが2回配信された場合(at-least-once配信ではこれが起こることが保証されています)、2回目の配信は、そのフラグがすでに終端状態を反映しているジョブに到達し、ガードされた書き込みは何にも一致しません。すでに解決済みのジョブを再処理しても、残高を動かすことはできません。キューはメッセージを何回でも配信できますが、正確性は配信保証ではなく条件付き書き込みに存在するため、レジャーは正しい状態を保ちます。

トレードオフ:台帳の複雑さと正確性

シンプルなモデルは「失敗イコール返金、完了」という1行のロジックです。状態はより少なく、コードもより少なく、推論すべきことは何もありません。しかし、分散システムではよくあることですが、失敗が最終的なものではなくなった瞬間に、このモデルは間違いとなります。低速な成功と競合するワータイムアウト、重複したコールバック、回復するリトライ、そのすべてがシンプルなモデルを静かに破壊します。そして、静かな課金バグは高くつく種類のバグです。

台帳モデルでは、2つのフラグ、1組の逆操作、バックオフ付きのリトライキュー、そしてデッドレターパスが必要になります。それは本物の複雑さであり、それを頭の中で把握しておくのは簡単ではありません。問題は、そのドメインがその複雑さに値するかどうかです。少数のクレジットを数え間違えても誰も害を受けない無料利用枠の機能であれば、これらすべてを省略し、時折発生する差異を許容してください。残高が金銭的な性質を持ち、ユーザーが二重請求に気づけばサポートチケットと信頼問題につながる有料クレジットの場合、追記専用の監査証跡とアトミックガードは、すでに返金したジョブが復活した最初の時点で、そのコストに見合う価値を発揮します。全体を機能させるルールは小さなものです。すべての台帳操作をべき等にし、すべての状態遷移を、1つのライターしか通過できないアトミックガードでゲートすることです。