Skip to content

fix(metadata): 历史序号 event_seq 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4825) - #4872

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4825-next-event-seq-loud
Aug 3, 2026
Merged

fix(metadata): 历史序号 event_seq 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4825)#4872
os-zhuang merged 1 commit into
mainfrom
claude/issue-4825-next-event-seq-loud

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4825

问题:不是「字节没落盘」,是「落盘的字节是错的」

DatabaseLoader.nextEventSeq() 把读 sys_metadata_history全部失败折成同一个答案:

} catch {
  // Table not provisioned yet or driver error — start at 1.
  return 1;
}

注释同时点名了两种原因,然后用同一个 return 1 对待两者。这是 #4728 刚修掉的同一种形状,
但危害是更贵的那一半:

#4632 的机械检查看不到它:DURABILITY_CRITICAL_CALLEES 建在「持久化调用」上,而这一类的
危害发生在上,读的结果决定了写什么(见下「关于词表」)。

为什么走方向 1 而不是方向 2(删掉这条 legacy 路径)

issue 给了两条方向,方向 2(确认无生产调用方后直接删除、收敛到 SysMetadataRepository)
更彻底 —— 但核实下来它不成立。TSDoc 里的 "legacy" 指的是「不是 canonical 的事务型
producer」,不是「没有生产调用方」。全仓查证:

  • MetadataManager.setDatabaseDriver() / setDataEngine() 在 platform kernel 上把
    DatabaseLoader 注册为生产 loader;
  • MetadataManager.save()(metadata-manager.ts:1591,以及 :408
    protocol === 'datasource:' && capabilities.write 分发)→ DatabaseLoader.save()
    createHistoryRecord()nextEventSeq();
  • MetadataManager.rollback()DatabaseLoader.registerRollback() → 同一条路径;
  • 且两者都有对外 REST 路由:rest-route-ledger.ts 里的
    POST /api/v1/meta/:type/:name/rollbackGET /api/v1/meta/:type/:name/history

所以这段代码在跑,而且跑在正是会撞号的那条路上。走方向 1。

改法:按错误类型判别,复用 #4728 的那套机制

