发现于 #5108(DatabaseLoader 读故障吞成空结果)实现期,不在该单范围内,故单独立卡。未认领 —— 只是记录。
现象一:降级结果被当成正常结果 memoize
packages/metadata/src/metadata-manager.ts 的 list(type) 收尾是无条件的:
const result = Array.from(items.values());
this.cacheListResult(type, result); // ← 不区分这次读是不是降级来的
return result;
#5108 落地后,某个 loader 读不到存储时 list() 会走 catch 分支、在 error 记一行、然后继续用剩下 loader 的内容拼出 result(best-effort 姿态是刻意的,没有异议)。问题在下一行:这个已知残缺的结果照样进了 listCache,和一次完整成功的读没有任何区别。
后果:
- 故障期间第一次
list('permission') 记一行 error,随后 30s(LIST_CACHE_TTL_MS)内的所有 list('permission') 直接命中缓存,loader 根本不再被调用 —— 既不重试、也不再有任何信号。那一行 error 覆盖的是一个 30s 的静默残缺服务窗口;
- 存储恢复后,最长还要再等 30s 才会有人去问 loader,恢复 recovery 日志也跟着晚;
- 缓存条目本身不带「这份是残缺的」标记,任何后续读者(含
#5108 新加的 error 报告的 once-only 判断)都无从分辨。
现象二:注释描述的行为代码里没有
listCache 字段的注释(:138-148)写着:
we only cache positive (non-empty) hits or repeated hits with a stable miss signature
cacheListResult() 是无条件 this.listCache.set(type, { ts: Date.now(), items }) —— 既不看 non-empty,也没有任何 "stable miss signature" 的概念。这段注释描述的是一套代码里不存在的策略。注释即契约,这是一处 declared ≠ enforced(Prime Directive #10 的形状,对准我们自己的内部文档)。
为什么 #5108 里没有顺手改
试过,然后否掉了 —— 而且否掉的理由本身就是这张卡要裁的东西。
同一段注释记着这个缓存为什么存在:
Built primarily to break the deadlock that occurs when security/permission middleware calls list('permission') from inside a user-initiated DB transaction: the DatabaseLoader's engine.find('sys_metadata', ...) would then try to acquire a fresh knex connection while the transaction is still holding SQLite's single connection — knex waits the full acquireConnectionTimeout (60s) before returning []. The cache absorbs the repeated lookups so the loader is only hit once per TTL window.
也就是说:这个缓存吸收的恰好就是一类降级读。「降级结果不入缓存」的直觉修法,会让上面那个事务内场景每次调用都重新烧一次 60s 超时 —— 从一个 30s 的静默窗口换成一个每次 60s 的挂起,明显更糟。
所以这不是一个可以顺手改的一行,而是一个要裁的取舍。候选方向(裁决留给维护者):
- 降级结果照存,但带上
degraded 标记 + 单独的、短得多的 TTL(例如 1-2s):既保住 knex 那条路径的吸收效果,又把静默窗口压到接近零;
- 降级结果照存、TTL 不变,但把注释改成代码真实做的事,并在
#5108 的 error 文案里明说「此后 30s 内不会重试」—— 承认现状、只修 declared ≠ enforced;
- 按注释原本承诺的做:只缓存完整成功的读 —— 需要先确认 knex 那条路径今天是否还会真的走到(注释所指的 SQLite 单连接场景可能已随驱动演进而变),否则就是拿一个已修的死锁换回来。
方向 1 看起来最合规矩(降级的东西显式标成降级,而不是混进正常缓存),但它要动 listCache 的数据形状,而那块面正被 #5109 占着 —— 两单同时改同一个字段会撞车,所以先记录、由 PM 排序。
不是重复,也不是子集。 #5109 是「集群对端的写入不失效本节点的 listCache」—— 失效路径少了一个触发源;本卡是「本节点自己明知这次读是残缺的,却照常把它当完整结果缓存」—— 写入路径少了一个判据。#5109 完整落地也不会碰到 cacheListResult 的条件性。两者共用 listCache 这个字段,建议串行做,别并行。
复现
const manager = new MetadataManager({ formats: ['json'], loaders: [] });
manager.registerLoader(new DatabaseLoader({ driver: brokenDriver })); // 读全部 reject
manager.registerInMemory('permission', 'from_code', { name: 'from_code' });
await manager.list('permission'); // 记一行 error,返回 [from_code],残缺结果入缓存
brokenDriver.heal(); // 存储恢复
await manager.list('permission'); // 仍然是缓存里那份残缺结果,loader 没被问
关联
#5108(发现处,已修 loader 层的吞异常)、#5109(同一个字段的另一条缺陷,建议串行)、AGENTS.md「Degradation log levels」、packages/metadata/src/metadata-manager.ts :138-148 / list() / cacheListResult()。
发现于 #5108(
DatabaseLoader读故障吞成空结果)实现期,不在该单范围内,故单独立卡。未认领 —— 只是记录。现象一:降级结果被当成正常结果 memoize
packages/metadata/src/metadata-manager.ts的list(type)收尾是无条件的:#5108落地后,某个 loader 读不到存储时list()会走catch分支、在error记一行、然后继续用剩下 loader 的内容拼出result(best-effort 姿态是刻意的,没有异议)。问题在下一行:这个已知残缺的结果照样进了listCache,和一次完整成功的读没有任何区别。后果:
list('permission')记一行 error,随后 30s(LIST_CACHE_TTL_MS)内的所有list('permission')直接命中缓存,loader 根本不再被调用 —— 既不重试、也不再有任何信号。那一行 error 覆盖的是一个 30s 的静默残缺服务窗口;#5108新加的 error 报告的 once-only 判断)都无从分辨。现象二:注释描述的行为代码里没有
listCache字段的注释(:138-148)写着:cacheListResult()是无条件this.listCache.set(type, { ts: Date.now(), items })—— 既不看 non-empty,也没有任何 "stable miss signature" 的概念。这段注释描述的是一套代码里不存在的策略。注释即契约,这是一处 declared ≠ enforced(Prime Directive #10 的形状,对准我们自己的内部文档)。为什么 #5108 里没有顺手改
试过,然后否掉了 —— 而且否掉的理由本身就是这张卡要裁的东西。
同一段注释记着这个缓存为什么存在:
也就是说:这个缓存吸收的恰好就是一类降级读。「降级结果不入缓存」的直觉修法,会让上面那个事务内场景每次调用都重新烧一次 60s 超时 —— 从一个 30s 的静默窗口换成一个每次 60s 的挂起,明显更糟。
所以这不是一个可以顺手改的一行,而是一个要裁的取舍。候选方向(裁决留给维护者):
degraded标记 + 单独的、短得多的 TTL(例如 1-2s):既保住 knex 那条路径的吸收效果,又把静默窗口压到接近零;#5108的 error 文案里明说「此后 30s 内不会重试」—— 承认现状、只修 declared ≠ enforced;方向 1 看起来最合规矩(降级的东西显式标成降级,而不是混进正常缓存),但它要动
listCache的数据形状,而那块面正被 #5109 占着 —— 两单同时改同一个字段会撞车,所以先记录、由 PM 排序。与 #5109 的关系
不是重复,也不是子集。 #5109 是「集群对端的写入不失效本节点的
listCache」—— 失效路径少了一个触发源;本卡是「本节点自己明知这次读是残缺的,却照常把它当完整结果缓存」—— 写入路径少了一个判据。#5109 完整落地也不会碰到cacheListResult的条件性。两者共用listCache这个字段,建议串行做,别并行。复现
关联
#5108(发现处,已修 loader 层的吞异常)、#5109(同一个字段的另一条缺陷,建议串行)、AGENTS.md「Degradation log levels」、
packages/metadata/src/metadata-manager.ts:138-148/list()/cacheListResult()。