Skip to content

fix(metadata): 降级的 list() 结果按「降级」缓存 —— degraded 标记 + 2s 短 TTL (#5184) - #5251

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5184-degraded-list-cache-policy
Aug 4, 2026
Merged

fix(metadata): 降级的 list() 结果按「降级」缓存 —— degraded 标记 + 2s 短 TTL (#5184)#5251
os-zhuang merged 2 commits into
mainfrom
claude/issue-5184-degraded-list-cache-policy

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5184

按 PM 裁定(11:00Z 分诊评论)取方向 1:降级结果照存,但条目带 degraded 标记 + 单独的短 TTL;同时把 listCache 字段注释改成代码真实做的事(现象二随手闭环)。

裁定内置的前提核实:knex/SQLite 单连接死锁今天仍然可达

裁定要求先确认注释里那个死锁场景在当前驱动栈下是否还走得到 —— 若已不可达,方向 3(只缓存完整读)才是更简单的正解。核实结论:仍然可达,因此维持方向 1。证据(全部读自合并后的 origin/main):

证据 位置 说明
DatabaseLoader 的读不串调用方事务 packages/metadata/src/loaders/database-loader.ts _find() 直接 this.engine.find(table, query),没有把 ctx.transaction 透下去 —— 正是注释描述的「事务持锁时再去要一条新连接」
driver-sql 仍把 SQLite 当单连接池 packages/plugins/driver-sql/src/sql-driver.ts activeTransactions 字段注释、assertBareKnexSafe() 「the pool holds a single connection … any bare this.knex query would dead-lock acquiring a second one」
该守卫在生产是 no-op 同上,assertBareKnexSafe 开头 if (this.isProductionEnv()) return; 所以生产环境不会快速失败,而是老老实实等满 acquireConnectionTimeout(60s)
同一危害在别处仍被手工规避 packages/plugins/plugin-audit/src/audit-writers.ts captureBefore 注释明写:不走 api.sudo() 而手动把 trx 串下去,否则「will deadlock for the full acquireConnectionTimeout (~60s)」

也就是说「降级结果干脆不入缓存」会把一个 30s 的静默窗口换成每次调用一个 60s 的挂起,明显更糟。这一核实在合入 origin/main(含 #5212sql-driver.ts 的改动)之后又复核了一遍,结论不变。

改了什么

现象一 —— 降级结果不再和完整读长得一模一样:

  • listCache 的条目类型从匿名的 { ts, items } 提为文件内(不导出)的 ListCacheEntry,新增 degraded: boolean;list() 在 loader 循环里记录这次读有没有丢 loader,cacheListResult()degraded 参数是必填的 —— 它正是这个缓存以前唯一丢掉的信息。
  • 降级条目按 DEGRADED_LIST_CACHE_TTL_MS(2s)过期,完整读仍是 LIST_CACHE_TTL_MS(30s)。取 1–2s 区间的上沿是有理由的:留着降级缓存的唯一目的就是吸收「一个打开的事务里连发的那串 list()」,那串调用彼此只差毫秒但可能被逐行工作拉开,2s 覆盖得住,同时仍比健康 TTL 短 15 倍。
  • 新增 readCachedList() 作为唯一的读缓存入口:TTL 分支只在这一处,返回整个条目(而不是只返回 items),所以「这份是残缺的」对任何后续消费方都是可读的 —— 包括 DatabaseLoader 把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108 那条 once-only 报告的判断依据。裁定要求的「flag 在被消费处可读、但不加公开 API」就是这样落的。
  • 效果对齐 issue 的复现:存储恢复后最迟 ~2s 就会有人再去问 loader,DatabaseLoader 把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108reportLoaderReadRecovered 恢复日志跟着在秒级出现,而不是最晚 30s。

现象二 —— 注释即契约:

  • 原注释声称缓存「only cache positive (non-empty) hits or repeated hits with a stable miss signature」。代码里从来没有这个条件。这是一处对着我们自己内部文档的 declared ≠ enforced,现在整段替换成 cacheListResult() / readCachedList() 真实做的事,并附上上面那份前提核实,免得下一个人再把「不缓存降级读」当成显而易见的修法。
  • 顺带把 reportLoaderReadFailure() 的文案改对:它原本承诺「30s 后重试」,现在报的是降级 TTL。这是裁定允许的「fast-expiry 交互所必需的调整」,fix(metadata): DatabaseLoader 的读故障不再被吞成「什么都没声明」 (#5108) #5183 落地的降级缝本身没有回改。

测试

新增 packages/metadata/src/metadata-manager-degraded-list-cache.test.ts(8 例),issue 的复现被逐字做成回归用例:

  • 降级读的条目 degraded === true、且仍然入缓存(降级窗口内第二次 list() 不碰 loader —— 吸收效果保住了);
  • heal() 之后越过降级 TTL,loader 被重新问到、返回的是恢复后的集合(修复前这里会继续吐那份残缺结果);
  • 恢复日志在 heal 后的秒级出现,且 error 仍然只有一行(更快的重试没有变成日志刷屏);
  • 完整读的 30s TTL 原样不动(2s/6s/29.999s 都命中缓存,30.001s 才重读);
  • 空的完整读照样入缓存 —— 这一条专门钉住替换后的注释所声称的行为(即原注释那个 "non-empty" 条件确实不存在)。

readCachedList() 改回「一律用 30s + 永远 degraded: false」(模拟修复前行为)重跑,8 例中 4 例失败 —— 回归覆盖是真的,不是自证。

范围

只动了认领时声明的文件面:packages/metadata/src/metadata-manager.ts、本包新增测试、一个 changeset。packages/spec/** 零改动;content/docs/releases/** 未碰;#5183/#5219/#5229 今日落地的面(loader 降级缝、invalidateForForeignWrite、集群订阅、NodeMetadataManager.handleFileEvent)未回改;IMetadataService 与公开导出未变(ListCacheEntry 刻意不导出)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7


Generated by Claude Code

#5184)

`MetadataManager.list()` memoized a known-partial result exactly like a
complete one: same 30s `LIST_CACHE_TTL_MS`, no marker on the entry. So the
single `error` line #5108 introduced covered a 30s window in which the failing
loader was never asked again — no retry, no second signal — and recovery went
unnoticed (and `reportLoaderReadRecovered` unlogged) for up to another 30s
after the store healed.

Not caching degraded reads was rejected on evidence, not on principle: the
knex/SQLite single-connection deadlock the `listCache` comment was built for is
still reachable on the current driver stack (`DatabaseLoader._find()` does not
thread the caller's transaction; driver-sql still models SQLite as a
single-connection pool via `activeTransactions` / `assertBareKnexSafe`, the
latter a no-op in production; plugin-audit's `captureBefore` threads the
transaction by hand for the same reason). Skipping the cache would trade one
30s silent window for a 60s stall per call.

- `listCache` entries carry `degraded`, set when a loader threw while the
  result was assembled. Read through the single `readCachedList()` helper so
  the flag and its TTL are applied in one place and stay visible to any future
  consumer.
- Degraded entries expire after `DEGRADED_LIST_CACHE_TTL_MS` (2s) instead of
  30s: the in-transaction burst is still absorbed, the silent window shrinks
  15×, recovery is reported within seconds.
- Complete reads are unchanged (cached, not degraded, 30s).
- The outage message now names the degraded TTL as the retry interval.

Also replaces the field comment's claim that the cache kept "only positive
(non-empty) hits or repeated hits with a stable miss signature" — a policy no
code ever implemented — with a description of what `cacheListResult()` really
does, pinned by a test that an empty complete read IS cached.

Internal caching policy only; `IMetadataService` and the public exports are
untouched.

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 12:27pm

Request Review

@github-actions github-actions Bot added size/m 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.

)

The sentence is true for sequential callers only — nothing is written until a
read completes, so concurrent callers all miss and each walk every loader.
Filed as #5253; noting it here so the comment this PR just turned into a
contract does not carry a fresh overclaim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7

Copy link
Copy Markdown
Contributor Author

范围外发现,已按 Prime Directive #10 单独立卡、未认领:

本 PR 不修它(越界),只做了一件属于本 PR 自己责任的事:既然这次把那段注释重写成了契约,就不能让它带着一句新的过度承诺 —— 第二个 commit 把那句话限定为「for SEQUENTIAL callers」并指向 #5253

两者共用 list() 同一段代码,#5253 建议排在本 PR 之后,别并行。

搜索过重复:listCache / in-flight / single-flight / DatabaseLoader transaction knex deadlock 关键词下的 open issue 只命中 #5184 自身与无关的 #5228,无同题卡。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 12:27
@os-zhuang
os-zhuang enabled auto-merge August 4, 2026 12:28
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

一处未能推上来的收尾,记在这里以免丢失。

新增测试里有一个 describe 标题带了中文:

describe('#5184 — 现象二: the comment now describes the code', () => {

仓库里没有任何测试标题含 CJK(全量 grep 过),AGENTS.md 也说仓库产物(标识符、测试标题这类)沿用现有英文惯例 —— 所以这是我引入的一处不一致。我已经在本地把它改成:

describe('#5184 — the comment now describes the code', () => {

改完重跑 8 例仍全绿。但推送时被拒:

remote: - A pull request for this branch has been added to a merge queue. Branches that
remote:   are queued for merging cannot be updated.

本 PR 已被(非我)标记 ready 并加入合并队列,分支处于锁定状态。为一个纯文案的 describe 标题去 dequeue 一个已排队、CI 全绿的 PR,风险明显大于收益,所以我不动队列,本地那个 commit 就不推了。

影响:零功能影响,纯标题措辞。留给之后任何一次触碰该文件时顺手带上即可 —— 不值得单独立卡。

本 PR head 47a6bb829 的 CI 已全绿:ESLint / TypeScript Type Check / Build Core / Test Core (3/3) / Dogfood Regression Gate (3/3) / Dogfood Verify CLI / Temporal Conformance (live PG + MySQL) / Check Changeset 均 success。


Generated by Claude Code

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.

MetadataManager.list() 把「已知残缺」的降级结果照常写进 30s listCache,且 listCache 的注释描述的条件缓存代码里并不存在

2 participants