发现于 #5253(`list()` single-flight)实现期,**不在该单范围内**(改的是 `unregister()` 的顺序,不是 `list()`),故单独立卡。已验证为**既有缺陷**:在 `origin/main` @ `c794f789f`(含 #5251)与 #5253 的改动上表现完全一致,不是 single-flight 引入的。 ## 现象 `packages/metadata/src/metadata-manager.ts` 的 `unregister()` 顺序是: 1. 从 `registry` 删掉该项; 2. `this.invalidateListCache(type)`; 3. `await loader.delete(type, name)` —— 对每个可写的 `datasource:` loader; 4. `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: ```ts // 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 后刷新列表,它还在 —— 与 #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…