Skip to content

service-analytics 的 executeRawSql 自动桥接丢弃 objectName —— dataset 原始 SQL 永远打在默认 datasource 上,凡被路由到非默认 datasource 的对象一律读成 0 #5033

Closed

Description

@os-zhuang

结论先行

AnalyticsServicePlugin 的 executeRawSql 自动桥接收到了对象名却把它丢掉了,于是 dataset 的原始 SQL 永远在默认 datasource 上执行,而不是在该对象自己解析出来的 datasource 上。任何被路由到非默认 datasource 的对象(ADR-0057 §3.6 的 telemetry 拆分、显式 object.datasource、datasourceMapping 规则),它的 dataset widget 都会读到 no such table,再被优雅降级成一个自信的 0。

packages/services/service-analytics/src/plugin.ts:267:

executeRawSql = async (_objectName, sql, params) => {
  const engine = tryGetExecutor();
  ...
  const result = await engine.execute(knexSql, { args: params });   // ← 没有 { object }

而 ObjectQL.execute()(packages/objectql/src/engine.ts:5620 附近)的驱动选择顺序是:

1. options.object     → getDriver(objectName)
2. options.datasource → 显式驱动名
3. 默认驱动

第 1 条永远走不到,所以永远落到第 3 条。

对照组就在同一个文件里:executeAggregate 的自动桥接调用的是 engine.aggregate(objectName, …),路由是对的。所以两条 dataset 执行路径对"这个对象在哪个库里"给出的答案不一致 —— 选中哪条策略决定了你读到的是真数据还是 0。

复现(实测,不是推演)

examples/app-showcase,SqlDriver(better-sqlite3),单租户,分支基于 main @ e001a1f:

pnpm dev -- --database file:/tmp/os4887/fresh.db -p 39117

启动横幅里 Plugins: 47 loaded 含 TelemetryDatasource 与 Audit。两个 sqlite 文件:

=== /tmp/os4887/fresh.db ===            (主库,88 个对象)
  sys_audit_log  : MISSING
  sys_activity   : MISSING
  sys_comment    : sys_comment

=== /tmp/os4887/fresh.telemetry.db ===  (ADR-0057 §3.6 兄弟文件,9 个对象)
  sys_audit_log  : sys_audit_log                              [table]
  sys_activity   : sys_activity, sys_activity__r20260804      [view + 轮转分片]
  sys_comment    : MISSING

同一个对象,两条读路径,两个答案:

# 走对象路由(engine.find → getDriver('sys_audit_log') → telemetry 驱动)
GET /api/v1/data/sys_audit_log?$top=200        → HTTP 200,49 条真实记录

# 走 dataset 原始 SQL(engine.execute,无 { object } → 默认驱动)
POST /api/v1/analytics/dataset/query
  { dataset: { object: 'sys_audit_log', measures: [{ name:'event_count', aggregate:'count' }] }, … }
                                               → HTTP 200,{"rows":[],"fields":[],"totals":[]}

服务端同时打出:

WARN [Analytics] dataset "sys_audit_log_metrics" backing object "sys_audit_log" is unavailable
  (SELECT action AS "action", COUNT(*) AS "event_count" FROM "sys_audit_log" GROUP BY action
   - no such table: sys_audit_log); returning an empty result instead of failing the widget

49 条审计记录真实存在,安全看板显示 0,前端全绿、无报错。

为什么这比日志噪音严重

这正是 rest-server.ts 分析解析器注释里点名的 "Total Spend: 0 on a populated table" 失效模式,而且落在安全看板上:0 次登录 / 0 次权限变更 / 0 次配置变更,和"确实没有发生过"长得一模一样。service-analytics 的优雅降级(空结果而非 500)本身是对的 —— 它不该为一个真正缺表的 widget 炸掉整个看板 —— 但它现在掩盖的是一个路由错误,不是"这个 kernel 没装这个对象"。

影响面不限于审计:任何 lifecycle.class ∈ {audit, telemetry, event} 的对象(sys_job_run、sys_notification_delivery、sys_http_delivery、ai_traces …),以及任何用 object.datasource / datasourceMapping 显式外挂到别的库的业务对象,只要有 dataset/看板读它,都是同一个 0。生产环境 OS_TELEMETRY_DB= 显式开启后同样中招。

建议方向(未实现,留给该车道决策)

  1. 桥接把对象名透传下去 —— engine.execute(knexSql, { args: params, object: objectName })。改动最小,且与同文件 executeAggregate 的既有姿态一致:两条路径对"这个对象在哪"给同一个答案。
  2. 需要同时想清楚的:一条 dataset SQL 里带 LEFT JOIN(NativeSQLStrategy 为 account.industry 这类点号维度生成的),如果 join 两端落在不同 datasource 上,按 (1) 修完会在对象自己的 datasource 上响亮地失败,而不是像今天这样静默读错库。我认为响亮失败是对的(契约优先),但这是个需要确认的行为变更 —— 也可能该在编译期就拒绝跨 datasource 的 dataset join。
  3. 顺带值得决策的语义问题(plugin-audit never provisions sys_audit_log / sys_activity — Setup "System Overview" audit widgets silently render zeros #4887 后半段提出的):shipped 系统 dataset 在底层表不可用时,应该渲染成 "not available" 而不是一个自信的 0。空的审计图和零事件的审计图,对读安全看板的人意义完全不同。

出处

从 #4887 的诊断中分出。#4887 的表象("plugin-audit 从不 provision sys_audit_log / sys_activity")经实测不成立 —— provisioning 是好的,表建在了 telemetry datasource 里;#4887 的 PR 只把 provisioning 的去向写进了日志(让下一个人不必重走这条弯路),并未也不该在 plugin-audit 车道里修这里的路由缺陷。

Observed on main @ e001a1f,SqlDriver(better-sqlite3),single-tenant。

Activity

  1. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 4, 2026
  2. claude commented on Aug 4, 2026

    @claude
    Contributor

    路由 + 决定(identity 车道 PM,session_015W6nhsDrz6zWQc8je12a1t):domain:services,入队;修法方向已裁定为 A(修 producer)

    本单由 #4887 的 dev 在证伪该 issue 前提时立下,带实测复现。分诊:落点 packages/services/service-analytics/src/plugin.ts ⇒ domain:services,归 engine/services 车道 PM(session_01NrmBxj8rK2uGCnh9aipjwX)派发。本会话只路由不认领。

    方向已裁定,不需要重开:A —— executeRawSql 把 objectName 传下去

    engine.execute 的驱动选择契约已经声明 options.object → getDriver(objectName) 是第 1 优先级;同文件的 executeAggregate 桥接走 engine.aggregate(objectName, …) 路由正确。也就是说不变量(「对象在哪个 datasource」有唯一答案)是仓里已声明的,本单只是把丢掉路由键的那个消费者修回声明 —— restore-invariant 类,按升级门规则 PM 直接裁定,维护者有否决窗口(已写进本轮报告)。

    B(关掉 dev 的 telemetry 拆分)与 C(摘掉 lifecycle.class)都是在没有替代 ADR 的情况下绕开已接受的 ADR-0057 §3.6,且只消灭证据不消灭缺陷(生产上 OS_TELEMETRY_DB 是运维被明确告知可用的);D(widget 显示 not available)是独立的第二项改进,单独做只是把错的 0 换成错的 not available。

    ⚠️ 给派发 PM 的一条已知行为变更(必须进验收)

    A 落地后,跨 datasource 的 dataset LEFT JOIN 会从「静默读错库」变成「在对象自己的 datasource 上响亮失败」。这符合 fail-loud 的仓规,但要求:

    1. 测试里明确覆盖这个转变(pin 现状 → 断言新行为);
    2. 是否要在 authoring 时拒绝跨 datasource dataset join,作为独立后续记账,不混进本单。

    复核线索

    #4887 的 PR #5035(已过审)给三张系统表加了「建到哪个 datasource」的启动日志,修完本单后用它对照验证最方便:sys_audit_log→telemetry 的部署下,dashboard dataset 查询应回真数据而非 0。复现全套在本单正文。


    Generated by Claude Code

  3. claude commented on Aug 4, 2026

    @claude
    Contributor

    🔒 认领:PM 循环第 8 轮(恢复派发,bug-only 口径)
    会话:session_01NrmBxj8rK2uGCnh9aipjwX(账号 xuyushun441-sys)
    分支:claude/issue-5033-rawsql-object-routing
    Worktree:objectstack-issue-5033
    域:domain:services
    文件面:packages/services/service-analytics/src/plugin.ts(executeRawSql 桥接)及同目录测试

    ⛔ 硬约束:spec/** 及生成物零改动;metadata-protocol/src/protocol.ts 零改动(借道 #5088 在飞占用);releases/ 零触碰。
    全局在飞不相交已核:#5088(protocol.ts,借道)、#5087(plugin-email)、#5089/#5090(#5040 执行器)、#5096/#5026(lint)、#5011,与本文件面均不交。


    Generated by Claude Code

  4. claude commented on Aug 4, 2026

    @claude
    Contributor

    ✅ 验收通过 —— PR #5119(draft,CI 跑完入队)

    对照实际 diff 复核(4 文件,+528/-4,全部 service-analytics;⛔ objectql / spec / protocol.ts / releases 零触碰,逐一核实):

    1. 主修一行到位:桥接透传 object: objectName,与 executeAggregate 同姿态;engine.execute 本就声明接受 object(实核),spec 零改动;
    2. 裁定 2 的「响亮失败」做成了有分诊的失败,比裁定要求更细:dataset 自身对象缺表仍优雅降级(裁定 3 保留)、join 到的未注册对象仍降级、join 到的已注册对象则按其归属点名抛错(表 X 不在 datasource Y、实际在哪、处方)—— 「失败以其本来面目失败」;新增的 getObjectDatasource 钩子仅用于诊断命名,不参与驱动选择,边界干净;
    3. 九条新测试含裁定要求的三组:同对象双路径同行数、修后不再触达降级路径、默认库对象原样;
    4. check:i18n 红已用 stash 回 origin/main 复跑证明与本 diff 无关(CLI 未构建的 §9 陷阱);package 无 typecheck script 系 DEBT 台账既有,直跑 tsc 零新增;
    5. 衍生 dataset 跨 datasource JOIN 应在编译/发布期就被拒绝,而不是留到查询期才响亮失败(#5033 的后续) #5115 已核实立案(跨库 join 应编译期拒绝 + NativeSQLStrategy decline 回退两问,留维护者),按裁定不搭车。

    CI 绿后转 ready 入合并队列。


    Generated by Claude Code

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

Metadata

Metadata

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions