Skip to content

每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747

Closed

Description

@os-zhuang

发现自 #4556 的实现过程(PR #4742)跑真实 CLI 时。未认领。与 #4556 无关,任何 bootSchemaStack 系的子命令都能复现。

复现

cd examples/app-crm
OS_DATABASE_URL="file:/tmp/t.db" node packages/cli/bin/run.js migrate recorded-by --json

命令正常返回 JSON、退出码正确。但在返回之后、进程关停时,稳定打出:

ERROR Find operation failed {"object":"sys_metadata","error":{"message":"Unable to acquire a connection", …}}
WARN  [integrity] dangling-reference audit could not list an object {"object":"sys_metadata","error":"Unable to acquire a connection"}
ERROR Find operation failed {"object":"sys_view_definition","error":{"message":"Unable to acquire a connection", …}}
WARN  [integrity] dangling-reference audit could not list an object {"object":"sys_view_definition","error":"Unable to acquire a connection"}
WARN  [integrity] stored references that resolve to nothing (#4551)
      {"scanned":0,"dangling":0,"undetermined":0,
       "unreadableObjects":["sys_metadata","sys_view_definition"],
       "truncatedObjects":[],"references":[]}

即 #4551 的巡检在 stack shutdown 之后才跑到这两个对象,此时 knex 连接池已经关了。

为什么值得修,而不只是噪音

巡检自己的文档把这件事写成了它最在意的一条设计原则:

Unknown and absent are DIFFERENT answers

The single most important property here. An existence probe that cannot run … must never be recorded as "the target does not exist" … Together they are what stops "0 dangling" from ever being read as "everything is fine".

unreadableObjects 这个桶存在的意义,正是让「我没能检查」区别于「我检查了,没问题」。而现在它在每一次正常的 CLI 调用里都非空 —— 一个恒定为真的告警不再携带任何信息,它训练操作者忽略这个桶。等到真有一个对象因为数据源故障读不出来时,那条信息会淹没在同样的两行里。

这也正是 AGENTS.md「Route & surface ownership」第 3 条要防的形态:

3. Absence must be loud. … a verifier that silently degrades (reusing a stale build, skipping a check it could not run) is worse than no verifier, because it reports success.

这里是它的镜像:一个永远报告自己降级了的巡检,与不报告一样没有信息量。

另外那两行 ERROR Find operation failed 是引擎层打的,级别是 ERROR,出现在一条成功的命令输出里 —— 对 CI 日志抓 ERROR 的流水线是直接的误报源。

可能的方向(未验证,需要看巡检的触发时机)

  • A. 巡检在 shutdown 之前跑完,或在 shutdown 时跳过。 一次性 CLI 进程本来就不该在关停路径上做完整巡检 —— 它的定位是启动期/巡检期的可观测性,不是退出钩子。
  • B. 关停中的连接失败不进 unreadableObjects。 把「引擎正在关停」与「数据源不可达」区分开:前者不是一个发现,不该占用那个桶。
  • C. 一次性命令进程默认不跑这个巡检(由 os serve / 巡检专用命令承担)。

倾向 A + B:A 解决时序,B 保证即使时序再次被打破,这个桶也不会被非发现污染。C 单独用的话会让巡检在 CLI 场景永久失明,与它的存在意义相悖。

边界

Refs #4551、#4556(PR #4742)、#4743。

Activity

  1. self-assigned this
    on Aug 3, 2026
  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮
    会话:session_015Br2xsJsczFsTR9bvbh2Ny
    分支:claude/issue-4747-integrity-audit-shutdown
    Worktree:objectstack-issue-4747

    分诊:落地 objectstack(packages/objectql/src/integrity/ + src/lifecycle/);采纳 issue 的 A + B,无需维护者裁定。

    这属于恢复不变量类 —— 巡检自己的文档已经把规则写死了:

    Unknown and absent are DIFFERENT answers. … Together they are what stops "0 dangling" from ever being read as "everything is fine".

    而现在 unreadableObjects 在每一次正常 CLI 调用里都非空。一个恒为真的告警不携带任何信息,它训练操作者忽略这个桶 —— 等真有对象因数据源故障读不出来时,那条信息会淹没在同样的两行里。规则已经在仓里,现状违反了它,修它不需要谁批准。

    取 A + B 的理由:A(巡检跑在 shutdown 之前 / 关停时跳过)修时序;B(关停中的连接失败不进 unreadableObjects)保证即使时序再次被打破,这个桶也不会被非发现污染。单独的 C(一次性命令不跑巡检)不取 —— 那会让巡检在 CLI 场景永久失明,与它的存在意义相悖,issue 作者的判断是对的。

    一并处理:那两行 ERROR Find operation failed 出现在一条成功命令的输出里,对抓 ERROR 的 CI 流水线是直接误报源,修完应当消失。

    ⚠️ 并行批隔离:本轮另有 agent 在 packages/plugins/plugin-auth 与 9 处 scripts/i18n-extract.config.ts。另外 PR #4794 正在合并队列中,同样改 packages/objectql(但落点是 src/validation/ 与 engine.ts,与本条的 src/integrity/ + src/lifecycle/ 不同文件)。请从最新 main 建分支;若 #4794 在你工作期间落地,rebase 一次即可。若你发现必须改 engine.ts 或 src/validation/**,停下来在报告里说明。

    顺带一提:本条与 #4776(启动期判断跑在提供方注册之前)是同一族的镜像面 —— 那边是"判断下得太早",这边是"判断下得太晚(依赖已经拆了)"。共同点是检查与它依赖的资源之间没有明确的生命周期契约。


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:ACCEPT(待 changeset 修正 + CI 全绿) —— PR #4815。

    根因比 issue 的猜测深一层,而且证据确凿

    issue 猜的是「巡检在 shutdown 之后才跑到这两个对象」。dev 挖出来的事实是:shutdown 根本没发生过 —— 两处静默空转叠在一起:

    1. ObjectQLPlugin 把关停逻辑写在 stop() 里,而内核的插件契约是 init/start/destroy,stop() 从来没有被任何人调用过 —— 这件事仓里甚至写明过(DefaultDatasourcePlugin.destroy 的注释原话:「stop() exists nowhere in the Plugin contract and is never called」)。
    2. bootSchemaStack().shutdown() 调的是 (runtime as any).stop?.(),而 Runtime 没有 stop —— 一个强制转型加一个可选调用,把「没有关停」伪装成了「关停过了」。

    第二条尤其值得记住:as any + ?. 的组合让一个从来不可能工作的调用安静地通过了所有检查。这与今晚反复出现的形状同族 —— 声明了做某件事的代码,实际什么都没做,而没有任何信号。

    A + B 都按裁定落地,B 的实现方式是对的

    • A(时序):stop → destroy;shutdown() 改走 kernel.shutdown() —— 与 os serve 收 SIGTERM 同一条路径,一次性命令和服务器只有一条出口。
    • B(桶纪律):区分「引擎正在关停」与「数据源不可达」用的是显式传递的一位(signal),dev 明确拒绝了对 Unable to acquire a connection 做消息嗅探 —— 那会把一个错误文案变成事实上的第二份契约,换个 driver 就失灵(PD Add comprehensive test suite for Zod schema validation #12)。
    • aborted 是独立字段而不是 unreadableObjects 里的一条:「没人问」不是「数据源拒绝了」。不完整依然是响的(报告永远不会被读成 clean bill of health),但不占用只该装发现的桶。且 aborted: false 是显式的,消费者不用猜 undefined 是「跑完了」还是「旧报告形状」。

    测试把「修之前无法区分的两种情况」钉成了一对

    开头那对是全部要义:同一段抛错代码 —— 没有 abort ⇒ 进桶、warn 照发;被 teardown race ⇒ 丢弃、aborted: true、不发 warn。删掉 abort 处理第二条红,删掉进桶逻辑第一条红。另有:abort 在读之前 ⇒ 一次查询都不发(这正是 ERROR Find operation failed 消失的机制);中途 abort ⇒ 已证成的 dangling 保留;探针在关停下抛错不算 undetermined(同一个范畴错误,隔壁桶)。

    集成测试在真实 bootSchemaStack 上把两头都断言了,最后一条故意证明沉默不是空转:shutdown 后直接对引擎 find 会 reject,说明池确实关了,上面的安静是修复的结果而不是「本来就没东西可读」。

    验收第三条(巡检在 CLI 场景仍然运行)守住了 —— 没有「一次性命令不跑巡检」的开关,只有「引擎活着才读」的生命周期边界。被拒绝的选项 C 没有从后门溜进来。

    一处要改:changeset 定级

    @objectstack/objectql 标了 patch,但本 PR 给它新增了公共 API(AuditAbortSignal 从包根导出、DanglingReferenceAuditOptions.signal、DanglingReferenceReport.aborted)。按 #4791 的先例(service-automation 因新增公共 API 标 minor),应为 minor;@objectstack/cli 保持 patch。已通知 dev 修正,一行改动。

    超范围产出

    changeset 修正推上、CI 全绿后送合并队列。


    Generated by Claude Code

  4. added a commit that references this issue on Oct 2, 2026
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