Skip to content

feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) - #3836

Merged
os-zhuang merged 1 commit into
mainfrom
claude/explicit-datasource-connection-fallback-5b83d0
Jul 28, 2026
Merged

feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828)#3836
os-zhuang merged 1 commit into
mainfrom
claude/explicit-datasource-connection-fallback-5b83d0

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3827. Closes #3828. Partially addresses #3826 (see below — #3826 停留在 open)。

问题

#3816 让「被显式绑定的 datasource 连不上」拒绝启动。但仍然能起来的那几类 —— policy 拒绝、autoConnect、以及操作者用 OS_ALLOW_DRIVER_CONNECT_FAILURE 放行的失败 —— 留下了两个洞:

  1. 看不见。 DatasourceSummary.status 对每一行都填死 'unvalidated'(契约声明三态,实现只发一态),所以一个死掉的 datasource 和一个健康但没测过的完全无法区分。checkDriversHealth() 也帮不上:它遍历已注册的 driver,而从未连上的 datasource 从未被注册,所以它是不出现而不是「报 unhealthy」。排查手段只剩「重启服务器再读日志」。
  2. 查询时的报错什么也没说。 getDriver() 用同一句话回答四种情况:policy 拒绝、逃生阀放行的连接失败、名字拼错、active: false。只有第三种是作者的 bug,另外三种把人引去找一个不存在的拼写错误。

两者同源:connect() 每次都产出了 ConnectResult,而每个调用方都把它扔了。

改动

  • 留存每个 datasource 的最后一次判决,附一个粗粒度 availability(available / blocked / failed / unattempted)。新增 getConnectionState() / listConnectionStates();disconnect() 会清掉,免得一个已删除的 pool 还在解释自己。
  • DatasourceSummary.status 说真话:ok | error | blocked | unvalidated,外加面向运维的 statusReasonblocked 是新增且刻意区分的 —— policy 拒绝是「决定」不是「故障」,不会自己好。除列表外,create/update 返回的 summary 也走同一路径,所以一次 pool 没开成的「保存」不再被呈现为成功。
  • ERR_DATASOURCE_UNAVAILABLE(HTTP 503):新的 DatasourceUnavailableError,在连接层记录过原因时抛出;没记录过的名字保持原句(那确实就是拼错了)。选 503 而非 400/500:请求本身没问题,而且这个状态可能自行恢复。

两个待定项,按保守解落地

你之前让我继续,我按下面两条推进:

reason 的可见性 —— 默认不外露,opt-in 才外露。 查询期错误绝不携带底层 cause:连接失败的文本里routinely 有 host / port / DSN,policy 的 reason 是写给运维的。两者都留在日志和 admin 列表(本来就有权限门)。DatasourceConnectDecision 新增 opt-in 的 publicReason,是唯一会到达终端用户的字符串:

canConnect: (ds) => ({
  allow: false,
  reason: `egress allow-list miss for ${ds.name} (${tenant.id}, plan=free)`,  // 仅日志 + admin
  publicReason: 'External datasources require the Scale plan.',               // 才会进报错
})

纯增量,cloud 侧不改也能编译、行为不变(不给 publicReason 就只说类别)。

② 503,用 code 识别。rest-server.ts 既有的 mapDataError 分支,放在 catch-all 之前,不然会退化成通用 400。

另外一个我自己做的判断:readiness 不受影响。 /ready 仍只反映已注册 driver 的健康度。autoConnect 语义是「能连就连」,把它变成整机下线开关会让一个可选副本拖垮整个 replica。可见性由 admin 列表和查询期报错承担。

#3826:只完成了守卫部分,issue 保持 open

核对代码后,我原来的说法需要修正:构造那一半已经收敛,没收敛的是 connect + 失败判决。真正的阻塞点也不是启动顺序(kernel 的 init-all→start-all 已经保证连接服务在 ObjectQLPlugin.start() 时存在),而是:

  1. connect() 接收的是 datasource 定义构建 driver,而 default 是以已构建实例的形式作为 driver.* kernel service 到达的 —— 没有 "adopt 一个现成 driver" 的入口;
  2. default 走连接服务,会使 ObjectQLPlugin 的启动依赖一个上层的可选 service(service-datasource),为每个 app 都需要的那一个 driver 反转分层。

这是设计决策,不是机械搬迁,而且 ADR §Risk 标注它是风险最高的一步 —— 我不打算在这个 PR 里草率做掉。已把结论写进 ADR-0062(顺带把 header 里 "D1–D8 implemented" 更正为 D1 partially),并落一个真正有用的东西:packages/runtime/src/degraded-boot-parity.test.ts,把两条路径钉在同一份运维可见契约上(默认 fail-fast、OS_ALLOW_DRIVER_CONNECT_FAILURE 解析完全一致、DEGRADED BOOT 上 stderr)。#3741#3758 就是漏改一边,代价是三个月和第二份 bug report;下次由 CI 来发现。

