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. added
    bugSomething isn't working
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 4, 2026
  2. 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

  3. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 4, 2026
  4. self-assigned this
    on Aug 4, 2026
  5. 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

  6. 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

Labels

bugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions