Repository navigation
MetadataManager.list() 把「已知残缺」的降级结果照常写进 30s listCache,且 listCache 的注释描述的条件缓存代码里并不存在 #5184
Description
发现于 #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'sengine.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 fullacquireConnectionTimeout(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 的关系
不是重复,也不是子集。 #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()。
Activity
os-zhuang commentedon Aug 4, 2026
分诊(PM 循环第 2 轮,会话
session_01Pbu27iNUfQCHeuS551Rqo7):入队pm:queue,排 #5218 之后(同一listCache字段,正文自己的串行建议采纳;#5109 已合入,占位解除)。PM 裁定(否决窗口,维护者可叫停):取方向 1 —— 降级结果照存但带
degraded标记 + 单独短 TTL(1–2s 量级),同时把:138-148注释改成代码真实做的事(现象二随手闭环)。两轴理由:长远上「降级的东西显式标成降级」正是本仓反复付费的那条规矩(AGENTS.md 降级分级、#5108/#5109 同族),方向 2 只是把谎话改口供、静默窗口原样保留,方向 3 有拿已修死锁换回来的真实风险;防 AI 错上,带标记的缓存条目让后续消费方(含 #5108 的 once-only 报告判断)能分辨残缺,而无标记缓存是结构性不可分辨。裁定内置一步核实:dev 须先确认注释里 knex/SQLite 单连接死锁场景在当前驱动栈下是否仍可达 —— 若已不可达,方向 3(只缓存完整读)成为更简单的正解,按证据改选并在报告说明,不需回来请示。这属实现内部取舍,不动公开契约;若实现中发现要改IMetadataService层的可见形状,needs_decision。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
认领:PM 循环第 3 轮(串行解除:#5218→PR #5229 已合并;裁定见 11:00Z 分诊评论 —— 方向 1,含 knex 前提核实步骤)
会话:
session_01Pbu27iNUfQCHeuS551Rqo7分支:
claude/issue-5184-degraded-list-cache-policyWorktree:
objectstack-issue-5184域:
domain:engine文件面:
packages/metadata/src/metadata-manager.ts(cacheListResult/listCache条目形状 /:138-148注释)及本包测试、changeset(越界即停,报告说明)metadata-manager.ts今日已被 #5183、#5219、#5229 改过三轮 —— 行号全部漂移,基于合并后 origin/main 工作。⛔ 不回改三单落地的面(loader 降级缝、invalidateForForeignWrite、FS 失效调用)。⛔packages/spec/**零改动。在飞不相交已核:#5038(objectql/automation)、#5134(driver-sql)、#5127(seed-loader)、#4754(runtime/rest/protocol/scripts)与本面不交。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
验收:ACCEPT —— PR #5251(等 CI 绿后转 ready 入队)
会话:
session_01Pbu27iNUfQCHeuS551Rqo7(PM 循环第 3 轮)裁定执行完整:方向 1 落地(
degraded标记 + 2s 短 TTL,完整读 30s 不动),且裁定内置的前提核实被认真做了 —— knex/SQLite 单连接死锁在当前栈上仍可达,四条证据(_find()不透传事务、driver-sql 单连接池模型、assertBareKnexSafe生产 no-op、plugin-audit 手工串 trx 的旁证),并在并入含 #5212 的 main 后复核一遍,结论不变 —— 「不缓存降级读」会把 30s 静默窗换成每次 60s 挂起,正确否决。实况核验(对 diff):3 文件全在声明面内 ✓ /
ListCacheEntry刻意不导出(无公开契约变化)✓ /readCachedList()单入口承载 TTL 分支与 flag 可读性 ✓ / 现象二闭环:虚假的「non-empty hits」注释整段替换为真实策略,且「空完整读照样入缓存」被专门测试钉住 ✓ /reportLoaderReadFailure文案改为报降级 TTL(裁定允许的必需调整,#5183 的缝未回改)✓ / 8 例回归中 4 例在回退旧策略后失败(真回归)✓ / 恢复日志秒级 + error 不刷屏有专门用例 ✓。三单同字段串行(#5109 → #5218 → 本单)至此清账,
listCache的失效路径、写入判据、条目形状三个缺陷全部闭环。Generated by Claude Code
fix(metadata): 降级的 list() 结果按「降级」缓存 —— degraded 标记 + 2s 短 TTL (object…
fix(metadata): invalidate the list cache AFTER the storage delete lan…
feat(spec,metadata,mcp): let a plural metadata read say it is known-p…