AI アシスタントが、値のキャッシュを Promise キャッシュに変えるパッチを出したとします。要約には、同時呼び出しで読み取りを共有し、失敗を除いて再試行でき、無効化後は新しく読むとあります。短く見慣れたコードですが、特定の順序で壊れます。

キャッシュが守るべき動作を書く

「キャッシュを改善して」では細部を実装者に任せることになります。ここでは五つの規則を定めます。

  1. 同じキーへの呼び出しは一つの処理中の読み取りを共有する。
  2. 成功結果は無効化するまで保持する。
  3. 失敗は残さず、次の呼び出しで再試行できる。
  4. 無効化は現在の項目を除き、次の呼び出しは新しく読む。
  5. 除去済みの古い読み取りが完了しても、新しい項目を削除・置換しない。

4 は将来の検索だけに影響します。処理中の読み取りをキャンセルしたり、待機中の呼び出し元から値を取り上げたりしません。全利用者が常に最新データを必要とするなら、バグがなくてもこのキャッシュだけでは不足します。

キーには結果を変えるすべての要素も必要です。ローカルファイルならパスで足りても、HTTP キャッシュではユーザー、権限、クエリ、リビジョンなども必要になる場合があります。

パッチを読む

await read(key) の後で値を保存すると、同時の二呼び出しがどちらもキャッシュを外れて読みます。処理中の Promise を保存すれば共有できます。失敗の後処理を加えると、例えば次のようになります。

function load(key) {
  if (entries.has(key)) return entries.get(key);
  const pending = Promise.resolve().then(() => read(key));
  entries.set(key, pending);
  void pending.catch(() => {
    entries.delete(key);
  });
  return pending;
}

function invalidate(key) {
  entries.delete(key);
}

読み取り開始前に Promise を保存するため、次の呼び出し元は共有できます。失敗時のハンドラーはキーを消しますが、消すのは_その時点の項目_であり、失敗した Promise とは限りません。

invalidate はこのパッチで変わっていません。未変更のコードは見落としやすく、そこを読まなければ後処理は普通に見えます。二つの関数を一緒に読むと問題が見えます。

壊れる順序を確認する

同じキーの二つの読み取りを A、B とします。

段階 イベント キャッシュ項目
1 最初の load(key) が A を開始 Promise A
2 invalidate(key) を実行 なし
3 二回目の load(key) が B を開始 Promise B
4 A が失敗し、後処理で key を削除 なし
5 B が処理中に三回目の load(key) Promise C。重複読み取り

4 で失敗した読み取りが B の項目を消します。この例の目に見える影響は余計な処理で、B と C が同じキーを同時に読みます。実際の読み取りの副作用が破損を起こし得るのでなければ、データ破損とは呼ばないでください。

必要な条件も表から分かります。処理中に無効化し、新しく呼び出した後で古い読み取りが失敗することです。完了してから無効化して再読するテストでは到達しません。

修正:項目が今も自分のものか確認する

削除前に、現在の項目が失敗した Promise と同じか確認します。

export function createCache(read) {
  const entries = new Map();
  return {
    load(key) {
      if (entries.has(key)) return entries.get(key);
      const pending = Promise.resolve().then(() => read(key));
      entries.set(key, pending);
      void pending.catch(() => {
        if (entries.get(key) === pending) entries.delete(key);
      });
      return pending;
    },
    invalidate(key) {
      entries.delete(key);
    },
  };
}

この同一性確認が修正のすべてです。キーの存在だけでは所有者は分からず、新しいリクエストが入っているかもしれません。entries.has(key) では依然として誤りです。

二点補足します。Promise.resolve().then(() => read(key)) は read の同期 throw を Promise の拒否に変え、read の実行より先に項目が保存されることも保証します。read が常に非同期なら別の書き方も可能ですが、同期エラーや再入呼び出しの扱いは意識して決めてください。また、後処理は別の .catch() に付くので、呼び出し元は元の Promise と拒否を受け取ります。失敗を代替値に変えると API の変更になり、別の判断が必要です。

Promise が処理中のままテストする

タイマーではなく deferred Promise で完了時点を制御します。重要な回帰テストはこれです。

const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // Let the injected reader start A.
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // Let the injected reader start B.
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);

a が最初の読み取りを制御し、完全な例では二回目の b も最後に解決します。A を拒否する前に assert.rejects を付けるため、テスト自身が未処理の拒否を起こしません。

この実装では Promise の同一性比較が使えますが、実際の読み取り回数も数えます。異なるラッパー Promise が同じ読み取りを共有したり、別の読み取りが同値を返したりするためです。求めるのは一つの読み取りで、特定の Promise オブジェクトではありません。

周辺状態も確認します。

テスト 確認すること
完了前に二回呼ぶ 実読み取りは一回、両者が値を受け取る
成功後の呼び出し 保持した値を返し、追加読取なし
失敗後の新しい呼び出し 新規読取が成功できる
無効化後の古い失敗 置換後の項目が残る
無効化後の古い成功 元の呼び出し元は古い値、後の呼び出し元は新しい値
読取処理の同期失敗 拒否が伝わり、再試行可能

古い成功には注意が必要です。このキャッシュは成功時に map に書かないので、無効化された読取が解決しても戻ってきません。ただし元の呼び出し元は古い値を受け取ります。それが必要な動作か確認してください。

作者が検証できるコメントを書く

この問題へのコメント例です。

拒否ハンドラーが、項目が失敗した Promise と同じかを確認せずキーで削除しています。処理中の A を無効化し、B を保存してから A が拒否されると、A の後処理が B を消します。B の処理中に次の検索が三つ目の読み取りを始め、「現在の項目につき読取一回」を破ります。削除前に Promise の同一性を確認してください。無効化後に拒否するテストで再現できます。

場所、条件、影響、テストを示しています。重大度は呼び出し元次第で、ローカルの重複読み取りなら性能問題、有料 API なら追加費用になります。分類する前に確認してください。

base の版も読みます。元から壊れていたなら新しい後処理に帰さず別に報告します。パッチがフォーマットや依存関係まで変えたなら理由も確認します。無関係な変更は本当の動作変更を見えにくくします。

ローカルのレビューでは Mac 向け Yenpo の Git 差分ビューアーで、選んだコミットやブランチの比較を残しながら周囲のコードを調べられます。

自分で実行する

promise-cache.mjs をダウンロードし、Node.js 22 以降で実行します。

node --test promise-cache.mjs

6 件のうち一つは再現テストで、危険な版で問題が起きれば成功します。他の五つは修正版を確認します。すべてメモリー内で、追い出し、プロセス間調整、キャンセルはありません。実際のパッチではこれらも、キーを作り invalidate を呼ぶ側と併せて確認します。

Yenpo の AI Readerでは Git 比較を開き、「Ask AI to review this comparison」を押します。添付を確認し、論点を絞った依頼に書き換えます。

キャッシュ変更をレビューしてください。処理中の読み取りを無効化し、置き換えを始める呼び出し元を探してください。古い読取の成功・失敗が新しい項目に影響するか確認し、コードと発生順序を示してください。既存問題と回帰は分けてください。

呼び出し元でその順序が起こることを確認してから、ターミナルでテストを実行します。

テストも変えるパッチなら、実装と同時に変わったアサーションを確認してください。エラー検査が弱い条件に変わると、成功結果が呼び出し元の回帰を隠す場合があります。