ユーザーが送信ボタンを連打すると、ボットが同じ回答を2、3回繰り返して出力し始めました。この重複は、AIが考え始める前に複数のメッセージを送信できるほど速くタイピングするユーザーにのみ発生し、発生パターンが稀であったため、本番環境では長い間隠れたままでした。データベースのロックが(取得されてから数ミリ秒後に)早すぎるタイミングで解除されてしまったため、会話が保護されない状態になり、複数のプロセスが同じプロンプトに回答できてしまう状況が生じていました。

なぜロックが失敗したのか

コードは単一のデータベースコールでロックを取得した後、すぐにリクエストハンドラーに制御を戻していました。ロックの有効期間はミリ秒単位であり、AIモデルが回答を生成するのに必要な時間よりもはるかに短いものでした。モデルが作業を開始する頃には、ロックはすでに消滅していたため、2番目のリクエストが同じ会話レコードを取得して別の回答を発行することを防ぐ手段が何もありませんでした。

2つの症状が現れました:

  • 全く同じ回答が立て続けに送信される。
  • 同じ質問に対して、わずかに言い回しが異なる回答が表示される(各プロセスが同じユーザー入力から独自のプロンプトを構築するため)。

ほとんどのユーザーはメッセージの間に間を置くため、このバグは表面化しませんでした。急速にタイピングするユーザーだけがレースコンディションを引き起こしており、そのケースも稀でした。

十分ではなかった中途半端な修正

最初の対応は、連続した入力を「デバウンス(debounce)」することを期待して、メッセージ到着後に短い遅延を追加することでした。これにより、2つのメッセージが立て続けに届いた場合には効果がありましたが、AIがテキストを生成している間に3つ目のメッセージが届くと、機能しなくなりました。

2つ目の問題は、タイマーと会話データが同じストレージバケットに保存されていたことで発生しました。ボットがリクエストの処理を完了すると、タイマーレコードを上書きしてしまい、実質的に自身のカウントダウンを削除してしまったのです。システムはどのメッセージに回答済みであるかの追跡ができなくなり、さらなる重複が発生する隙を与えてしまいました。

信頼できるガードの構築:バージョンカウンター、分離されたタイマー、そしてリース

チームは、以下の3つの柱を中心にフローを再設計しました:

  • バージョンカウンター – 到着する各メッセージは、会話と共に保存されているカウンターをインクリメントします。このカウンターにより、最後の回答以降にいくつのメッセージが届いたかをシステムが把握できるため、回答生成中に新しい入力があるかどうかを簡単に検出できます。
  • 専用のデバウンス・ウィンドウ – タイマーは会話ペイロードから分離された、別のストレージ領域に保存されるようになりました。デバウンス期間に上限を設けることで、ユーザーがボットを際限なく停止させてしまうことを防ぎます。
  • セッション・リース – 元のロックは、明示的な有効期限(タイムスタンプ)を持つ「リース」に置き換えられました。リースは「Compare-and-Swap (CAS)」操作を使用して取得されます。つまり、プロセスが現在のリース値を読み取り、古い値が一致する場合にのみ新しい値を書き込むことで、会話に対する独占的な権利を得る仕組みです。プロセスがクラッシュした場合でも、リースは自動的に期限切れとなり、次のハンドラーが会話を利用できるようになります。

新しいパイプラインの仕組み

  1. メッセージの到着 – システムはバージョンカウンターをインクリメントし、デバウンスタイマーを(再)設定します。AIの起動を待たずに、即座にクライアントへレスポンスを返します。
  2. タイマーの満了 – タイマーハンドラーがリースの取得を試みます。CASが成功すればハンドラーは処理を進め、失敗した場合は、別のプロセスがすでに会話を所有していると判断してバックオフします。
  3. 新しい入力のチェック – ハンドラーは、現在のバージョンカウンターをタイマー開始時に記録した値と比較します。カウンターが進んでいれば、保留中のメッセージを1つのプロンプトに集約します。
  4. 回答の生成 – AIモデルが1回だけ実行され、最近のすべてのユーザー入力を網羅した単一の回答を生成します。
  5. 最終的なサニティチェック – 回答を送信する直前に、ハンドラーは再度バージョンカウンターを読み取ります。生成中に新しいメッセージが届いていた場合、その回答は破棄され、プロセスはタイマーを再起動します。これにより、古い回答がユーザーに届くのを防ぎます。

このアプローチにより、重複回答が排除され、会話が停止する時間を制限でき、さらにリースが自動的に期限切れになるため、プロセスのクラッシュからも自動的に復旧できるようになりました。

まとめ

クリティカルセクションが始まる前に消えてしまうロックには、何の保護機能もありません。一時的なデータベースロックを、明示的な期限付きリースに置き換え、タイマーを会話データから分離することで、ボットはユーザーが猛烈な速さでタイピングした場合でも、常に最新の単一の回答を保証できるようになりました。このエピソードは、時代を超えた教訓を物語っています。並行処理の保護策は、それが保護する対象の処理時間よりも長く持続しなければならず、そうでなければバグをすり抜けさせる「見えない障壁」になってしまうのです。