Skip to content

MetadataManager.list() 把「已知残缺」的降级结果照常写进 30s listCache,且 listCache 的注释描述的条件缓存代码里并不存在 #5184

Closed

Description

@os-zhuang

发现于 #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 的挂起,明显更糟。

所以这不是一个可以顺手改的一行,而是一个要裁的取舍。候选方向(裁决留给维护者):

  1. 降级结果照存,但带上 degraded 标记 + 单独的、短得多的 TTL(例如 1-2s):既保住 knex 那条路径的吸收效果,又把静默窗口压到接近零;
  2. 降级结果照存、TTL 不变,但把注释改成代码真实做的事,并在 #5108 的 error 文案里明说「此后 30s 内不会重试」—— 承认现状、只修 declared ≠ enforced;
  3. 按注释原本承诺的做:只缓存完整成功的读 —— 需要先确认 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

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(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

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 3 轮(串行解除:#5218→PR #5229 已合并;裁定见 11:00Z 分诊评论 —— 方向 1,含 knex 前提核实步骤)
    会话:session_01Pbu27iNUfQCHeuS551Rqo7
    分支:claude/issue-5184-degraded-list-cache-policy
    Worktree:objectstack-issue-5184
    域:domain:engine
    文件面:packages/metadata/src/metadata-manager.ts(cacheListResult / listCache 条目形状 / :138-148 注释)及本包测试、changeset(越界即停,报告说明)

    ⚠️ 同日 churn: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

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    验收: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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions