Skip to content

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

Description

@os-zhuang

发现于 #5253(list() single-flight)实现期,不在该单范围内(改的是 unregister() 的顺序,不是 list()),故单独立卡。已验证为既有缺陷:在 origin/main @ c794f789f(含 #5251)与 #5253 的改动上表现完全一致,不是 single-flight 引入的。

现象

packages/metadata/src/metadata-manager.tsunregister() 顺序是:

  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);未认领,留给分诊。

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions