假设 AI 助手交给你一个补丁,把值缓存改成了 Promise 缓存。说明写得很完整:并发调用共用一次读取;读取失败就删掉缓存,方便重试;失效之后会重新读取。代码不长,写法也很眼熟,但在某种调用顺序下会出错。
先写清楚缓存该做什么
“优化一下缓存”把细节全留给了写代码的人。这次审查按下面五条规则来:
- 同一个 key 的并发调用,共用一次还没完成的读取。
- 读取成功后结果一直保留,直到被主动失效。
- 读取失败不能一直占着这个 key,下次调用可以重试。
- 失效会移除当前条目,下次调用重新读取。
- 已经被移除的旧读取完成时,不能删掉或覆盖后来的新条目。
第 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 审查这次比较」。先核对附加的对比,再把草稿换成具体问题:
审查这次缓存改动。找出能在读取尚未完成时使它失效、再启动新读取的调用方。检查旧读取成功或失败后,是否会影响新条目。请引用相关代码,列出触发问题的调用顺序,并区分原有问题和这次引入的回归。
确认调用方确实会触发这个顺序,再在终端运行测试。
如果补丁还改了测试,记得把断言的变化和实现一起审。异常检查被换成更弱的条件时,绿色结果可能掩盖未修改调用方里的回归。