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

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 1:19pm

Request Review

@github-actions github-actions Bot 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

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.

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/l tests tooling

Projects

None yet

2 participants