发现于 #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 内,这些面会继续把一个已删项当作存在:
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);未认领,留给分诊。
发现于 #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):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:实际输出(两个版本各跑一次,完全一致):
影响
list()是枚举面的入口:REST/api/v1/metadata/:type、Studio 左栏、同步/导出、以及任何「按已声明集合判断存在性」的消费者(权限、共享规则、策略、api endpoint)都读它。删除后最多 30s 内,这些面会继续把一个已删项当作存在: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);未认领,留给分诊。