bf2fbd6c5e
OcrClient emits through the same helper and its rows land in the same table as chat rows. Covering only chat and embed would leave one table holding rows that have a tenant and rows that never will, and the issue's own irreversibility argument applies to those rows too. The design said two paths because the issue said two paths. Corrected at the source rather than only in the plan, so a later reader does not find OCR work with no design behind it.
27 lines
6.2 KiB
Markdown
27 lines
6.2 KiB
Markdown
---
|
|
type: design
|
|
node_id: design:issue11-caller-dimensions
|
|
title: "调用方自定义维度设计(issue #11)"
|
|
date: 2026-08-17
|
|
---
|
|
|
|
# 调用方自定义维度设计(issue #11)
|
|
|
|
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
|
|
|
|
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
|
|
|
|
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
|
|
- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。
|
|
- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
|
|
- **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
|
|
- **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN` 被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
|
|
- **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
|
|
- **库止步于列 + policy 模板,绝不自动 ENABLE RLS**: 启用 RLS 而无匹配 policy 是 **default-deny**(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = **无声全局丢数据**——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 `CREATE POLICY`;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
|
|
- **哨兵值而非 NULL**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见且静默跳过**,故 NULL 的 `tenant_id` 不是"未归属"而是**对所有人永久不可见的黑洞**。用 `NOT NULL DEFAULT ''` 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
|
|
- **超限报错而非静默丢弃**: Langfuse 的"value 超 200 字符直接丢弃"**不抄**,违反 P5。报错点在 `chat()` 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 `overlay` 保护键先例)。
|
|
- **不进缓存 key**: `cache_namespace` 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
|
|
- **被否决备选**: 纯 `meta` JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 `cache_namespace` 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");`tenant_id` 混在 `meta` 里当约定 key(拼错不报错,静默降级成普通维度)。
|
|
- **审查留痕(Codex,2026-08-17)**: 报 4 项,逐条核实后**全部采纳**。① `embed()` 路径覆盖不足——`EmbeddingClient` 不走 chat 洋葱,`_emit()` 在 `embedding.py:360` 现场构造 `ChatRequest`,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② **非有限 float 会击穿"序列化不可达"论断**——`json.dumps` 把 `nan` 写成 `NaN` 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 `math.isfinite` + 序列化 `allow_nan=False` 双层收口;③ 校验入口表述只写 `chat()`,与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 `FORCE`/`USING`+`WITH CHECK`/`NULLIF(current_setting(...))` 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
|
|
- **另开议题**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
|