Skip to content

fix(metadata-protocol): 元数据存储读不到不再被讲成「这一项不存在」 (#5532) - #5705

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5532-getmeta-outage-vs-miss
Aug 6, 2026
Merged

fix(metadata-protocol): 元数据存储读不到不再被讲成「这一项不存在」 (#5532)#5705
os-zhuang merged 2 commits into
mainfrom
claude/issue-5532-getmeta-outage-vs-miss

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5532

这条修的是什么

sys_metadata 读不到的时候,协议层会把「读不到」讲成「这一项不存在」。四处
customization-overlay 读各自裹着一个裸 catch {},注释自己就写着 "DB not
available",然后照 miss 处理;空值一路穿过读链,每个消费方给它起了一个不同却
同样错的名字:

入口 故障时的答案 真相
getMetaItemCached Metadata item object/acct not found 读不到
getMetaItem ?state=draft NO_DRAFT / 404「没有待发布的草稿」 读不到
getMetaItem ?preview=draft 静默回退成已发布内容 读不到
getMetaItems items: []「这个环境一个都没声明」 读不到

对客户端的后果就是 issue 描述的那个:Studio / Setup 在元数据库故障期把每一个对象
显示成「不存在」,处置方向和真相完全相反。

ADR-0110 D3 已经为这件事立过规矩:miss 与 outage 是两个不同的事实、安全含义相反。
#5108 按这条修掉了 DatabaseLoader 的复数读,#5089 修掉了 listForIndex;本 PR 是
同一条规矩在协议自己的 overlay 读上,单数与复数、active 与 draft 一并覆盖

前提复核(issue 是线索,不是规格)

按 issue 的 harness 在合并后的 origin/main 上实跑(真 ObjectStackProtocolImplementation,
驱动每个读方法都 reject):

RESOLVE getMetaItem(econnrefused)         -> {"type":"object","name":"acct", …没有 item}
THROW   getMetaItemCached(econnrefused)   status=undefined code=undefined
                                          msg=Metadata item object/acct not found
THROW   getMetaItem(state=draft, …)       status=404 code=NO_DRAFT
RESOLVE getMetaItem(previewDrafts, …)     -> 静默回退成 active 读
RESOLVE getMetaItems(econnrefused)        -> {"type":"object","items":[]}

根因前提成立,并且 issue 列为「未验证」的两处同型点(draft 分支、复数读)实测
确属同病,同 PR 一并修。

次生事实已漂移,如实记录:issue 写的 wire 结果是「400 无 code,内部措辞逐字上线」。
那是 #5489 落地前的形状。今天 mapDataError 的终末兜底已经是
UNCLASSIFIED_FAULT(500 + INTERNAL_ERROR,措辞 withheld),实测:

mapDataError(new Error('Metadata item object/acct not found'))
  -> {"status":500,"body":{"error":"Internal server error","code":"INTERNAL_ERROR"}}

所以本 PR 的 wire 翻转不是 PM 分诊预设的 400 -> 404,而是 500 -> 404。方向不变
(把错误答案改成对的答案),但值不同,changeset 与测试都按实测写。顺带一提:这也让
方向 C 比立单时更值得做 —— 一个真的 miss 现在被当成服务器故障回 500。

改了什么

1. 判别按错误类型,不按异常猜(方向 A)

唯一良性的读失败是「sys_metadata 还没建表」:那时确实没有 overlay 行,落回
registry 就是真相,首次启动不该爆炸。判定走 isMissingTableError —— DatabaseLoader
(#5108)与本包 SysMetadataRepository(#4867)问的同一个谓词,一个驱动怪癖只教给
平台一次。其余一律不良性(保守方向和该谓词自身一致:认不出的错误 不是 良性)。

新增一个私有方法 rethrowUnlessMetadataStoreUnprovisioned,四处 overlay 读共用。

2. 故障照实上报

status: 503 / code: SERVICE_UNAVAILABLE,驱动原始错误挂 cause

  • 503 而不是 500:请求本身没有任何问题,这是可能自愈的依赖故障,调用方/代理
    应该 重试 —— 和 mapDataErrorERR_DATASOURCE_UNAVAILABLE 的裁决一致。
  • SERVICE_UNAVAILABLE 是标准目录里 503 自己的码(HttpStatusErrorCodeMap[503],
    ADR-0112),不是新造的字符串;没有 往 error-code ledger 里加新词汇 —— 今天没有
    任何被测量到的消费方需要比「503 元数据面不可用」更细的粒度,按创业期聚焦原则,
    词汇跟着需要它的实现一起回来。
  • 消息 不拼接 驱动报文:sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437 记过原因(5xx 的散文在边界被无条件丢弃,而本文件
    写侧两个 500 正是当初拼进去、原样上线的标本)。运维照样拿得到 —— 驱动错误在
    cause 上,logWithheldServerFault 会把整个对象打出来。

3. 终末 not found 结构化(方向 C)

getMetaItemCached 的终末 miss 现在带 status: 404 / code: RESOURCE_NOT_FOUND
(标准目录的 404 floor,也正是这条路由 app 可见性闸门已经在发的那个拼写)。

packages/rest 生产码一行未改,packages/metadata 也没碰:接住方
(#5437 / #5464 / #5489)本来就是对的,缺陷在产出方。rest 侧只加了断言测试。

wire 可见变化

场景 之前 之后
元数据存储不可达(单数读) 500 + INTERNAL_ERROR(#5489 前:400 逐字上线) 503 + SERVICE_UNAVAILABLE
元数据存储不可达(?state=draft) 404 + NO_DRAFT 503 + SERVICE_UNAVAILABLE
元数据存储不可达(复数读) 200 {"items":[]} 503 + SERVICE_UNAVAILABLE
真的没有这一项 500 + INTERNAL_ERROR 404 + RESOURCE_NOT_FOUND
sys_metadata 尚未建表 落回 registry 不变

反向验证(方向在跑之前先定)

两半都预判为「普通红」,并且 红得不一样,这正是要点:

  • 把四处 } catch { /* DB not available */ } 还原 → 7 红 / 5 绿,而且红成 issue
    报告的形状(单数与 preview 读 RESOLVE 出无 item,复数读 resolve {items: []},
    draft 读抛 404)。预判是 6(六个 outage 用例);第七个是 miss/outage 对照用例,它的
    outage 那一半 就是那六个之一 —— 如实记下来,不四舍五入。
  • 把终末那句无 code 的 throw new Error('Metadata item …/… not found') 还原 →
    3 红 / 9 绿:两个「真 miss 是结构化 404」用例,加上首次启动的良性 miss 用例,
    全部红在 status/codeundefined 上;而每一个 503 用例保持 绿。这个分离
    是刻意的 —— 它证明 404 是方向 C 自己的贡献,不是 outage 拆分的副产品。

「良性 / 健康」那个 describe 是反向的护栏:它专门抓过度收紧 —— 把首次启动、或者一个
本来就不存在的条目,也讲成故障。

Fixture 分诊(消费半径,不是编辑包)

改动在 packages/metadata-protocol,踩红的 fixture 在 packages/objectql:
protocol-meta.test.ts 两个用例把旧行为钉死了 ——
"should handle DB errors gracefully and return undefined item" / "…return empty items",
new Error('DB down') 然后断言答案是空值。按三分诊里的 整条替换:它们的断言
之所以通过,正是因为故障被吞掉了。各自换成「不可读 = 上报 503」并 补上 良性对照
(「未建表仍然优雅降级」)—— 老用例里对的那一半保留下来了。

测试

新增:

  • packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts(12 用例)
  • packages/rest/src/rest-meta-outage-vs-miss.test.ts(6 用例,只加断言、不动 rest 生产码)

改写:packages/objectql/src/protocol-meta.test.ts(2 个旧 fixture → 4 个用例)

跑过(合并 origin/main 之后重跑一遍,含 #5682):

metadata-protocol  45 files / 419 tests  passed
objectql          121 files / 1972 tests passed
rest               52 files /  757 tests passed
runtime            98 files / 1436 tests passed
cli                83 files /  825 tests passed
metadata           24 files /  495 tests passed
plugin-security    34 files /  731 tests passed
plugin-auth        34 files /  786 tests passed
service-analytics  51 files /  905 tests passed
service-automation 61 files /  730 tests passed

pnpm --filter @objectstack/objectql typecheck   -> Done
pnpm check:type-check-coverage                  -> OK (63/78,ledger 未增长)
pnpm check:nul-bytes                            -> OK (5613 files)
pnpm check:durability-log-level                 -> OK (24 seams,全部 loud/rethrow/propagate)

范围外发现

同文件里还有两处同族的裸 catch,不在本 PR 范围,已另开 issue,按 Prime
Directive #10 不夹带修:

  • getEffectiveLock 的 overlay 读 —— 故障时 lock 落成 'none',即保护闸门 fail-open;
  • getMetaItemLayered 的 overlay 读 —— 故障时 overlay 静默为 null,三层视图把
    「读不到」画成「没有 overlay」。

🤖 Generated with Claude Code

https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx


Generated by Claude Code

os-zhuang and others added 2 commits August 6, 2026 01:05
…a missing item (#5532)

The customization-overlay reads in `getMetaItems`/`getMetaItem` each wrapped
their sys_metadata access in a bare `catch {}` and answered with their own
empty value, so a metadata store the protocol could not reach was
indistinguishable from an item nobody ever customised. The emptiness then
travelled the read chain and each consumer named it differently and wrongly:
`getMetaItemCached` as `Metadata item <type>/<name> not found`, the
`state='draft'` read as `NO_DRAFT`/404, `getMetaItems` as `items: []`.

ADR-0110 D3: a miss and an outage are different facts with opposite meanings.
#5108 fixed this in DatabaseLoader's plural read and #5089 in listForIndex;
this is the same rule on the protocol's own overlay reads.

Discrimination is by error TYPE through `isMissingTableError` — the predicate
DatabaseLoader (#5108) and SysMetadataRepository (#4867) already ask, so a
driver quirk is taught to the platform once. The one benign reason (the table
is not provisioned yet) still falls through to the registry; everything else
throws 503 + SERVICE_UNAVAILABLE with the driver error as `cause`, which the
REST boundary's existing #5437/#5464 sanitising and logging already handle.

The terminal miss in `getMetaItemCached` is structured too: 404 +
RESOURCE_NOT_FOUND, so a plain miss stops falling out of `mapDataError`'s
catch-all as an unattributable 500 (and, pre-#5489, as a 400 shipping the
internal wording verbatim).

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

vercel Bot commented Aug 6, 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 6, 2026 1:27am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 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-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 02:05
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit e6db317 Aug 6, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5532-getmeta-outage-vs-miss branch August 6, 2026 02:18
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…jectstack-ai#5706) (objectstack-ai#5736)

`getEffectiveLock` is the only source of truth for the ADR-0010 §3.3 lock
gate, and both callers are write-path admission — `assertLockAllowsWrite`
(save/publish/rollback) and `assertLockAllowsDelete`. Its overlay read was
wrapped in a bare `catch` that fell through to `lock: 'none'`.

`'none'` is not a neutral placeholder there: it is the verdict "the author
declared no protection", which `evaluateLockForWrite` /
`evaluateLockForDelete` turn straight into "allow". A `sys_metadata` read
that FAILED therefore became a write that was PERFORMED on an item whose
overlay row declared it protected. Measured on origin/main, with the row
carrying `_lock` and only the gate's own read rejecting: `saveMetaItem`
resolved `success: true` after `update:sys_metadata` on a `no-overlay`
item, and `deleteMetaItem` the same on a `no-delete` one — while the same
rows read successfully produce 403 ITEM_LOCKED. The audit trail did not
compensate: the allowed path writes its ordinary `outcome: 'allowed'` row.

Reuses `rethrowUnlessMetadataStoreUnprovisioned` (objectstack-ai#5705) rather than
inventing a second predicate — an unprovisioned `sys_metadata` genuinely
has no overlay row, so `'none'` is the truth and first boot still saves;
every other error becomes 503/SERVICE_UNAVAILABLE with the driver error as
`cause`. Wire-visible, and deliberate: refusing one uncertain write beats
performing one that had to be refused.

Unaffected and pinned by regression tests: artifact-level locks (answered
from the in-memory registry before the overlay read), a genuine miss on a
healthy store, and control-plane kernels.


Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

Co-authored-by: Claude <noreply@anthropic.com>
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