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.

@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 09:16
@os-zhuang
os-zhuang merged commit 48c110e into main Jul 28, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/explicit-datasource-connection-fallback-5b83d0 branch July 28, 2026 09:16
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