Repository navigation
Description
发现于 #5253(list() single-flight)实现期,不在该单范围内(改的是 unregister() 的顺序,不是 list()),故单独立卡。已验证为既有缺陷:在 origin/main @ c794f789f(含 #5251)与 #5253 的改动上表现完全一致,不是 single-flight 引入的。
现象
packages/metadata/src/metadata-manager.ts 的 unregister() 顺序是:
- 从
registry删掉该项; this.invalidateListCache(type);await loader.delete(type, name)—— 对每个可写的datasource:loader;publishRealtimeMetadataEvent/notifyWatchers。
第 2 步与第 3 步之间有一个真实的 await 窗口(一次 DB 往返)。窗口内到达的 list(type):
- 缓存刚被失效 ⇒ miss;
registry已经没有该项,但 loader 还有(删除尚未落库);- 于是读出「已删项」,并按完整读写进
listCache,拿到 30s 的健康 TTL。
第 3 步完成后没有任何东西再失效一次 —— notifyWatchers() 只走 watch 回调(notifyWatchersLocal + 集群 publish),不碰 listCache;invalidateListCache 在本次 unregister() 里只被调用过那一次,而且是在删除落库之前。结果:一个已经从存储里删掉的项,继续从缓存里被 list() 端出来最多 30s。
register() 没有这个问题:它先把新值写进 registry(第 1 步),而 list() 合并时 registry 优先于 loader,所以窗口内的读读到的是写后的值。只有删除方向是「registry 先空、loader 后空」,中间那段时间 loader 是唯一的真相来源,而它还是旧的。
复现(已跑通)
临时探针,packages/metadata/src/ 下,vitest:
// loader:protocol 'datasource:'、capabilities.write = true、
// delete() 挂在一个闸门上,release() 之后才真正从 store 里删。
await manager.register('view', 'doomed', { name: 'doomed' });
const removal = manager.unregister('view', 'doomed'); // 停在 await loader.delete 上
await Promise.resolve();
// 窗口内的并发读:registry 已空,loader 还在
const during = await manager.list('view');
// => [{ name: 'doomed' }] —— 并且被当作完整读缓存,30s TTL
loader.release();
await removal; // 删除真正落库
// loader.store.has('doomed') === false
const after = await manager.list('view');
console.log(after);
// 实际:[{"name":"doomed"}] ← 存储里已经没有了,缓存还在端
// 期望:[]实际输出(两个版本各跑一次,完全一致):
AFTER DELETE, list("view") = [{"name":"doomed"}]
影响
list() 是枚举面的入口:REST /api/v1/metadata/:type、Studio 左栏、同步/导出、以及任何「按已声明集合判断存在性」的消费者(权限、共享规则、策略、api endpoint)都读它。删除后最多 30s 内,这些面会继续把一个已删项当作存在:
- 管理员删掉一个 view/action 后刷新列表,它还在 —— 与 meta overlays: a deleted overlay stays in the /meta listing though dispatch correctly 404s (#4432 residual) #5079 描述的困惑同一类(那张卡是 overlay 删除路径上的顺序性缺失,本卡是
unregister()上的并发窗口,机制不同,故独立立卡而非子单); - 更要紧的是反向:门控类型(
permission/ api)的删除在这 30s 内对list()不可见,而get()/ dispatch 路径已经生效,于是不同面在同一时刻给出互相矛盾的答案。
30s 是健康 TTL 的全长 —— 这条路径写进去的条目 degraded: false(所有 loader 都答了,没人抛),所以 #5184 的 2s 短 TTL 不适用,不会替它兜底。
期望
删除在落到每个 loader 之后再失效一次(或者把失效移到 loader 删除之后),使「registry 空 + loader 空」这个终态成为被缓存的那个状态。#5219 / #5229 立的那条线在这里同样适用:被事件叫醒的消费者不该同时看到事件和事件前的缓存 —— 这里连事件都还没发,缓存里就已经装进了事件前的答案。
顺带值得一并看的:unregister() 里 loader.delete() 的失败只 logger.warn 后继续(Failed to delete ...),也就是删除失败时 registry 已经空了而存储还在 —— 下一次 TTL 过期后的 list() 会把它读回来。这是同一段代码的另一个语义问题,是否一并处理请分诊定夺。
发现于 #5253 实现期(会话 session_01Pbu27iNUfQCHeuS551Rqo7);未认领,留给分诊。
Activity
os-zhuang commentedon Aug 4, 2026
分诊(PM 循环第 4 轮,会话
session_01Pbu27iNUfQCHeuS551Rqo7):挂pm:blocked,Blocked-by: PR #5260(同文件metadata-manager.ts,须基于其合并后的 single-flight 形状工作)。合入后转pm:queue即派。探针已证实为既有缺陷(main 与 #5260 表现一致),修法方向是unregister()的失效/删除顺序 —— 届时对照register()侧「先写 registry 再失效」的正确顺序与 #5219 的 invalidate-before-notify 纪律定形状。listCache同字段第五案,与 #5109/#5218/#5184/#5253 的案卷互为参照。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
认领:PM 循环第 4 轮(阻塞解除:#5253→PR #5260 已合并,single-flight 形状已在 main)
会话:
session_01Pbu27iNUfQCHeuS551Rqo7分支:
claude/issue-5259-unregister-invalidate-orderWorktree:
objectstack-issue-5259域:
domain:engine文件面:
packages/metadata/src/metadata-manager.ts(仅unregister()的失效/删除顺序及其loader.delete()失败上报)、本包测试、changeset(越界即停,报告说明)修法参照 13:16Z 分诊:对照
register()侧正确顺序与 #5219 invalidate-before-notify 纪律;正文末段点名的「loader.delete()失败只 warn、registry 已空而存储仍有行」一并按 AGENTS.md 降级分级判层。⛔ 不回改 #5260 的 single-flight/#5251 的 degraded 逻辑(基于其上工作 —— 注意 #5260 已让invalidateListCache()同时撤销在飞读,顺序修正要与之协同)。在飞不相交已核:#5038(objectql/automation)、#5257(core)与本面不交。Generated by Claude Code
os-zhuang commentedon Aug 4, 2026
验收:ACCEPT —— PR #5277(转 ready + auto-merge)
会话:
session_01Pbu27iNUfQCHeuS551Rqo7(PM 循环第 4 轮)修法比派发词更好:没有采用「await 前后各失效一次」的直觉解法,而是提炼出真正的不变量 —— 失效必须是最后一步,在每个存储都已持有即将被宣告的状态之后;并指出
register()从无此病的原因(先写 registry,而合并时 registry 压过 loader,其 save 窗口内视图已等于写后状态)。删除方向翻译为「存储优先」:窗口内 registry 与 loader 都还持有该项,并发读观察到的是自洽的删除前状态 —— 而那就是当时的事实。与 #5260 的协同是承重的,且被钉住:三行表格分清谁兜住谁 —— 已完成的读靠
listCache.delete,在飞的读只能靠inflightListReads.delete(#5260 的撤销),之后到达的读见终态。派发词要求的「哪个机制交付了这条保证」得到了精确回答。失败路径:
warn→error,每个未删项报一次而非每 loader 一次(理由充分:每行点名不同的、不会被重试的幸存项;合并等于把名单悄悄丢掉);「删除失败仍丢 registry 条目」的选择有完整论证(loader 里那行还在,保留只是决定哪份副本赢 —— 丢掉让下次读穿透到存储事实并立即可见);契约未变(不 throw),并诚实记录了 register/unregister 的 throw 非对称留待独立裁决。实况核验:4 文件在面内;
DURABILITY_CRITICAL_CALLEES按 AGENTS.md 同 PR 加词表,且起了具名 seamdeleteMetaItemFromLoader(避免delete裸名认领全仓每个.delete()—— 脚本头注释警告过的假阳性)✓ / 13 例中 6 例反向验证在 main 上红(含 issue 探针原样)✓ / metadata 478 全绿、门禁 durability(23 seams)/startup-registry/eslint 全过、tsc 92→92 零新增 ✓ / 已并08f93bc1a后重跑 ✓ / ⛔ #5260/#5251/#5183/#5219/#5229 的面零回改、spec/releases/core/objectql/protocol 零触碰 ✓。顺带发现:#5276(
MetadataLoader接口无delete声明,duck-typing 探测 → 无delete的可写 loader 被一声不吭跳过)已立finding;本 PR 只把两处as any收敛为具名DeletableMetadataLoader形状,契约缺口留卡 —— 分寸正确。listCache家族五案(#5109/#5218/#5184/#5253/#5259)至此全部闭环。Generated by Claude Code
fix(metadata): invalidate the list cache AFTER the storage delete lan…
fix(metadata): a writable `datasource:` loader must implement `delete…