ユーザーがボタンをクリックする。リクエストが停滞する。10秒間の沈黙。ユーザーはフォールバックボタンをクリックする。今や、一つの意図に対して二つのジョブが実行されている。結果として、重複したサイドエフェクト、二重課金、そして午後の時間を台無しにするデータ混乱が発生する。

これはフロントエンドのバグではない。ボタンの無効化やReactのデバウンスタイマーでは解決できない。最初のリクエストはすでに送信中(in flight)だったのだ。ネットワークが単に応答を飲み込んでしまったに過ぎない。もしバックエンドが、すべての着信リクエストを新しい命令として扱うのであれば、リトライはリスク(負債)となる。API設計とデータベーススキーマでこれを修正する必要がある。

解決策は、シンプルな構造的分離から始まる。

ジョブとアテンプト(試行)を分離する

ジョブを、ユーザーが何をしたいかを示す永続的な記録だと考えてほしい。そこには所有者、パラメータ、ターゲットプロバイダー、そして正確な意図が記録される。アテンプト(Attempt)は、その意図を果たすための具体的な試行である。

印刷所を想像してみてほしい。ファイルを渡すと、チケット番号#45が発行される。そのチケットが「ジョブ」だ。店はまずインクジェットプリンターを試す。紙詰まりが起きた。これが「アテンプト1」だ。次にファイルをレーザープリンターに回す。これが「アテンプト2」だ。プロセス全体を通じて、チケット#45は変わらない。もし店が試すプリンターごとに新しいチケットを発行していたら、あなたは3回支払うことになり、不要なコピーを3部受け取ることになってしまうだろう。

データベースもこれを反映すべきだ。一つのテーブルがジョブを保持し、別のテーブルがアテンプトを保持する。ジョブの行は一定のまま、その下にアテンプトが蓄積されていく。

この分離によって制御が可能になる。また、ネットワークの瞬断を乗り越えることができる「べき等性キー(idempotency key)」を付与する場所も確保できる。

すべてのジョブにべき等性キーを要求する

ジョブを作成するすべてのPOSTリクエストは、一意のべき等性キーを伴わなければならない。このキーはセッションではなく、ユーザーに属するものだ。所有者IDとキーを組み合わせ、その2つのカラムに対してデータベースの一意制約(unique constraint)を適用する。

なぜデータベース制約なのか? インサート前にアプリケーションコードで存在確認を行うと、レースコンディション(競合状態)が発生するからだ。全く同じリクエストが、マイクロ秒単位の隙間を縫って同時に通り抜けてしまう可能性がある。データベースに強制(enforce)させるのだ。ユーザーが同じ所有者IDとキーを2回送ってきた場合、2回目のリクエストは一意制約違反を検知し、既存のジョブを返す。両方のリクエストは同じジョブIDを受け取る。これにより、重複した作業が始まることはない。

スコープについては厳格であるべきだ。もし誰かがキーを再利用しながら入力ペイロードを変更した場合、コンフリクト(競合)を返すべきだ。べき等性キーは、単なるユーザーではなく、正確な「意図」に紐付いていなければならない。同じキーで入力が異なるということは、クライアントが混乱していることを意味する。システムは推測するのではなく、それを拒否すべきだ。

状態遷移を保護する

アテンプトは状態遷移であり、新しいジョブではない。以前のアテンプトがまだ「開始中」または「不明」な状態である場合、APIは新しいアテンプトの生成を拒否しなければならない。

その理由はタイムアウトだ。プロバイダーへのリクエストがタイムアウトすると、クライアント側には失敗として見えるが、サーバー側のプロセスはまだ生きている可能性がある。GPUクラスターは依然として推論リクエストを処理しているかもしれないし、コンテナがまだブロブストレージに書き込みを行っているかもしれない。タイムアウトしたアテンプトを「失敗」としてマークし、すぐに2番目のアテンプトを実行してしまうと、重複したサイドエフェクトというギャンブルをすることになる。

タイムアウトは「失敗」ではなく「不明な状態」として扱う。以前のアテンプトが終了状態(terminal state)に達するか、帯域外(out-of-band)のプロセスによって明示的にキャンセルされるまで、新しいアテンプトをブロックする。この一時停止はユーザーにとって不快かもしれない。待たなければならないからだ。しかし、これにより2つのワーカーが同じダウンストリームのリソースを書き換えてしまうという混乱を防ぐことができる。

Compare-and-Swapでレースコンディションを解決する

最も困難な問題は、複数のアテンプトが完了したときに発生する。例えば、システムがまずプライマリプロバイダーに対してアテンプト1を実行したとする。10秒間の沈黙の後、フォールバックに対してアテンプト2を実行した。そして今、両方のアテンプトが完了した。両方の結果を同じジョブの行に書き込ませるわけにはいかない。

Compare-and-swap(比較・交換)ロジックを使用する。ジョブの行にバージョン番号を追加する。アテンプトが完了するとき、以下の条件を伴うアップデートを実行する:

  • 現在のバージョンが、アテンプト開始時に読み取ったものと一致していること。
  • 他のアテンプトがすでに結果スロットを確保していないこと。
  • 両方の条件を満たせば、結果を書き込み、バージョンをインクリメントする。

SQLの用語で言えば、WHERE id = $1 AND version = $2 AND completed_by IS NULL のような更新ステートメントになる。もし更新によって影響を受けた行がゼロであれば、別のアテンプトがすでに勝利している。遅れて到着したリクエストは無視しなければならない。その結果は破棄する。マージも追加もしてはならない。その作業は捨て去るのだ。先に勝った結果を上書きしてしまう遅延結果はデータ破損であり、唯一安全な手段はそれを破棄することである。

This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.

Test the Breakpoints

You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.

  • Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
  • Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
  • Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
  • Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.

These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.

Validate Provider Intent Before You Fail Over

If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.

One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.

Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.

Keep One Job Per Intent

Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.

Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.