Skip to content

[决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776

Description

@os-zhuang

背景

2026-08-03 一次 showcase 冷启日志(24 条启动告警)被逐条查证后拆出 5 个 issue。其中三个是同一个形状,分别落在三个互不相干的子系统:

issue 消费方 提供方 时差 后果
#4771 service-automation 的 flow 节点类型校验 ApprovalsServicePlugin 注册 approval 执行器 0.8s 8 条「will fail at execution time」全是误报
#4772 plugin-auth 探测 cache 服务 CacheServicePlugin 注册 cache 21ms 误报,且可能限流真的一直没用上共享 cache
#4769 ADR-0104「空库即已迁移」自证 同一次启动的首启 seed ~1s 部署证明了一个它同一次启动就违反的契约,第二启起永久 10 条 ERROR

三处代码此前互不知情,由三拨人在三个时期写下,却犯了同一个错。这通常说明缺陷不在这三处,而在它们共同依赖的那个东西。

具体问题

共同依赖是这个:在启动期问注册表「有没有 X」,得到的「没有」和真正的「没有」是同一个值。

const cache = ctx.getService('cache');   // undefined
// 这个 undefined 有两种含义,而调用方无从区分:
//   (a) 这个部署没配 cache 服务          —— 结论成立,该告警
//   (b) CacheServicePlugin 还有 21ms 才注册 —— 结论不成立,不该告警

三个缺陷都是同一条推理链:

  1. 启动期读注册表 → 拿到 undefined / 空集合;
  2. 把它当成终局事实(而不是「此刻尚不知道」);
  3. 记账这个结论 —— 缓存进进程内变量(bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772)、打成断言式告警(bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771)、甚至写进数据库(bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次 pnpm dev 起永久 10 条 ERROR #4769attested: datastore-created-empty 写进 sys_migration);
  4. 提供方随后注册好了,但第 3 步的记账不会被撤销

第 3 步是关键。如果只是读到 undefined 然后什么都不做,提供方晚到几毫秒完全无害 —— 坏的是把一个尚未成型的世界的观测结果固化下来#4769 是这条链的极端形态:观测结果被写进了持久层,于是这次启动的误判变成了下次启动的强制契约

顺带一提三个副作用,都不是巧合:

可选方案

A. 只修个案(现状 + 已派发的三个 PR)

三个 issue 各自把检查推迟到 kernel:ready 或改成惰性解析。已经在做(#4769 / #4771 / #4772 本轮第 3 批已派发)。

  • 长远合理性:❌ 不解决任何结构问题。三处独立写下同一个错,说明第四处、第五处只是还没被人读到那行日志而已 —— 而发现它们的唯一途径是「有人恰好去查证一条启动告警」,本次就是这么发现的,成本是维护者的一整轮排查。
  • 防 AI 写错:❌ 反向。修完之后这三处日志变干净,下一个 agent 在别处写下同样的 if (!ctx.getService('x')) { warn(...); this.fallback = true } 时,没有任何东西会拦它 —— 而且现在仓里连坏样例都看不到了。

B. 加一条 lint / 门禁,禁止在 start() 里对注册表做终局判断

沿 #4632 的路子:立词表,把「启动期 getService 结果被缓存或被用于 warn」标成违规。

C. 收紧 kernel 注册表契约:让「尚未知道」在类型上就不等于「没有」

把启动期的注册表查询改成三态(或等价形式:kernel:ready 之前拒绝回答存在性问题、强制调用方要么等待要么声明依赖):

type ServiceLookup<T> = { status: 'present'; value: T }
                      | { status: 'absent' }        // 只在 kernel:ready 之后可能返回
                      | { status: 'not-yet-known' } // 启动期未完成时的唯一答案

调用方必须处理 not-yet-known —— 类型系统不允许把它和 absent 一起 ?? 掉。

  • 长远合理性:✅ 最贴合 Prime Directive Add comprehensive test suite for Zod schema validation #12(contract-first)。它把一个时序事实(启动是分阶段的、词汇表在某一刻才封闭)提升成类型事实。ADR-0018 已经明文允许插件在运行时扩展节点词汇表 —— 那么「词汇表何时封闭」就是这个系统的一等语义,而不是每个调用方各自揣摩的实现细节。三处独立犯错正是「这个语义没有被表达出来」的证据。代价诚实说明:这是公共契约变更,getService 的返回形状影响全部 47 个插件,迁移面很大;而且一旦做,就应该一次做对,不宜分批。
  • 防 AI 写错:✅ 三个方案里唯一结构性阻止的。当前形状下,AI 写出这个 bug 的成本是零 —— if (!svc) fallback() 是任何模型的第一反应,而且它在 kernel:ready 之后是完全正确的写法,所以连 code review 都很难判它有罪(它的对错取决于调用点在哪个启动阶段,而那一点在代码里根本不可见)。三态返回让「这段代码跑在哪个阶段」变成调用方必须回答的问题。这正是「契约收紧优于消费端宽容」——getService 现在返回的 undefined 就是最典型的宽容值,而 ?? 回退正是掩盖它的手段。

我的建议

倾向 C,但不建议现在就做,建议的落法是分两步,且第二步需要你拍板才启动:

  1. 先做 B(本轮即可派),把 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 的词表扩到这一类:启动期对注册表的终局判断 + 结果被缓存/被写进持久层。它抓不全,但能立刻止血,而且和 [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 一样,词表本身会在被使用的过程中长出覆盖面。这一步在我的权限内,你不否决我就派。
  2. C 需要你定夺,因为它是公共契约变更(getService 影响 47 个插件),迁移面和节奏都超出我能自行裁定的范围。我想请你回答的其实是一个更小的问题:「启动是分阶段的」这件事,该不该成为 kernel 的显式契约? 如果是,C 的具体形状(三态返回 / 阶段门 / 依赖声明)可以再设计;如果你认为这属于「实现细节,靠纪律和 lint 管住就行」,那 B 就是终点,我不再往下推。

需要额外说明的一点:#4769 已经证明了这不只是日志质量问题。 那条链的终点是往数据库写了一行永久的、错误的合规证明 —— 一次启动给自己开了一张它随即违反的证明,而下一次启动会拿这张证明去拒绝数据。日志噪音可以忍,持久层里的假证据不能。这是我认为 B 不够、值得考虑 C 的主要理由。

关联

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions