假設 AI 助手交給你一個修補程式,把值快取改成了 Promise 快取。說明寫得很完整:併發呼叫共用一次讀取;讀取失敗就刪掉快取,方便重試;失效之後會重新讀取。程式碼不長,寫法也很眼熟,但在某種呼叫順序下會出錯。

先寫清楚快取該做什麼

「最佳化一下快取」把細節全留給了寫程式碼的人。這次審查按下面五條規則來:

  1. 同一個 key 的併發呼叫,共用一次還沒完成的讀取。
  2. 讀取成功後結果一直保留,直到被主動失效。
  3. 讀取失敗不能一直佔著這個 key,下次呼叫可以重試。
  4. 失效會移除目前項目,下次呼叫重新讀取。
  5. 已經被移除的舊讀取完成時,不能刪掉或覆蓋後來的新項目。

第 4 條隻影響之後的查詢:它不會取消正在進行的讀取,也收不回已經交給呼叫方的 Promise。如果產品要求所有呼叫方都拿到最新資料,這個快取就算毫無 bug 也不夠用。

key 還得包含所有會影響結果的因素。本機檔案載入器用檔案路徑可能就夠了;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,第二個呼叫可以直接共用。讀取失敗時,處理函式會刪掉這個 key。問題是它刪的是這個位置上現在放著的東西,不一定是失敗的那個 Promise。

注意,invalidate 在修補程式裡沒有改動。審查時很容易跳過沒改的程式碼,這樣一來,新加的清理邏輯看上去就是普通的錯誤處理。要把兩個函式放在一起讀,bug 才會露出來。

出錯的呼叫順序

把同一個 key 的兩次讀取記作 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 步,失敗的 A 刪掉了已經屬於 B 的項目。在這個例子裡,能看到的後果是多餘的工作:B 和 C 同時讀取同一個 key。除非真實的讀取函式有副作用、確實可能寫壞資料,否則別把它說成「資料損壞」。

這張表也說明了觸發條件:讀取還沒完成時發生失效,接著有新的查詢,然後舊讀取失敗。一個等所有操作都完成後再「讀取 → 失效 → 再讀取」的測試,永遠走不到這一步。

修復:刪除前確認項目還是自己的

刪除之前檢查一下,這個位置放的還是不是失敗的那個 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);
    },
  };
}

修復的關鍵就是這個身分比較。key 還在,不代表項目還是自己的,它可能已經被新請求佔了。把判斷寫成 if (entries.has(key)) entries.delete(key) 依然有問題。

程式碼裡還有兩個小細節。Promise.resolve().then(() => read(key)) 會把 read 的同步異常變成 Promise 拒絕,並且保證呼叫 read 之前項目已經存好。如果你的 read 一定是非同步的,可以換種寫法,但同步異常和重入呼叫怎麼處理,要事先想清楚。另外,清理邏輯掛在單獨的 .catch() 上,呼叫方拿到的仍是原來的 Promise,照樣能收到拒絕。如果改成失敗時返回一個預設值,就改變了介面行為,得另外討論。

在請求還沒完成時斷言

用可以手動完成的 Promise 控制順序,不要靠定時器。最關鍵的回歸測試如下:

const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // 让注入的读取器启动 A。
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // 让注入的读取器启动 B。
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);

a 控制第一次讀取,完整示例裡還有控制第二次讀取的 b,最後會讓 B 完成。assert.rejects 要在 A 失敗之前掛上,否則測試自己就會觸發未處理的 Promise 拒絕。

對這個實作,比較 Promise 身分是可行的,但最好同時統計底層讀取次數。兩個包裝 Promise 可能共用一次讀取,兩次獨立讀取也可能恰好返回相同的值。規則要求的是「唯讀一次」,而不是某個特定的 Promise 物件。

周邊的幾種狀態也要覆蓋:

測試場景 應該斷言什麼
完成前連續呼叫兩次 底層唯讀一次,兩個呼叫方都拿到結果
成功後再次呼叫 使用快取,不新增讀取
失敗後再呼叫 能啟動新讀取併成功
失效後舊請求失敗 新項目仍在快取中
失效後舊請求成功 舊呼叫方得到舊資料,後續呼叫仍拿新資料
載入器同步拋錯 呼叫方收到拒絕,後續仍可重試

「失效後舊請求成功」這一行值得多看一眼。這個快取在成功時不會寫 Map,所以已經失效的 Promise 完成後不會把自己放回去,但它原來的呼叫方仍然拿到舊資料。確認這正是你想要的行為。

寫一條作者能驗證的審查意見

針對這個 bug,審查意見可以這樣寫:

拒絕處理函式只按 key 刪除,沒有確認目前項目是否仍是失敗的 Promise。讀取 A 進行中被失效、讀取 B 寫入快取、隨後 A 失敗時,A 的清理會刪掉 B。下一次查詢會在 B 還沒完成時發起第三次讀取,違反了「目前項目唯讀一次」的規則。刪除前應比較 Promise 身分;「失效後失敗」的測試可以重現這個順序。

它寫清了位置、觸發條件、後果和測試。嚴重程度要看真實的呼叫方:重複讀取本機檔案是效能問題,重複呼叫收費 API 就是在花錢。先弄清楚,再定級。

還要和基線版本對比。如果某個問題在修補程式之前就存在,就單獨說明,不要算到新的清理邏輯頭上。如果 AI 修補程式順手改了格式、升級了依賴,也要問清原因。無關改動越多,真正的行為變化越難看清。

做本機審查時,可以用 Yenpo 的 Mac Git 差異檢視器保留選定的提交或分支對比,再結合修補程式周圍的程式碼核對。

自己跑一遍

下載 promise-cache.mjs,用 Node.js 22 或更新版本執行:

node --test promise-cache.mjs

六個測試裡有一個是重現測試:舊實作出現 bug 時它才透過。其餘五個驗證修復後的快取。示例只是一個記憶體快取,沒有容量淘汰、跨程序協調和取消。審查真實修補程式時,這些也要考慮,還要看看構造 key 和呼叫 invalidate 的地方。

使用 Yenpo 的 AI Reader 時,開啟 Git 對比,點選「讓 AI 審查這次比較」。先核對附加的對比,再把草稿換成具體問題:

審查這次快取改動。找出能在讀取尚未完成時使它失效、再啟動新讀取的呼叫方。檢查舊讀取成功或失敗後,是否會影響新項目。請引用相關程式碼,列出觸發問題的呼叫順序,並區分原有問題和這次引入的回歸。

確認呼叫端確實會觸發這個順序,再在終端機執行測試。

如果修補程式還改了測試,記得把斷言的變化和實作一起審。異常檢查被換成更弱的條件時,綠色結果可能掩蓋未修改呼叫方里的回歸。