61122ce437
Issue #11 asks for a tenant column so a multi-tenant caller can isolate rows in the database. Widened to caller-defined dimensions in general, but only the caller's own: model name and friends keep their existing columns, and the library writes nothing into the new container. Two independent findings force tenant_id to be a real column rather than a key inside JSON. An RLS policy on meta->>'tenant_id' parses fine, but the planner discards statistics for non-LEAKPROOF functions under RLS, and ->> is not marked leakproof; the pgsql-general report that hit this ended up moving the indexed column out of JSONB. Separately, the planner has no usable statistics for JSONB at all -- @> falls back to a hardcoded 0.1% selectivity. A configurable promoted-column whitelist is rejected: when two downstreams infer different types for the same key, the second ADD COLUMN is silently skipped by IF NOT EXISTS and the wrong type is written from then on, without an error. The library stops at the column plus a documented policy template. It must never enable RLS itself -- with no matching policy that is default-deny, which would silently fail every write for the two downstreams that are not multi-tenant.
198 lines
18 KiB
Markdown
198 lines
18 KiB
Markdown
# 调用方自定义维度设计(issue #11)
|
|
|
|
- **状态**: 待 Codex 审 → 待人类审批
|
|
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
|
|
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
|
|
|
|
---
|
|
|
|
## 1. 需求与边界
|
|
|
|
### 1.1 issue 原始诉求
|
|
|
|
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
|
|
|
|
诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()` 与 `embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。
|
|
|
|
**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
|
|
|
|
### 1.2 本次放大的范围(2026-08-17 人类决策)
|
|
|
|
issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:
|
|
|
|
- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
|
|
- 保留期与访问控制不在本次范围(issue 第 4 条,另开)。
|
|
|
|
### 1.3 明确不做
|
|
|
|
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,另开 issue)。
|
|
|
|
---
|
|
|
|
## 2. 关键既有事实(设计必须服从的约束)
|
|
|
|
| # | 事实 | 出处 | 对本设计的约束 |
|
|
|---|---|---|---|
|
|
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
|
|
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
|
|
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
|
|
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
|
|
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
|
|
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
|
|
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
|
|
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |
|
|
|
|
---
|
|
|
|
## 3. 备选方案对比
|
|
|
|
### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)
|
|
|
|
`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。
|
|
|
|
**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
|
|
|
|
**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。
|
|
|
|
### 方案 B: 纯 `meta` JSON,不提任何列
|
|
|
|
最小改动、最通用。**否决**,两条独立的实证:
|
|
|
|
① **RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
|
|
|
|
② **planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**。
|
|
|
|
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
|
|
|
|
### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)
|
|
|
|
最通用,下游不必等库发版。**否决**,理由分三层:
|
|
|
|
① **业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
|
|
|
|
② **本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。
|
|
|
|
③ **与端口契约冲突**。`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。
|
|
|
|
**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。
|
|
|
|
### 推荐
|
|
|
|
**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
|
|
|
|
---
|
|
|
|
## 4. 设计细节
|
|
|
|
### 4.1 公共 API
|
|
|
|
两条路径对称新增两个 keyword-only 参数(F2/F3):
|
|
|
|
`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`,`embed(texts, *, ..., 同两参数)`。
|
|
|
|
**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
|
|
|
|
**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
|
|
|
|
### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)
|
|
|
|
| 项 | 规则 | 依据 |
|
|
|---|---|---|
|
|
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
|
|
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
|
|
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
|
|
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
|
|
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
|
|
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
|
|
|
|
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在 `chat()` 入口而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223` 的 `validate_request_overlay`)。
|
|
|
|
### 4.3 内部流转
|
|
|
|
`ChatRequest` 增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。
|
|
|
|
`TelemetryEmitter._record` 是唯一改动点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
|
|
|
|
**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
|
|
|
**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
|
|
|
### 4.4 存储层
|
|
|
|
两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):
|
|
|
|
- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
|
|
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`
|
|
|
|
**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
|
|
|
|
**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。
|
|
|
|
**库不自动建索引**。`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
|
|
|
|
### 4.5 RLS: 库止步于列 + 模板
|
|
|
|
**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:
|
|
|
|
① **default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。
|
|
|
|
② **库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。
|
|
|
|
③ **权限不对等**。`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
|
|
|
|
④ **SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
|
|
|
|
**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
|
|
|
|
文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING` 与 `WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。
|
|
|
|
---
|
|
|
|
## 5. 旧版行为审计
|
|
|
|
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
|
|
|
|
| 契约 | 处置 |
|
|
|---|---|
|
|
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
|
|
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
|
|
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
|
|
|
|
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
|
|
|
|
---
|
|
|
|
## 6. 非功能维度
|
|
|
|
**并发与取消**: 新增字段是不可变快照,随 `ChatRequest` 在洋葱内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验发生在进洋葱之前的同步代码里,不涉及 await,无取消窗口。
|
|
|
|
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
|
|
|
|
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
|
|
|
|
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。`meta` 的 JSON 序列化在 emitter 内完成,序列化失败被 emitter 的降级 try 捕获成 warning——但这条路径实际不可达,因为值类型已在入口校验为标量子集(§4.2),这是**双层防御**而非依赖降级兜底。
|
|
|
|
---
|
|
|
|
## 7. 错误分类与测试策略
|
|
|
|
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
|
|
|
|
**测试策略**(合并前需先失败后通过的证据):
|
|
|
|
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
|
|
|
|
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
|
|
|
|
契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。
|
|
|
|
**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
|
|
|
|
---
|
|
|
|
## 8. 开放问题(留待人类审批时确认)
|
|
|
|
1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
|
|
2. `tenant_id` 长度上限 128 同为推断值。
|
|
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次,建议另开 issue。
|