fix(metadata): 降级的 list() 结果按「降级」缓存 —— degraded 标记 + 2s 短 TTL (#5184) - #5251
Conversation
#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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 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:
|
) 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
|
范围外发现,已按 Prime Directive #10 单独立卡、未认领:
本 PR 不修它(越界),只做了一件属于本 PR 自己责任的事:既然这次把那段注释重写成了契约,就不能让它带着一句新的过度承诺 —— 第二个 commit 把那句话限定为「for SEQUENTIAL callers」并指向 #5253。 两者共用 搜索过重复: Generated by Claude Code |
|
一处未能推上来的收尾,记在这里以免丢失。 新增测试里有一个 仓库里没有任何测试标题含 CJK(全量 grep 过),AGENTS.md 也说仓库产物(标识符、测试标题这类)沿用现有英文惯例 —— 所以这是我引入的一处不一致。我已经在本地把它改成: 改完重跑 8 例仍全绿。但推送时被拒: 本 PR 已被(非我)标记 ready 并加入合并队列,分支处于锁定状态。为一个纯文案的 describe 标题去 dequeue 一个已排队、CI 全绿的 PR,风险明显大于收益,所以我不动队列,本地那个 commit 就不推了。 影响:零功能影响,纯标题措辞。留给之后任何一次触碰该文件时顺手带上即可 —— 不值得单独立卡。 本 PR head Generated by Claude Code |
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透下去 —— 正是注释描述的「事务持锁时再去要一条新连接」packages/plugins/driver-sql/src/sql-driver.tsactiveTransactions字段注释、assertBareKnexSafe()this.knexquery would dead-lock acquiring a second one」assertBareKnexSafe开头if (this.isProductionEnv()) return;acquireConnectionTimeout(60s)packages/plugins/plugin-audit/src/audit-writers.tscaptureBeforeapi.sudo()而手动把 trx 串下去,否则「will deadlock for the full acquireConnectionTimeout (~60s)」也就是说「降级结果干脆不入缓存」会把一个 30s 的静默窗口换成每次调用一个 60s 的挂起,明显更糟。这一核实在合入
origin/main(含 #5212 对sql-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」就是这样落的。DatabaseLoader把存储读故障吞成空结果 —— ADR-0110 D3 的 miss/outage 之分在复数读路径上不成立 #5108 的reportLoaderReadRecovered恢复日志跟着在秒级出现,而不是最晚 30s。现象二 —— 注释即契约:
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 被重新问到、返回的是恢复后的集合(修复前这里会继续吐那份残缺结果);把
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