fix(metadata): list() 读取按类型 single-flight —— 并发调用共享同一次 loader 遍历 (#5253) - #5260
Merged
Conversation
`MetadataManager.list()` was "read the cache → walk the loaders → write the
cache" with nothing shared in between. The cache is written only once a read
FINISHES, so it absorbed the caller that arrived second in time but never the
caller that arrived second in flight: every list(type) issued while the first
read was still walking the loaders missed, and each walked every loader itself.
The `listCache` field comment states the guarantee the cache exists for — "the
loader is only hit once per TTL window" — and it held for sequential callers
only.
That is not a rounding error on the path the cache was built for: the comment
names security middleware calling list('permission') while DatabaseLoader waits
out knex's 60s acquireConnectionTimeout inside a transaction holding SQLite's
only connection. Every concurrent request arriving in those 60s burned its own
60s, because nothing had been cached yet.
- One read per type. A caller that finds a read already running for a type joins
it (`inflightListReads`, a type → Promise map) instead of starting a second
identical walk.
- Sharers share the outcome, as an explicit contract: `list()` is best-effort and
does not throw, so a lost loader is not an error to fail over from — it is the
answer, and every sharer of one read receives it.
- #5251's degraded judgment is untouched and cannot be bypassed by sharing: a
shared read that lost a loader is still memoized `degraded: true` on the 2s
TTL, never laundered onto the 30s healthy TTL.
- Mid-read invalidation: the invalidation WINS. `invalidateListCache()` retracts
the in-flight read, so it loses the right to memoize its pre-write answer, and
a caller arriving after the write starts a fresh read rather than joining a
pre-write one — #5219/#5229's bar ("a woken watcher must not observe the event
and the pre-event cache together") restated for concurrency. Callers already
waiting still receive the in-flight result; restarting them would turn a write
burst into an unbounded retry loop on the one path the cache keeps off the
loaders.
- The in-flight map is self-cleaning: an entry is dropped when its read settles,
by that read only, so a fresh read that replaced it keeps its slot.
The issue's repro is the regression skeleton (3 concurrent callers ⇒ 1 loader
walk); 13 tests also pin sequential behavior unchanged, the shared-degraded
contract and its 2s TTL, the mid-read invalidation decision in all four of its
consequences, and that nothing accumulates in the map.
Internal caching policy only — no change to `IMetadataService` or any export.
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 13:15
os-zhuang
enabled auto-merge
August 4, 2026 13:15
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 #5253
按 12:41Z 的 PM 裁定实现方向 1(single-flight):
type → Promise的 in-flight 表,并发调用共享同一个 promise,settle 后清表。问题
MetadataManager.list()是「查缓存 → 走 loader → 写缓存」的裸结构,中间没有任何共享。缓存只在读完成之后才写入,所以它吸收得了时间上第二个到达的调用者,吸收不了飞行中第二个到达的调用者:第一次读还在走 loader 期间发出的每一个list(type)都 miss,都各自把每个 loader 完整走一遍。listCache字段注释里那句「the loader is only hit once per TTL window」只对串行调用者成立 —— #5251 已经给它挂上了for SEQUENTIAL callers的限定和指向本单的指针。这不是「多花一点点」。注释自己点名的场景就是并发最密的地方:security/permission 中间件在请求路径上调
list('permission'),而DatabaseLoader的读在事务持着 SQLite 唯一连接时要等满 knex 的acquireConnectionTimeout(60s)。那 60s 里到达的每一个并发请求都自己再烧一次 60s,因为还没有任何东西被写进缓存。日常版本温和得多但每天在跑:冷启动,以及每个失效点(register()/unregister()、#5109 集群对端、#5218 FS)之后紧跟的那一小簇并发list()。改动
同一类型的读现在是 single-flight。
list(type)发现该类型已有读在飞,就加入它,而不是另起一次相同的遍历。list()是 best-effort 枚举缝、不抛(严格对应物仍是listForIndex()与loadDiagnosed()),所以「丢了一个 loader」不是一个可以 fail over 的错误,它就是答案;为加入者私下重跑一次,只会在同一个窗口里对着同一场故障再走一遍同样的 loader —— 正是这张表存在的目的所要避免的。这段写进了inflightListReads的字段注释,并由测试钉住。degraded判定原封不动,且不能被绕过。 共享读丢了 loader,依然按degraded: true+ 2s 短 TTL 记忆,不会因为「被共享过」就混上 30s 的健康 TTL;而所有共享者收到的正是那同一份残缺集合。invalidateListCache()现在同时撤销在飞的读。被撤销的读继续跑完,交给已经在等的调用者(他们是在写之前问的),但失去写缓存的资格 —— 早于写组装出来的答案不会活过它所早于的那次写;而在失效之后才到达的调用者会开一次全新的读,而不是加入一个早于写的读。后半句是 fix(metadata): 集群对端的元数据写入现在会失效本节点的 listCache / registry (#5109) #5219 / fix(metadata): FS 监听改动同样失效本节点的 listCache/registry (#5218) #5229 那条线在并发上的复述:被事件叫醒的消费者不该同时看到事件和事件前的状态。为什么不把已经在等的调用者重启:那会把一串写变成 best-effort 路径上的无界重试循环,而这条路径正是缓存要挡在 loader 之外的那条。register()那侧本来就没问题:它先把新值写进registry,而list()合并时 registry 优先于 loader,所以窗口内的读读到的是写后的值。测试
新增
packages/metadata/src/metadata-manager-list-single-flight.test.ts(13 例),其中 issue 正文的复现即回归骨架:list('permission')⇒loadManyCalls === 1(改动前是 3);degraded: true,故障行只报一次,且按 2s(而非 30s)过期;register()/invalidateForForeignWrite()两个缝各一例):等待者仍拿到写前结果;写前结果不被记忆(listCache无条目);写后到达者开新读并看到新写入的项;被撤销的读 settle 时不误删顶替它的新读的登记。13 例中有 10 例在改动前红(3 例是本来就应绿的行为钉子)。
范围
只动
packages/metadata/src/metadata-manager.ts(list()、新的 in-flight 表、invalidateListCache()一行、以及 #5251 挂了限定的那句listCache注释 —— 现在对并发也成立,#5253 指针一并移除)+ 本包测试 + 一个 changeset。没有碰 #5251 的degraded/TTL 逻辑、#5183 的 loader 缝、#5219 的集群订阅、#5229 的 FS 失效。IMetadataService契约与任何 public export 均无变化。越界发现(已另立卡,未在本 PR 修)
unregister()先失效 listCache 再删 loader:删除落库前到达的并发 list() 会把「已删项」重新缓存满 30s,之后没有任何东西再失效它 #5259 ——unregister()先失效 listCache 再删 loader:删除落库前到达的并发list()会把「已删项」按完整读重新缓存满 30s,之后没有任何东西再失效它(notifyWatchers()不碰listCache)。已用探针验证,并确认在origin/main@c794f789f与本 PR 上表现完全一致 —— 既有缺陷,不是 single-flight 引入的。改的是unregister()的顺序,不在本单范围内。Generated by Claude Code