⚠️ 刻意没有另起一套错误判别 —— 同一个包里两套判别就是新的债。
packages/metadata/src/utils/schema-sync-errors.ts(#4728 / PR #4823 落地)重构成
一个匹配器 + 两个词表:codeerrnomessage → 跟随 cause 链(上限 4 层),
isSchemaAlreadyExistsError() 与新增的 isMissingTableError() 都是它的薄封装。既有
predicate 的签名与行为逐字节不变(其原有测试原样通过)。

两个 predicate 不是彼此的取反:各自只回答「这是那唯一一种良性原因吗?」,默认都是
「不良性」,所以两边都不认识的错误在两边都响亮。

  • 良性的「表还没建」 —— SQLite no such table: …、Postgres SQLSTATE 42P01 /
    relation "…" does not exist、MySQL ER_NO_SUCH_TABLE / errno 1146。没有行,就没有
    可撞的号,1 确实是下一个号 → 静默返回 1。
  • 其余一切读失败 —— nextEventSeq() 原样抛出。调用方 createHistoryRecord()
    console.error 上报后跳过这条历史记录

判别方向刻意保守。does not exist 本身不够:role "…" does not exist(42704)、
database "…" does not exist(3D000)、column "…" does not exist(42703)全是真实失败,
而且每一条都对应「表里可能满是行」的场景 —— 判成良性正是撞号的来源。所以消息匹配要求
table/relation 与该短语同现,code 集合只收表级 SQLSTATE。

上报的文案与两条边界

error 一行同时给出 AGENTS.md「Degradation log levels」要求的两样东西,外加一个这条特有的:

  1. 后果:该条历史未写入;元数据写入本身已成功,所以服务器仍报告健康,而变更历史
    正在悄悄出现空洞,版本时间线与 rollback 目标将不完整;
  2. 为什么是空洞而不是错号:从 1 发号会与既有行撞号,把「不完整」变成「顺序错误」——
    前者可见、后者无人能发现;
  3. 修复动作:修掉下面那条驱动/数据源错误;下一次元数据写入会重试并补报恢复。

两条边界刻意保持不变:

按「说一次」的纪律:historySeqFailureReported 一次性开关 + 恢复时补一条 info

测试

钉住的是落盘的值,不只是「有没有写」——因为危害就在值上:

  • 良性(表不存在)→ event_seq1,console.error/info 一行没有;
  • 真实读失败、且历史表已有 N 行 → 落盘序列停在 [1, 2]不是 [1, 2, 1]
    (改前就是后者:一条撞号的行成功写入、悄无声息),同时 error 响亮上报;
  • 同一调用点、相反结论的对照用例(仿 fix(metadata): sys_metadata 的 DDL 失败必须响亮,只静默「表已存在」一种 (#4728) #4823 的写法),否则一个 () => true 的分类器
    也能过;
  • 主写入不受影响(save()success: true,记录可读回);
  • 只说一次 + 恢复补 info,恢复后从 2 续号而非再从 1;
  • registerRollback() 这条 rollback 历史路径同样覆盖;
  • classifier 层:两个方向 + 「两者互不为取反」+ role/database/column does not exist
    必须判为良性 + cause 链深度上限。

pnpm check:durability-log-level 仍绿(8 个 seam,全部响亮或 rethrow),
scripts/durability-degradation.baseline.json 无需改动(仍为空)。

关于词表(_find 是否该进 DURABILITY_CRITICAL_CALLEES)—— 只给看法,未动手

issue 末尾那个问题是独立决定,本 PR 没有改词表,看法记在这里供维护者判断:

不建议把 _find 加进现有词表。 词表里现有的五个条目全是写 / provisioning 调用,
失败本身就等于持久化损失,「记 error 或 rethrow」是完整的补救。读不是:绝大多数包着读的
catch 合理地停在 warn/debug。而且 AST 是按 callee 名字匹配的,加 _find
(以及必然连坐的 find/findOne/count)会在几百个良性读 seam 上炸开 —— 一个塞满
两百条例外的 shrink-only baseline,正是它自己的 $comment 警告过的「门禁失去意义」。

有害的形状比「catch 里有个读」窄得多,是三部分同时成立:
读失败 → catch 用一个编造的值顶替没读到的数据 → 那个值被写下去 / 记录下来
nextEventSeq 三条全中(_findreturn 1event_seq: 1 落库);
catch { return [] } 只让列表渲染成空,前两条中、第三条不中,是完全合法的。

所以它需要的是词表之外的第二条规则,形状与 AGENTS.md 里紧跟在降级规则之后的
「Startup registry reads」那条一样(那条也是写侧规则的读侧对应物,靠「三部分同时成立」
而不是靠 callee 词表立住)。问句大致是:

这个 catch 是否用一个编造的值顶替了它没能读到的数据?那个值是否随后被写入或记录?
两个都是 → 这个 fallback 值必须由错误分类挣来,否则就不该产生。

能不能机械化是开放问题,我不会替它打包票。 写侧门禁成立是因为「callee 名在词表里」
是个便宜的 AST 判断;读侧要的是数据流(catch 的返回值最终进了某个被持久化的字段),
通用版本是 taint analysis,而一个 60% 精度的门禁配 shrink-only baseline 比没有更糟。
建议:先把规则写进 AGENTS.md(#4632 的价值本来也主要来自规则本身),机械检查作为独立的、
刻意收窄到「catchreturn 一个字面量」的后续单,先量命中率再决定是否上门禁。

顺带:#4777(把词表扩到启动期注册表判断)是同一观察的另一侧 —— 两条都说明 #4632 的词表
应当保持原样(只收持久化写调用),新形状各立新规则,而不是往老列表里塞新词。

顺带发现,已单开不在本 PR 修

#4867 —— packages/metadata-protocol/src/sys-metadata-repository.ts
nextEventSeq()nextItemVersion()逐字同形catch { return 1 }。而那是
canonical 路径(本单正文与分诊都把它称作「历史写入应当收敛过去的地方」),并且那里有
两个数字:event_seq,以及 version —— 后者的 TSDoc 明说它刻意从 history 取 MAX
「so delete + recreate continues incrementing instead of restarting at 1」,一次读失败正好
把它恢复成它明确要避免的行为,而 rollback(type, name, version) 正是按 version 定位快照。
跨包复用本 PR 新增的判别器需要先定它的落点(内部工具 vs 下沉到共同依赖),已在 #4867
列出三个选项,留给维护者定。

验证

pnpm --filter @objectstack/metadata exec vitest run --maxWorkers=2
  Test Files  15 passed (15)
       Tests  336 passed (336)

packages/metadatatypecheck script(在 check-type-check-coverage.mjs 的 DEBT 账本里)。
手动量了两次 tsc --noEmit:改动前 92,改动后 92 —— 本 PR 新增 0 个类型错误
(过程中一版测试写法引入过 5 个 TS2348,已用一个带类型的 DriverFind 别名 + 单一
driverWithBreakableHistoryReads() helper 消掉,顺带去掉了四处重复的 driver 包装)。


Generated by Claude Code

`DatabaseLoader.nextEventSeq()` folded every failure of its
`sys_metadata_history` read into one answer:

    } catch {
      // Table not provisioned yet or driver error — start at 1.
      return 1;
    }

The comment named BOTH reasons and then answered both the same way. Only
one is benign. With N rows already in the table, a flaky read (connection
drop, timeout, privileges) handed the next history row `event_seq = 1`,
colliding with an existing row — while the insert SUCCEEDED and nothing
was logged. `event_seq` is the ordering key that history listing and
rollback targeting both stand on, so the timeline is silently wrong from
then on, and neither a retry nor a restart repairs it.

This is the #4728 shape one layer down, but the costlier half: not bytes
that never landed, but bytes that landed wrong.

Discriminate by error TYPE, reusing #4728's machinery rather than
starting a second classifier in the same package. `schema-sync-errors.ts`
now holds ONE matcher (code -> errno -> message -> `cause` chain) with
two vocabularies; `isSchemaAlreadyExistsError` keeps its exact signature
and behaviour, and `isMissingTableError` joins it. They are not each
other's negation: both ask "is this the one benign reason?" and default
to not-benign, so an unrecognised error is loud under both.

  - benign "table not provisioned" (SQLite `no such table`, PG `42P01` /
    `relation "x" does not exist`, MySQL `ER_NO_SUCH_TABLE` / errno 1146)
    -> return 1; there are no rows, so nothing can collide.
  - everything else -> rethrow. `createHistoryRecord()` reports at
    `error` (consequence, why a hole beats a wrong number, the fix) and
    SKIPS the history row.

Classification stays narrow on purpose: a bare `does not exist` also
covers role/database/column, all real failures against a table that may
be full of rows, so the message test requires table/relation alongside.

Two boundaries deliberately unchanged: the metadata write itself does not
fail (the record already landed; reporting it failed would be a worse lie
than the bug), and this path's known non-transactional concurrency race
stays a documented limitation — a read failure is a different thing.
Reported once, with an `info` on recovery.

Tests pin the VALUE that lands, not just whether a write happened: with
rows 1 and 2 present, a failed read leaves [1, 2] rather than [1, 2, 1].
Both directions plus a same-call-site/opposite-verdict case, so neither a
`() => true` classifier nor the old `return 1` could pass.

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

vercel Bot commented Aug 3, 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 3, 2026 11:31am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 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.

@github-actions github-actions Bot added the size/l label Aug 3, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 11:33
@os-zhuang
os-zhuang enabled auto-merge August 3, 2026 11:33

Copy link
Copy Markdown
Contributor Author

复核通过 —— ACCEPT,已标 ready 并送合并队列

1. 方向 2 是被证伪的,不是被跳过的

我派发时写过:方向 2(确认无生产调用方后直接删除)若成立会更彻底,但前提是真的核实过,而不是看 TSDoc 说它是 legacy 就下结论。dev 去查了,结论是不能删:

MetadataManager.save()(metadata-manager.ts:1591:408datasource:/write 分发)与 MetadataManager.rollback() 都会走到 createHistoryRecord()nextEventSeq(),且两者都经 rest-route-ledger.ts 暴露在活的 REST 路由上(POST /api/v1/meta/:type/:name/rollbackGET .../history)。

并给出了那个词的正确读法:TSDoc 里的 "legacy" 意思是「不是那个 canonical 的事务性生产者」,不是「没有生产调用方」。 一个只读注释就动手删的 PR,会拆掉两条活路由。

2. 复用范式的方式比我要求的更细

我要求「能复用就复用,不要另起一套判别逻辑」。实际做法是把 schema-sync-errors.ts 重构成一个匹配器 + 两套词汇(code → errno → message → cause 链,深度 4),isSchemaAlreadyExistsError 签名与行为逐字不变,isMissingTableError 作为兄弟加入。

关键设计写在这句里:两者刻意不是彼此的否定,且都默认「非良性」。 并且有一条测试直接钉住它:

✓ the two classifications are independent, not complementary
    — an unrecognised failure is benign under NEITHER — the default is loud

若做成互补(不是 A 就是 B),任何无法识别的错误都会被塞进某一边 —— 那正是本单要修的病换个位置复发。

3. 「洞」优于「错的数字」—— 这个取舍是对的

真实读失败时:以 error 上报(后果 + 为什么洞优于错号 + 修复动作,只说一次,恢复时补 info),然后跳过这条历史行,而不是写入一个编造的 event_seq。元数据写入本身仍然成功 —— 理由说得很好:

the record already landed, so failing it would be a worse lie than the bug

记录已经落盘了,这时候让写入失败等于报告一个更大的谎。历史缺一行是可见的缺口;历史多一行撞号的假数据是不可见的污染。

4. 测试断言的是落地的,不是机制

with rows 1 and 2 present, a failed read leaves [1, 2], not the pre-fix [1, 2, 1]

这正是我派发时要求的:危害是「落盘的字节是错的」,测试就得钉住落盘的字节。加上 DISTINGUISHES the two: same call site, opposite verdictsapplies to the rollback history path too, not just save(),覆盖是完整的。

5. typecheck 两头都量了

packages/metadata 无 typecheck 脚本(DEBT 条目),dev 手工两次测量:原始树 92 error,改后 92 error,零新增。并且顺带指出 check-type-check-coverage.mjs 本身不会重跑 tsc(0 个 spawnSync/execSync)—— 这个观察值得记,它意味着那条"覆盖检查"检查的是脚本存在与否,不是类型是否真的干净。

核对

  • 门禁 check:durability-log-level8 durability-critical catch seam(s), all loud or rethrowing,baseline JSON 未变、仍为空 ✅
  • packages/spec / skills/** / content/docs/** 零改动 ✅
  • 词表问题按指示只报告未动手 ✅(处理见下)

范围外发现:#4867 —— 同一个 bug 在 canonical 路径上,而且更糟

packages/metadata-protocol/src/sys-metadata-repository.tsnextEventSeq()nextItemVersion() 带着逐字相同catch { return 1 }。更糟在于 nextItemVersion 的 TSDoc 自己写着:它读 MAX 正是**「so delete + recreate continues incrementing instead of restarting at 1」**。

一次读失败,恰好恢复了这段代码被写出来就是为了避免的那个行为。MetadataManager.rollback(type, name, version)POST /api/v1/meta/:type/:name/rollback 正是靠那个 version 定位快照的。

dev 还准确指出「在事务里」不构成豁免:事务解决的是并发撞号,不是「把一次读失败折叠成一个编造的值」。 这两件事今天在本仓已经被混淆过一次。

#4867 里同时标出了它的前置决定:isMissingTableError 目前是 @objectstack/metadata 内部的,跨包复用需要在「导出 / 下沉到共享依赖(注意 Prime Directive #2)/ 抄一份(不推荐 —— 那正是本 PR 避开的第二分类器债)」之间做选择。这条我会单独安排。


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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[metadata] nextEventSeq() 把驱动读失败也当成「表还没建」,静默从 1 重新发号 —— #4632 同形,机械检查覆盖不到

2 participants