AI アシスタントが、値のキャッシュを Promise キャッシュに変えるパッチを出したとします。要約には、同時呼び出しで読み取りを共有し、失敗を除いて再試行でき、無効化後は新しく読むとあります。短く見慣れたコードですが、特定の順序で壊れます。
キャッシュが守るべき動作を書く
「キャッシュを改善して」では細部を実装者に任せることになります。ここでは五つの規則を定めます。
- 同じキーへの呼び出しは一つの処理中の読み取りを共有する。
- 成功結果は無効化するまで保持する。
- 失敗は残さず、次の呼び出しで再試行できる。
- 無効化は現在の項目を除き、次の呼び出しは新しく読む。
- 除去済みの古い読み取りが完了しても、新しい項目を削除・置換しない。
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」を押します。添付を確認し、論点を絞った依頼に書き換えます。
キャッシュ変更をレビューしてください。処理中の読み取りを無効化し、置き換えを始める呼び出し元を探してください。古い読取の成功・失敗が新しい項目に影響するか確認し、コードと発生順序を示してください。既存問題と回帰は分けてください。
呼び出し元でその順序が起こることを確認してから、ターミナルでテストを実行します。
テストも変えるパッチなら、実装と同時に変わったアサーションを確認してください。エラー検査が弱い条件に変わると、成功結果が呼び出し元の回帰を隠す場合があります。