fix(metadata): 集群对端的元数据写入现在会失效本节点的 listCache / registry (#5109) - #5219
Merged
Conversation
…tCache/registry (#5109) `attachClusterPubSub()`'s subscriber did exactly one thing on an incoming `metadata.changed`: `notifyWatchersLocal()`. It never touched `registry` or `listCache`. So a peer's write woke this node's watchers while every `list(type)` kept answering the pre-write set for up to LIST_CACHE_TTL_MS (30s) — and a watcher that answered the wake-up by re-reading through `list()` was handed the stale set back. An invalidation notice carrying invalidated data, contradicting the channel's own documented purpose ("consumed by peers to invalidate their local caches"). Both foreign-write seams — the repository watch loop and the cluster peer replay — now converge on one private `invalidateForForeignWrite(type, name)`, which is `applyRepoEvent`'s long-standing shape lifted out verbatim: - Delete, never pre-fill. The body reaching us is a snapshot of someone else's write and may already be superseded; pre-filling races with the true head and would require re-canonicalising a definition we never loaded. `get()` falls through to the loaders / repository instead. - Synchronously on receipt, before the notify. The `setImmediate` exists so a slow *watcher callback* cannot back-pressure the pubsub dispatch loop; invalidation is two `Map.delete`s running no consumer code, so deferring it would only leave a receipt-to-tick window in which reads still answer stale. - A nameless event (`MetadataWatchEvent.name` is optional in the spec) invalidates the list cache only — dropping the whole type store would evict `registerInMemory()` artefacts no loader can restore. Loopback suppression still short-circuits first, so a node's own broadcast never costs it a needless cache rebuild. Tests: 7 new cases in metadata-manager-cluster.test.ts driving two managers over one bus and one shared `datasource:` store; 6 of them fail on main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 10:46
This was referenced Aug 4, 2026
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5109
问题
attachClusterPubSub()的订阅回调在收到对端metadata.changed广播时,只做一件事 ——notifyWatchersLocal()。它既不碰this.registry,也不碰this.listCache。于是:节点 A 改一条 view/permission/flow,节点 B 的 watcher(ObjectQL SchemaRegistry 桥、HMR SSE)确实被叫醒了,但 B 上任何走
list(type)的读在LIST_CACHE_TTL_MS = 30_000窗口内继续返回改动前的清单;被叫醒的 watcher 如果回头调list()重新拉取,拉到的还是旧的 —— 一份「失效通知」附带着失效数据。单机部署完全无感,只有多节点才暴露。这与该通道自己声明的用途相反(
ClusterMetadataChangedPayload的注释原文:"consumed by peers to invalidate their local caches",另见content/docs/kernel/cluster.mdx§6.2、metadata-lifecycle.mdx)。修法:收敛到
applyRepoEvent的既有形状issue 把
applyRepoEvent()点名为「同文件内已做对的那条路径,可作修法样板」,本 PR 就照办 —— 并把两条「外部写入」缝(仓库 watch 循环、集群对端回放)提取到同一个私有方法invalidateForForeignWrite(type, name)。两者的共同点正是这个方法的定义域:它们都是在得知一次落在别处的写入(repo head;另一个节点的sys_metadata),而本节点手里的缓存已被那次写入悄悄作废。本地写入不走这里 ——register()/unregister()/registerInMemory()把 registry 更新成自己刚写的值,并各自调invalidateListCache()。applyRepoEvent的行为不变(它原先直接listCache.delete(type),现在经invalidateListCache(type),顺带同步失效EndpointMatcher索引 —— 纯增量,原先靠 watcher 那条缝也会失效)。三个刻意的选择
1. 删除 registry 条目,而不预填。 这正是 issue 里那句「要不要连
registry一起删」的裁决点,答案沿用applyRepoEvent自 ADR-0008 PR-6 起的理由,并对集群路径再补一条:到手的 body 是别人那次写入的快照,可能已被后续写入取代;预填会与真实 head 竞态,并要求我们去规范化一份自己没有加载过的定义。删掉之后get()自然穿透到 loader / repository —— 真相所在。这条也是本 PR 能修复list()的必要条件:registry 条目在list()里盖过 loader 的同名项,只清listCache会让 B 永远端着自己那份旧副本(测试drops B's stale registry entry…就是钉这一点的)。2. 同步失效,且先失效再通知(PR 描述里点名说明,对应 PM 指出的 async 接缝)。失效发生在收到消息的当拍,不在
setImmediate内;通知仍然延迟一拍。理由:setImmediate的存在理由写在原注释里 —— 不让消费方的 watcher 回调(任意用户代码)背压 pubsub 派发循环。失效只是两次Map.delete,不执行任何消费方代码,没有需要延迟的东西;await都足以撞进去 —— 那只是把 30 秒的 bug 缩短成一拍的 bug,不是修好它;register/unregister/applyRepoEvent)一致,于是回头list()的 watcher 拿到的是写后清单,issue 说的「失效通知与失效数据自相矛盾」才真正消解。测试
invalidates SYNCHRONOUSLY on receipt — not inside the deferred replay把这个选择钉死:await的恢复是 microtask,setImmediate回调此刻还没跑(断言 watcher 尚未被调用),而缓存已经没了。把失效挪进setImmediate会让这条断言变红。3. 无名事件只失效清单缓存。
MetadataWatchEvent.name在 spec 里是optional,所以无名事件是合法上线的。无名就无法定位 registry 条目;此时不把整个 type 的 registry 一并清掉 —— 那会驱逐registerInMemory()注册的、任何 loader 都无法恢复的代码态构件(origin: 'code'的 datasource、stack 声明的 roles/permissions,ADR-0015 Addendum),拿一次不可恢复的丢失去换一个猜测。回环抑制(
originNode)仍然在最前面短路,本节点自己的广播不会让自己白白重建缓存(有测试)。测试
packages/metadata/src/metadata-manager-cluster.test.ts新增 7 例,骨架就是 issue 的复现思路:两个MetadataManager接同一个IPubSub,并共享同一个datasource:协议的可写 loader —— 两个副本共用一张sys_metadata的在测替身。list('view')→ Aregister()→ Blist('view')unregister()→ Blist('view')list()get()穿透到共享存储registerInMemory条目其中 6 例在 main 上是红的(第 7 例是回环守卫,两边都绿)。
全包与下游:
类型:
@objectstack/metadata无typecheck脚本(scripts/check-type-check-coverage.mjs里的 DEBT 条目,87)。直接跑tsc --noEmit -p packages/metadata/tsconfig.json对比:改动前 92 → 改动后 92,净增 0(新测试的 import 带上了.js扩展名,回调参数显式标注,避免 AGENTS.md 记的那个「缺扩展名 → 全变 any → 一堆 TS7006」陷阱)。eslint对两个改动的 TS 文件零输出。文档
content/docs/kernel/cluster.mdx§6.2 只改了描述对端收到广播后做什么的那一段:原文「replay the watch event locally — there is currently noversion/name/ … field」会让人以为事件里根本没有名字信息(其实name在内嵌的 watch event 里,正是本修复用来定位 registry 条目的东西)。现在把「先同步失效、再延迟回放」写清楚,并把「没有 name 字段」限定回 payload 顶层。没有重写整节,§6.2开头那句 "Cross-node metadata invalidation already works" 现在才真的成立。范围与不做的事
packages/metadata/src/metadata-manager.ts的集群 pubsub / watcher 通知 / listCache 失效一段 + 本包测试 + 一个 changeset + 上面那段文档;DatabaseLoader把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108)今天落地的 loader 降级面(loaders/**、reportLoaderReadFailure/reportLoaderReadRecovered)—— 建立在它们之上,一个字没改;packages/spec/**零改动;MetadataManager.list()把「已知残缺」的降级结果照常写进 30s listCache,且listCache的注释描述的条件缓存代码里并不存在 #5184 不冲突:那单裁的是cacheListResult()要不要区分降级结果(写入路径的判据),本 PR 一行没碰cacheListResult;本 PR 补的是失效路径的一个触发源。MetadataManager.list()把「已知残缺」的降级结果照常写进 30s listCache,且listCache的注释描述的条件缓存代码里并不存在 #5184 自己也写明两者「不是重复,也不是子集,建议串行」。顺带发现(未在本 PR 修)
listCache——handleFileEvent只通知 watcher(#5109 的本地同形缺陷) #5218 —— FS 监听改动同样不失效本节点的listCache:NodeMetadataManager.handleFileEvent()也是只notifyWatchers、不失效,是 集群对端的元数据写入不失效本节点的listCache/ registry —— 收到广播的节点最长 30s 继续服务旧定义 #5109 的本地同形缺陷(触发面是开发期watch: true,不是生产多节点)。已按 Prime Directive chore: version packages #10 单独立卡、未认领,并在卡里注明可复用本 PR 落地的invalidateForForeignWrite作样板。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code