Skip to content

unregister() 先失效 listCache 再删 loader:删除落库前到达的并发 list() 会把「已删项」重新缓存满 30s,之后没有任何东西再失效它 #5259

Closed

Description

@os-zhuang

发现于 #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:

// 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 内,这些面会继续把一个已删项当作存在:

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

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

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

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

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮(阻塞解除:#5253→PR #5260 已合并,single-flight 形状已在 main)
    会话:session_01Pbu27iNUfQCHeuS551Rqo7
    分支:claude/issue-5259-unregister-invalidate-order
    Worktree: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

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    验收: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 加词表,且起了具名 seam deleteMetaItemFromLoader(避免 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

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