验证

  • service-datasource — 120 passed(+54:判决留存、privileged/public 分离、publicReason 不跨次泄漏、fail-fast 抛出时仍记录、disconnect 清理、status 映射四态)
  • objectql — 1144 passed(+16:四种情况的报错分流、不含 cause 的断言、checkDriversHealth 结构性看不见未注册 datasource)
  • rest — 410 passed(+2:503 映射、不退化成 400)
  • runtime — 693 passed(+30:parity 守卫 12 例;端到端 policy 拒绝后查询报错含 publicReason、不含运维 reason;admin 列表显示 blocked + statusReason)
  • pnpm build 全量通过;service-datasource typecheck、examples typecheck、ESLint、changeset 门禁本地全绿

顺带修正了 DEGRADED BOOT banner —— 它原本声称查询会报 "Datasource 'x' is not registered",这话在本 PR 之后不再成立。

Migration

增量为主。DatasourceSummary.status 多了 'blocked',穷举 switch 的消费方需要补一个分支;原本 'ok' / 'error' 的含义不变,原本被报成 'unvalidated' 的行现在报真实状态。连接层记录过的 datasource,查询期错误从裸 Error 变为 DatasourceUnavailableError(状态码从原先的兜底值变 503);按旧文案 is not registered 匹配的代码,在「名字未声明」这唯一一种它本来就准确的情况下仍然有效。


Generated by Claude Code

…when queried (#3827, #3828)

#3816 made an explicitly-bound datasource that cannot connect refuse the boot.
Two gaps survived it, both in the cases that still boot — a policy denial, an
`autoConnect` datasource, or any failure waved through with
OS_ALLOW_DRIVER_CONNECT_FAILURE:

  * It was invisible. `DatasourceSummary.status` was the literal 'unvalidated'
    for every row — the contract declared three states, the implementation ever
    emitted one — so a dead datasource looked exactly like a healthy-untested
    one. `checkDriversHealth()` could not help either: it iterates REGISTERED
    drivers, and one that never connected was never registered, so it is absent
    from the probe rather than unhealthy. The diagnostic procedure was "restart
    the server and re-read the boot logs".
  * The query-time error said nothing. `getDriver()` answered four situations
    with one sentence — refused by policy, failed under the escape hatch, a
    misspelled name, `active: false`. Only the third is an authoring bug, so the
    others sent the reader hunting for a typo that does not exist.

Both come from one root: `connect()` produced a `ConnectResult` per attempt and
every caller threw it away.

- Retain the last verdict per datasource, with a coarse `availability`
  (available / blocked / failed / unattempted). `getConnectionState`,
  `listConnectionStates`; `disconnect()` drops it so a removed pool stops
  explaining itself.
- `DatasourceSummary.status` tells the truth: ok | error | blocked |
  unvalidated, plus an operator-facing `statusReason`. `blocked` is new and
  deliberate — a policy denial is a decision, not a fault. Surfaced in the admin
  list and in the summary returned from create/update, so a "Save" whose pool
  failed to open is no longer presented as success.
- New `DatasourceUnavailableError` (`ERR_DATASOURCE_UNAVAILABLE`, HTTP 503) from
  `@objectstack/objectql`, thrown when the connection layer recorded WHY a
  declared datasource has no driver. An undeclared name keeps the original
  message. 503 over 400/500: nothing about the request is wrong, and it may clear.
- Privileged/public split for the reason. The error NEVER carries the underlying
  cause — connect failures contain hosts, ports and DSNs; a policy's `reason` is
  written for operators. Those stay in logs and the admin-gated list.
  `DatasourceConnectDecision` gains an opt-in `publicReason`, the only string
  that reaches an end user.
- Readiness stays ungated on this: an optional datasource being down must not
  pull an otherwise-working replica out of the load balancer.

Also lands the #3826 drift guard and corrects ADR-0062's status. The ADR claimed
D1 implemented; only the construction half converged — `default` is still
registered as a `driver.*` kernel service and connected by
`ObjectQLEngine.init()`, with its own failure verdict, pool teardown and no
connect policy. The blocker is an input-shape mismatch, not ordering: `connect()`
takes a definition and BUILDS the driver, while `default` arrives pre-built, and
routing it through the service would make ObjectQLPlugin's boot depend on an
optional higher-layer service. Until that is designed,
`degraded-boot-parity.test.ts` pins both paths to the same operator-visible
contract so a change to one that forgets the other fails CI.

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

vercel Bot commented Jul 28, 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 Jul 28, 2026 8:59am

Request Review

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/objectql, @objectstack/rest, @objectstack/runtime, packages/services.

34 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/error-catalog.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/automation/webhooks.mdx (via packages/services)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/runtime, packages/services)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, packages/services)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

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.

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

Projects

None yet

2 participants