Skip to content

fix(metadata): list() 读取按类型 single-flight —— 并发调用共享同一次 loader 遍历 (#5253) - #5260

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5253-list-single-flight
Aug 4, 2026
Merged

fix(metadata): list() 读取按类型 single-flight —— 并发调用共享同一次 loader 遍历 (#5253)#5260
os-zhuang merged 2 commits into
mainfrom
claude/issue-5253-list-single-flight

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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) 发现该类型已有读在飞,就加入它,而不是另起一次相同的遍历。

  • 共享结果是显式契约,不是巧合。 加入者拿到的是那次读的同一份结果 —— 包括某个 loader 读不到时的那份 known-partial 集合。list() 是 best-effort 枚举缝、不抛(严格对应物仍是 listForIndex()loadDiagnosed()),所以「丢了一个 loader」不是一个可以 fail over 的错误,它就是答案;为加入者私下重跑一次,只会在同一个窗口里对着同一场故障再走一遍同样的 loader —— 正是这张表存在的目的所要避免的。这段写进了 inflightListReads 的字段注释,并由测试钉住。
  • fix(metadata): 降级的 list() 结果按「降级」缓存 —— degraded 标记 + 2s 短 TTL (#5184) #5251degraded 判定原封不动,且不能被绕过。 共享读丢了 loader,依然按 degraded: true + 2s 短 TTL 记忆,不会因为「被共享过」就混上 30s 的健康 TTL;而所有共享者收到的正是那同一份残缺集合。
  • 飞行中发生失效:失效方胜出。invalidateListCache() 现在同时撤销在飞的读。被撤销的读继续跑完,交给已经在等的调用者(他们是在写之前问的),但失去写缓存的资格 —— 早于写组装出来的答案不会活过它所早于的那次写;而在失效之后才到达的调用者会开一次全新的读,而不是加入一个早于写的读。后半句是 fix(metadata): 集群对端的元数据写入现在会失效本节点的 listCache / registry (#5109) #5219 / fix(metadata): FS 监听改动同样失效本节点的 listCache/registry (#5218) #5229 那条线在并发上的复述:被事件叫醒的消费者不该同时看到事件和事件前的状态。为什么不把已经在等的调用者重启:那会把一串写变成 best-effort 路径上的无界重试循环,而这条路径正是缓存要挡在 loader 之外的那条。
  • 表会自清。 条目在读 settle 时删除,且只由它自己删 —— 已经顶替它的新读保住自己的位置,所以不会出现「第一次读 settle 顺手把第二次读的登记删掉,于是开出第三次读」。

register() 那侧本来就没问题:它先把新值写进 registry,而 list() 合并时 registry 优先于 loader,所以窗口内的读读到的是写后的值。

测试

新增 packages/metadata/src/metadata-manager-list-single-flight.test.ts(13 例),其中 issue 正文的复现即回归骨架:

  • 正文复现:3 个并发 list('permission')loadManyCalls === 1(改动前是 3);
  • 共享者拿到的是同一个结果对象,且正是随后被记忆的那一个;
  • in-flight 槽自清(settle 后表为空);settle 之后的第二波走缓存,TTL 过后整波只开一次新读;
  • 串行调用者行为不变:一个 TTL 窗口一次遍历;
  • 不同类型互不共享;
  • 降级共享读:三个并发者拿到同一份残缺集合,条目 degraded: true,故障行只报一次,且按 2s(而非 30s)过期;
  • 飞行中失效(register() / invalidateForForeignWrite() 两个缝各一例):等待者仍拿到写前结果;写前结果被记忆(listCache 无条目);写后到达者开新读并看到新写入的项;被撤销的读 settle 时误删顶替它的新读的登记。

13 例中有 10 例在改动前红(3 例是本来就应绿的行为钉子)。

# 改动前(origin/main 的 metadata-manager.ts + 新测试)
Tests 10 failed | 3 passed (13)
# 改动后
Test Files 21 passed (21)
Tests 465 passed (465) # pnpm --filter @objectstack/metadata test
# 下游抽查(list() 的主要消费面)
@objectstack/rest:test: Tests 608 passed (608)
@objectstack/runtime:test: Tests 1313 passed (1313)
# tsc --noEmit -p packages/metadata/tsconfig.json
# 本 PR 两个文件贡献 0 条错误(该包按 #4311 台账挂 DEBT,无 typecheck 脚本)

范围

只动 packages/metadata/src/metadata-manager.ts(list()、新的 in-flight 表、invalidateListCache() 一行、以及 #5251 挂了限定的那句 listCache 注释 —— 现在对并发也成立,#5253 指针一并移除)+ 本包测试 + 一个 changeset。没有碰 #5251degraded/TTL 逻辑、#5183 的 loader 缝、#5219 的集群订阅、#5229 的 FS 失效。IMetadataService 契约与任何 public export 均无变化。

越界发现(已另立卡,未在本 PR 修)


Generated by Claude Code

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

vercelBot commented Aug 4, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 4, 2026 1:19pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/l and removed documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude