代表的な面接トピック

コーディング面接:キーごとのリクエスト結合(singleflight)の実装

コーディング難しい
Offer.cc 編集チーム公開日 更新日

質問

同じキーを持つ同時呼び出し元が1つのタスク結果またはエラーを共有し、異なるキーは独立して実行される、並行処理セーフな非同期ヘルパーを実装してください。タイムアウト、キャンセル、クリーンアップ、およびテストについて説明してください。

プロンプトとコンテキスト

並行処理セーフな非同期ヘルパーである coalesce(key, task) を実装してください。特定の key に対して同時に実行できる task は最大1つです。同じキーを持つ同時呼び出し元は待機(await)し、まったく同じ値またはエラーを受け取る必要があります。異なるキーは独立して実行される必要があります。

タスクは同期的に例外をスローするか、非同期的に reject される可能性があります。呼び出し元は独自の待機タイムアウトを設定できます。成功後および失敗後の両方でエントリを削除し、以降の呼び出しで再試行できるようにする必要があります。キャンセルセマンティクス、エラー伝播、およびテストについて説明してください。

面接官がテストしていること

本質は、重複排除を証明可能な並行処理の不変条件に変換することです。非同期処理を開始する前に共有 Promise を設定し、マップがまだそのエントリを指している場合にのみ削除し、キーの独立性を維持します。面接官はまた、1つの呼び出し元が待機を中止することと共有ワークをキャンセルすることを区別できるか、そしてメモリの無制限な増加に対処できるかも見ています。

明確化のための質問

  1. キーは空でない文字列、または正規化されている必要がありますか?関係のないリクエストが誤って結合されないように、空のキーは拒否します。
  2. 呼び出し元のタイムアウトによってアップストリームのワークをキャンセルすべきですか?デフォルトでは、その呼び出し元の待機のみを停止し、他の待機者のために共有ワークは実行したままにします。
  3. エラーはキャッシュすべきですか?いいえ。次の呼び出しで再試行できるように、確定(settlement)後に削除します。
  4. プロセス間の結合は必要ですか?いいえ。このプロンプトは単一プロセスのメモリが対象です。プロセス間の調整は別の設計になります。

30秒の回答

処理中のワークを Map<key, Entry> で保持します。開始時、ヒットした場合は既存の Promise を返します。ミスの場合は、Promise を作成し、await する前にマップに配置してからタスクを実行します。finally では、マップにまだその同じエントリが含まれている場合にのみ削除します。同一キーの呼び出し元は1つの実行を共有し、異なるキーは互いをブロックせず、失敗時は再試行のために状態を解放します。呼び出し元のタイムアウトは、共有ワークをキャンセルすることなくその待機と競合(race)します。テストでは、重複呼び出し、独立したキー、同期的スロー、reject と再試行、クリーンアップの競合をカバーします。

ステップバイステップの詳細解説

共有 Promise と、必要に応じて内部コントローラーを保持できる Entry を定義します。順序付けが重要な部分です。

ts
const inFlight = new Map<string, Promise<unknown>>();

function coalesce<T>(key: string, task: () => Promise<T>): Promise<T> {
  if (!key) return Promise.reject(new Error("key must not be empty"));
  const existing = inFlight.get(key);
  if (existing) return existing as Promise<T>;

  let shared: Promise<T>;
  try {
    shared = Promise.resolve().then(task);
  } catch (error) {
    shared = Promise.reject(error);
  }
  inFlight.set(key, shared);
  shared.finally(() => {
    if (inFlight.get(key) === shared) inFlight.delete(key);
  }).catch(() => undefined);
  return shared;
}

オブジェクトエントリには、追加で開始時刻、待機者数、および AbortController を記録できます。Promise.resolve().then(task) により、同期的なスローと非同期の reject が単一のパスをたどるようになります。マップへの挿入は最初の await の前に発生する必要があります。そうしないと、イベントループの2つのターンで両方ともミスを観測してしまう可能性があります。同一性チェックにより、古いタスクの finally が新しいエントリを削除するのを防ぎます。

呼び出し元のタイムアウトは外部ポリシーです。

ts
function waitWithTimeout<T>(shared: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    shared,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error("wait timeout")), ms),
    ),
  ]);
}

共有タスクは他の待機者のために完了まで実行されます。全員が離脱した際にキャンセルすることがプロダクト要件である場合は、参照カウントを追加し、その競合を規約とテストで明示的に定義します。

期待されるマップ操作は O(1) です。K 個の異なる処理中キーがある場合、状態は O(K) です。1つの結果を配信するコストは、待機者の数に比例します。本番コードではキーのカーディナリティを制限し、マップが無制限のキャッシュにならないように、所要時間、タイムアウト、待機者数、エラーのメトリクスを公開する必要があります。

質の高い模範解答

まず境界を明確にします。単一プロセス、処理中の重複排除のみ、結果のキャッシュなしとします。Map が各エントリを保存します。ミスの場合は直ちに Promise を作成して登録し、その後にユーザータスクを呼び出します。すべての呼び出し元が同じ Promise を受け取るため、値とエラーは同一になります。クリーンアップではオブジェクトの同一性を比較し、古い完了処理が新しい世代のエントリを削除するのを防ぎます。

キャンセルとは「共有ワークではなく待機のキャンセル」を意味します。タイムアウトした1つの呼び出し元が他の待機者に AbortError を送信したり、唯一のアップストリーム操作を中断したりすることはありません。真のキャンセルが必要な場合は、共有コントローラーと待機者の参照カウントを使用し、カウントがゼロになったときにのみキャンセルします。

テストにはバリアを使用して同時呼び出し元を一斉に解放し、タスクの呼び出しが1回であることと結果が1つ共有されていることをアサートします。また、独立したキーの並行実行、同期的スロー、非同期 reject、失敗後の再試行、成功後の新規タスク、古い finally と新しいエントリの競合、および1つの呼び出し元がタイムアウトしても別の呼び出し元が成功することなどをテストします。最後に、高カーディナリティキーの容量制限とメトリクスについて触れて締めくくります。

よくある間違い

  • マップに挿入する前に task() を await してしまい、重複実行を許してしまうこと。
  • finally 内でキーによる無条件の削除を行い、古いタスクが新しいエントリを削除できるようにしてしまうこと。
  • 1つの呼び出し元の AbortSignal を共有ワークに直接渡し、すべての待機者をキャンセルしてしまうこと。
  • 再試行のために削除せず、reject された Promise を永遠に保持し続けること。
  • 1つのグローバルロックを使用して、無関係なキーを直列化してしまうこと。
  • 同時到着、同期的スロー、クリーンアップの競合をテストせず、順次呼び出しのみをテストすること。

フォローアップの質問と回答

真のキャンセルはどのようにサポートしますか?

プロセス間の結合が必要になるのはどのような場合ですか?

高カーディナリティキーによるリークはどのように防ぎますか?

真のキャンセルには、共有コントローラー、参照カウント、および明示的な待機者ゼロ時のポリシーが必要です。プロセス間のケースでは、Redis、ゲートウェイ、またはその他のコーディネーターに加えて、リース、リーダー障害処理、重複実行の許容が必要です。高カーディナリティキーには、容量制限、TTL またはエビクションポリシー、拒否動作、メトリクスが必要です。これらの制御は、処理中のワークのみが保存されるというコアのルールを維持する必要があります。

公開情報ソース

関連する質問

関連面接ツール

コーディング問題にはスクリーンショットを使用

問題をキャプチャし、制約条件、解法アプローチ、コード、エッジケース、計算量の順に進めます。

ツールを見る