Skip to content

fix(metadata): 集群对端的元数据写入现在会失效本节点的 listCache / registry (#5109) - #5219

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5109-cluster-invalidate-list-cache
Aug 4, 2026
Merged

fix(metadata): 集群对端的元数据写入现在会失效本节点的 listCache / registry (#5109)#5219
os-zhuang merged 1 commit into
mainfrom
claude/issue-5109-cluster-invalidate-list-cache

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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 的在测替身。

为什么不能用 MemoryLoader:它的 protocol 是 memory:,而 register() 只持久化到 datasource: 且声明 capabilities.write 的 loader。没有共享的可写存储,A 的写入对 B 根本不可见,#5109 的回归也就无从观察。

用例 钉住的行为
B 预热 list('view') → A register() → B list('view') issue 的原始复现,不等 30s TTL
A unregister() → B list('view') 删除方向同样传播
同步失效、先于延迟回放 上文选择 2
watcher 在被叫醒后回头 list() 拿到写后清单(矛盾消解)
B 的 registry 旧副本被删,get() 穿透到共享存储 上文选择 1
无名事件 只失效清单缓存,保留 registerInMemory 条目
回环事件 本节点自己的广播不触发失效

其中 6 例在 main 上是红的(第 7 例是回环守卫,两边都绿)。

# 有修复
 Test Files  1 passed (1)
      Tests  13 passed (13)

# git stash 掉 metadata-manager.ts 后
     × B's pre-warmed list() sees A's register() without waiting out the 30s TTL
     × propagates A's unregister() to B's list()
     × invalidates SYNCHRONOUSLY on receipt — not inside the deferred replay
     × a watcher that re-reads via list() on the wake-up gets the post-write set
     × drops B's stale registry entry so get() falls through to the shared store
     × a nameless remote event invalidates the list cache but keeps in-memory-only entries
 Test Files  1 failed (1)
      Tests  6 failed | 7 passed (13)

全包与下游:

pnpm --filter @objectstack/metadata test        → 18 files / 433 tests passed
pnpm --filter @objectstack/service-cluster test →  3 files /  45 tests passed

类型:@objectstack/metadatatypecheck 脚本(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 no version / name / … field」会让人以为事件里根本没有名字信息(其实 name内嵌的 watch event 里,正是本修复用来定位 registry 条目的东西)。现在把「先同步失效、再延迟回放」写清楚,并把「没有 name 字段」限定回 payload 顶层。没有重写整节,§6.2 开头那句 "Cross-node metadata invalidation already works" 现在才真的成立。

范围与不做的事

顺带发现(未在本 PR 修)


🤖 Generated with Claude Code

https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7


Generated by Claude Code

…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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 10:38am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests size/m tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata)
  • content/docs/kernel/cluster.mdx (via packages/metadata)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/metadata)
  • content/docs/releases/v12.mdx (via @objectstack/metadata)
  • content/docs/releases/v9.mdx (via @objectstack/metadata)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 10:46
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 533a0a4 Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5109-cluster-invalidate-list-cache branch August 4, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

集群对端的元数据写入不失效本节点的 listCache / registry —— 收到广播的节点最长 30s 继续服务旧定义

2 participants