Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 296c765337 | |||
| 429d767737 | |||
| 91354e4e10 | |||
| 4b06093d6c | |||
| ea9b6fbcd9 | |||
| daf7ab3268 | |||
| 511aa4899c | |||
| c26b34e854 | |||
| 33ed7ecdfc | |||
| e0a33ecf93 | |||
| 0721cf60aa | |||
| ba4a138692 | |||
| 483683b834 | |||
| 7b49e580c0 | |||
| e17e1067a1 | |||
| 21a19ab374 | |||
| e949edb62a | |||
| ecc22b34fc | |||
| d4b40b0e64 | |||
| 1471e0a2c6 | |||
| e9adb36577 | |||
| 172f3180e5 | |||
| 8f792bc697 | |||
| 5b2e3ba82d | |||
| 39fcf2631d | |||
| 72b6b54719 | |||
| 2af445cfc2 | |||
| 8b0f66b1a6 | |||
| 9d9e4ee533 | |||
| 56f380534c | |||
| b6165ff438 | |||
| 9bdd312928 | |||
| 25cb0a6e0c | |||
| d553d142c3 | |||
| 6ad58a6553 | |||
| 702040d1a3 | |||
| 4be2b4f287 | |||
| dba706b59c | |||
| 6af4673534 | |||
| bf2fbd6c5e | |||
| 80aa2b216d | |||
| a052f3eb28 | |||
| b671fb629a | |||
| 61122ce437 | |||
| 4351e2be73 |
@@ -56,7 +56,23 @@ PGW_BREAKER_BACKEND=memory # memory | redis
|
||||
PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式)
|
||||
PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
||||
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||
# PGW_TELEMETRY_SCHEMA_MODE=manual # auto | manual;三态: 不设 = 按后端派生(sqlite→auto、postgres→manual),
|
||||
# # 显式设置则两侧都可覆盖。auto = 库给已存在的旧表自动 ALTER 补列;
|
||||
# # manual = 库不发 ALTER,只 warning 点名缺列并打印可执行 SQL,
|
||||
# # 按现有列裁剪 INSERT 继续写(遥测不会因缺列而全线丢失)。
|
||||
# # 缺省为何不对称: postgres 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁,
|
||||
# # 会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 await;
|
||||
# # 且这类部署有 DBA、有迁移工具、讲最小权限,DDL 该由他们择时执行。
|
||||
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
|
||||
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
|
||||
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
|
||||
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
|
||||
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
|
||||
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
|
||||
# # 缺省为何是"不截断": 遥测被下游当**审计证据**用——出了问题要回答"当时到底发了什么",
|
||||
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
|
||||
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
|
||||
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
|
||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||
|
||||
+134
@@ -1,5 +1,139 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.3(2026-08-19)
|
||||
|
||||
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
|
||||
|
||||
同一版里,issue #12 补上这条边界的另一半——**删数据**,并把它落成三样**手段**: 遥测正文的可配置上限、`tools/` 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样**没有一样改变缺省行为**——不设 `PGW_TELEMETRY_TEXT_CAP` 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 `DELETE`/`DROP` 权限: 保留期是 `tools/` 下的独立脚本,库不 import 它。
|
||||
|
||||
### 请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的
|
||||
|
||||
1.2.1 的 README 给的 RLS 模板把**写侧**也绑在了 `app.tenant_id` 这个 GUC 上:
|
||||
|
||||
```sql
|
||||
-- 1.2.1 的模板,有缺陷,勿用
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
但 `PostgresRecorder` 用**一个连接池给所有租户**写遥测,源码里从不发 `set_config('app.tenant_id', ...)`——库既拿不到也不该猜租户上下文该怎么设。于是 `WITH CHECK` 里的 `current_setting` 恒为 NULL、等值比较恒不为真,**库的每一条 `INSERT` 都被 policy 拒绝**。而遥测的失败方向是静默降级,所以表现不是报错,是**整张表零行**——业务调用一切正常,不看日志根本发现不了。
|
||||
|
||||
照抄过就请现在查这两条:
|
||||
|
||||
| 查什么 | 中招的样子 |
|
||||
|---|---|
|
||||
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
|
||||
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
|
||||
|
||||
本版的新模板把写侧改为 `WITH CHECK (true)`,隔离交由**读侧**的 `USING` 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。
|
||||
|
||||
### 破坏性变更(五项)
|
||||
|
||||
| # | 变更 | 影响与应对 |
|
||||
|---|---|---|
|
||||
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
|
||||
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
|
||||
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
|
||||
| ④ | `GatewaySettings` 再新增**必填**字段 `telemetry_text_cap: int \| None` | 同 ③,只影响直接构造这条路。`None`(不截断)是**取值**而不是默认值——字段本身没有默认值;`<= 0` 在 `__post_init__` 直接 `ValueError`,不会被当成"不截断" |
|
||||
| ⑤ | `TelemetryEmitter` 新增 **keyword-only 必填**参数 `text_cap` | 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 `TypeError`。同样**故意不给默认值**: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 `text_cap` 全汇流到这里,而 `GatewaySettings` 那道只管 env 一条路 |
|
||||
|
||||
### 新增
|
||||
|
||||
- **`PGW_TELEMETRY_SCHEMA_MODE`(可选键,值域 `auto` / `manual`)**,**三态**:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则**有意不对称**——`postgres` → `manual`,`sqlite` → `auto`。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 `runs/*.db`),没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
|
||||
- **公共函数 `telemetry_schema_sql(backend) -> str`**(已进顶层 `__all__`):返回可直接粘进迁移文件的完整脚本——注释头 + `CREATE TABLE IF NOT EXISTS`(全量列)+ 各补列语句。PG 变体带 `ADD COLUMN IF NOT EXISTS`,整段**可重复执行**;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 `backend` 抛 `ValueError`。
|
||||
- **manual 档的缺列告警**逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且**只在准备期发一次**,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。
|
||||
|
||||
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
|
||||
|
||||
| 手段 | 内容 |
|
||||
|---|---|
|
||||
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
|
||||
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
|
||||
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
|
||||
|
||||
### 变更
|
||||
|
||||
- **Postgres 的写入去掉了冲突目标**:`ON CONFLICT (call_id) DO NOTHING` → `ON CONFLICT DO NOTHING`。普通表上语义**逐字等价**(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 `(call_id)` 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区后主键变成 `(call_id, created_at)`,该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 `INSERT OR IGNORE` 本就无目标,未动。
|
||||
- **manual 档按现有列裁剪 `INSERT`**。这不是可选增强而是关掉 `ALTER` 的前提:旧表缺列时若仍发全量 `INSERT`,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
|
||||
- **schema 常量收敛为单一事实源** `telemetry/schema.py`(内部模块):列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是**正确性**而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
### 不变
|
||||
|
||||
- **manual 档仍然建表**。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 `to_regclass` 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
|
||||
- **auto 档行为与从前逐字相同**,包括补列失败时**不裁剪**:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||
- 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
|
||||
- **遥测缺省不截断**: 不设 `PGW_TELEMETRY_TEXT_CAP` 时落库正文与今天逐字节相同。`digest_messages`(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断**只产出新对象、绝不就地修改**——`digest_messages` 对非 list 的 `content` 是原样透传**同一个 dict 对象**,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 `build_cache_key` 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
|
||||
- embedding 与 OCR 两条链路各自既有的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系;多模态 `image_url` 早已是 sha256 摘要,不受 cap 影响。
|
||||
|
||||
### 库对下游数据库的承诺(Expand/Contract,本版成文)
|
||||
|
||||
以下五条此前已被实现满足,但从未写成承诺。本版起它们是**承诺**:新列**只增不删不改名**且一律追加在既有列之后;新列必**可空**或带**非易失常量默认值**(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);`INSERT` **永远显式写出列名**;库**从不 `SELECT *`**、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的**冲突处理不绑定具体约束**。
|
||||
|
||||
合起来它们保证:你可以自行给 `llm_calls` 加列、加索引、挂 RLS,乃至把它建成 `PARTITION BY RANGE (created_at)` 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
同一条边界的另一半是**删数据**: 库不持有 `DELETE`/`DROP` 权限,保留期与访问控制以 README 模板加 `tools/` 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`(那是 DDL,同样不触发不可变性触发器)。分区在这里**不可替代**,不是性能偏好。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- 用 `from_env()` / `from_settings()` 装配的下游**无需改代码**;Postgres 下游升级后建议执行一次 `python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))"` 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
|
||||
- 直接构造 `SQLiteRecorder` / `PostgresRecorder` 或直接构造 `GatewaySettings` 的调用点必须补上新参数/新字段,否则 `TypeError`。
|
||||
- **截断不需要任何升级动作**: 不设 `PGW_TELEMETRY_TEXT_CAP` 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
|
||||
- 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
|
||||
- README 的安装 pin 由 `>=1.2.1,<2` 收紧为 `>=1.2.3,<2`。按旧 pin 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关**,而包内那份 README 描述的正是它们。
|
||||
|
||||
## 1.2.1(2026-08-18)
|
||||
|
||||
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
|
||||
|
||||
不可逆性是这个 issue 的核心论点,且成立: 先启用遥测再补列,补列之前写进去的每一行都没有归属,事后无法还原哪行属于谁。
|
||||
|
||||
### 新增
|
||||
|
||||
- **四个公共方法各增两个 keyword-only 参数 `tenant_id` 与 `meta`**,都带默认值 `None`,**既有调用点零改动**: `GatewayClient.chat()`、`EmbeddingClient.embed()`、`OcrClient.recognize_text()`、`OcrClient.parse_layout()`。issue 只诉求前两条链路;OCR 经同一个 `TelemetryEmitter` 写**同一张表**,只覆盖两条会让同表内一部分行有归属、一部分永远空白,故一并纳入(与 issue #10 同一判断)。
|
||||
- **遥测表 `llm_calls` 新增两列**,排在既有 22 列**末尾**,两端类型按各自后端的原生能力取:
|
||||
|
||||
| 列 | Postgres | SQLite |
|
||||
|---|---|---|
|
||||
| `tenant_id` | `TEXT NOT NULL DEFAULT ''` | `TEXT NOT NULL DEFAULT ''` |
|
||||
| `meta` | `JSONB NOT NULL DEFAULT '{}'::jsonb` | `TEXT NOT NULL DEFAULT '{}'` |
|
||||
|
||||
- **老表经现有 `_BACKFILL` 机制自动补列**(先探测再 `ALTER`,失败只逐行降级),补列后**老行的 `tenant_id` 读出是空串而非 NULL**。这个区别是刻意的: PG 的 RLS `USING` 表达式返回 false **或 null** 的行都不可见、且静默跳过不报错,所以 NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**;哨兵空串则显式可查,`COUNT(*) WHERE tenant_id = ''` 一条 SQL 就能审出还有多少行待归属。补列本身两端都不停机: PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作。
|
||||
- **`TelemetryRecorder.record_llm_call` 由 22 字段扩为 24**(`inspect.signature` 实测),`ChatRequest` 同步新增两个带默认值的字段。`meta` 以 `json.dumps(sort_keys=True, ensure_ascii=False, allow_nan=False)` 序列化,空 dict 落 `'{}'` 而非 NULL。
|
||||
|
||||
### 校验规则(超限报错,不静默丢弃)
|
||||
|
||||
校验在四个公共入口收口、进洋葱之前抛裸 `ValueError`,四条链路共用同一份实现:
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| `tenant_id` | 长度 ≤ **128**;不得含首尾空白;空串是哨兵值的地盘,调用方传空串多为 bug |
|
||||
| `meta` 键数 | ≤ **16** |
|
||||
| `meta` 键 | 必须匹配 `[a-z0-9_.]{1,64}`;**`pg_` 前缀保留**给库将来的内建维度(本版库自身不写任何该前缀的键) |
|
||||
| `meta` 值 | 仅 `str` / `int` / `float` / `bool`,嵌套需调用方自行序列化;字符串值 ≤ **256** 字符;`float` 必须有限,`nan` / `inf` 报错(它们不是合法 JSON,PG 的 JSONB 会拒收) |
|
||||
|
||||
报错点选在入口而非遥测写入点: 遥测层的一切失败都按降级方向铁律吞成 warning,校验放那里等于没有校验。**超限一律报错**,不采用"超长就丢弃"的做法——那违反 P5「严禁默认值掩盖错误」,会把调用方的输入错误转化成静默丢数据。
|
||||
|
||||
### 不变
|
||||
|
||||
- **`tenant_id` 与 `meta` 都不进缓存 key**。租户级的缓存隔离由既有的 `cache_namespace` 负责,重复进 key 只会让全部存量缓存冷启动;且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。
|
||||
- 既有 22 列的列名与列序、`ON CONFLICT (call_id) DO NOTHING` 幂等、单条写失败逐行丢弃的降级方向全部未动。**错误面零变更**,下游 `except` 写法不受影响。
|
||||
- 缓存命中行与终态失败行同样带维度,且读的是**本次** `request` 而不是缓存里的历史响应——这两类行恰恰是审计最需要的(命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
|
||||
|
||||
### 边界: 库只交付列,RLS 与索引由下游执行
|
||||
|
||||
**库不会执行 `ENABLE` / `FORCE ROW LEVEL SECURITY`,也不会建任何索引。** 需要数据库层的强制隔离,下游 DBA 必须自行执行 RLS DDL 与 `CREATE POLICY`(并建 `(tenant_id, created_at)` 复合索引——启用 RLS 后 policy 会给每条查询隐式追加 `tenant_id` 等值谓词,它必然是前导列);**不执行则 `tenant_id` 只是一个可查、可过滤的普通列,没有任何数据库层强制**。
|
||||
|
||||
不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。
|
||||
|
||||
RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- **升级无需任何代码改动**: 两个新参数都是带默认值的 keyword-only,既有调用点原样工作;不传即写入哨兵空串与空 `{}`。
|
||||
- README 的安装 pin 由 `>=1.2,<2` 收紧为 `>=1.2.1,<2`。按 `>=1.2,<2` 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.0 就没有租户维度**。
|
||||
- README 的配置参考表此前漏列了源级 `MISSING_DONE` 与 `EXTRA_BODY`(正文别处却引用了后者)、`{SCOPE}__QUOTA_FULL`、embedding 专用键、`PGW_CACHE_BACKEND` 的 `memory` 档与三个可选 `PGW_*` 键,本版按 `config.py` 的 `_SOURCE_FIELDS` 与 `_load_pgw` 逐项补齐。代码零变更。
|
||||
|
||||
## 1.2.0(2026-08-16)
|
||||
|
||||
网关拒绝一次调用时,**它说的话不再丢失**(issue #10)。下游一轮 1050 张医学影像的批处理里,1 张在读表格这一步收到 400、被判确定性失败而放弃;事后想知道"这张图到底哪里不合规",无从查起——响应体在 transport 翻译层之后就不存在于进程任何位置了。
|
||||
|
||||
@@ -96,7 +96,7 @@ make ci # 只读验证(check + test)
|
||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
||||
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
||||
| 8 | **验证已发布** | `pip download --no-deps --index-url .../pypi/simple/ "polygateway==X.Y.Z"`,并解包确认新代码在内。**不验证不算发布完成** |
|
||||
| 9 | **建 Release + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);随后打开包页面确认有正文与仓库链接,**Link to a repository 只能在网页手动做**(该实例的 link API 返 404) |
|
||||
| 9 | **建 Release + 挂仓库 + 核对包页面** | `POST /api/v1/repos/iomgaa/PolyGateway/releases`(body 取 CHANGELOG 本版段;历史上只打 tag 不建 release,Releases 页长期为空);挂仓库走 `POST /api/v1/packages/iomgaa/pypi/polygateway/-/link/PolyGateway`(**2026-08-16 实测返 201 可用**,此前记录的"该实例 link API 返 404、只能网页手动"已过时);随后打开包页面确认有正文与仓库链接 |
|
||||
|
||||
> [!CRITICAL]
|
||||
> **发布完成的判据是外部可见结果,不是本地步骤跑通**: 收尾必须以下游视角逐一打开产物页面(registry 包页面正文与仓库链接、仓库 Releases 页、`pip install` 后包内文件),看到什么算什么,缺的当场补进本清单——1.1.2 三步全绿却出现包页面空白(`pyproject` 缺 `readme`)、Releases 页 0 条、包未挂仓库。
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
|
||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace/租户 + salt,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 22 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||
@@ -31,7 +33,7 @@
|
||||
|
||||
```bash
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,postgres,structured]>=1.2,<2"
|
||||
"polygateway[redis,postgres,structured]>=1.2.3,<2"
|
||||
```
|
||||
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
@@ -81,7 +83,7 @@ async def main() -> None:
|
||||
await client.aclose() # 归还连接与治理后端资源
|
||||
```
|
||||
|
||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制;`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||
`chat()` 原生接受 OpenAI 多模态 content 数组(`image_url` data URL),VLM 调用无需专门客户端;`session_id` / `parent_call_id` / `cache_salt` 关键字参数用于链路追踪与缓存控制,`tenant_id` / `meta` 用于遥测归属(见下文「多租户与自定义维度」);`overlay` 传采样参数(`temperature` / `seed` / `max_tokens` 等,恒定值宜配在源的 `EXTRA_BODY` 上)——它会进缓存 key,故逐次变化的 `seed` 天然不命中缓存。
|
||||
|
||||
### 3. OCR 与 Embedding
|
||||
|
||||
@@ -112,6 +114,254 @@ except RequestRejectedError:
|
||||
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
||||
```
|
||||
|
||||
### 5. 多租户与自定义维度
|
||||
|
||||
```python
|
||||
resp = await client.chat(
|
||||
messages,
|
||||
tenant_id="acme-corp", # 遥测表的真实列,可挂 RLS
|
||||
meta={"batch_id": "b-42", "stage": "extract"}, # 任意自定义 KV,进 meta 列
|
||||
)
|
||||
```
|
||||
|
||||
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
||||
|
||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||
|
||||
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
|
||||
|
||||
## 遥测表 schema 与升级纪律
|
||||
|
||||
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
||||
|
||||
| 库会发 | 库不发 |
|
||||
|---|---|
|
||||
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
||||
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
||||
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
||||
|
||||
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
||||
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
||||
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
||||
|
||||
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
||||
|
||||
| 表状态 | `auto` | `manual` |
|
||||
|---|---|---|
|
||||
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
||||
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
||||
|
||||
无论哪档,遥测的失败方向都是**静默降级**:缺列、补列失败、写入失败都只 warning,绝不冒泡打断业务调用。
|
||||
|
||||
### 自取建表脚本
|
||||
|
||||
`telemetry_schema_sql` 输出与库运行时执行的 DDL **同源**(同一份常量),照它建完表,库探测到的列就是齐的:
|
||||
|
||||
```python
|
||||
import polygateway
|
||||
|
||||
print(polygateway.telemetry_schema_sql("postgres")) # 或 "sqlite";非法值抛 ValueError
|
||||
```
|
||||
|
||||
```bash
|
||||
# 直接落成迁移文件:注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
> migrations/001_llm_calls.sql
|
||||
```
|
||||
|
||||
PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**(它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故请挑低峰);SQLite 没有该语法,脚本以注释标明"仅当该列不存在时执行"。注意这与库**内部**执行的 ALTER 是两份文本:库侧一律先探测后 ALTER,不用 `IF NOT EXISTS`,正是为了在稳态下一条排他锁都不取。
|
||||
|
||||
### Expand/Contract 承诺
|
||||
|
||||
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
||||
|
||||
| 承诺 | 你可以据此做什么 |
|
||||
|---|---|
|
||||
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
||||
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
||||
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
||||
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
|
||||
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
|
||||
|
||||
## 生产部署 DDL 模板(PostgreSQL)
|
||||
|
||||
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
|
||||
|
||||
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
|
||||
|
||||
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
||||
|
||||
| # | 锚点 | 做什么 |
|
||||
|---|---|---|
|
||||
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
||||
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
||||
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
||||
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
|
||||
| 5 | `immutable` | 触发器兜底(只防误操作) |
|
||||
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
|
||||
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
|
||||
|
||||
### 1. 三角色
|
||||
|
||||
| 角色 | 拿到什么 | 谁在用 |
|
||||
|---|---|---|
|
||||
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
||||
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
||||
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
||||
|
||||
<!-- pg-template:roles -->
|
||||
|
||||
```sql
|
||||
CREATE ROLE polygateway_owner NOLOGIN;
|
||||
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
|
||||
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
|
||||
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
|
||||
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
|
||||
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
|
||||
```
|
||||
|
||||
### 2. 分区表
|
||||
|
||||
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
|
||||
|
||||
```bash
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
| psql "$PGW_TELEMETRY_PG_DSN"
|
||||
```
|
||||
|
||||
<!-- pg-template:table -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
|
||||
CREATE TABLE llm_calls (
|
||||
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
|
||||
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
DROP TABLE llm_calls_seed;
|
||||
ALTER TABLE llm_calls OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
<!-- pg-template:partition -->
|
||||
|
||||
```sql
|
||||
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
|
||||
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
|
||||
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'`、`retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
|
||||
|
||||
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at` 由 `DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
|
||||
|
||||
### 3. 权限与不可变性
|
||||
|
||||
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
|
||||
|
||||
<!-- pg-template:grants -->
|
||||
|
||||
```sql
|
||||
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
|
||||
GRANT SELECT ON llm_calls TO polygateway_report;
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
|
||||
```
|
||||
|
||||
<!-- pg-template:immutable -->
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
|
||||
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
|
||||
```
|
||||
|
||||
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
|
||||
|
||||
`DROP PARTITION` 与 `DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
|
||||
|
||||
### 4. 行级安全与多租户隔离
|
||||
|
||||
> **照抄过 1.2.1 那份 RLS 模板的部署请先查一遍**:那份模板把**写侧**也绑在 `app.tenant_id` 这个 GUC 上,而库从不设这个 GUC,于是它的每一条 `INSERT` 都被 policy 拒绝——遥测的失败方向是静默降级,表现不是报错而是**整张表零行**。用能绕过 RLS 的角色(superuser 或带 `BYPASSRLS`)执行 `SELECT count(*) FROM llm_calls;`,并在应用日志里搜 `Postgres 遥测写入失败(丢弃该行):`。下面这份是修正后的模板。
|
||||
|
||||
<!-- pg-template:rls -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
|
||||
WITH CHECK (true);
|
||||
CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
<!-- pg-template:index -->
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`。
|
||||
|
||||
**写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
|
||||
|
||||
四个陷阱,每一个的失败形态都是**静默的**:
|
||||
|
||||
| 陷阱 | 后果 |
|
||||
|---|---|
|
||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||
| 读侧 policy 漏写 `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
|
||||
|
||||
### 5. 库本身需要的最小权限
|
||||
|
||||
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
||||
|
||||
| 库会发的语句 | 需要什么 |
|
||||
|---|---|
|
||||
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
||||
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
||||
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
||||
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
|
||||
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
|
||||
|
||||
### 6. 合规下游的推荐配置
|
||||
|
||||
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
|
||||
|
||||
```dotenv
|
||||
PGW_TELEMETRY_BACKEND=postgres
|
||||
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
|
||||
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
|
||||
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
|
||||
```
|
||||
|
||||
| 层 | 配置 |
|
||||
|---|---|
|
||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
|
||||
|
||||
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
||||
|
||||
### 7. SQLite 侧的保留期
|
||||
|
||||
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db`、`runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
|
||||
|
||||
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
||||
|
||||
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
||||
|
||||
## 错误模型(四分类)
|
||||
|
||||
一切失败在 transport 层翻译为四类之一,治理行为由分类决定,业务侧不需要判断状态码:
|
||||
@@ -148,12 +398,18 @@ except RequestRejectedError:
|
||||
|
||||
| 键形态 | 作用 |
|
||||
|---|---|
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD ∈ BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/TRUST_ENV |
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `redis`(需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
|
||||
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
|
||||
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
|
||||
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text`、`response`、`thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
|
||||
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
|
||||
|
||||
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
|
||||
|
||||
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
|
||||
|
||||
@@ -180,7 +436,7 @@ graph LR
|
||||
| `telemetry/` | SQLite / Postgres 遥测后端 |
|
||||
| `structured/` | 结构化输出策略 |
|
||||
|
||||
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D14 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D15 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||
|
||||
## 可靠性证据
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.2.0"
|
||||
version = "1.2.3"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
|
||||
@@ -119,7 +119,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构决策记录(D1–D14,含讨论过程与备选方案)
|
||||
## 3. 架构决策记录(D1–D15,含讨论过程与备选方案)
|
||||
|
||||
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
|
||||
|
||||
@@ -248,6 +248,22 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
**影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。
|
||||
|
||||
### D15 库对下游数据库只做 SELECT/INSERT + 可选 CREATE;改结构与删数据归下游(2026-08-19,issue #13 立,issue #12 补删数据一面)
|
||||
|
||||
**决策**: 遥测表 `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类——catalog 探测(PG `to_regclass` + `pg_attribute`,SQLite `PRAGMA table_info`)、显式列名的 `INSERT`、以及表不存在时的 `CREATE TABLE IF NOT EXISTS`;**改结构(`ALTER`)与删数据(`UPDATE`/`DELETE`/`TRUNCATE`/`DROP`)一律归下游**。`ALTER` 保留唯一一个受控出口:`PGW_TELEMETRY_SCHEMA_MODE=auto` 时给已存在的旧表补列,而该档在 PG 侧**不是缺省**(缺省按后端派生: sqlite→auto、postgres→manual)。配套五条 Expand/Contract 承诺:新列只增不删不改名且追加在既有列之后、新列必可空或带非易失常量默认值、`INSERT` 永远显式列名、库从不 `SELECT *` 也从不读回该表数据、写入的冲突处理不绑定具体约束。
|
||||
|
||||
**背景与讨论**: 补列此前没有任何开关,库一升级、下次调用即在下游生产库上发 DDL。issue #13 的三条指控成立: ① 与最小权限原则冲突;② 多进程/多版本共存时谁先补列是竞态;③ DDL 不进任何迁移记录,DBA 事后无从审计。量级判据是 `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 `await`。调研的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。
|
||||
|
||||
两条边界是讨论出来的、不是照抄先例: **① 缺省按后端不对称**(D-a,人类拍板)——issue 引用的全部先例语境都是共享的生产 PG,而本库的 SQLite 侧是下游自己的本地文件(没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作),两侧统一 manual 会给零运维场景强加运维步骤;两侧有意不对称在本库已有先例(§7.8 的建表探测,issue #9)。**② manual 档不连 `CREATE TABLE` 一起停**——新建表没有既有数据与并发访问者,不存在锁队列与数据风险,停掉它会让"零配置起步"断掉(Celery 的先例同样是"自动建表 + 永不 ALTER")。**③ 关掉 `ALTER` 必须配套按现有列裁剪 `INSERT`**,否则旧表缺列时每行写入都被拒,是把自动补列换成静默全失能,比原问题更严重地违反「遥测必录」。
|
||||
|
||||
五条承诺本身是既有实现的**成文化**(零代码变更),但成文后才可被下游依赖——它同时是遥测保留期方案(issue #12)能成立的前提: 下游拿这份 schema 自己加 `PARTITION BY RANGE (created_at)` 建成分区表后,库的 `to_regclass` 探测、列探测与 `INSERT` 路由都照常工作。第五条(冲突处理不绑定约束)是审查带出的**新增**承诺,并伴随一处真实修复,见 §7.8。
|
||||
|
||||
**删数据这一半(2026-08-19,issue #12)**: D15 里 `DELETE`/`TRUNCATE`/`DROP` 归下游,不只是"库不去做",是库连**手段**都不该持有——保留期与访问控制因此以 README 的 DDL 模板加 `tools/telemetry_retention.py` 独立脚本交付,库本体不 import 该脚本,连接串上也不需要任何删权限。这是 (b) 保留期与 (c) 不可变性两条诉求的**权限张力**逼出来的唯一解: 模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`——分区在这里**不可替代**,不是性能偏好(`DROP PARTITION` 是 DDL,同样不触发行级的不可变性触发器,且 O(1)、不留膨胀)。脚本只是存量普通表的兜底: 默认 dry-run,探测到分区表即以退出码 3 让路。库本体在 #12 里唯一的代码面是**预防性**的正文截断(§7.8)——没写进去的数据不需要删,这也是三个子问题里唯一能靠库解决的那个。
|
||||
|
||||
**被否决的备选**: 两侧统一缺省 manual(语义最一致,但现有 SQLite 下游升级即需人工干预,而这些场景根本没有承接手工 SQL 的角色);保持 auto 缺省只加关闭档(默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足);Celery 式"自动建表但永不 ALTER、无开关"(SQLite 场景纯净损失,且真想要自动补列的下游没有出路);APScheduler 4.x 式"schema 不认识就拒绝启动"(与「遥测初始化失败必须静默降级」的库铁律正面冲突,不可选)。
|
||||
|
||||
**影响**: §7.8 补列一节按档位重写;新增配置键 `PGW_TELEMETRY_SCHEMA_MODE`(§9)与公共函数 `telemetry_schema_sql`;两个 recorder 新增 keyword-only 必填参数 `auto_migrate`、`GatewaySettings` 新增必填字段 `telemetry_auto_migrate`(缺省规则只写在 config 一处,不与类签名漂移);五条承诺进 README(随包分发)。issue #12 实现同一条边界的"删数据"一面: 新增可选键 `PGW_TELEMETRY_TEXT_CAP` 与遥测正文截断(§7.8、§9),保留期与访问控制走文档模板 + `tools/` 脚本,库的权限面不扩大。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体架构
|
||||
@@ -363,6 +379,16 @@ flowchart TB
|
||||
|
||||
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
||||
|
||||
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
|
||||
|
||||
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
|
||||
|
||||
② **两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。
|
||||
|
||||
③ **维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。
|
||||
|
||||
④ **库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误模型
|
||||
@@ -475,12 +501,20 @@ flowchart TB
|
||||
|
||||
### 7.8 遥测与成本
|
||||
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。
|
||||
**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。
|
||||
|
||||
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||
|
||||
**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。
|
||||
|
||||
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
|
||||
|
||||
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
|
||||
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
||||
|
||||
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"` 的 `text`、`response`、`thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
|
||||
|
||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
|
||||
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
|
||||
@@ -544,6 +578,8 @@ src/polygateway/
|
||||
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
|
||||
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
|
||||
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis`、`PGW_TELEMETRY_BACKEND=sqlite|postgres`、`PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
|
||||
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
|
||||
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# 调用方自定义维度设计(issue #11)
|
||||
|
||||
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
|
||||
- **触发**: 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 条)。**已另开 issue #12**(2026-08-17)。
|
||||
|
||||
**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
### 1.3 明确不做
|
||||
|
||||
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。
|
||||
|
||||
---
|
||||
|
||||
## 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, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)` 与 `parse_layout(image, *, ..., 同两参数)`。
|
||||
|
||||
**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。
|
||||
|
||||
**为什么 `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: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
|
||||
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
|
||||
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |
|
||||
|
||||
**非有限 float 必须在入口拒绝(Codex 审查发现)**。`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。
|
||||
|
||||
**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()` 与 `embed()`)而非遥测写入点,理由是 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` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。
|
||||
|
||||
**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**。`EmbeddingClient` 不经过 chat 洋葱: `embed()` → `_embed_batch()` → `_attempt()` → `_emit()`,而 `_emit()` 在 `embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。
|
||||
|
||||
**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()` → `_call()` → `_attempt()` → `_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。
|
||||
|
||||
**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
|
||||
|
||||
**缓存命中行与终态失败行同样带维度**: 前者读 `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 的行。
|
||||
|
||||
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:
|
||||
|
||||
| 契约 | 处置 |
|
||||
|---|---|
|
||||
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
|
||||
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
|
||||
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
|
||||
|
||||
**有意放弃**: 无。**未声明的隐式丢弃**: 无。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
|
||||
|
||||
**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
|
||||
|
||||
**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。
|
||||
|
||||
**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
|
||||
|
||||
`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误分类与测试策略
|
||||
|
||||
**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
|
||||
|
||||
**测试策略**(合并前需先失败后通过的证据):
|
||||
|
||||
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。
|
||||
|
||||
**三条链路各测一遍**——`chat()`、`embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text` 与 `parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。
|
||||
|
||||
非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})` 抛 `ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
|
||||
|
||||
集成层——真实 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 #13**(2026-08-17)。
|
||||
@@ -0,0 +1,145 @@
|
||||
# issue #12 设计: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例)
|
||||
> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
|
||||
|
||||
1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
|
||||
2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
|
||||
|
||||
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) |
|
||||
| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 |
|
||||
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
|
||||
|
||||
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
|
||||
## 3. 三个子问题的边界
|
||||
|
||||
| 子问题 | 库能做什么 | 性质 |
|
||||
|---|---|---|
|
||||
| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 |
|
||||
| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP |
|
||||
| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 |
|
||||
|
||||
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
|
||||
|
||||
## 4. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
|
||||
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
|
||||
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
|
||||
| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
|
||||
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
|
||||
|
||||
## 5. 设计: (a) 正文截断
|
||||
|
||||
### 5.1 配置与装配
|
||||
|
||||
| 层 | 形态 |
|
||||
|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0` 报 `ValueError` |
|
||||
| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) |
|
||||
|
||||
### 5.2 作用面与切法
|
||||
|
||||
截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"` 的 `text` 字段)、`response`、`thinking`。
|
||||
|
||||
**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
|
||||
|
||||
**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
|
||||
|
||||
非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
|
||||
|
||||
**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。
|
||||
|
||||
**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73` 与 `ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `<ocr:kind image_bytes=N>` 占位、`response` 走 `_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
|
||||
|
||||
### 5.3 红线
|
||||
|
||||
**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
|
||||
|
||||
## 6. 设计: (b) 保留期
|
||||
|
||||
**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
|
||||
|
||||
### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
|
||||
|
||||
PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
|
||||
|
||||
修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。
|
||||
|
||||
**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。
|
||||
|
||||
### 6.2 模板与工具
|
||||
|
||||
分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13 的 `telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
|
||||
|
||||
**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则):
|
||||
|
||||
| 项 | 设计 |
|
||||
|---|---|
|
||||
| 参数 | `--backend sqlite\|postgres`、`--path/--dsn`、`--older-than-days N`、`--apply`(**默认 dry-run**)、`--batch-size`、`--vacuum`(仅 SQLite,显式) |
|
||||
| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 |
|
||||
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE |
|
||||
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) |
|
||||
| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
|
||||
|
||||
## 7. 设计: (c) 访问控制与不可变性(纯文档)
|
||||
|
||||
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
|
||||
|
||||
| 内容 | 要点 |
|
||||
|---|---|
|
||||
| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) |
|
||||
| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 |
|
||||
| 分区 | 与 §6 的 retention 模板同一段落 |
|
||||
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
|
||||
|
||||
**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
|
||||
## 8. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 |
|
||||
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 |
|
||||
| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 |
|
||||
| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
|
||||
|
||||
## 9. 错误处理与测试策略
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 |
|
||||
| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" |
|
||||
| unit | config: 未设 → `None`;`<= 0` → `ValueError`;合法值透传到 emitter |
|
||||
| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 |
|
||||
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
|
||||
| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 |
|
||||
| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
|
||||
|
||||
遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。
|
||||
|
||||
## 10. 兼容性、文档与发布
|
||||
|
||||
**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。
|
||||
|
||||
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。
|
||||
|
||||
## 11. 开放问题
|
||||
|
||||
1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。
|
||||
2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
|
||||
3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。
|
||||
@@ -0,0 +1,146 @@
|
||||
# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来)
|
||||
> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
两个遥测后端在构造期(SQLite)/首次写入前(PG)会对下游数据库发 DDL: 表不存在则 `CREATE TABLE`,表存在但缺列则逐列 `ALTER TABLE ... ADD COLUMN`。**补列没有任何开关**,库升级后首次调用即自动执行,而 issue #11 刚给这张表加了两列,这条路径的使用频率正在上升。
|
||||
|
||||
issue #13 的三条指控成立: ① 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突; ② 多进程/多版本共存时谁先补列是竞态; ③ DDL 不进任何迁移记录,下游 DBA 事后无从审计表何时被谁改过。调研的 11 个同类先例(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中,**没有一个支持"库在下游库里自动 ALTER 出列"作为默认行为**。
|
||||
|
||||
### 1.1 issue 未区分、但决定方案形状的两点
|
||||
|
||||
**① SQLite 与 Postgres 的风险完全不对称。** issue 引用的全部先例(Hangfire 的锁队列雪崩、Prefect 的多实例竞态、Alembic 的 DBA 审计链)语境都是**共享的生产 PG**: `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。本库的 SQLite 侧则是下游自己的本地文件(VT / CHSAnalyzer / dissect 的 `runs/*.db` 全是这个形态): 没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作。让 SQLite 也要求"升级后手工跑一条 SQL",是给零运维场景强加运维步骤。两侧有意不对称在本库已有先例——`sqlite.py` 文件头写着"别为了代码对称把建表探测加回来"(issue #9)。
|
||||
|
||||
**② 关掉 ALTER 必须配套"按现有列裁剪 INSERT",否则是把自动补列换成静默全失能。** 今天 `_INSERT` 是 24 列的固定语句。旧表缺 `tenant_id` 时若不 ALTER,INSERT 会因未知列**全部失败** → 逐行 warning → 遥测彻底丢失。这比自动 ALTER 更严重地违反"遥测必录"。故降级写入不是可选增强,是本变更成立的前提。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| D-a | 默认档 | **不对称**: PG 默认 manual(不 ALTER),SQLite 默认 auto(保持自动);同一配置项两侧均可覆盖 |
|
||||
| D-b | SQL 投放渠道 | warning 打印完整语句 **+** 新增公共函数供下游主动索取 |
|
||||
| D-c | 缺省规则落点 | **config 层派生**,recorder 的开关参数为 keyword-only **必填** |
|
||||
| D-d | 交付节奏 | 独立分支实现,与 issue #12 合并发 **1.2.3** |
|
||||
|
||||
## 3. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 按后端不对称默认 + 三态配置 + 裁剪写入 + schema SQL 公共函数 | PG 侧满足 issue 全部诉求;SQLite 侧零运维负担不变;代价是同一配置键在两后端缺省值不同,须文档讲清 | ✅ |
|
||||
| B | 两侧统一默认 manual | 语义最一致、最贴 issue 原文 | ❌ 现有 SQLite 下游(VT/CHS/dissect)升级即需人工干预,否则新维度静默缺失,而这些场景根本没有承接手工 SQL 的角色 |
|
||||
| C | 保持 auto 默认,只加关闭档 | 非破坏性 | ❌ 默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足,只是提供了绕法 |
|
||||
| D | Celery 式: 自动建表但**永不** ALTER,无开关 | 最简、无配置面 | ❌ SQLite 场景纯净损失;且下游若确实想要自动补列,库不给任何出路 |
|
||||
| E | APScheduler 4.x 式: schema 不认识就 `RuntimeError` 拒绝启动 | 最安全的一致性保证 | ❌ 与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选 |
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 配置与装配
|
||||
|
||||
新增环境键 `PGW_TELEMETRY_SCHEMA_MODE`,值域 `auto | manual`,**三态**: 未设 = 按后端派生,显式设置 = 两侧都可覆盖。
|
||||
|
||||
| 层 | 形态 | 理由 |
|
||||
|---|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_SCHEMA_MODE`(可选键),经既有 `_load_choice` 校验值域 | 与 `PGW_LIMITER_BACKEND` 等同族 |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_auto_migrate: bool`,**无默认值**(与既有全部字段一致) | settings 承载的是装配事实而非环境文本;派生只发生一次 |
|
||||
| recorder | `SQLiteRecorder(db_path, *, auto_migrate: bool)`、`PostgresRecorder(dsn, *, pool=None, auto_migrate: bool)`,keyword-only **必填** | D-c: 关键行为参数不给默认值(P4);缺省规则只写在 config 一处,不会与类签名漂移 |
|
||||
|
||||
`telemetry_backend=none` 时无 recorder 消费该字段,派生为 `False`。
|
||||
|
||||
### 4.2 行为矩阵
|
||||
|
||||
| 场景 | auto(今天的行为) | manual(新增) |
|
||||
|---|---|---|
|
||||
| 表不存在 | 建表 | **仍然建表** |
|
||||
| 表存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 表存在、缺列 | 逐列 ALTER;失败只 warning,不判死 | **不发 DDL**;warning 逐列点名 + 打印可执行 SQL(仅一次);按现有列裁剪 INSERT 继续写入 |
|
||||
| 列探测失败 | warning,沿用全量 24 列 | warning,沿用全量 24 列 |
|
||||
|
||||
**manual 档为什么不连 `CREATE TABLE` 一起停**: issue 把建表列为现状描述而非指控(它已在 #3/#9 收口为"先探测后建")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。Celery 的先例同样是"自动建表 + 永不 ALTER"。
|
||||
|
||||
### 4.3 裁剪写入
|
||||
|
||||
`effective_columns = [c for c in COLUMNS if c in existing]`(保序),据此实例级构造 INSERT 语句,`record_llm_call` 按 `self._columns` 取值。SQLite 在 `__init__` 末尾定型,PG 在 `_prepare_schema` 成功后与 `_schema_ready` **一起**赋值(两者必须同时生效,否则会出现"已就绪但语句还是旧的"的窗口)。
|
||||
|
||||
缺列 warning 必须**逐列点名**并写明后果("以下维度不会被记录: tenant_id, meta"),不能只说"缺列"——静默丢维度的后果是多租户账目全归空串且无任何报错。warning 只在准备期发一次,不逐行。
|
||||
|
||||
`call_id` 若不在现有列内,说明该表不是本库的 `llm_calls`(下游魔改或撞名),warning 升级措辞并照常尝试写入(由数据库自己拒绝),库不做二次判定。
|
||||
|
||||
### 4.4 新公共函数(D-b)
|
||||
|
||||
```python
|
||||
polygateway.telemetry_schema_sql(backend: str) -> str
|
||||
```
|
||||
|
||||
返回可直接粘进迁移文件的完整脚本: 注释头 + `CREATE TABLE IF NOT EXISTS`(全量列) + 分隔注释 + 各补列语句(PG 用 `ADD COLUMN IF NOT EXISTS`;SQLite 无该语法,以注释标明"仅当列不存在时执行")。非法 `backend` 抛 `ValueError`(公共入口显式校验,先例同 issue #11 的维度校验)。
|
||||
|
||||
**这不是锦上添花而是正确性要求**: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 `_DDL` / `_BACKFILL` / `_COLUMNS` 在 `sqlite.py` 与 `postgres.py` 各存一份,公共函数若再写一份,三份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。故新增 `telemetry/schema.py` 收敛为单一事实源,两个 recorder 与公共函数共用;顶层 `__init__` re-export 进 `__all__`。依赖方向不变(schema.py 在 telemetry 层内部,不 import 任何其他层),import-linter 契约无需改动。
|
||||
|
||||
### 4.5 Expand/Contract 成文化(零代码)
|
||||
|
||||
库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)、写入的冲突处理不绑定具体约束**。最后一条是 Codex 审查带出的**新增承诺**,见 §4.6。
|
||||
|
||||
它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测、列探测与 INSERT 路由都照常工作。
|
||||
|
||||
### 4.6 冲突目标改为无绑定(Codex 审查发现,阻断级)
|
||||
|
||||
PG 侧今天的写入是 `ON CONFLICT (call_id) DO NOTHING`,它要求一个恰好匹配 `(call_id)` 的唯一约束。而 PostgreSQL 要求分区表的唯一约束**必须包含分区键**——issue #12 的按 `created_at` 分区方案会把主键逼成 `(call_id, created_at)`,届时该语句**直接报错**,遥测在分区部署下全线写不进去。
|
||||
|
||||
改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 两种表形态都合法,普通表上与今天逐字等价(表上只有主键这一个唯一约束),SQLite 侧的 `INSERT OR IGNORE` 本就无目标、无需改动。
|
||||
|
||||
改动归属本 issue 而非 #12: 本 issue 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。分区部署下幂等语义的差异(缓存命中行复用历史 `call_id`)由 #12 的文档承接。
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
| 既有行为 | 处置 |
|
||||
|---|---|
|
||||
| SQLite 构造期 `PRAGMA table_info` 探测 | 保留 |
|
||||
| SQLite 逐列独立 try、`duplicate column` 视为成功(多进程共库竞态) | 保留(auto 档) |
|
||||
| SQLite 补列失败只 warning、绝不清空 `_conn` | 保留 |
|
||||
| SQLite 不做建表前探测(issue #9 的有意不对称) | 保留 |
|
||||
| PG `to_regclass` 建表前探测(权限检查早于 IF NOT EXISTS) | 保留 |
|
||||
| PG `pg_attribute` 列探测(避开 `ADD COLUMN IF NOT EXISTS` 的排他锁) | 保留 |
|
||||
| PG 补列失败不置 `_failed`、探测失败只跳过本次下次重试 | 保留 |
|
||||
| 24 列模块级固定 INSERT 常量 | **替换**为按探测结果裁剪的实例语句 |
|
||||
| `_DDL`/`_BACKFILL`/`_COLUMNS` 两文件各一份 | **替换**为 `telemetry/schema.py` 单一事实源 |
|
||||
| 补列无开关、库升级即自动执行 | **替换**为 `schema_mode` 三态配置 |
|
||||
| PG `ON CONFLICT (call_id) DO NOTHING` | **替换**为无冲突目标的 `ON CONFLICT DO NOTHING`(§4.6);普通表上语义逐字等价 |
|
||||
| SQLite `INSERT OR IGNORE` | 保留(本就无冲突目标) |
|
||||
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
|
||||
|
||||
无有意放弃项。
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | DDL 与探测仍只发生在构造期(SQLite)/首次准备期(PG,由既有 `_init_lock` 串行);manual 档不发 DDL,多进程竞态面积**缩小**;裁剪是纯计算,不新增 await 点;PG 既有 `except asyncio.CancelledError: raise` 全部保留 |
|
||||
| 降级方向 | 遥测属静默降级档: 缺列 → 降级写入 + warning,**绝不判死、绝不报错**;与"限流/熔断后端不可用须报错"的方向差异不变 |
|
||||
| 幂等与重复 | 探测与裁剪是纯读,重复执行安全;auto 档 ALTER 经探测 + duplicate 容错幂等;`ON CONFLICT (call_id) DO NOTHING` / `INSERT OR IGNORE` 不受影响 |
|
||||
| 持久化与原子性 | 无跨行事务;单条 INSERT 原子;裁剪不触及主键 `call_id`,幂等键语义不变;部分写入不可能发生 |
|
||||
|
||||
## 7. 错误处理与测试策略
|
||||
|
||||
遥测路径的一切失败仍不落四分类、不冒泡;`telemetry_schema_sql` 的非法参数是公共入口校验,抛裸 `ValueError`。
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit(真实临时 SQLite) | manual + 22 列旧表 → `PRAGMA` 列数不变(证明未 ALTER)、INSERT 成功且能读回、warning 同时含缺列名与 ALTER 语句;auto + 22 列旧表 → 补列(现状回归) |
|
||||
| unit | `telemetry_schema_sql` 与 `COLUMNS` 同源(输出含全部列名且顺序一致)、非法 backend 报 `ValueError` |
|
||||
| unit | config 派生: 未设键 → sqlite `True` / postgres `False`;显式设置覆盖两侧;非法值报错;`backend=none` → `False` |
|
||||
| integration(真实 PG) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 |
|
||||
| integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning |
|
||||
|
||||
每条行为变更须有先失败后通过的证据(测试结果门)。
|
||||
|
||||
## 8. 兼容性、文档与发布
|
||||
|
||||
**破坏性**(CHANGELOG 须给"请先读这一条"待遇): ① PG 下游升级后不再自动补列,新列需手工执行(库会打印语句); ② 两个 recorder 新增 keyword-only 必填参数,直接构造的调用点需改(全库 35 处,除 `client.py` 的两处装配点外均在测试内); ③ `GatewaySettings` 新增必填字段,影响"构造函数全量注入"这条装配路。
|
||||
|
||||
文档同步: README(配置键、Expand/Contract 承诺、schema SQL 用法)、`.env.example`、ARCHITECTURE §7.8、Wiki `参考-配置键` / `参考-公共API` / `指南-遥测与成本`。
|
||||
|
||||
## 9. 开放问题
|
||||
|
||||
1. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
|
||||
2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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 条(保留期与访问控制)另开,**已建 issue #12**。
|
||||
- **范围补正(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,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
|
||||
- **另开议题(已建 issue #13)**: `_BACKFILL` 自动 ALTER 是否应降级为默认关闭(Hangfire `EnableHeavyMigrations` 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue12-telemetry-retention
|
||||
title: "issue #12: 遥测表的正文体量、保留期与访问控制"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #12: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。
|
||||
|
||||
- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。
|
||||
- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。
|
||||
- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。
|
||||
- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。
|
||||
- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。
|
||||
- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。
|
||||
- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder` 加 `purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。
|
||||
- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue13-schema-mode
|
||||
title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。
|
||||
- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。
|
||||
- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。
|
||||
- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。
|
||||
- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。
|
||||
- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。
|
||||
- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。
|
||||
- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。
|
||||
@@ -160,6 +160,31 @@
|
||||
"id": "plan:issue10-error-body-retention-plan",
|
||||
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:issue11-caller-dimensions",
|
||||
"label": "调用方自定义维度实现计划(issue #11)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue13-schema-mode",
|
||||
"label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "design:issue12-telemetry-retention",
|
||||
"label": "issue #12: 遥测表的正文体量、保留期与访问控制",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue13-schema-mode",
|
||||
"label": "实现计划: issue13-schema-mode",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue12-telemetry-retention",
|
||||
"label": "实现计划: issue12-telemetry-retention",
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
@@ -288,6 +313,27 @@
|
||||
"relation": "implements",
|
||||
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
||||
"added": "2026-08-16T09:50:57.830855+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:issue11-caller-dimensions",
|
||||
"target": "design:issue11-caller-dimensions",
|
||||
"relation": "implements",
|
||||
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
|
||||
"added": "2026-08-17T10:09:08.967997+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue13-schema-mode",
|
||||
"target": "design:issue13-schema-mode",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md",
|
||||
"added": "2026-08-19T13:10:55.616264+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue12-telemetry-retention",
|
||||
"target": "design:issue12-telemetry-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md",
|
||||
"added": "2026-08-19T13:10:57.986963+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-3
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-16 09:50 UTC
|
||||
> 自动生成,更新时间:2026-08-19 13:10 UTC
|
||||
|
||||
## design (28)
|
||||
## design (34)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -16,10 +16,15 @@
|
||||
- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design`
|
||||
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
|
||||
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
|
||||
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
|
||||
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design`
|
||||
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
|
||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
|
||||
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
|
||||
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
|
||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||
@@ -30,6 +35,7 @@
|
||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (12)
|
||||
@@ -46,7 +52,7 @@
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (23)
|
||||
## plan (29)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
@@ -58,6 +64,9 @@
|
||||
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
||||
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
|
||||
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
|
||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||
@@ -67,8 +76,11 @@
|
||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
|
||||
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
|
||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||
|
||||
## schema (1)
|
||||
|
||||
@@ -102,3 +102,15 @@
|
||||
- [2026-08-16 09:50 UTC] 新增 plan: 实现计划: HTTP 错误响应体留存(Issue #10) (plan:issue10-error-body-retention-plan)
|
||||
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
||||
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
||||
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
|
||||
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
|
||||
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
|
||||
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode)
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention)
|
||||
- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention
|
||||
- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# 实现计划: 调用方自定义维度(issue #11)
|
||||
|
||||
- **目标**: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——`tenant_id` 为真实列(可挂 RLS),其余进 `meta` JSON 容器。
|
||||
- **方案概述**: `llm_calls` 增两列(`tenant_id TEXT NOT NULL DEFAULT ''`、`meta` JSON);三个公共入口(`chat`/`embed`/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 `ValueError`;`TelemetryRecorder` 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md`(已人类审批 2026-08-17)。
|
||||
- **涉及技术**: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
|
||||
## 范围: 三条遥测链路(2026-08-17 人类追认)
|
||||
|
||||
设计初稿只覆盖 `chat()` 与 `embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。
|
||||
|
||||
**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/types.py` | 修改 | 新增 `validate_caller_dimensions()`;`ChatRequest` 增两字段 |
|
||||
| `src/polygateway/ports.py` | 修改 | `TelemetryRecorder.record_llm_call` 22 → 24 字段 |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | DDL 增两列、`_BACKFILL_COLUMNS` 增两项、`_COLUMNS` 增两项 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上(`_DDL`/`_BACKFILL`/`_COLUMNS`) |
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_record` 归一化并透传;三个 emit 入口从 request 读取 |
|
||||
| `src/polygateway/client.py` | 修改 | `chat()` 增两参数并校验 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embed()` 增两参数;沿 `_embed_batch`/`_attempt`/`_emit` 透传 |
|
||||
| `src/polygateway/ocr.py` | 修改 | `recognize_text`/`parse_layout` 增两参数;沿 `_call`/`_attempt`/`_emit` 透传 |
|
||||
| `tests/unit/test_types.py` | 修改 | 校验函数红线 |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 三个 emit 入口带维度 |
|
||||
| `tests/unit/test_client.py`、`test_embedding.py`、`test_ocr_client.py` | 修改 | 三条链路各自的端到端透传 |
|
||||
| `tests/unit/test_ports.py` | 修改 | 端口 24 字段契约 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG 补列与写入 |
|
||||
| `README.md`、`CHANGELOG.md`、Gitea wiki | 修改 | 能力表、RLS 模板、版本说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
校验函数(`types.py`,紧邻 `validate_request_overlay` 放置,同款风格):
|
||||
|
||||
```python
|
||||
def validate_caller_dimensions(
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any] | None,
|
||||
*,
|
||||
origin: str,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""
|
||||
```
|
||||
|
||||
`ChatRequest` 新字段(必须带默认值,ARCH §5.1 约定①):
|
||||
|
||||
```python
|
||||
tenant_id: str | None = None
|
||||
meta: Mapping[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
`TelemetryRecorder.record_llm_call` 新增两个**无默认值**参数(`ports.py` 现有纪律),排在 `reasoning_tokens` 之后:
|
||||
|
||||
```python
|
||||
tenant_id: str, # 已归一化: None → ''
|
||||
meta: str, # 已序列化: 空 dict → '{}'
|
||||
```
|
||||
|
||||
**归一化在 emitter 完成,不在 recorder**——与 `sampling` 列由 `canonical_sampling_json()` 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 校验函数与请求字段
|
||||
|
||||
**文件**: `src/polygateway/types.py`(修改)、`tests/unit/test_types.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
在 `validate_request_overlay` 之后新增 `validate_caller_dimensions()`,按 Phase 组织(照搬既有风格):
|
||||
- Phase 1 `tenant_id`: `None` 直接放行;非 `str` 报错;**`tenant_id != tenant_id.strip()` 报错**(首尾空白一律拒绝,不是"strip 后为空才拒绝"——`" t1"` 与 `"t1"` 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);`strip()` 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
|
||||
- Phase 2 `meta` 键形态: 非 `str` 报错;不匹配 `^[a-z0-9_.]{1,64}$` 报错;以 `pg_` 开头报错(保留前缀)。
|
||||
- Phase 3 `meta` 键数量: > 16 报错。
|
||||
- Phase 4 `meta` 值: 类型不属 `(str, int, float, bool)` 报错(注意 `bool` 是 `int` 子类,先判 `bool` 无妨,两者都合法);`float` 且 `not math.isfinite(v)` 报错;`str` 且长度 > 256 报错。
|
||||
- 返回 `(tenant_id, dict(meta or {}))` —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 `overlay` 先例)。
|
||||
|
||||
`ChatRequest` 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;`meta` 不进缓存 key(`cache_namespace` 已负责租户隔离,ARCH §7.5)。
|
||||
|
||||
**`ChatRequest.meta` 必须保存校验函数返回的那个浅拷贝**,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 `overlay` 拷贝语义的理由)。
|
||||
|
||||
**验收标准**: 每条红线抛 `ValueError` 且消息含 `origin`;合法输入返回浅拷贝且与入参不是同一对象。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
逐条红线各一个用例——`tenant_id` 空串/纯空白/**`" t1"`/`"t1 "`(首尾空白)**/超长/非 str;`meta` 键非 str/含大写/含连字符/超 64 字符/`pg_` 前缀/17 个键;值为 `list`/`dict`/`None`/`nan`/`inf`/`-inf`/超 256 字符的 str。另加合法路径用例: `tenant_id=None` + `meta={}` 放行、`meta` 值为 `bool`/`int`/`float` 有限值放行、返回值是拷贝(改返回值不影响入参)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_types.py -v` → 全 PASS
|
||||
|
||||
**另需一条缓存隔离测试**(放 `tests/unit/test_cache.py` 或 `test_client.py`): 相同 `messages` + 相同 `cache_namespace`、**仅 `meta` 不同**的两次调用,第二次**仍应命中缓存**。设计明确 `meta` 不进缓存 key(`cache_namespace` 已负责租户隔离);没有这条测试,实现者顺手把 `meta` 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
|
||||
|
||||
- [ ] 提交点: `feat: validate the dimensions a caller may attach to a call`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 端口与两个遥测后端 schema
|
||||
|
||||
**文件**: `src/polygateway/ports.py`、`src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、`tests/unit/test_ports.py`(均修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`ports.py`: `record_llm_call` 增 `tenant_id: str` 与 `meta: str`(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
|
||||
|
||||
`sqlite.py` 三处同步改(顺序必须一致):
|
||||
- `_DDL` 在 `reasoning_tokens` 之后追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta TEXT NOT NULL DEFAULT '{}'`;
|
||||
- `_BACKFILL_COLUMNS` 追加 `("tenant_id", "TEXT NOT NULL DEFAULT ''")` 与 `("meta", "TEXT NOT NULL DEFAULT '{}'")` —— SQLite 硬性要求 `NOT NULL` 列必须带非 NULL 常量默认值,缺默认值会报 `Cannot add a NOT NULL column with default value NULL`;
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
`postgres.py` 同三处:
|
||||
- `_DDL` 追加 `tenant_id TEXT NOT NULL DEFAULT ''` 与 `meta JSONB NOT NULL DEFAULT '{}'::jsonb`;
|
||||
- `_BACKFILL` 追加两条 `ALTER TABLE llm_calls ADD COLUMN ...`(默认值均为非易失常量,PG 11+ 不重写全表);
|
||||
- `_COLUMNS` 追加两项。
|
||||
|
||||
**新列必须排在末尾**(`created_at` 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(`sqlite.py:53` 既有注释)。
|
||||
|
||||
**验收标准**: 两个后端的 `_COLUMNS` 逐字同名同序;新建库与旧表补列后列集合一致。
|
||||
|
||||
**测试要求**(先失败后通过): 扩展现有列序断言测试,断言两后端 `_COLUMNS` 相等且末两项为 `("tenant_id", "meta")`;`test_ports.py` 断言 `record_llm_call` 的参数集合含新两项且**无默认值**(用 `inspect.signature` 实测,不凭记忆)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: give the telemetry table a tenant column and a meta container`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Emitter 透传与归一化
|
||||
|
||||
**文件**: `src/polygateway/middleware/telemetry.py`(修改)、`tests/unit/test_telemetry.py`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
`_record` 增两个形参,**位置排在现有末参 `reasoning_tokens` 之后**(它是 keyword-only,顺序不影响调用,但与 `_COLUMNS`/端口的追加位置保持一致便于逐行比对):
|
||||
|
||||
```python
|
||||
async def _record(
|
||||
self, *, ...,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str | None, # 新增: 未归一化,None 合法
|
||||
meta: Mapping[str, Any], # 新增: 未序列化,空 dict 合法
|
||||
) -> None:
|
||||
```
|
||||
|
||||
在传给 recorder 前归一化: `tenant_id or ''`;`json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)`,空 dict 直接用字面量 `'{}'`。
|
||||
|
||||
`allow_nan=False` 是第二道闸(主防线是 Task 1 的入口校验)——`json.dumps` 默认把 `nan` 写成 `NaN` 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。
|
||||
|
||||
三个 emit 入口统一从 `request.tenant_id` / `request.meta` 读取,不各自组装(遥测调用点收敛铁律):
|
||||
- `emit_attempt`、`emit_terminal_failure`: 直接读 `request`;
|
||||
- `emit_cache_hit`: **同样读 `request` 而非缓存中的历史响应**——维度是"本次调用由谁发起",不是历史那次。
|
||||
|
||||
**验收标准**: 三条路径写出的行都带维度;`tenant_id=None` 落 `''`;`meta={}` 落 `'{}'` 而非 NULL。
|
||||
|
||||
**测试要求**(先失败后通过): 用 fake recorder 断言三个入口各自收到的 `tenant_id`/`meta` 值;`emit_cache_hit` 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 **A**(这是最容易实现反的一处);`meta` 序列化后键有序(`sort_keys=True`,便于跨行比对)。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the single telemetry helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `chat()` 公共入口
|
||||
|
||||
**文件**: `src/polygateway/client.py`(修改)、`tests/unit/test_client.py`(修改)
|
||||
|
||||
**行为**: `chat()` 签名末尾增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`(带默认值的 keyword-only,签名冻结承诺不破)。在既有 `validate_request_overlay` 调用旁调 `validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)")`,把返回值填进 `ChatRequest`。校验必须在**进洋葱之前**——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。
|
||||
|
||||
**验收标准**: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 `chat()` 抛 `ValueError` 且**未产生任何遥测行**。
|
||||
|
||||
**测试要求**(先失败后通过): 端到端——`chat(..., tenant_id="t1", meta={"batch": "b-42"})` 后 fake recorder 收到的行带这两个值;非法 `meta` 抛 `ValueError` 且 recorder **零调用**(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 `''` 与 `'{}'`。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: let chat() take a tenant and caller-defined dimensions`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `embed()` 链路透传
|
||||
|
||||
**文件**: `src/polygateway/embedding.py`(修改)、`tests/unit/test_embedding.py`(修改)
|
||||
|
||||
**行为**: `embed()` 增两个 keyword-only 参数并在入口校验(`origin="embed(tenant_id=..., meta=...)"`),沿 `_embed_batch()` → `_attempt()` → `_emit()` **逐层透传**,在 `_emit()`(`embedding.py:360`)构造 `ChatRequest` 时填入。
|
||||
|
||||
该链路已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数。**不顺手把它们收成值对象**——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。
|
||||
|
||||
**验收标准**: 多批(`texts` 长度 > `batch_size`)时**每一批的行都带同一份维度**——维度属于本次 `embed()` 调用,不随批次变化。
|
||||
|
||||
**测试要求**(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的**每一行**都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_embedding.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions down the embedding chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: OCR 链路透传
|
||||
|
||||
**文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改)
|
||||
|
||||
**行为**: `recognize_text()` 与 `parse_layout()` 各增两个 keyword-only 参数并在入口校验(`origin` 分别标明方法名),沿 `_call()` → `_attempt()` → `_emit()` 透传,在 `_emit()`(`ocr.py:398`)构造 `ChatRequest` 时填入。结构与 Task 5 同构。
|
||||
|
||||
**验收标准**: 两个公共方法都覆盖(只改一个即漏)。
|
||||
|
||||
**测试要求**(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 `ValueError` 且零遥测。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v` → PASS
|
||||
|
||||
- [ ] 提交点: `feat: carry caller dimensions through the OCR chain`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 真实后端集成验收
|
||||
|
||||
**文件**: `tests/integration/test_postgres_telemetry.py`(修改)、`tests/unit/test_telemetry.py`(补 SQLite 真实文件用例)
|
||||
|
||||
**行为**: 覆盖三件事,每件两个后端各测一遍。
|
||||
|
||||
1. **新建库**: 表列齐全,写入后读回维度一致。
|
||||
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
|
||||
3. **补列失败的降级方向**: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。**两端的失败构造方式不同,不可笼统写"各测一遍"**:
|
||||
- **Postgres**: 用只有 `SELECT, INSERT ON llm_calls` 权限的角色连接——`ALTER TABLE` 的 ownership 检查早于 `IF NOT EXISTS` 的存在性判断,故必然失败。断言: 记 warning、`_failed` **未**置位、后续 INSERT 仍尝试。
|
||||
- **SQLite**: 无角色权限模型,等价构造是**文件只读**(`chmod 444` 或以 `file:...?mode=ro` 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 `self._conn`、不抛出 `__init__`"(即 `sqlite.py:112` 那条既有纪律),**不断言"写入仍成功"**——那在只读库上本就不可能。
|
||||
|
||||
**验收标准**: PG 与 SQLite 行为对称;补列走既有 `_BACKFILL`,不新增 DDL 路径。
|
||||
|
||||
**测试要求**(先失败后通过): 上述三项即测试本体,**同样适用红绿证据门**——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。
|
||||
|
||||
**验证命令**:
|
||||
`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS
|
||||
`conda run -n PolyGateway pytest tests/ -q` → 全套件 PASS(**命令末尾不接管道**,否则退出码失真)
|
||||
|
||||
- [ ] 提交点: `test: prove old telemetry tables gain the tenant column safely`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 文档同步
|
||||
|
||||
**文件**: `README.md`、`CHANGELOG.md`、Gitea wiki 的 `指南-遥测与成本` 与 `参考-公共API` 两页
|
||||
|
||||
按 `docs-convention.md` §2「新公共 API / 新能力」行,须同步「对应指南页 + `参考-公共API` + 侧边栏 + CHANGELOG」。本次**扩写** `指南-遥测与成本`(新增"多租户与自定义维度"一节)而非新建页,故**侧边栏与 `Home.md` 不动**——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 `指南-多租户`),则必须一并改 `_Sidebar.md` 与 `Home.md` 分流表。
|
||||
|
||||
**行为**:
|
||||
|
||||
- **CHANGELOG**: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、**以及库不建索引/不启用 RLS 的边界**。
|
||||
- **README**: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 `inspect.signature` **实测**后再写(发布流程 §4.4.1 第 1 步的教训)。
|
||||
- **`参考-公共API`**: 更新 `chat()`、`embed()`(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
|
||||
- **`指南-遥测与成本`**: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(`ENABLE` + `FORCE` + `USING`/`WITH CHECK` 双写 + `NULLIF(current_setting(..., true), '')`)与复合索引 `(tenant_id, created_at)`,并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在**显式事务内**用 `set_config(..., true)`(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行。
|
||||
- wiki 必须明确: **执行这些 DDL 是下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个普通列,没有数据库层强制。
|
||||
|
||||
**验收标准**: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。
|
||||
|
||||
**验证命令**: `conda run -n PolyGateway make lint` → PASS;人工核对 wiki 页面渲染。
|
||||
|
||||
- [ ] 提交点: `docs: document caller dimensions and the RLS template`
|
||||
|
||||
---
|
||||
|
||||
## 全局验收
|
||||
|
||||
- [ ] `conda run -n PolyGateway make lint` → PASS(含 import-linter 依赖契约)
|
||||
- [ ] `conda run -n PolyGateway make test` → PASS,覆盖率不低于改动前
|
||||
- [ ] 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
|
||||
- [ ] 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
|
||||
- [ ] 旧表补列后老行读出空串的证据(issue 核心论点的验收)
|
||||
@@ -0,0 +1,180 @@
|
||||
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
|
||||
|
||||
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451` 的 `_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter` 增 `text_cap` 必填 |
|
||||
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings` 增 `telemetry_text_cap` |
|
||||
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
|
||||
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
|
||||
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
|
||||
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
|
||||
| `tests/unit/test_config.py` | 修改 | 配置校验 |
|
||||
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
|
||||
| `README.md`、`CHANGELOG.md`、`.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
|
||||
|
||||
```python
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
"""
|
||||
```
|
||||
|
||||
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class TelemetryEmitter:
|
||||
def __init__(
|
||||
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None: ...
|
||||
```
|
||||
|
||||
三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性):
|
||||
|
||||
```python
|
||||
class GatewayClient: # client.py:130 起的构造签名
|
||||
def __init__(self, *, ..., text_cap: int | None = None) -> None: ...
|
||||
# EmbeddingClient / OcrClient 同款
|
||||
```
|
||||
|
||||
**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。
|
||||
|
||||
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
|
||||
|
||||
```python
|
||||
telemetry_text_cap: int | None
|
||||
```
|
||||
|
||||
`tools/telemetry_retention.py` 的 CLI 契约:
|
||||
|
||||
```text
|
||||
--backend sqlite|postgres 必填
|
||||
--path PATH | --dsn DSN 按 backend 二选一,必填
|
||||
--older-than-days N 必填,N >= 0
|
||||
--apply 缺省不带即 dry-run(只统计不删)
|
||||
--batch-size N 仅 postgres,缺省 1000
|
||||
--vacuum 仅 sqlite,须与 --apply 同时给
|
||||
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 正文截断与 emitter 参数
|
||||
|
||||
- [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。
|
||||
- **行为**:
|
||||
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。
|
||||
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。
|
||||
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
|
||||
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
|
||||
- `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
|
||||
- **验收**:
|
||||
- `cap=None` → 落库正文与今天逐字节相同。
|
||||
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
|
||||
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
|
||||
- 非字符串 content(如 `123`、`None`、嵌套 dict)不抛异常。
|
||||
- `response`/`thinking` 同样受 cap。
|
||||
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
|
||||
- **测试**:
|
||||
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
|
||||
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
|
||||
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
|
||||
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
|
||||
- **提交**: `feat: cap telemetry bodies at a configurable length`
|
||||
|
||||
## Task 2: 配置与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。
|
||||
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings` 增 `telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0` 报 `ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
|
||||
- **验收**: 未设 → `None`;`"0"` 与 `"-1"` 报 `ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
|
||||
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: wire the telemetry text cap through settings`
|
||||
|
||||
## Task 3: 保留期脚本
|
||||
|
||||
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`。
|
||||
- **行为**: 按上文 CLI 契约实现。
|
||||
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
|
||||
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
|
||||
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
|
||||
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
|
||||
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
|
||||
- dry-run 后行数不变,stdout 含将删行数与时间范围。
|
||||
- `--apply` 后仅超期行被删,未超期行完好。
|
||||
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
|
||||
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
|
||||
- `--vacuum` 不带 `--apply` 时退出码 1。
|
||||
- **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。
|
||||
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。
|
||||
- **提交**: `feat: add a retention script downstreams can schedule`
|
||||
|
||||
## Task 4: 生产部署模板与其机械化验收
|
||||
|
||||
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
|
||||
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
|
||||
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
|
||||
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
|
||||
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
|
||||
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
- **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"` 的 `text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。
|
||||
- **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。
|
||||
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
|
||||
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
|
||||
- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
|
||||
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
|
||||
- `report` 角色能读、不能写。
|
||||
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
|
||||
- 分区表上写入成功且落进当月分区。
|
||||
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
|
||||
- **提交**: `docs: ship a production deployment template with its own test`
|
||||
|
||||
## Task 5: CHANGELOG 与 wiki
|
||||
|
||||
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`。
|
||||
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
|
||||
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
|
||||
- **测试**: 无自动化测试。
|
||||
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
|
||||
- **提交**: `docs: record the retention boundary and its knobs`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 五个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
|
||||
@@ -0,0 +1,177 @@
|
||||
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
|
||||
|
||||
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
|
||||
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings` 增 `telemetry_auto_migrate` |
|
||||
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
|
||||
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
|
||||
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
|
||||
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
|
||||
| `.env.example`、`README.md`、`CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
|
||||
|
||||
```python
|
||||
COLUMNS: tuple[str, ...] # 24 个 INSERT 字段(call_id 起、meta 止)
|
||||
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
|
||||
PG_DDL: str
|
||||
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
|
||||
PG_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
|
||||
```
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS` 含 `created_at`,可作对照)。
|
||||
|
||||
**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。
|
||||
|
||||
两个语句构造函数:
|
||||
|
||||
```python
|
||||
def insert_sql(backend: str, columns: Sequence[str]) -> str:
|
||||
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
|
||||
|
||||
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
|
||||
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
|
||||
"""
|
||||
|
||||
def telemetry_schema_sql(backend: str) -> str:
|
||||
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
|
||||
```
|
||||
|
||||
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class SQLiteRecorder:
|
||||
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
|
||||
|
||||
class PostgresRecorder:
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
|
||||
```
|
||||
|
||||
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
|
||||
|
||||
```python
|
||||
telemetry_auto_migrate: bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 建 `telemetry/schema.py` 单一事实源
|
||||
|
||||
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
|
||||
- **行为**: 把 `sqlite.py` 的 `_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS` 与 `postgres.py` 的 `_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()` 与 `telemetry_schema_sql()`。
|
||||
- **验收**:
|
||||
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
|
||||
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
|
||||
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。
|
||||
- `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`。
|
||||
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
|
||||
- **提交**: `refactor: make the telemetry schema a single source of truth`
|
||||
|
||||
## Task 2: PG 写入去掉冲突目标
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
|
||||
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12 按 `created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
|
||||
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
|
||||
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
|
||||
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
|
||||
|
||||
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`。
|
||||
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
|
||||
- **行为**:
|
||||
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`。
|
||||
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns` 与 `self._insert = insert_sql(backend, effective)`;`record_llm_call` 按 `self._columns` 取值。
|
||||
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
|
||||
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
|
||||
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
|
||||
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
|
||||
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
|
||||
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
|
||||
- manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。
|
||||
- auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
|
||||
- manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
|
||||
- **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436` 的 `captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
|
||||
- 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。
|
||||
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
|
||||
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
|
||||
|
||||
## Task 4: 配置派生与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
|
||||
- **行为**:
|
||||
- `config.py` 增 `_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
|
||||
- `GatewaySettings` 增 `telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`。
|
||||
- `.env.example` 在 `PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
|
||||
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
|
||||
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: derive the schema mode from the telemetry backend`
|
||||
|
||||
## Task 5: 公共导出
|
||||
|
||||
- [ ] **文件**: `src/polygateway/__init__.py`、`tests/unit/test_package.py`。
|
||||
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
|
||||
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
|
||||
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
|
||||
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
|
||||
|
||||
## Task 6: 真实 Postgres 集成验收
|
||||
|
||||
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
|
||||
- **验收**:
|
||||
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
|
||||
- **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
|
||||
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
|
||||
- **提交**: `test: prove manual mode leaves a stale table untouched`
|
||||
|
||||
## Task 7: 文档与承诺
|
||||
|
||||
- [ ] **文件**: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
|
||||
- **行为**:
|
||||
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
|
||||
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
|
||||
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
|
||||
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
|
||||
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` ∪ `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
|
||||
- **提交**: `docs: document the schema mode and the expand-contract promise`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 七个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:issue11-caller-dimensions
|
||||
title: "调用方自定义维度实现计划(issue #11)"
|
||||
date: 2026-08-17
|
||||
---
|
||||
|
||||
# 调用方自定义维度实现计划(issue #11)
|
||||
|
||||
正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`。
|
||||
|
||||
- **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。
|
||||
- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。
|
||||
- **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。
|
||||
- **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。
|
||||
- **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue12-telemetry-retention
|
||||
title: "实现计划: issue12-telemetry-retention"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue12-telemetry-retention
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
|
||||
|
||||
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。
|
||||
|
||||
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter` 的 `text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient` 的 `__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py` 挂 `PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue13-schema-mode
|
||||
title: "实现计划: issue13-schema-mode"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue13-schema-mode
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
|
||||
|
||||
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
|
||||
|
||||
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS` 是 **INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint` 带 `--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。
|
||||
@@ -23,6 +23,7 @@ from polygateway.errors import (
|
||||
from polygateway.ocr import OcrClient
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||
from polygateway.types import (
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
@@ -32,7 +33,7 @@ from polygateway.types import (
|
||||
SourceConfig,
|
||||
)
|
||||
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.2.3"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
@@ -64,4 +65,5 @@ __all__ = [
|
||||
"__version__",
|
||||
"gather_bounded",
|
||||
"register_provider",
|
||||
"telemetry_schema_sql",
|
||||
]
|
||||
|
||||
@@ -367,7 +367,9 @@ class RedisGate:
|
||||
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 try_enter 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._decision(source_name, result)
|
||||
|
||||
async def record_success(
|
||||
@@ -385,7 +387,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_success 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def record_failure(
|
||||
@@ -407,7 +411,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_failure 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||
@@ -419,7 +425,9 @@ class RedisGate:
|
||||
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 release_probe 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||
@@ -429,7 +437,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return int(result) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -247,7 +247,9 @@ class RedisLimiter:
|
||||
],
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 try_acquire 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
if ok != 1:
|
||||
return None
|
||||
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
|
||||
@@ -265,7 +267,9 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._release_lua(keys=[gl, sl], args=[lease_id])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 release 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
|
||||
wk = self._window_keys(source_key, window)
|
||||
@@ -283,7 +287,9 @@ class RedisLimiter:
|
||||
wk = self._window_keys(source_key, window)
|
||||
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 source_stats 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return SourceStats(
|
||||
inflight=int(res[0]),
|
||||
rpm_used=max(0, int(res[1])),
|
||||
@@ -295,14 +301,18 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 mark_progress 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def progress_age_s(self) -> float:
|
||||
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
|
||||
try:
|
||||
res = await self._progress_age_lua(keys=[self._progress_key()])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 progress_age_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return float("inf") if int(res) == -1 else int(res) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -34,7 +34,12 @@ from polygateway.sources import (
|
||||
SourceCooldownMemo,
|
||||
)
|
||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||
from polygateway.types import ChatRequest, LLMResponse, validate_request_overlay
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
LLMResponse,
|
||||
validate_caller_dimensions,
|
||||
validate_request_overlay,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Iterable, Mapping
|
||||
@@ -131,6 +136,7 @@ class GatewayClient:
|
||||
quota_full: str = "wait",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
cache_namespace: str | None = None,
|
||||
cache_ttl_s: int | None = None,
|
||||
@@ -141,7 +147,11 @@ class GatewayClient:
|
||||
sleep: Any = asyncio.sleep,
|
||||
rng: Any = random.random,
|
||||
) -> None:
|
||||
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None
|
||||
emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
|
||||
if telemetry is not None
|
||||
else None
|
||||
)
|
||||
terminal = RetryMW(
|
||||
scope=scope,
|
||||
sources=sources,
|
||||
@@ -205,12 +215,18 @@ class GatewayClient:
|
||||
structured: type[BaseModel] | Literal["json"] | None = None,
|
||||
stream: bool = True,
|
||||
overlay: Mapping[str, Any] | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||
|
||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||
参数不影响既有调用点(issue #4)。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||
"""
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
@@ -221,6 +237,12 @@ class GatewayClient:
|
||||
# 造成的竞态。同一份快照填 overlay 与 sampling——前者会被结构化注入,
|
||||
# 后者跨层恒定,供缓存 key 与遥测读取(设计决策 A/B/E)
|
||||
sampling = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
|
||||
# 同理必须在洋葱之外: 洋葱内的一切失败都被遥测层降级成 warning(库铁律
|
||||
# 「遥测写失败降级不冒泡」),校验放里面等于没有校验——非法维度会变成
|
||||
# 静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="chat(tenant_id=..., meta=...)"
|
||||
)
|
||||
request = ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
@@ -231,6 +253,8 @@ class GatewayClient:
|
||||
stream=stream,
|
||||
overlay=sampling,
|
||||
sampling=sampling,
|
||||
tenant_id=dimension_tenant_id,
|
||||
meta=dimensions,
|
||||
)
|
||||
return await self._handler(request)
|
||||
|
||||
@@ -293,6 +317,7 @@ class GatewayClient:
|
||||
pricing=PricingTable.from_file(settings.pricing_path)
|
||||
if settings.pricing_path is not None
|
||||
else None,
|
||||
text_cap=settings.telemetry_text_cap,
|
||||
cache=cache if cache is not None else _build_cache(settings),
|
||||
cache_namespace=settings.cache_namespace,
|
||||
cache_ttl_s=settings.cache_ttl_s,
|
||||
@@ -381,11 +406,15 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||
return PostgresRecorder(
|
||||
settings.telemetry_pg_dsn, auto_migrate=settings.telemetry_auto_migrate
|
||||
)
|
||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||
return SQLiteRecorder(
|
||||
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
|
||||
)
|
||||
|
||||
|
||||
def _build_structured(
|
||||
|
||||
@@ -55,6 +55,11 @@ _LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||
# 遥测 schema 档位(issue #13): auto 允许 recorder 给旧表 ALTER 补列,manual 不发 DDL
|
||||
_SCHEMA_MODES = frozenset({"auto", "manual"})
|
||||
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
|
||||
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
|
||||
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
|
||||
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
|
||||
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
|
||||
_DEFAULT_STALL_WINDOW_S = 300.0
|
||||
@@ -131,6 +136,16 @@ class GatewaySettings:
|
||||
telemetry_backend: str
|
||||
telemetry_sqlite_path: str | None
|
||||
telemetry_pg_dsn: str | None
|
||||
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);env 的三态
|
||||
# 派生只写在 `_load_schema_mode` 一处,不与 recorder 的类签名漂移。
|
||||
# backend=none 时恒 False 这条跨字段不变量则由 `_validate_telemetry`
|
||||
# 把关,对直接构造与 `dataclasses.replace` 同样生效
|
||||
telemetry_auto_migrate: bool
|
||||
# 遥测落库正文的字符上限(issue #12);None = 不截断,与本字段出现之前逐字节相同。
|
||||
# 缺省不截断是人类决策: 截断后的遥测不再是审计证据、也无法用于复现与重放,而
|
||||
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
|
||||
# `dataclasses.replace` 与 env 三条路一并覆盖
|
||||
telemetry_text_cap: int | None
|
||||
redis_url: str | None
|
||||
pricing_path: str | None
|
||||
structured_max_retries: int
|
||||
@@ -211,7 +226,23 @@ class GatewaySettings:
|
||||
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||
他有权知道库动了他给的值。
|
||||
|
||||
`telemetry_auto_migrate` 同理归一化而非报错: backend=none 时根本没有
|
||||
recorder 消费它,True 是个自相矛盾却无害的状态。`from_env` 那条路的派生
|
||||
已经给出 False,归一化是为了直接构造与 `dataclasses.replace` 也一致——
|
||||
不变量挂在构造期,才不用每加一个装配工厂就多一处要同步。
|
||||
|
||||
`telemetry_text_cap` 的值域则是**报错**而非归一化: 0 与负数都不是"不截断"
|
||||
的写法(不截断写 None),把它们悄悄改成 None 等于用默认值掩盖调用方的错误。
|
||||
报错文本同时点出字段名与 env 键名,两条装配路的调用方各看得懂自己那套。
|
||||
"""
|
||||
if self.telemetry_text_cap is not None and self.telemetry_text_cap <= 0:
|
||||
raise ValueError(
|
||||
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
|
||||
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
|
||||
)
|
||||
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
|
||||
object.__setattr__(self, "telemetry_auto_migrate", False)
|
||||
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
|
||||
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||
if self.telemetry_backend != "postgres":
|
||||
@@ -434,6 +465,7 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
redis_url = env.get("REDIS_URL") or None
|
||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
|
||||
auto_migrate = _load_schema_mode(env, telemetry_backend)
|
||||
return {
|
||||
"limiter_backend": limiter_backend,
|
||||
"breaker_backend": breaker_backend,
|
||||
@@ -444,6 +476,8 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
if telemetry_backend == "sqlite"
|
||||
else None,
|
||||
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
||||
"telemetry_auto_migrate": auto_migrate,
|
||||
"telemetry_text_cap": _load_text_cap(env),
|
||||
"redis_url": redis_url,
|
||||
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
||||
"structured_max_retries": _load_structured_retries(env),
|
||||
@@ -451,6 +485,56 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _load_schema_mode(env: Mapping[str, str], telemetry_backend: str) -> bool:
|
||||
"""把 `PGW_TELEMETRY_SCHEMA_MODE` 的三态解成 `telemetry_auto_migrate`(issue #13)。
|
||||
|
||||
三态: 键未设 → 按后端**不对称**派生;显式 auto/manual → 两侧都可覆盖。
|
||||
不对称的理由是两个后端的风险量级不同: SQLite 是下游自己的本地文件(没有
|
||||
DBA、没有迁移工具、没有第二个系统碰它),ALTER 是毫秒级元数据操作,要求
|
||||
手工跑 SQL 是给零运维场景强加运维步骤;PG 是共享的生产表,ALTER 取
|
||||
ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询,而遥测是业务
|
||||
路径上的内联 await。
|
||||
|
||||
`_load_choice` 带 default,不能直接用来读这个键——default 会把"未设"和
|
||||
"设成默认值"抹平成同一种,三态就塌回两态,后端派生也就再没机会生效。故
|
||||
先用 `_first` 探"设没设",确认设了才交给 `_load_choice` 做值域校验(错误
|
||||
信息点出 env 键名这件事仍由它负责)。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
telemetry_backend: 已校验过值域的遥测后端名。
|
||||
|
||||
Returns:
|
||||
recorder 是否获准给旧表自动 ALTER 补列;backend=none 时无人消费,
|
||||
构造期守卫会再把它归一化为 False。
|
||||
"""
|
||||
if _first(env, _SCHEMA_MODE_KEY) is None:
|
||||
return telemetry_backend == "sqlite"
|
||||
return _load_choice(env, _SCHEMA_MODE_KEY, _SCHEMA_MODES, "auto") == "auto"
|
||||
|
||||
|
||||
def _load_text_cap(env: Mapping[str, str]) -> int | None:
|
||||
"""读 `PGW_TELEMETRY_TEXT_CAP`(issue #12);键未设即 None = 不截断。
|
||||
|
||||
与相邻的 `PGW_TELEMETRY_SCHEMA_MODE` 不同,这个键是**二态**而非三态:
|
||||
"未设"本身就是最终答案(不截断),没有需要按后端派生的第二种缺省,故不必像
|
||||
那边一样先探"设没设"再分两条路取值,读到什么解什么即可。
|
||||
|
||||
值域(> 0)刻意不在此处判: 构造期守卫那道同时覆盖直接构造与
|
||||
`dataclasses.replace`,而报错文本已点出本键名,env 路的调用方不会看丢。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
|
||||
Returns:
|
||||
遥测正文的字符上限;键未设或为空串时返回 None(不截断)。
|
||||
"""
|
||||
found = _first(env, _TEXT_CAP_KEY)
|
||||
if found is None:
|
||||
return None
|
||||
return int(_cast(found[1], "int", found[0]))
|
||||
|
||||
|
||||
def _strip_dsn_driver(dsn: str) -> str:
|
||||
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
|
||||
scheme, sep, rest = dsn.partition("://")
|
||||
|
||||
@@ -21,7 +21,7 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -47,6 +47,7 @@ from polygateway.types import (
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -103,6 +104,7 @@ class EmbeddingClient:
|
||||
quota_full: str = "wait",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
batch_size: int,
|
||||
normalize: bool = False,
|
||||
expected_dim: int | None = None,
|
||||
@@ -127,7 +129,9 @@ class EmbeddingClient:
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None
|
||||
self._emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
|
||||
)
|
||||
self._telemetry = telemetry
|
||||
self._pricing = pricing
|
||||
self._batch_size = batch_size
|
||||
@@ -145,10 +149,22 @@ class EmbeddingClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> EmbeddingResponse:
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。"""
|
||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
||||
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
||||
"""
|
||||
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
||||
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
||||
# 必须在切批之前校验: 洋葱/链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验——非法维度
|
||||
# 会变成静默丢失的遥测行,而调用照常发出(issue #11 §4.2)
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
||||
)
|
||||
if not texts:
|
||||
return EmbeddingResponse(
|
||||
vectors=[],
|
||||
@@ -167,7 +183,11 @@ class EmbeddingClient:
|
||||
for start in range(0, len(texts), self._batch_size):
|
||||
outcomes.append(
|
||||
await self._embed_batch(
|
||||
texts[start : start + self._batch_size], session_id, parent_call_id
|
||||
texts[start : start + self._batch_size],
|
||||
session_id,
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
)
|
||||
)
|
||||
return self._merge(outcomes)
|
||||
@@ -175,7 +195,12 @@ class EmbeddingClient:
|
||||
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
||||
|
||||
async def _embed_batch(
|
||||
self, batch: list[str], session_id: str | None, parent_call_id: str | None
|
||||
self,
|
||||
batch: list[str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome:
|
||||
fails = 0
|
||||
reasons: dict[str, str] = {}
|
||||
@@ -187,7 +212,9 @@ class EmbeddingClient:
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(batch, *picked, reasons, session_id, parent_call_id)
|
||||
outcome = await self._attempt(
|
||||
batch, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _BatchOutcome):
|
||||
return outcome
|
||||
fails += 1
|
||||
@@ -266,6 +293,8 @@ class EmbeddingClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _BatchOutcome | _FailedBatch:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
@@ -286,17 +315,45 @@ class EmbeddingClient:
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
await self._record_quietly(self._quota.mark_progress())
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, result)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
batch, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -307,7 +364,17 @@ class EmbeddingClient:
|
||||
if not dead:
|
||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
||||
actual = source.effective_est_tokens()
|
||||
await self._emit(batch, source, call_id, started, session_id, parent_call_id, error=exc)
|
||||
await self._emit(
|
||||
batch,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedBatch(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit, actual)
|
||||
@@ -351,16 +418,22 @@ class EmbeddingClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: EmbeddingTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(embedding 不走 chat
|
||||
# 洋葱),故调用方维度必须在这里显式填回,否则 embed 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in batch],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
response = None
|
||||
if result is not None:
|
||||
@@ -490,6 +563,9 @@ class EmbeddingClient:
|
||||
pricing=PricingTable.from_file(gw.pricing_path)
|
||||
if gw.pricing_path is not None
|
||||
else None,
|
||||
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
batch_size=settings.batch_size,
|
||||
normalize=settings.normalize,
|
||||
expected_dim=settings.expected_dim,
|
||||
|
||||
@@ -26,13 +26,73 @@ from polygateway.middleware.cache import digest_messages
|
||||
from polygateway.types import canonical_sampling_json, merge_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from polygateway.ports import CallNext, TelemetryRecorder
|
||||
from polygateway.pricing import PricingTable
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
|
||||
|
||||
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
|
||||
|
||||
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
|
||||
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
|
||||
|
||||
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
|
||||
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
|
||||
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
|
||||
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
|
||||
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
|
||||
|
||||
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
|
||||
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
|
||||
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。
|
||||
"""
|
||||
if not meta:
|
||||
return "{}"
|
||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
if cap is None or len(text) <= cap:
|
||||
return text
|
||||
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
|
||||
|
||||
|
||||
def _cap_part(part: Any, cap: int) -> Any:
|
||||
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
|
||||
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
return {**part, "text": _cap_text(part["text"], cap)}
|
||||
return part
|
||||
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
|
||||
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
|
||||
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
|
||||
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
|
||||
且全程无任何报错。
|
||||
"""
|
||||
if cap is None:
|
||||
return messages
|
||||
capped: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
capped.append({**msg, "content": _cap_text(content, cap)})
|
||||
elif isinstance(content, list):
|
||||
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
|
||||
else:
|
||||
capped.append(msg)
|
||||
return capped
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AttemptUsage:
|
||||
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
||||
@@ -72,11 +132,27 @@ class _AttemptUsage:
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
recorder: TelemetryRecorder,
|
||||
*,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None:
|
||||
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
|
||||
|
||||
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
||||
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
|
||||
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
||||
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||
"""
|
||||
if text_cap is not None and text_cap <= 0:
|
||||
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
||||
self._recorder = recorder
|
||||
self._pricing = pricing
|
||||
self._text_cap = text_cap
|
||||
|
||||
async def emit_attempt(
|
||||
self,
|
||||
@@ -111,6 +187,8 @@ class TelemetryEmitter:
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||
@@ -139,6 +217,11 @@ class TelemetryEmitter:
|
||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
|
||||
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
|
||||
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def emit_terminal_failure(
|
||||
@@ -166,6 +249,9 @@ class TelemetryEmitter:
|
||||
reasoning_tokens=None,
|
||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||
tenant_id=request.tenant_id,
|
||||
meta=request.meta,
|
||||
)
|
||||
|
||||
async def _record(
|
||||
@@ -190,6 +276,9 @@ class TelemetryEmitter:
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||
@@ -206,8 +295,12 @@ class TelemetryEmitter:
|
||||
)
|
||||
else:
|
||||
cost = None
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
|
||||
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
|
||||
messages_json = json.dumps(
|
||||
_cap_messages(digest_messages(request.messages), self._text_cap),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await self._recorder.record_llm_call(
|
||||
call_id=call_id,
|
||||
parent_call_id=request.parent_call_id,
|
||||
@@ -216,8 +309,8 @@ class TelemetryEmitter:
|
||||
provider=provider,
|
||||
source_name=source_name,
|
||||
messages=messages_json,
|
||||
response=response_text,
|
||||
thinking=thinking,
|
||||
response=_cap_text(response_text, self._text_cap),
|
||||
thinking=_cap_text(thinking, self._text_cap),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
usage_source=usage_source,
|
||||
@@ -231,6 +324,10 @@ class TelemetryEmitter:
|
||||
model_reported=model_reported,
|
||||
sampling=sampling,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
|
||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||
tenant_id=tenant_id or "",
|
||||
meta=_canonical_meta_json(meta),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
+87
-11
@@ -18,7 +18,7 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -46,6 +46,7 @@ from polygateway.types import (
|
||||
OcrTextResult,
|
||||
Usage,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -108,6 +109,7 @@ class OcrClient:
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
text_cap: int | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
@@ -126,7 +128,7 @@ class OcrClient:
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
|
||||
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
|
||||
self._telemetry = telemetry
|
||||
self._memo = SourceCooldownMemo(now=now)
|
||||
self._now = now
|
||||
@@ -142,9 +144,21 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrTextResult:
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。"""
|
||||
outcome = await self._call("text", image, session_id, parent_call_id)
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrTextResult(
|
||||
text=result.text,
|
||||
@@ -161,9 +175,20 @@ class OcrClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
) -> OcrLayoutResult:
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。"""
|
||||
outcome = await self._call("layout", image, session_id, parent_call_id)
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
"""
|
||||
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
|
||||
)
|
||||
outcome = await self._call(
|
||||
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrLayoutResult(
|
||||
elements=result.elements,
|
||||
@@ -195,6 +220,8 @@ class OcrClient:
|
||||
image: bytes,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome:
|
||||
if not isinstance(image, bytes):
|
||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||
@@ -213,7 +240,7 @@ class OcrClient:
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
kind, image, *picked, reasons, session_id, parent_call_id
|
||||
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
|
||||
)
|
||||
if isinstance(outcome, _AttemptOutcome):
|
||||
return outcome
|
||||
@@ -294,9 +321,13 @@ class OcrClient:
|
||||
reasons: dict[str, str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
) -> _AttemptOutcome | _FailedAttempt:
|
||||
call_id = str(uuid.uuid4())
|
||||
started = self._now()
|
||||
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
|
||||
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
|
||||
try:
|
||||
result = await self._invoke(kind, image, source, call_id)
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
@@ -304,20 +335,47 @@ class OcrClient:
|
||||
self._feed_outcome(source.name, ok=True)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, result
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
result,
|
||||
)
|
||||
return _AttemptOutcome(result, source, call_id, latency_ms)
|
||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||
await self._gate_on_terminal(exc, entry)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if entry.is_probe:
|
||||
await self._record_quietly(self._breaker.release_probe(entry))
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
except (SourceDeadError, TransientError) as exc:
|
||||
@@ -327,7 +385,16 @@ class OcrClient:
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
self._feed_outcome(source.name, ok=False)
|
||||
await self._emit(
|
||||
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
|
||||
kind,
|
||||
image,
|
||||
source,
|
||||
call_id,
|
||||
started,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedAttempt(exc, immediate=dead)
|
||||
finally:
|
||||
@@ -389,16 +456,22 @@ class OcrClient:
|
||||
started: float,
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
) -> None:
|
||||
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
||||
if self._emitter is None:
|
||||
return
|
||||
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
|
||||
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
)
|
||||
latency_ms = int((self._now() - started) * 1000)
|
||||
response = None
|
||||
@@ -500,6 +573,9 @@ class OcrClient:
|
||||
backpressure=gw.backpressure,
|
||||
quota_full=gw.quota_full,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
||||
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -245,10 +245,15 @@ class StructuredOutputStrategy(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;22 字段冻结(M1 设计 §4.4 + issue #3/#4),唯一调用点是 TelemetryEmitter。
|
||||
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
|
||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||
"""
|
||||
|
||||
async def record_llm_call(
|
||||
@@ -276,4 +281,6 @@ class TelemetryRecorder(Protocol):
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str,
|
||||
meta: str,
|
||||
) -> None: ...
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
① 结构性失败 → warning 一次后永久降级(所有写入短路);
|
||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||
构造不连库(lazy),22 列 schema 与 SQLite 版同名同序。
|
||||
构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。
|
||||
|
||||
**"结构性"的判据是「确定写不进去」,不是「初始化时出过错」**(issue #9):
|
||||
只有建池失败(重试要在业务路径上内联吞掉 connect 超时)与"表确定不存在
|
||||
@@ -21,45 +21,17 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.telemetry.schema import (
|
||||
COLUMNS,
|
||||
PG_BACKFILL,
|
||||
PG_DDL,
|
||||
insert_sql,
|
||||
missing_columns_warning,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
|
||||
_BACKFILL = (
|
||||
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
||||
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
||||
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
|
||||
)
|
||||
|
||||
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
|
||||
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
|
||||
|
||||
@@ -69,42 +41,22 @@ _EXISTING_COLUMNS = (
|
||||
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||
)
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
f"INSERT INTO llm_calls ({', '.join(_COLUMNS)}) "
|
||||
f"VALUES ({', '.join(f'${i + 1}' for i in range(len(_COLUMNS)))}) "
|
||||
"ON CONFLICT (call_id) DO NOTHING"
|
||||
)
|
||||
|
||||
|
||||
class PostgresRecorder:
|
||||
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
||||
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None) -> None:
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None:
|
||||
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
|
||||
|
||||
Args:
|
||||
dsn: asyncpg 连接串(已剥驱动后缀)。
|
||||
pool: 外部注入的池;注入方自己负责关闭。
|
||||
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
|
||||
则一条 ALTER 都不发——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE
|
||||
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
||||
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
|
||||
签名漂移(设计 D-c)。
|
||||
"""
|
||||
try:
|
||||
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
||||
except ImportError as exc:
|
||||
@@ -114,6 +66,10 @@ class PostgresRecorder:
|
||||
self._dsn = dsn
|
||||
self._pool: asyncpg.Pool | None = pool
|
||||
self._external_pool = pool is not None
|
||||
self._auto_migrate = auto_migrate
|
||||
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
|
||||
self._columns: tuple[str, ...] = COLUMNS
|
||||
self._insert = insert_sql("postgres", COLUMNS)
|
||||
self._schema_ready = False
|
||||
self._failed = False # 结构性降级标志: 置位后所有写入短路
|
||||
self._init_lock = asyncio.Lock()
|
||||
@@ -156,7 +112,7 @@ class PostgresRecorder:
|
||||
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
writable = await self._prepare_table(conn)
|
||||
columns = await self._prepare_table(conn)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -164,14 +120,20 @@ class PostgresRecorder:
|
||||
# 只跳过本次记录,下次调用重新准备
|
||||
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
|
||||
return None
|
||||
if not writable:
|
||||
if columns is None:
|
||||
self._failed = True
|
||||
return None
|
||||
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||
self._columns = columns
|
||||
self._insert = insert_sql("postgres", columns)
|
||||
self._schema_ready = True
|
||||
return pool
|
||||
|
||||
async def _prepare_table(self, conn: object) -> bool:
|
||||
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
|
||||
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||
|
||||
返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
||||
@@ -184,33 +146,77 @@ class PostgresRecorder:
|
||||
"""
|
||||
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
|
||||
if exists:
|
||||
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
|
||||
return True
|
||||
return await self._resolve_columns(conn) # 旧表可能缺列
|
||||
try:
|
||||
await conn.execute(_DDL) # type: ignore[attr-defined]
|
||||
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
||||
return False
|
||||
return True # 新建表列已齐全,无需再走补列
|
||||
return None
|
||||
return COLUMNS # 新建表列已齐全,无需再走补列
|
||||
|
||||
async def _backfill_columns(self, conn: object) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
||||
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
|
||||
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
|
||||
|
||||
两条纪律各有实测理由:
|
||||
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
||||
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。
|
||||
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
||||
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
||||
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
|
||||
**先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
|
||||
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
try:
|
||||
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||
for column, statement in _BACKFILL:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
return COLUMNS
|
||||
if self._auto_migrate:
|
||||
await self._backfill_columns(conn, existing)
|
||||
return COLUMNS
|
||||
return self._trim_columns(existing)
|
||||
|
||||
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
|
||||
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
|
||||
|
||||
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
|
||||
INSERT,`insert_sql` 会 ValueError,而 `_prepare_schema` 里那次调用在 try
|
||||
**之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡)
|
||||
——回落必须发生在把空列集交给它之前。
|
||||
"""
|
||||
effective = tuple(column for column in COLUMNS if column in existing)
|
||||
if not effective:
|
||||
logger.warning(
|
||||
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||
"现有列: {}",
|
||||
sorted(existing),
|
||||
)
|
||||
return COLUMNS
|
||||
missing = [column for column in COLUMNS if column not in existing]
|
||||
if missing:
|
||||
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
|
||||
logger.warning(
|
||||
"{}",
|
||||
missing_columns_warning("postgres", missing, alien_table="call_id" not in existing),
|
||||
)
|
||||
return effective
|
||||
|
||||
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
||||
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不置 `_failed`**。
|
||||
|
||||
不置 `_failed` 的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
||||
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
||||
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
||||
请显式选 manual。
|
||||
"""
|
||||
try:
|
||||
for column, statement in PG_BACKFILL:
|
||||
if column not in existing:
|
||||
await conn.execute(statement) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
@@ -219,14 +225,18 @@ class PostgresRecorder:
|
||||
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
"""
|
||||
pool = await self._ensure_ready()
|
||||
if pool is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
row = tuple(fields[col] for col in self._columns)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(_INSERT, *row)
|
||||
await conn.execute(self._insert, *row)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。
|
||||
|
||||
两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块。
|
||||
收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源——常量在
|
||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
|
||||
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
|
||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||
互不依赖(import-linter 契约执法)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
TABLE = "llm_calls"
|
||||
|
||||
# 支持的后端;`insert_sql` / `telemetry_schema_sql` 的取值域
|
||||
_BACKENDS = ("sqlite", "postgres")
|
||||
|
||||
SQLITE_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
|
||||
PG_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||
SQLITE_BACKFILL = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
|
||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
)
|
||||
|
||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||
# 下游的那份"的列集合与列定义**无法分叉**——本模块存在的全部理由就是不许漂移。
|
||||
_PG_BACKFILL_DECLS = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
|
||||
)
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||
# **库内执行的这份有意不带 `IF NOT EXISTS`**: PG 对它即便列已存在也会先取 ACCESS
|
||||
# EXCLUSIVE 锁,而遥测是业务路径上的内联 await,故库侧一律"先探测后 ALTER"
|
||||
# (postgres.py `_backfill_columns` 记有实测)。给人执行的那份见 `telemetry_schema_sql`。
|
||||
PG_BACKFILL = tuple(
|
||||
(column, f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}")
|
||||
for column, decl in _PG_BACKFILL_DECLS
|
||||
)
|
||||
|
||||
COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
|
||||
_COLUMN_SET = frozenset(COLUMNS)
|
||||
|
||||
|
||||
def insert_sql(backend: str, columns: Sequence[str]) -> str:
|
||||
"""按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。
|
||||
|
||||
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部
|
||||
字符串拼进 SQL(占位符只保护值,保护不了列名)。空集同样来自探测结果,而
|
||||
`INSERT INTO llm_calls () VALUES ()` 两端都语法非法——本函数自己拒,不把这个
|
||||
不变量押在调用方身上。sqlite 用 `?`、postgres 用 `$n`,
|
||||
两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)。
|
||||
|
||||
**PG 的 `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求
|
||||
分区表的唯一约束必须包含分区键,按 `created_at` 分区(issue #12 的保留期方案)后
|
||||
主键变成 `(call_id, created_at)`,带目标的语句匹配不到任何约束,PG 直接拒收
|
||||
("there is no unique or exclusion constraint matching the ON CONFLICT
|
||||
specification"),而遥测写失败只逐行 warning——分区部署下会全线静默丢数据。
|
||||
无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
columns: 要写入的列,顺序即占位符顺序(调用方须按同序取值)。
|
||||
|
||||
Returns:
|
||||
完整的 INSERT 语句。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
selected = tuple(columns)
|
||||
if not selected:
|
||||
raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL")
|
||||
unknown = [column for column in selected if column not in _COLUMN_SET]
|
||||
if unknown:
|
||||
raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}")
|
||||
names = ", ".join(selected)
|
||||
if backend == "sqlite":
|
||||
placeholders = ", ".join("?" for _ in selected)
|
||||
return f"INSERT OR IGNORE INTO {TABLE} ({names}) VALUES ({placeholders})"
|
||||
placeholders = ", ".join(f"${i + 1}" for i in range(len(selected)))
|
||||
return f"INSERT INTO {TABLE} ({names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
||||
|
||||
|
||||
# 缺列告警要打印的补列语句: 库内执行的那份怎么写,打印给人的就怎么写(同源不许漂移)。
|
||||
# SQLite 侧常量只有列定义,故在此按 TABLE 拼成整条 ALTER;PG 侧常量本就是整条语句。
|
||||
_ALTER_BY_BACKEND = {
|
||||
"sqlite": {
|
||||
column: f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}"
|
||||
for column, decl in SQLITE_BACKFILL
|
||||
},
|
||||
"postgres": dict(PG_BACKFILL),
|
||||
}
|
||||
|
||||
_BACKEND_LABELS = {"sqlite": "SQLite", "postgres": "Postgres"}
|
||||
|
||||
# PG 的 ALTER 取 ACCESS EXCLUSIVE 锁,执行时机得由 DBA 自己挑;SQLite 是下游本地文件,无此顾虑
|
||||
_EXECUTION_NOTES = {"sqlite": "", "postgres": "(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁)"}
|
||||
|
||||
|
||||
def missing_columns_warning(backend: str, missing: Sequence[str], *, alien_table: bool) -> str:
|
||||
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
|
||||
|
||||
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
|
||||
看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。
|
||||
|
||||
**住在本模块而不是两个 recorder 里**: 这条消息拼的是给人执行的 DDL,与库自己
|
||||
执行的 ALTER 必须同源——本模块存在的全部理由就是不许这两者漂移。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
missing: 缺失的列名(按 `COLUMNS` 保序)。
|
||||
alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。
|
||||
|
||||
Returns:
|
||||
单条 warning 的完整文本(库只在准备期发一次,不逐行发)。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
alters = _ALTER_BY_BACKEND[backend]
|
||||
selected = tuple(missing)
|
||||
statements = [f"{alters[column]};" for column in selected if column in alters]
|
||||
unknown = [column for column in selected if column not in alters]
|
||||
if unknown:
|
||||
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
|
||||
statements.append(
|
||||
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
|
||||
f'polygateway.telemetry_schema_sql("{backend}")'
|
||||
)
|
||||
label = _BACKEND_LABELS[backend]
|
||||
head = (
|
||||
f"{label} 遥测表 {TABLE} 缺主键列 call_id,很可能不是本库的遥测表"
|
||||
"(库不做二次判定,仍照常尝试写入)"
|
||||
if alien_table
|
||||
else f"{label} 遥测表 {TABLE} 缺列,且 auto_migrate=False(库不发任何 DDL)"
|
||||
)
|
||||
return (
|
||||
f"{head};以下维度不会被记录: {', '.join(selected)}。"
|
||||
f"补列请自行执行{_EXECUTION_NOTES[backend]}:\n" + "\n".join(statements)
|
||||
)
|
||||
|
||||
|
||||
def telemetry_schema_sql(backend: str) -> str:
|
||||
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。
|
||||
|
||||
给不愿意让库在自己的生产表上发 DDL 的下游用: 输出与库运行时执行的 DDL 同源,
|
||||
照它建完表,库探测到的列就是齐的。
|
||||
|
||||
**补列语句与库内执行的那份是两套文本,不是一份**: 这份给人执行,必须可重复执行,
|
||||
故 PG 变体带 `ADD COLUMN IF NOT EXISTS`(它会先取 ACCESS EXCLUSIVE 锁,但执行时机
|
||||
由 DBA 自己挑,锁风险可控);库内那份不带,靠先探测后 ALTER 规避锁。SQLite 没有
|
||||
`ADD COLUMN IF NOT EXISTS` 语法,只能以注释交代"仅当该列不存在时执行"。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
|
||||
Returns:
|
||||
含注释的完整 SQL 脚本。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
if backend == "sqlite":
|
||||
ddl = SQLITE_DDL
|
||||
notes = (
|
||||
f"-- 旧表补列(库升级后新增的列)。SQLite 无 ADD COLUMN IF NOT EXISTS 语法,\n"
|
||||
f"-- 以下每条**仅当该列不存在时执行**(先 PRAGMA table_info({TABLE}) 对照)。"
|
||||
)
|
||||
alters = [
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl};" for column, decl in SQLITE_BACKFILL
|
||||
]
|
||||
else:
|
||||
ddl = PG_DDL
|
||||
notes = (
|
||||
"-- 旧表补列(库升级后新增的列)。带 IF NOT EXISTS,整段可重复执行;\n"
|
||||
"-- 注意它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,请挑低峰执行。"
|
||||
)
|
||||
alters = [
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS {column} {decl};"
|
||||
for column, decl in _PG_BACKFILL_DECLS
|
||||
]
|
||||
header = (
|
||||
f"-- PolyGateway 遥测表 {TABLE}({backend})\n"
|
||||
f'-- 由 polygateway.telemetry_schema_sql("{backend}") 生成,与库运行时执行的 DDL 同源。\n'
|
||||
"-- 新建库执行整段;已有旧表则建表语句自动跳过,只需关注下方补列语句。"
|
||||
)
|
||||
return "\n".join([header, "", ddl.strip(), "", notes, *alters, ""])
|
||||
@@ -22,109 +22,102 @@ from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||
_BACKFILL_COLUMNS = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
)
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
f"INSERT OR IGNORE INTO llm_calls ({', '.join(_COLUMNS)}) "
|
||||
f"VALUES ({', '.join('?' for _ in _COLUMNS)})"
|
||||
from polygateway.telemetry.schema import (
|
||||
COLUMNS,
|
||||
SQLITE_BACKFILL,
|
||||
SQLITE_DDL,
|
||||
insert_sql,
|
||||
missing_columns_warning,
|
||||
)
|
||||
|
||||
|
||||
class SQLiteRecorder:
|
||||
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None:
|
||||
"""建连接与表,并按探测到的列定型本实例的 INSERT 语句。
|
||||
|
||||
Args:
|
||||
db_path: 库文件路径;父目录不存在会自动创建。
|
||||
auto_migrate: True 则给已存在的旧表自动补列(SQLite 侧的缺省档:
|
||||
下游本地文件,无 DBA 无迁移工具);False 则一条 ALTER 都不发,
|
||||
改为按现有列裁剪写入。keyword-only **必填**: 缺省规则只写在
|
||||
config 一处,不与本类签名漂移(设计 D-c)。
|
||||
"""
|
||||
self._auto_migrate = auto_migrate
|
||||
self._lock = threading.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
||||
self._columns: tuple[str, ...] = COLUMNS
|
||||
self._insert = insert_sql("sqlite", COLUMNS)
|
||||
try:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute(_DDL)
|
||||
conn.execute(SQLITE_DDL)
|
||||
conn.commit()
|
||||
self._conn = conn
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||
self._backfill_columns()
|
||||
self._prepare_columns()
|
||||
|
||||
def _backfill_columns(self) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||
def _prepare_columns(self) -> None:
|
||||
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
||||
|
||||
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。
|
||||
无守卫的探测会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
if self._conn is None:
|
||||
return
|
||||
try:
|
||||
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
||||
logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
return
|
||||
for column, decl in _BACKFILL_COLUMNS:
|
||||
if self._auto_migrate:
|
||||
self._backfill_columns(existing)
|
||||
return
|
||||
self._adopt_existing_columns(existing)
|
||||
|
||||
def _adopt_existing_columns(self, existing: set[str]) -> None:
|
||||
"""manual 档: 不发任何 DDL,按现有列裁剪 INSERT,并把缺列一次讲清楚。
|
||||
|
||||
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
|
||||
INSERT,`insert_sql` 会 ValueError,而遥测构造期抛异常就是把"初始化失败静默
|
||||
降级"的铁律破成崩溃——回落必须发生在把空列集交给它之前。
|
||||
"""
|
||||
effective = tuple(column for column in COLUMNS if column in existing)
|
||||
if not effective:
|
||||
logger.warning(
|
||||
"SQLite 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||
"现有列: {}",
|
||||
sorted(existing),
|
||||
)
|
||||
return
|
||||
self._columns = effective
|
||||
self._insert = insert_sql("sqlite", effective)
|
||||
missing = [column for column in COLUMNS if column not in existing]
|
||||
if missing:
|
||||
# 单参数传入: 补列 SQL 里带 `'{}'` 字面量,拼进 format 模板会被当占位符
|
||||
logger.warning(
|
||||
"{}",
|
||||
missing_columns_warning("sqlite", missing, alien_table="call_id" not in existing),
|
||||
)
|
||||
|
||||
def _backfill_columns(self, existing: set[str]) -> None:
|
||||
"""auto 档: 给已存在的旧表补新列(issue #3);逐列独立 try,失败只降级为逐行丢弃。
|
||||
|
||||
补列失败绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。失败后写入沿用全量列(今天的行为): auto 档承诺的是
|
||||
"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||
"""
|
||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||
for column, decl in SQLITE_BACKFILL:
|
||||
if column in existing:
|
||||
continue
|
||||
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
|
||||
@@ -137,10 +130,14 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 21 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
"""
|
||||
if self._conn is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
row = tuple(fields[col] for col in self._columns)
|
||||
try:
|
||||
await asyncio.to_thread(self._write, row)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -149,7 +146,7 @@ class SQLiteRecorder:
|
||||
def _write(self, row: tuple) -> None:
|
||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||
with self._lock:
|
||||
self._conn.execute(_INSERT, row)
|
||||
self._conn.execute(self._insert, row)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -6,6 +6,8 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
@@ -31,6 +33,18 @@ USAGE_SOURCES = frozenset({"measured", "estimated", "unavailable"})
|
||||
_EST_TOKENS_QUOTA_DIVISOR = 60
|
||||
"""未显式配置时的预扣量除数: 假定一次调用约占一秒钟的 TPM 配额份额。"""
|
||||
|
||||
_TENANT_ID_MAX_LEN = 128
|
||||
_META_MAX_KEYS = 16
|
||||
_META_VALUE_MAX_LEN = 256
|
||||
_META_RESERVED_PREFIX = "pg_"
|
||||
_META_KEY_RE = re.compile(r"[a-z0-9_.]{1,64}")
|
||||
"""调用方维度的形态上限(issue #11 §4.2)。
|
||||
|
||||
数值取自同类系统的量级(Loki labels 15 / Salesforce 自定义索引 25 /
|
||||
Sentry tag 200 字符),非本项目实测;键字符集照搬 OTel semconv。
|
||||
`pg_` 前缀留给库将来的内建维度——同类做法见 LangSmith 的 `ls_`、
|
||||
Traceloop 的 `traceloop.`;本版库自身不写入任何该前缀的键。"""
|
||||
|
||||
|
||||
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
|
||||
"""校验采样参数覆盖层并返回浅拷贝;origin 用于把错误指回配置/调用点。
|
||||
@@ -59,6 +73,87 @@ def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict
|
||||
return dict(overlay)
|
||||
|
||||
|
||||
def validate_caller_dimensions(
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any] | None,
|
||||
*,
|
||||
origin: str,
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
"""校验调用方自定义维度并返回浅拷贝;origin 用于把错误指回调用点(issue #11 §4.2)。
|
||||
|
||||
一切超限**报错而非静默丢弃**(P5): 同类系统里 Langfuse 对超长 value 直接
|
||||
丢掉,那会让调用方以为记上了而实际没有。报错点必须在进洋葱之前——洋葱内
|
||||
的失败都被遥测层降级成 warning,校验放那里等于没有校验。
|
||||
"""
|
||||
_validate_tenant_id(tenant_id, origin)
|
||||
if meta is None:
|
||||
return tenant_id, {}
|
||||
# 键形态须先于值校验: 非 str 键若拖到值校验之后,会以更晦涩的形态报出来
|
||||
_validate_meta_keys(meta, origin)
|
||||
_validate_meta_values(meta, origin)
|
||||
return tenant_id, dict(meta)
|
||||
|
||||
|
||||
def _validate_tenant_id(tenant_id: str | None, origin: str) -> None:
|
||||
"""租户标识形态;首尾空白**拒绝而非 strip**(见函数体注释)。"""
|
||||
if tenant_id is None:
|
||||
return
|
||||
if not isinstance(tenant_id, str):
|
||||
raise ValueError(f"{origin} 的 tenant_id 必须是 str: {tenant_id!r}")
|
||||
# 悄悄 strip 会让 " t1" 变成 "t1": 二者在 RLS policy 的等值比较下是两个
|
||||
# 不同租户,替调用方改写值等于把它的行藏进另一个租户,且不报错
|
||||
if tenant_id != tenant_id.strip():
|
||||
raise ValueError(
|
||||
f"{origin} 的 tenant_id 不得含首尾空白: {tenant_id!r}"
|
||||
"(RLS 等值比较下它与去空白版本是两个租户)"
|
||||
)
|
||||
if not tenant_id:
|
||||
raise ValueError(f"{origin} 的 tenant_id 不得为空串(空串是未归属行的哨兵值)")
|
||||
if len(tenant_id) > _TENANT_ID_MAX_LEN:
|
||||
raise ValueError(f"{origin} 的 tenant_id 超长(上限 {_TENANT_ID_MAX_LEN}): {len(tenant_id)}")
|
||||
|
||||
|
||||
def _validate_meta_keys(meta: Mapping[str, Any], origin: str) -> None:
|
||||
"""键形态与数量;键集合被假定为低基数且稳定,故收紧到 OTel semconv 字符集。"""
|
||||
# 数量闸先于逐键校验: 这道闸要防的正是"整个请求体被塞进 meta"的形态,
|
||||
# 那时逐键正则会先跑上万次才报出真正的原因,拖慢的恰是出错路径
|
||||
if len(meta) > _META_MAX_KEYS:
|
||||
raise ValueError(f"{origin} 的 meta 键数超限(上限 {_META_MAX_KEYS}): {len(meta)}")
|
||||
for key in meta:
|
||||
if not isinstance(key, str):
|
||||
raise ValueError(f"{origin} 的 meta 键必须是 str: {key!r}")
|
||||
if key.startswith(_META_RESERVED_PREFIX):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 键 {key!r} 使用了保留前缀 {_META_RESERVED_PREFIX!r}"
|
||||
"(留给库将来的内建维度,避免与调用方的键撞名)"
|
||||
)
|
||||
if not _META_KEY_RE.fullmatch(key):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 键 {key!r} 不合法: 只允许小写字母/数字/下划线/点,长度 1-64"
|
||||
)
|
||||
|
||||
|
||||
def _validate_meta_values(meta: Mapping[str, Any], origin: str) -> None:
|
||||
"""值只收扁平标量;非有限 float 必须挡在这里。
|
||||
|
||||
`json.dumps` 会把 `nan`/`inf` 写成 `NaN`/`Infinity` 字面量——不是合法 JSON,
|
||||
PG 的 JSONB 拒收。放行则写入失败会被遥测的降级 try 吞成 warning,即把调用方
|
||||
的输入错误转成静默丢遥测(Codex 审查推翻了初稿"序列化不可达"的论断)。
|
||||
"""
|
||||
for key, value in meta.items():
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 值必须是 str/int/float/bool: {key}={value!r}"
|
||||
"(嵌套结构请调用方自行序列化)"
|
||||
)
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"{origin} 的 meta 值不得是 nan/inf: {key}={value!r}(非合法 JSON)")
|
||||
if isinstance(value, str) and len(value) > _META_VALUE_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"{origin} 的 meta 值超长(上限 {_META_VALUE_MAX_LEN}): {key} 长 {len(value)}"
|
||||
)
|
||||
|
||||
|
||||
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""合并配置级与调用级采样参数;调用级优先(issue #4 设计决策 A)。"""
|
||||
return {**extra_body, **sampling}
|
||||
@@ -129,6 +224,21 @@ class ChatRequest:
|
||||
不同深度取值不同;缓存 key 与三个遥测入口需要一个跨层恒定的读取点,否则
|
||||
同一列在不同行口径分叉。"""
|
||||
|
||||
# —— 调用方自定义维度(issue #11;追加在末尾,不扰动既有字段的位置构造)——
|
||||
tenant_id: str | None = None
|
||||
"""调用方的租户标识,进遥测的 `tenant_id` 真实列(issue #11)。
|
||||
|
||||
独立成字段而非混进 `meta`,因为它是唯一享有真实列待遇的维度——可挂 RLS、
|
||||
可进复合索引。混在 `meta` 里则调用方拼错(`tenantId`)不会报错,只会静默
|
||||
降级成一个普通维度,正是本 issue 抱怨的失败形态。"""
|
||||
|
||||
meta: Mapping[str, Any] = field(default_factory=dict)
|
||||
"""调用方自定义维度的只读快照,库不解释其含义,库内中间件**永不修改**。
|
||||
|
||||
**不进缓存 key**: 租户隔离已由 `cache_namespace` 负责并已进 key(ARCH §7.5),
|
||||
再进一次既重复又会让存量缓存全量冷启动;且 `meta` 承载的是审计维度而非
|
||||
语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
|
||||
@@ -119,7 +119,7 @@ class TestBreakerRecoveryFullChain:
|
||||
|
||||
class TestCancellationThroughStack:
|
||||
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def hanging_handler(request):
|
||||
@@ -144,7 +144,7 @@ class TestCancellationThroughStack:
|
||||
|
||||
class TestTelemetryAcrossPaths:
|
||||
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
|
||||
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
|
||||
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
|
||||
@@ -155,7 +155,7 @@ class TestTelemetryAcrossPaths:
|
||||
assert hits == 1 and total == 2
|
||||
|
||||
async def test_transient_attempts_each_recorded(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(request):
|
||||
@@ -193,7 +193,7 @@ class TestRejectionReasonIsQueryable:
|
||||
)
|
||||
|
||||
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
client = _full_client(
|
||||
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
||||
)
|
||||
@@ -257,7 +257,7 @@ class TestSamplingThroughStack:
|
||||
return _sse()
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||
client = _full_client(handler, telemetry=recorder)
|
||||
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
||||
recorder.close()
|
||||
@@ -276,7 +276,7 @@ class TestSamplingThroughStack:
|
||||
|
||||
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
|
||||
db = tmp_path / "t.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||
client = GatewayClient(
|
||||
scope="llm",
|
||||
sources=[src],
|
||||
|
||||
@@ -14,12 +14,16 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
from polygateway.telemetry.schema import COLUMNS, telemetry_schema_sql
|
||||
|
||||
_EXPECTED_COLUMNS = [
|
||||
"call_id",
|
||||
@@ -45,6 +49,8 @@ _EXPECTED_COLUMNS = [
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
]
|
||||
|
||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||
@@ -86,8 +92,13 @@ async def dsn():
|
||||
|
||||
async def _record_minimal(
|
||||
recorder: PostgresRecorder, call_id: str | None = None, **overrides
|
||||
) -> None:
|
||||
fields = {
|
||||
) -> dict[str, object]:
|
||||
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。
|
||||
|
||||
返回值不是顺手加的: 逐列断言若在测试里另抄一份期望值,抄错的那一列会以
|
||||
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证。
|
||||
"""
|
||||
fields: dict[str, object] = {
|
||||
"call_id": call_id if call_id is not None else _cid("c1"),
|
||||
"parent_call_id": None,
|
||||
"session_id": "sess-1",
|
||||
@@ -110,9 +121,13 @@ async def _record_minimal(
|
||||
"model_reported": None,
|
||||
"sampling": None,
|
||||
"reasoning_tokens": None,
|
||||
# 到达 recorder 时已由 emitter 归一化: None → '',空 dict → '{}'
|
||||
"tenant_id": "",
|
||||
"meta": "{}",
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
return fields
|
||||
|
||||
|
||||
async def _fetch(dsn: str, sql: str, *args):
|
||||
@@ -125,6 +140,17 @@ async def _fetch(dsn: str, sql: str, *args):
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _execute_script(dsn: str, sql: str) -> None:
|
||||
"""整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(sql)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
_LEGACY_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
@@ -179,7 +205,7 @@ class TestObservabilityColumns:
|
||||
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||
|
||||
async def test_values_round_trip(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||
@@ -207,7 +233,7 @@ class TestObservabilityColumns:
|
||||
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
||||
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
||||
schema_dsn, schema = legacy_schema
|
||||
recorder = PostgresRecorder(schema_dsn)
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||
@@ -232,7 +258,7 @@ class TestObservabilityColumns:
|
||||
|
||||
class TestSchema:
|
||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder)
|
||||
rows = await _fetch(
|
||||
@@ -245,7 +271,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_call_id_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("dup"))
|
||||
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
|
||||
@@ -257,7 +283,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_concurrent_writes_all_land(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
||||
@@ -275,14 +301,14 @@ class TestSchema:
|
||||
class TestDegradation:
|
||||
async def test_unreachable_server_degrades_silently(self):
|
||||
"""结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。"""
|
||||
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x")
|
||||
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
|
||||
await _record_minimal(recorder) # 不抛
|
||||
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_row_failure_does_not_poison_later_rows(self, dsn):
|
||||
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
|
||||
await _record_minimal(recorder, call_id=_cid("good"))
|
||||
@@ -296,7 +322,7 @@ class TestDegradation:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_aclose_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
await _record_minimal(recorder)
|
||||
await recorder.aclose()
|
||||
await recorder.aclose()
|
||||
@@ -315,7 +341,7 @@ async def least_privilege_dsn(dsn):
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
from polygateway.telemetry.postgres import _DDL
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
name = f"pgwtest_lp_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
@@ -327,7 +353,7 @@ async def least_privilege_dsn(dsn):
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(f"SET search_path = {name}")
|
||||
await admin.execute(_DDL) # 表由**别的账号**建好,与现场一致
|
||||
await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||
@@ -368,7 +394,7 @@ class TestLeastPrivilegeDeployment:
|
||||
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
|
||||
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
|
||||
low_dsn, schema = least_privilege_dsn
|
||||
recorder = PostgresRecorder(low_dsn)
|
||||
recorder = PostgresRecorder(low_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lp1"))
|
||||
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
|
||||
@@ -385,3 +411,801 @@ class TestLeastPrivilegeDeployment:
|
||||
assert schema # teardown 会连表带角色删净
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
|
||||
_PRE_TENANT_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
_PRE_TENANT_INSERT = (
|
||||
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
|
||||
)
|
||||
|
||||
|
||||
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度
|
||||
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
|
||||
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。
|
||||
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")]
|
||||
|
||||
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
|
||||
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def captured_warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
|
||||
|
||||
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
|
||||
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
|
||||
"""
|
||||
from loguru import logger
|
||||
|
||||
messages: list[str] = []
|
||||
sink_id = logger.add(messages.append, level="WARNING")
|
||||
yield messages
|
||||
logger.remove(sink_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pre_tenant_schema(dsn):
|
||||
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
|
||||
|
||||
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列,
|
||||
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_pre_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
|
||||
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_schema(dsn):
|
||||
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_new_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def least_privilege_pre_tenant_dsn(dsn):
|
||||
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
|
||||
|
||||
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
|
||||
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
|
||||
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
if not await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
):
|
||||
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
yield _search_path_dsn(low, name)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
|
||||
await admin.execute(f"DROP OWNED BY {name}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
class TestCallerDimensionsAcceptance:
|
||||
"""issue #11 的机械化验收(PG 侧,真实实例): 新建库 / 旧表补列 / 补列失败方向。"""
|
||||
|
||||
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
|
||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||
fresh_dsn, schema = fresh_schema
|
||||
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
fresh_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||
rows = await _fetch(
|
||||
fresh_dsn,
|
||||
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
|
||||
_cid("dim"),
|
||||
)
|
||||
assert rows[0]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(
|
||||
self, pre_tenant_schema
|
||||
):
|
||||
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
|
||||
|
||||
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
|
||||
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
|
||||
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
|
||||
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
# 22 → 24 个 recorder 字段(加 created_at 共 25 个物理列),且新列追加在末尾
|
||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||
rows = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT call_id, tenant_id, meta FROM llm_calls "
|
||||
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
[_cid("new"), _cid("old")],
|
||||
)
|
||||
by_id = {r["call_id"]: r for r in rows}
|
||||
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
|
||||
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert json.loads(by_id[_cid("old")]["meta"]) == {}
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_alter_is_denied_for_a_role_that_can_still_insert(
|
||||
self, least_privilege_pre_tenant_dsn
|
||||
):
|
||||
"""库外事实先钉死: 表存在、写得进去,补列的 ALTER 仍被拒(ownership 检查早于存在性判断)。
|
||||
|
||||
没有这条,下面那个降级用例可能因为 ALTER 其实成功了而变成"永远通过"的空断言。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(least_privilege_pre_tenant_dsn, timeout=10)
|
||||
try:
|
||||
assert await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_backfill_failure_degrades_per_row_not_wholesale(
|
||||
self, least_privilege_pre_tenant_dsn, captured_warnings
|
||||
):
|
||||
"""补列失败的降级方向: 记 warning、不置 `_failed`、后续 INSERT 仍照发。
|
||||
|
||||
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
|
||||
且一旦 DBA 补上列也不会自愈——必须等重启。
|
||||
"""
|
||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
||||
assert recorder._failed is False
|
||||
assert any("补列失败" in m for m in captured_warnings)
|
||||
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
|
||||
assert any("写入失败" in m for m in captured_warnings)
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
|
||||
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
|
||||
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
|
||||
_PARTITIONED_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT NOT NULL,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
PRIMARY KEY (call_id, created_at)
|
||||
) PARTITION BY RANGE (created_at)
|
||||
"""
|
||||
|
||||
_PARTITION_DDL = (
|
||||
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls "
|
||||
"FOR VALUES FROM ('{start}') TO ('{end}')"
|
||||
)
|
||||
|
||||
|
||||
def _current_month_bounds() -> tuple[str, str]:
|
||||
"""当前月的 [月初, 下月初) 边界字面量;分区键落在区间外会因找不到分区而写失败。"""
|
||||
now = datetime.now(UTC)
|
||||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = (start + timedelta(days=32)).replace(day=1)
|
||||
fmt = "%Y-%m-%d %H:%M:%S%z"
|
||||
return start.strftime(fmt), end.strftime(fmt)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
|
||||
|
||||
与 legacy_schema 同款隔离: 绝不碰共享的 public.llm_calls,teardown 只 DROP
|
||||
自己建的 schema(CASCADE 连分区一并删)。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_part_{uuid4().hex[:8]}"
|
||||
start, end = _current_month_bounds()
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PARTITIONED_DDL.format(schema=name))
|
||||
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestConflictTargetFreeInsert:
|
||||
"""issue #13: INSERT 不绑定冲突目标,普通表与分区表两种形态都写得进去。"""
|
||||
|
||||
async def test_plain_table_still_dedupes_by_call_id(self, fresh_schema, captured_warnings):
|
||||
"""普通表上语义不变: 重复 call_id 仍只落一行,且不是被拒后丢弃。
|
||||
|
||||
表上只有主键这一个唯一约束,故无目标的 DO NOTHING 与 `(call_id)` 逐字等价;
|
||||
断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。
|
||||
"""
|
||||
fresh_dsn, _ = fresh_schema
|
||||
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"))
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
rows = await _fetch(
|
||||
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup")
|
||||
)
|
||||
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_partitioned_table_accepts_writes(self, partitioned_schema, captured_warnings):
|
||||
"""分区表上写入成功且能读回——改动前这里必红。
|
||||
|
||||
带目标的 `ON CONFLICT (call_id)` 在主键为 `(call_id, created_at)` 的表上
|
||||
匹配不到任何约束,PG 报 "there is no unique or exclusion constraint matching
|
||||
the ON CONFLICT specification";该错误被逐行降级吞成 warning,于是分区部署下
|
||||
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
|
||||
"""
|
||||
part_dsn, _ = partitioned_schema
|
||||
recorder = PostgresRecorder(part_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
rows = await _fetch(
|
||||
part_dsn,
|
||||
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
|
||||
_cid("part"),
|
||||
)
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
class TestManualSchemaModeAcceptance:
|
||||
"""issue #13 manual 档的真实实例验收: 旧表原样不动,写入照常,缺列只作提示。
|
||||
|
||||
manual 档的承诺是"库一条 DDL 都不发"——单元测试只能验"没调用 execute",
|
||||
真表上才验得了"表结构确实没变"。两条用例分别覆盖有权补列却不补(纪律)与
|
||||
无权补列(现场),后者正是 auto 档会刷出 `补列失败` warning 的那张表。
|
||||
"""
|
||||
|
||||
async def test_manual_leaves_the_stale_table_untouched(
|
||||
self, pre_tenant_schema, captured_warnings
|
||||
):
|
||||
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的两维度静默不写。
|
||||
|
||||
与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
|
||||
同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
# 表结构逐字不动: 既没多出 tenant_id/meta,也没被顺手改了列序
|
||||
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
|
||||
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man")
|
||||
)
|
||||
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
|
||||
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
|
||||
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
|
||||
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
assert [m for m in captured_warnings if "补列失败" in m] == []
|
||||
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
|
||||
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
|
||||
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_manual_on_a_role_that_cannot_alter_emits_no_backfill_failure(
|
||||
self, least_privilege_pre_tenant_dsn, captured_warnings
|
||||
):
|
||||
"""缺列旧表 + 只授 SELECT/INSERT 的角色 + manual: 补列失败的 warning 彻底消失。
|
||||
|
||||
auto 档在这张表上会刷出 `补列失败` 再刷 `写入失败`(见
|
||||
`test_backfill_failure_degrades_per_row_not_wholesale`)——那是 issue #13 要
|
||||
消灭的噪声。manual 档下 ALTER 压根不发,取而代之的是一条点名缺列并附可直接
|
||||
执行的 ALTER 的提示,而遥测照常落库。
|
||||
"""
|
||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}'
|
||||
)
|
||||
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5)
|
||||
|
||||
assert [m for m in captured_warnings if "补列失败" in m] == []
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
assert recorder._failed is False
|
||||
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
|
||||
assert len(notices) == 1 # 准备期一次,第二行不再重复
|
||||
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
|
||||
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
|
||||
assert (
|
||||
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
|
||||
)
|
||||
assert (
|
||||
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb;"
|
||||
in notices[0]
|
||||
)
|
||||
|
||||
# 该角色无权 ALTER,表必然还是旧形态: 缺的两列确实没被写
|
||||
cols = await _fetch(
|
||||
least_privilege_pre_tenant_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND table_name = 'llm_calls' "
|
||||
"ORDER BY ordinal_position",
|
||||
)
|
||||
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
|
||||
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
least_privilege_pre_tenant_dsn,
|
||||
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
|
||||
f"{_RUN_PREFIX}-manlp%",
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")]
|
||||
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
|
||||
assert rows[1]["cost"] == 2.5
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
_PHYSICAL_COLUMNS_SQL = (
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position"
|
||||
)
|
||||
|
||||
|
||||
class TestPublishedSchemaScript:
|
||||
"""issue #13: README 叫下游执行的那份脚本,在真实实例上必须建得出、且可重复执行。
|
||||
|
||||
这份脚本是 `telemetry_schema_sql("postgres")` 的输出,manual 档下游拿它建表,
|
||||
库随后靠列探测决定写哪些列——脚本与 `COLUMNS` 一旦漂移,表现是"照文档建完表,
|
||||
库仍报缺列"。人工核对不构成回归保护: 改一次 README 或 DDL 就会悄悄失去它。
|
||||
"""
|
||||
|
||||
async def test_script_builds_the_full_table_and_is_rerunnable(self, fresh_schema):
|
||||
"""空 schema 里执行一遍建出全部物理列;再执行一遍不报错。
|
||||
|
||||
第二遍是 `ADD COLUMN IF NOT EXISTS` 的幂等性验收: 去掉 IF NOT EXISTS 后,
|
||||
建表语句会被 `IF NOT EXISTS` 跳过而补列语句撞上 "column ... already exists",
|
||||
整段脚本第二次执行即失败——而"可重复执行"正是这份脚本对下游的承诺。
|
||||
"""
|
||||
fresh_dsn, schema = fresh_schema
|
||||
script = telemetry_schema_sql("postgres")
|
||||
|
||||
await _execute_script(fresh_dsn, script)
|
||||
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||
assert set(actual) == set(COLUMNS) | {"created_at"}
|
||||
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
||||
assert actual == _EXPECTED_COLUMNS
|
||||
|
||||
await _execute_script(fresh_dsn, script) # 可重复执行: 第二遍不得抛
|
||||
rerun = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||
assert rerun == actual # 且第二遍没有偷偷改动表结构
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue #12 Task 4: README 的生产部署 DDL 模板,逐条在真实 PG 上执行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 模板 SQL **只有一份**,在 README 里。测试从 README 解析出来跑,而不是在这里另抄
|
||||
# 一份: 抄一份就是两份会各自漂移的东西,而"README 里的 SQL 能跑"这个承诺恰恰只在
|
||||
# 同源时才成立(doctest / Rust doc tests / mdbook test 都是这个范式)。
|
||||
_README = Path(__file__).resolve().parents[2] / "README.md"
|
||||
|
||||
# 锚点写成 HTML 注释,渲染时不可见,比按章节标题或代码块序号定位稳固得多。
|
||||
_TEMPLATE_BLOCK = re.compile(r"<!-- pg-template:([a-z_]+) -->\s*\n```sql\n(.*?)\n```", re.DOTALL)
|
||||
|
||||
# 顺序即执行顺序;数量与名字都钉死——解析不到或多出一块必须当场红,
|
||||
# 绝不能退化成空列表让这条测试变成永远绿的摆设。
|
||||
_EXPECTED_TEMPLATE_BLOCKS = (
|
||||
"roles",
|
||||
"table",
|
||||
"partition",
|
||||
"grants",
|
||||
"immutable",
|
||||
"rls",
|
||||
"index",
|
||||
)
|
||||
|
||||
# README 里必须原样保留、由本测试做受控替换的标识符。README 那份是给下游照抄的,
|
||||
# 故占位符是**合法可执行的具体值**而不是 `<schema>` 之类的尖括号洞。
|
||||
_TEMPLATE_PLACEHOLDERS = (
|
||||
"polygateway_owner",
|
||||
"polygateway_app",
|
||||
"polygateway_report",
|
||||
"CHANGE_ME_APP",
|
||||
"CHANGE_ME_REPORT",
|
||||
"SCHEMA public",
|
||||
"llm_calls_2026_01",
|
||||
"'2026-01-01 00:00:00+00'",
|
||||
"'2026-02-01 00:00:00+00'",
|
||||
)
|
||||
|
||||
# 应用角色在生产里能发的唯一一类写语句(与库的 INSERT 同形,只列 NOT NULL 列)
|
||||
_TEMPLATE_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2)"
|
||||
)
|
||||
|
||||
|
||||
def _template_blocks() -> dict[str, str]:
|
||||
"""从 README 解析带锚点的 SQL 块;顺序即文中出现顺序。"""
|
||||
return dict(_TEMPLATE_BLOCK.findall(_README.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TemplateEnv:
|
||||
"""模板部署完成后的现场句柄:三个角色各自的连接串 + 当月分区名。"""
|
||||
|
||||
admin_dsn: str
|
||||
app_dsn: str
|
||||
report_dsn: str
|
||||
schema: str
|
||||
partition: str
|
||||
seeded: tuple[str, str] # (tenant-a 的行, tenant-b 的行)
|
||||
|
||||
|
||||
def _localize(sql: str, schema: str, roles: dict[str, str], month: datetime) -> str:
|
||||
"""把 README 里给下游照抄的标识符换成本次运行专属的临时对象。
|
||||
|
||||
替换规则写在测试里而不是让 README 变得不可直接复制: README 里那份必须是
|
||||
下游 `pip install` 后照抄就能用的,占位符因此都是合法 SQL 值。
|
||||
"""
|
||||
start = month.strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
end = (month + timedelta(days=32)).replace(day=1).strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
for placeholder, actual in (
|
||||
# 长名在前: 三个角色名互不为前缀,但顺序稳定便于排查
|
||||
("polygateway_owner", roles["owner"]),
|
||||
("polygateway_report", roles["report"]),
|
||||
("polygateway_app", roles["app"]),
|
||||
("CHANGE_ME_APP", _PROBE_PASSWORD),
|
||||
("CHANGE_ME_REPORT", _PROBE_PASSWORD),
|
||||
("SCHEMA public", f"SCHEMA {schema}"),
|
||||
("llm_calls_2026_01", f"llm_calls_{month:%Y_%m}"),
|
||||
("'2026-01-01 00:00:00+00'", f"'{start}'"),
|
||||
("'2026-02-01 00:00:00+00'", f"'{end}'"),
|
||||
):
|
||||
sql = sql.replace(placeholder, actual)
|
||||
return sql
|
||||
|
||||
|
||||
def _role_dsn(dsn: str, role: str, schema: str) -> str:
|
||||
low = re.sub(r"//[^@/]+@", f"//{role}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
return _search_path_dsn(low, schema)
|
||||
|
||||
|
||||
async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -> None:
|
||||
"""删净临时 schema 与三个角色(角色是**全局**对象,漏删会跨 run 残留)。"""
|
||||
import asyncpg
|
||||
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
for role in roles.values():
|
||||
await admin.execute(f"DROP OWNED BY {role}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {role}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def production_template(dsn):
|
||||
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
|
||||
|
||||
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls`
|
||||
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
suffix = uuid4().hex[:8]
|
||||
schema = f"pgwtpl_{suffix}"
|
||||
roles = {
|
||||
"owner": f"pgwtpl_owner_{suffix}",
|
||||
"app": f"pgwtpl_app_{suffix}",
|
||||
"report": f"pgwtpl_report_{suffix}",
|
||||
}
|
||||
month = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
blocks = _template_blocks()
|
||||
# 解析不到就地红: 空 dict 会让下面的 for 一句不执行,测试变成"只验证了能连上库"
|
||||
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS), (
|
||||
f"README 的模板锚点与预期不符: {list(blocks)}"
|
||||
)
|
||||
|
||||
seeded = (_cid("tpl-a"), _cid("tpl-b"))
|
||||
admin_dsn = _search_path_dsn(dsn, schema)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
|
||||
# 若它在下面的清理块内触发,清理会去 DROP 从未建过的角色而把 skip 盖掉
|
||||
can_create = await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
)
|
||||
if not can_create:
|
||||
await admin.close()
|
||||
pytest.skip("当前账号无权建临时角色,跳过生产模板用例")
|
||||
try:
|
||||
await admin.execute(f"CREATE SCHEMA {schema}")
|
||||
await admin.execute(f"SET search_path = {schema}")
|
||||
# README §2 写明的前置步骤: 先用库自带脚本建出普通表当模子
|
||||
await admin.execute(telemetry_schema_sql("postgres"))
|
||||
for name in _EXPECTED_TEMPLATE_BLOCKS:
|
||||
await admin.execute(_localize(blocks[name], schema, roles, month))
|
||||
# 种两个租户的行(超级用户绕过 RLS,属于布景不属于被测行为)
|
||||
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
|
||||
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
|
||||
except BaseException:
|
||||
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
|
||||
# 而 `TestSchema` 那条按 table_name 查 information_schema 的用例不带
|
||||
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
|
||||
await admin.close()
|
||||
await _drop_template_objects(dsn, schema, roles)
|
||||
raise
|
||||
finally:
|
||||
if not admin.is_closed():
|
||||
await admin.close()
|
||||
|
||||
yield _TemplateEnv(
|
||||
admin_dsn=admin_dsn,
|
||||
app_dsn=_role_dsn(dsn, roles["app"], schema),
|
||||
report_dsn=_role_dsn(dsn, roles["report"], schema),
|
||||
schema=schema,
|
||||
partition=f"llm_calls_{month:%Y_%m}",
|
||||
seeded=seeded,
|
||||
)
|
||||
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
for role in roles.values():
|
||||
await admin.execute(f"DROP OWNED BY {role}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {role}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
class TestProductionTemplate:
|
||||
"""issue #12: README 的生产部署 DDL 模板必须逐条可执行,且行为与文中描述一致。
|
||||
|
||||
模板出错的代价全部落在下游身上(照抄就中招),而人工核对不构成回归保护——
|
||||
改一次 README 就会悄悄失去它。故这里从 README **直接解析** SQL 来执行。
|
||||
"""
|
||||
|
||||
def test_readme_exposes_exactly_the_expected_template_blocks(self):
|
||||
"""先钉死解析本身: 锚点没了、改名了、块数变了,这条当场红。
|
||||
|
||||
没有它,`production_template` 里解析出空 dict 时下面每条用例都会以
|
||||
"表不存在"之类的间接形态失败,真因(README 结构变了)要靠猜。
|
||||
"""
|
||||
blocks = _template_blocks()
|
||||
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS)
|
||||
assert all(sql.strip() for sql in blocks.values())
|
||||
joined = "\n".join(blocks.values())
|
||||
for placeholder in _TEMPLATE_PLACEHOLDERS:
|
||||
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
|
||||
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
|
||||
|
||||
async def test_app_can_insert_but_cannot_mutate(self, production_template):
|
||||
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
|
||||
|
||||
权限检查早于行级触发器,故这里拿到的必须是 InsufficientPrivilegeError——
|
||||
若换成触发器的 RaiseError,说明 REVOKE 那一块没生效,而"不可变"就只剩
|
||||
一层属主随手可关的兜底。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.app_dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("UPDATE llm_calls SET response = 'x'")
|
||||
finally:
|
||||
await conn.close()
|
||||
rows = await _fetch(
|
||||
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
|
||||
|
||||
async def test_report_can_read_but_cannot_write(self, production_template):
|
||||
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.report_dsn, timeout=10)
|
||||
try:
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
|
||||
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[0], "tenant-a")]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_reads_are_fail_closed_until_the_tenant_guc_is_set(self, production_template):
|
||||
"""未设 `app.tenant_id` → 零行(fail-closed);设了 → 只看得到本租户。
|
||||
|
||||
两个断言缺一不可: 只验"设了能看到自己的"漏掉了 GUC 未设时全表泄露,
|
||||
只验"未设是零行"则一条永远返回 false 的 policy 也能通过。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.app_dsn, timeout=10)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
assert await conn.fetch("SELECT call_id FROM llm_calls") == []
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-b', true)")
|
||||
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[1], "tenant-b")]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_rows_land_in_the_current_month_partition(self, production_template):
|
||||
"""分区表写入成功,且行确实落进当月分区(不是落进某个兜底分区)。"""
|
||||
env = production_template
|
||||
rows = await _fetch(
|
||||
env.admin_dsn,
|
||||
"SELECT tableoid::regclass::text AS part FROM llm_calls WHERE call_id = $1",
|
||||
env.seeded[0],
|
||||
)
|
||||
assert [r["part"].split(".")[-1] for r in rows] == [env.partition]
|
||||
|
||||
async def test_trigger_blocks_delete_while_drop_partition_still_works(
|
||||
self, production_template
|
||||
):
|
||||
"""兜底触发器拦得住 DELETE(连超级用户也拦),却拦不住 DROP PARTITION。
|
||||
|
||||
这正是 README 说"清理只能走 DROP PARTITION 而不是 DELETE"的机械化依据:
|
||||
既要对应用角色 REVOKE DELETE、又要能清理过期数据,分区是唯一不冲突的解。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.admin_dsn, timeout=10)
|
||||
try:
|
||||
with pytest.raises(asyncpg.exceptions.RaiseError) as exc:
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", env.seeded[0])
|
||||
assert "不可变审计表" in str(exc.value)
|
||||
await conn.execute(f"ALTER TABLE llm_calls DETACH PARTITION {env.partition}")
|
||||
await conn.execute(f"DROP TABLE {env.partition}")
|
||||
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
|
||||
|
||||
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。
|
||||
|
||||
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
|
||||
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path),
|
||||
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
|
||||
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
|
||||
|
||||
_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2, $3)"
|
||||
)
|
||||
|
||||
|
||||
def _partitioned_ddl() -> str:
|
||||
"""由库的真实 `PG_DDL` 派生一份 RANGE 分区版建表语句。
|
||||
|
||||
不另抄一份 DDL: 抄的那份与库的 schema 必然漂移,而漂移后本测试验的就不再是
|
||||
"库建的表被做成分区后脚本认不认得"。两处改动都是分区表的**硬性要求**——
|
||||
分区表上的唯一约束必须包含分区键,故 `call_id` 单列主键不再合法。
|
||||
"""
|
||||
body, count = re.subn(
|
||||
r"call_id(\s+)TEXT PRIMARY KEY", r"call_id\1TEXT NOT NULL", PG_DDL, count=1
|
||||
)
|
||||
if count != 1:
|
||||
raise AssertionError("PG_DDL 的 call_id 主键声明形态已变,分区版 DDL 需同步")
|
||||
body = body.strip().rstrip(";").strip()
|
||||
if not body.endswith(")"):
|
||||
raise AssertionError("PG_DDL 结尾形态已变,分区版 DDL 需同步")
|
||||
return (
|
||||
f"{body[:-1].rstrip()},\n"
|
||||
" PRIMARY KEY (call_id, created_at)\n"
|
||||
") PARTITION BY RANGE (created_at)"
|
||||
)
|
||||
|
||||
|
||||
def _dsn_value() -> str | None:
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||
if not raw:
|
||||
return None
|
||||
scheme, sep, rest = raw.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
def _stamp(delta: timedelta) -> datetime:
|
||||
return datetime.now(UTC) + delta
|
||||
|
||||
|
||||
def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(_SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_ROOT,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dsn():
|
||||
value = _dsn_value()
|
||||
if value is None:
|
||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
|
||||
if not value.rstrip("/").endswith("/polygateway"):
|
||||
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(f"SET search_path = {name}")
|
||||
await conn.execute(ddl)
|
||||
for statement in extra:
|
||||
await conn.execute(statement)
|
||||
finally:
|
||||
await conn.close()
|
||||
return name
|
||||
|
||||
|
||||
async def _drop_schema(dsn_value: str, name: str) -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(schema_dsn, timeout=10)
|
||||
try:
|
||||
await conn.executemany(_INSERT, rows)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _call_ids(schema_dsn: str) -> list[str]:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(schema_dsn, timeout=10)
|
||||
try:
|
||||
rows = await conn.fetch("SELECT call_id FROM llm_calls ORDER BY call_id")
|
||||
finally:
|
||||
await conn.close()
|
||||
return [r["call_id"] for r in rows]
|
||||
|
||||
|
||||
async def _public_count(dsn_value: str) -> int:
|
||||
"""共享表的行数;本测试全程不得让它变动一行。"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
|
||||
return -1
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
|
||||
name = await _make_schema(
|
||||
dsn,
|
||||
"part",
|
||||
_partitioned_ddl(),
|
||||
extra=(
|
||||
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
|
||||
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
|
||||
),
|
||||
)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def plain_schema(dsn):
|
||||
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
|
||||
name = await _make_schema(dsn, "plain", PG_DDL)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
|
||||
|
||||
class TestPartitionedTarget:
|
||||
async def test_partitioned_table_exits_three_without_deleting_anything(
|
||||
self, partitioned_schema
|
||||
):
|
||||
schema_dsn, schema = partitioned_schema
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
[
|
||||
("part-old-1", "", _stamp(timedelta(days=-30))),
|
||||
("part-old-2", "acme", _stamp(timedelta(days=-20))),
|
||||
],
|
||||
)
|
||||
|
||||
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
|
||||
result = _run(
|
||||
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "DROP PARTITION" in combined
|
||||
assert "DETACH" in combined
|
||||
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
|
||||
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
|
||||
|
||||
class TestPlainTableBatches:
|
||||
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
|
||||
schema_dsn, schema = plain_schema
|
||||
before_public = await _public_count(dsn)
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-40))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||
("old-3", "acme", _stamp(timedelta(days=-20))),
|
||||
("old-4", "acme", _stamp(timedelta(days=-15))),
|
||||
("old-5", "", _stamp(timedelta(days=-10))),
|
||||
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||
],
|
||||
)
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--batch-size",
|
||||
"2",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
assert "将删除行数: 5" in result.stdout
|
||||
assert "'acme': 3" in result.stdout
|
||||
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
|
||||
assert "批次 1" in result.stdout
|
||||
assert "批次 3" in result.stdout
|
||||
assert "批次 4" not in result.stdout
|
||||
assert "已删除 5 行" in result.stdout
|
||||
assert await _public_count(dsn) == before_public
|
||||
|
||||
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert "将删除行数: 1" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
|
||||
|
||||
class TestMissingAsyncpg:
|
||||
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
|
||||
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
|
||||
|
||||
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
|
||||
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
|
||||
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
|
||||
"""
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
stub = tmp_path / "stub"
|
||||
stub.mkdir()
|
||||
(stub / "asyncpg.py").write_text(
|
||||
'raise ImportError("asyncpg 未安装(测试构造)")\n', encoding="utf-8"
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": os.pathsep.join(
|
||||
[str(stub), *([p] if (p := os.environ.get("PYTHONPATH")) else [])]
|
||||
),
|
||||
}
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
env=env,
|
||||
)
|
||||
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert "asyncpg" in result.stderr
|
||||
assert "pip install" in result.stderr
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
@@ -321,9 +321,7 @@ class TestStallBudget:
|
||||
async def advance(_n):
|
||||
clock.advance(_STALL)
|
||||
|
||||
mw = _mw(
|
||||
[src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport
|
||||
)
|
||||
mw = _mw([src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport)
|
||||
with pytest.raises(AllSourcesExhausted) as ei:
|
||||
await mw(_REQ)
|
||||
assert ei.value.reason == "stalled" # 不是 retry_exhausted: 429 确实没烧重试预算
|
||||
@@ -541,9 +539,7 @@ class TestUnknownSourceIsAssemblyDefect:
|
||||
"""
|
||||
src = make_source("s1")
|
||||
# 限流后端的源名单与治理循环拿到的源对不上 = 装配缺陷
|
||||
limiter = InMemoryLimiter(
|
||||
scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL
|
||||
)
|
||||
limiter = InMemoryLimiter(scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL)
|
||||
gate = QuotaGate(limiter, scope="llm")
|
||||
with pytest.raises(SourceNotConfiguredError) as ei:
|
||||
await getattr(gate, method)(src)
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.errors import ResultInvalidError, TransientError
|
||||
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
|
||||
_MSGS = [{"role": "user", "content": "hi"}]
|
||||
|
||||
@@ -327,3 +328,51 @@ class TestStructuredRehydration:
|
||||
key = build_cache_key("m", _MSGS, "proj", None)
|
||||
raw = await backend.get(key)
|
||||
assert raw is not None and "structured_data" not in json.loads(raw)
|
||||
|
||||
|
||||
class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
||||
"""红线之一(issue #12): 遥测截断绝不能改到缓存 key。
|
||||
|
||||
`digest_messages` 对 content 非 list 的消息**原样透传同一个 dict 对象**
|
||||
(本文件上方公式测试依赖的也是这份对象),遥测拿到的与算 key 用的是同一份。
|
||||
就地截断会让同一组 messages 在遥测前后算出两个不同的 key——全量 miss、
|
||||
且没有任何报错。故这里测的是"截断没有就地改掉调用方的对象",不只是
|
||||
"截断函数是纯的"。
|
||||
"""
|
||||
|
||||
class _Rows:
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
async def record_llm_call(self, **fields):
|
||||
self.rows.append(fields)
|
||||
|
||||
async def test_key_is_byte_identical_across_a_capped_emit(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "合同正文" * 31},
|
||||
{"role": "user", "content": [{"type": "text", "text": "标书正文" * 30}]},
|
||||
]
|
||||
before = build_cache_key("m", messages, "proj", None)
|
||||
|
||||
rec = self._Rows()
|
||||
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
|
||||
request=ChatRequest(messages=messages),
|
||||
source=SourceConfig(
|
||||
name="s1",
|
||||
provider="p",
|
||||
base_url="https://gw.example/v1",
|
||||
api_key="sk",
|
||||
model="m",
|
||||
timeout_s=10.0,
|
||||
),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
# 截断确实发生了(否则本用例恒真)
|
||||
logged = json.loads(rec.rows[0]["messages"])
|
||||
assert "(略 116 字)" in logged[0]["content"]
|
||||
assert "(略 112 字)" in logged[1]["content"][0]["text"]
|
||||
|
||||
assert build_cache_key("m", messages, "proj", None) == before
|
||||
|
||||
@@ -184,6 +184,72 @@ class TestSamplingOverlay:
|
||||
assert [c["seed"] for c in captured] == [1, 2]
|
||||
|
||||
|
||||
class _MemoryRecorder:
|
||||
"""收下遥测行原样存起来;断言"哪些行被写了"必须能看到零行的情形。"""
|
||||
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
async def record_llm_call(self, **fields):
|
||||
self.rows.append(fields)
|
||||
|
||||
|
||||
class TestCallerDimensions:
|
||||
"""调用方自定义维度进遥测(issue #11 Task 4)。"""
|
||||
|
||||
_MSG = [{"role": "user", "content": "hi"}]
|
||||
|
||||
async def test_dimensions_reach_telemetry_row(self):
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
await client.chat(self._MSG, tenant_id="t1", meta={"batch": "b-42"})
|
||||
row = recorder.rows[-1]
|
||||
assert row["tenant_id"] == "t1"
|
||||
assert json.loads(row["meta"]) == {"batch": "b-42"}
|
||||
|
||||
async def test_default_path_writes_sentinels(self):
|
||||
"""不传两参数时落哨兵值而非 NULL(§4.4: NULL 在 RLS 下是永久不可见的黑洞)。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
await client.chat(self._MSG)
|
||||
row = recorder.rows[-1]
|
||||
assert row["tenant_id"] == "" and row["meta"] == "{}"
|
||||
|
||||
async def test_invalid_meta_key_rejected_before_any_telemetry(self):
|
||||
"""校验早于遥测(§4.2 核心承诺): 放进洋葱就会被降级成 warning 而调用照常发出。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await client.chat(self._MSG, meta={"BAD-KEY": 1})
|
||||
assert recorder.rows == []
|
||||
|
||||
async def test_non_finite_float_rejected_before_any_telemetry(self):
|
||||
"""nan 产出的是 PG 拒收的非法 JSON;放行等于把调用方 bug 变成静默丢遥测(§6)。"""
|
||||
recorder = _MemoryRecorder()
|
||||
async with _client(telemetry=recorder) as client:
|
||||
with pytest.raises(ValueError, match="nan"):
|
||||
await client.chat(self._MSG, meta={"k": float("nan")})
|
||||
assert recorder.rows == []
|
||||
|
||||
async def test_meta_does_not_enter_cache_key(self):
|
||||
"""仅 meta 不同必须仍命中缓存(F1): 进 key 会让存量缓存全量冷启动且不报错。
|
||||
|
||||
带对照组: 只断言"命中"的话,缓存 key 退化成常量(忽略一切输入)时本用例
|
||||
照样绿——那是恒真断言。故再改一个**确实进 key** 的维度(namespace)断言
|
||||
miss,证明 key 仍在区分输入,"meta 不进 key"才是被测出来的结论。
|
||||
"""
|
||||
cache = InMemoryCache() # 两个 client 共用一份存储,否则对照组的 miss 是白来的
|
||||
client = _client(cache=cache, cache_namespace="proj", cache_ttl_s=3600)
|
||||
async with client:
|
||||
first = await client.chat(self._MSG, meta={"batch": "b-1"})
|
||||
second = await client.chat(self._MSG, meta={"batch": "b-2"})
|
||||
assert first.cache_hit is False and second.cache_hit is True
|
||||
|
||||
other_ns = _client(cache=cache, cache_namespace="other", cache_ttl_s=3600)
|
||||
async with other_ns:
|
||||
assert (await other_ns.chat(self._MSG, meta={"batch": "b-1"})).cache_hit is False
|
||||
|
||||
|
||||
class TestModelFingerprint:
|
||||
"""配置级采样参数须进缓存身份,否则改 temperature 后仍读旧缓存(决策 C)。"""
|
||||
|
||||
@@ -285,6 +351,97 @@ class TestFactories:
|
||||
assert isinstance(client, GatewayClient)
|
||||
|
||||
|
||||
class TestTelemetryTextCapWiring:
|
||||
"""`PGW_TELEMETRY_TEXT_CAP` 必须走通全部三条 `from_settings` 装配路(issue #12)。
|
||||
|
||||
三条链路写的是**同一张** `llm_calls` 表:只接通 chat,embed 与 OCR 的行就
|
||||
永远不受 cap 约束,同表内一半受控一半不受控——那正是本 issue 要消灭的状态。
|
||||
"""
|
||||
|
||||
_CAP_ENV = dict(_ENV, PGW_TELEMETRY_TEXT_CAP="8")
|
||||
_OCR_CAP_ENV = {
|
||||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||||
"OCR__MONKEY__1__API_KEY": "none",
|
||||
"OCR__MONKEY__1__MODEL": "monkey-ocr",
|
||||
"OCR__MONKEY__1__TIMEOUT_S": "120",
|
||||
"LLM_MAX_RETRIES": "3",
|
||||
"LLM_RETRY_BASE_DELAY": "2.0",
|
||||
"LLM_RETRY_MAX_DELAY": "30.0",
|
||||
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
|
||||
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
|
||||
"PGW_CACHE_BACKEND": "none",
|
||||
"PGW_TELEMETRY_BACKEND": "none",
|
||||
"PGW_TELEMETRY_TEXT_CAP": "8",
|
||||
}
|
||||
|
||||
def test_gateway_from_settings_wires_the_cap(self):
|
||||
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
client = GatewayClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._terminal._emitter._text_cap == 8
|
||||
# 对照组: 不设该键时 emitter 拿到的必须是 None,否则 8 可能是硬编码来的
|
||||
unset = GatewayClient.from_settings(
|
||||
GatewaySettings.from_env("LLM", env=_ENV), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._terminal._emitter._text_cap is None
|
||||
|
||||
def test_embedding_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import EmbeddingSettings
|
||||
from polygateway.embedding import EmbeddingClient
|
||||
|
||||
gateway = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
client = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=gateway, batch_size=2), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert client._emitter._text_cap == 8
|
||||
unset = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2),
|
||||
telemetry=_MemoryRecorder(),
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
def test_ocr_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import OcrSettings
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_CAP_ENV))
|
||||
client = OcrClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._emitter._text_cap == 8
|
||||
no_cap = dict(self._OCR_CAP_ENV)
|
||||
no_cap.pop("PGW_TELEMETRY_TEXT_CAP")
|
||||
unset = OcrClient.from_settings(
|
||||
OcrSettings.from_env("OCR", env=no_cap), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
async def test_capped_body_reaches_the_recorder_end_to_end(self, monkeypatch):
|
||||
"""装配路通了还不够: 真跑一次 chat,落库的 messages 与 response 确已截断。
|
||||
|
||||
`from_settings` 自建 transport(没有 client_factory 入口),故在装配点
|
||||
换掉该类以接上 MockTransport——洋葱其余各层仍是 `from_settings` 装的真件。
|
||||
"""
|
||||
recorder = _MemoryRecorder()
|
||||
long_text = "甲乙丙丁戊己庚辛壬癸" # 10 字,cap=8 → 略 2 字
|
||||
monkeypatch.setattr(
|
||||
"polygateway.client.OpenAICompatTransport",
|
||||
lambda **kwargs: OpenAICompatTransport(
|
||||
client_factory=lambda source: httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(lambda request: _sse(content=long_text))
|
||||
)
|
||||
),
|
||||
)
|
||||
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
async with GatewayClient.from_settings(settings, telemetry=recorder) as client:
|
||||
await client.chat([{"role": "user", "content": long_text}])
|
||||
row = recorder.rows[-1]
|
||||
assert json.loads(row["messages"])[0]["content"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
assert row["response"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
|
||||
def test_non_positive_cap_rejected_on_the_direct_construction_path(self):
|
||||
"""直接构造是库承诺的另一条公共装配路;cap=0 会让每条正文只剩省略标记。"""
|
||||
with pytest.raises(ValueError, match="text_cap"):
|
||||
_client(telemetry=_MemoryRecorder(), text_cap=0)
|
||||
|
||||
|
||||
class TestSharedBackend:
|
||||
async def test_two_clients_share_global_concurrency_gate(self):
|
||||
"""VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。"""
|
||||
|
||||
@@ -331,6 +331,88 @@ class TestAssemblyGuards:
|
||||
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
|
||||
|
||||
|
||||
class TestTelemetrySchemaMode:
|
||||
"""PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。
|
||||
|
||||
键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA、
|
||||
没有迁移工具、没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
|
||||
PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
|
||||
而遥测是业务路径上的内联 await,故默认 manual。显式设置两侧都可覆盖——
|
||||
"可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它。
|
||||
"""
|
||||
|
||||
def _sqlite_env(self, **overrides):
|
||||
return _env(
|
||||
PGW_TELEMETRY_BACKEND="sqlite",
|
||||
PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
def _pg_env(self, **overrides):
|
||||
return _env(
|
||||
PGW_TELEMETRY_BACKEND="postgres",
|
||||
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
def test_unset_key_derives_auto_for_sqlite(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
|
||||
assert s.telemetry_auto_migrate is True
|
||||
|
||||
def test_unset_key_derives_manual_for_postgres(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._pg_env())
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_unset_key_derives_manual_for_none_backend(self):
|
||||
"""backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
|
||||
s = GatewaySettings.from_env("LLM", env=_env())
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_explicit_manual_overrides_sqlite_default(self):
|
||||
s = GatewaySettings.from_env(
|
||||
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
|
||||
)
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_explicit_auto_overrides_postgres_default(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
|
||||
assert s.telemetry_auto_migrate is True
|
||||
|
||||
def test_invalid_mode_rejected_naming_the_env_key(self):
|
||||
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
|
||||
GatewaySettings.from_env(
|
||||
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
|
||||
)
|
||||
|
||||
|
||||
class TestTelemetryTextCap:
|
||||
"""`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。
|
||||
|
||||
与 `PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
|
||||
(不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套。
|
||||
"""
|
||||
|
||||
def test_unset_key_means_no_truncation(self):
|
||||
"""缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
|
||||
assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None
|
||||
|
||||
def test_positive_value_is_parsed_as_int(self):
|
||||
s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
|
||||
assert s.telemetry_text_cap == 2000
|
||||
|
||||
@pytest.mark.parametrize("raw", ["0", "-1"])
|
||||
def test_non_positive_rejected(self, raw):
|
||||
"""0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
|
||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))
|
||||
|
||||
def test_non_integer_rejected_naming_the_env_key(self):
|
||||
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
|
||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
|
||||
|
||||
|
||||
class TestOcrSettings:
|
||||
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
|
||||
|
||||
@@ -555,8 +637,24 @@ class TestCrossFieldInvariants:
|
||||
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
|
||||
dataclasses.replace(base, telemetry_backend="postgres")
|
||||
|
||||
def test_none_backend_forces_auto_migrate_off(self):
|
||||
"""backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。
|
||||
|
||||
env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
|
||||
官方的装配路仍能把 True 传进来——不变量归位到构造期,三条路才一致。
|
||||
"""
|
||||
base = self._base() # telemetry_backend="none"
|
||||
replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
|
||||
assert replaced.telemetry_auto_migrate is False
|
||||
|
||||
# —— 标量域 ——
|
||||
|
||||
def test_non_positive_text_cap_rejected(self):
|
||||
"""env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="telemetry_text_cap"):
|
||||
dataclasses.replace(base, telemetry_text_cap=0)
|
||||
|
||||
def test_negative_structured_retries_rejected(self):
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="structured_max_retries"):
|
||||
|
||||
@@ -412,6 +412,45 @@ class TestEmbedTelemetry:
|
||||
assert len(rec.rows[1]["messages"]) < 1000 # 长文本截断后入库
|
||||
|
||||
|
||||
class TestEmbedCallerDimensions:
|
||||
"""issue #11: 调用方自定义维度必须沿 embed 链四层透传到每一行遥测。"""
|
||||
|
||||
async def test_single_batch_row_carries_dimensions(self):
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
await client.embed(["a"], tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert rec.rows[0]["tenant_id"] == "t1"
|
||||
assert rec.rows[0]["meta"] == '{"batch": "b-42"}'
|
||||
|
||||
async def test_every_batch_row_carries_the_same_dimensions(self):
|
||||
"""维度属于本次 `embed()` 调用,不随批次变化。
|
||||
|
||||
只断言首行会漏掉"只有第一批带维度"的实现——那正是逐层透传最容易漏的形态。
|
||||
"""
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok", "ok", "ok"], batch_size=1, telemetry=rec)
|
||||
await client.embed(["a", "b", "c"], tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert len(rec.rows) == 3 # 切成三批,每批一行
|
||||
assert [r["tenant_id"] for r in rec.rows] == ["t1", "t1", "t1"]
|
||||
assert [r["meta"] for r in rec.rows] == ['{"batch": "b-42"}'] * 3
|
||||
|
||||
async def test_invalid_meta_rejected_before_any_telemetry(self):
|
||||
"""校验在切批之前: 遥测层的失败都被降级成 warning,放下游等于没有校验。"""
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await client.embed(["a"], meta={"Bad Key": 1})
|
||||
assert rec.rows == []
|
||||
assert client._transport.calls == [] # 连调用都没发出
|
||||
|
||||
async def test_defaults_land_as_sentinels(self):
|
||||
rec = _MemoryRecorder()
|
||||
client, _ = _embed_client([_src()], ["ok"], batch_size=2, telemetry=rec)
|
||||
await client.embed(["a"])
|
||||
assert rec.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
|
||||
assert rec.rows[0]["meta"] == "{}"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _captured_warnings():
|
||||
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。"""
|
||||
|
||||
@@ -480,6 +480,59 @@ class TestTelemetry:
|
||||
assert (await limiter.source_stats("m1")).tpm_used == 0 # settle(0) 全额退回预扣
|
||||
|
||||
|
||||
class TestOcrCallerDimensions:
|
||||
"""issue #11: 调用方自定义维度必须沿 OCR 链四层透传到每一行遥测。
|
||||
|
||||
OCR 行与 chat 行落在同一张 `llm_calls` 表: 不覆盖这条链会让同一张表里
|
||||
一部分行有租户归属、一部分永远空白,而"先启用后加列则归属无法还原"。
|
||||
"""
|
||||
|
||||
async def test_recognize_text_row_carries_dimensions(self):
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert recorder.rows[0]["tenant_id"] == "t1"
|
||||
assert recorder.rows[0]["meta"] == '{"batch": "b-42"}'
|
||||
|
||||
async def test_parse_layout_row_carries_dimensions(self):
|
||||
"""两个公共方法都是入口: 只测一个会漏掉另一个的透传缺口。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["layout"], telemetry=recorder)
|
||||
await client.parse_layout(b"jpg", tenant_id="t2", meta={"batch": "b-43"})
|
||||
assert recorder.rows[0]["tenant_id"] == "t2"
|
||||
assert recorder.rows[0]["meta"] == '{"batch": "b-43"}'
|
||||
|
||||
async def test_failed_attempt_row_also_carries_dimensions(self):
|
||||
"""失败行同样需要归属: 某租户的请求没被服务,正是审计最需要的一行。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client(
|
||||
[_src()],
|
||||
[TransientError("boom", status_code=500), "text"],
|
||||
telemetry=recorder,
|
||||
)
|
||||
await client.recognize_text(b"jpg", tenant_id="t1", meta={"batch": "b-42"})
|
||||
assert len(recorder.rows) == 2 # 失败尝试 + 成功尝试
|
||||
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
|
||||
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
|
||||
|
||||
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
|
||||
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
await getattr(client, method)(b"jpg", meta={"Bad Key": 1})
|
||||
assert recorder.rows == []
|
||||
assert client._transport.calls == [] # 连调用都没发出
|
||||
|
||||
async def test_defaults_land_as_sentinels(self):
|
||||
recorder = _MemoryRecorder()
|
||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||
await client.recognize_text(b"jpg")
|
||||
assert recorder.rows[0]["tenant_id"] == "" # 空串哨兵,不是 None
|
||||
assert recorder.rows[0]["meta"] == "{}"
|
||||
|
||||
|
||||
class TestAssembly:
|
||||
_ENV = {
|
||||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||||
|
||||
@@ -113,7 +113,7 @@ async def _recorded_cost(result, source):
|
||||
source_name=source.name,
|
||||
usage_source=result.usage_source,
|
||||
)
|
||||
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
|
||||
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
|
||||
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
|
||||
source=source,
|
||||
call_id="cid-1",
|
||||
|
||||
@@ -25,3 +25,15 @@ def test_ocr_public_surface_exported():
|
||||
):
|
||||
assert hasattr(polygateway, name), name
|
||||
assert name in polygateway.__all__, name
|
||||
|
||||
|
||||
def test_telemetry_schema_sql_exported():
|
||||
"""issue #13: manual 档下游需要主动索取"库要求的最小 schema"的顶层入口。
|
||||
|
||||
同时钉住公共面**只增这一个名字**: `missing_columns_warning` 是 recorder 内部
|
||||
共用的文案构造函数,导出它等于多一份永久承诺(库承诺公共面只增不删)。
|
||||
"""
|
||||
assert "telemetry_schema_sql" in polygateway.__all__
|
||||
assert callable(polygateway.telemetry_schema_sql)
|
||||
assert "missing_columns_warning" not in polygateway.__all__
|
||||
assert not hasattr(polygateway, "missing_columns_warning")
|
||||
|
||||
@@ -118,6 +118,8 @@ class _DummyRecorder:
|
||||
model_reported,
|
||||
sampling,
|
||||
reasoning_tokens,
|
||||
tenant_id,
|
||||
meta,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@@ -205,6 +207,29 @@ class TestGateUpdate:
|
||||
)
|
||||
|
||||
|
||||
class TestTelemetryRecorderSignature:
|
||||
"""`record_llm_call` 的冻结签名以 `inspect.signature` 实测,不凭记忆断言。
|
||||
|
||||
该 Protocol 的纪律是新增参数**不设默认值**(ports.py docstring):库外无第三方
|
||||
实现者,而带默认值的参数会让 emitter 漏传时静默落默认值——遥测里的租户归属
|
||||
一旦静默错位,事后无从分辨是"没传"还是"就是空的"。
|
||||
"""
|
||||
|
||||
def test_caller_dimensions_are_declared(self):
|
||||
import inspect
|
||||
|
||||
params = inspect.signature(TelemetryRecorder.record_llm_call).parameters
|
||||
assert {"tenant_id", "meta"} <= set(params)
|
||||
|
||||
@pytest.mark.parametrize("name", ["tenant_id", "meta"])
|
||||
def test_caller_dimensions_have_no_default(self, name):
|
||||
import inspect
|
||||
|
||||
param = inspect.signature(TelemetryRecorder.record_llm_call).parameters[name]
|
||||
assert param.default is inspect.Parameter.empty
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
|
||||
|
||||
class TestOcrPorts:
|
||||
"""M3 三个 OCR Protocol(设计 §3.2): runtime_checkable 结构判定。"""
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
|
||||
class TestEmitterCost:
|
||||
async def test_success_row_costed(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
@@ -182,13 +182,13 @@ class TestEmitterCost:
|
||||
|
||||
async def test_cache_hit_row_costs_zero(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
|
||||
assert rec.rows[0]["cost"] == 0.0
|
||||
|
||||
async def test_failure_row_cost_none(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
@@ -201,7 +201,7 @@ class TestEmitterCost:
|
||||
|
||||
async def test_unknown_model_none_without_blocking(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(model="mystery"),
|
||||
@@ -215,7 +215,7 @@ class TestEmitterCost:
|
||||
async def test_no_pricing_keeps_none(self):
|
||||
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec)
|
||||
emitter = TelemetryEmitter(rec, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""`tools/telemetry_retention.py` 的 SQLite 分支测试(issue #12 Task 3)。
|
||||
|
||||
一律经 `subprocess` 跑真实脚本 + 真实临时 SQLite 库文件: 脚本是独立运维工具、
|
||||
不被库 import,用 monkeypatch 或直接 import 私有函数测出来的"通过"与运维实际
|
||||
执行的那条路径不是同一条(退出码、argparse 行为、stdout 全都测不到)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from polygateway.telemetry.schema import SQLITE_DDL
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
|
||||
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 库写入 SQLite 的 created_at 是 UTC 的 'YYYY-MM-DD HH:MM:SS' 文本(schema 的
|
||||
# DEFAULT (datetime('now'))),测试数据必须同款,否则字符串比较的口径就假了
|
||||
_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
|
||||
"VALUES (?, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, ?, ?)"
|
||||
)
|
||||
|
||||
|
||||
def _stamp(delta: timedelta) -> str:
|
||||
return (datetime.now(UTC) + delta).strftime(_TIME_FORMAT)
|
||||
|
||||
|
||||
def _make_db(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
|
||||
"""按库的真实 DDL 建临时库并灌入 (call_id, tenant_id, created_at) 三元组。"""
|
||||
path = tmp_path / "telemetry.db"
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.executescript(SQLITE_DDL)
|
||||
conn.executemany(_INSERT, rows)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return path
|
||||
|
||||
|
||||
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(_SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_ROOT,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def _rows(path: Path) -> list[str]:
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return [r[0] for r in conn.execute("SELECT call_id FROM llm_calls ORDER BY call_id")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _aged_db(tmp_path: Path) -> Path:
|
||||
return _make_db(
|
||||
tmp_path,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-30))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-20))),
|
||||
("old-3", "acme", _stamp(timedelta(days=-10))),
|
||||
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestSqliteDryRun:
|
||||
def test_dry_run_deletes_nothing_and_reports_counts_range_and_tenants(self, tmp_path):
|
||||
"""缺省(不带 --apply)是 dry-run: 一行不删,且报出足以判断"删的是不是我想删的"的三样。"""
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["fresh-1", "fresh-2", "old-1", "old-2", "old-3"]
|
||||
assert "将删除行数: 3" in result.stdout
|
||||
assert "created_at 范围:" in result.stdout
|
||||
assert "按 tenant_id 分布" in result.stdout
|
||||
# 空串是"未归属"的哨兵而非 NULL,repr 让它在输出里不被误读成缺失
|
||||
assert "'acme': 2" in result.stdout
|
||||
assert "'': 1" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
|
||||
def test_dry_run_reports_the_actual_created_at_window(self, tmp_path):
|
||||
"""时间范围报的必须是**命中行**的窗口,不是全表的。"""
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
low, high = conn.execute(
|
||||
"SELECT MIN(created_at), MAX(created_at) FROM llm_calls WHERE call_id LIKE 'old-%'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert f"{low} ~ {high}" in result.stdout
|
||||
|
||||
|
||||
class TestSqliteApply:
|
||||
def test_apply_removes_only_expired_rows(self, tmp_path):
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(path), "--older-than-days", "7", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["fresh-1", "fresh-2"]
|
||||
assert "已删除 3 行" in result.stdout
|
||||
|
||||
def test_older_than_days_zero_deletes_everything_before_now(self, tmp_path):
|
||||
"""N=0 的边界: 截止时刻即"此刻",此刻之前的全删、之后的(未来戳)留下。"""
|
||||
path = _make_db(
|
||||
tmp_path,
|
||||
[
|
||||
("past", "", _stamp(timedelta(seconds=-5))),
|
||||
("future", "", _stamp(timedelta(hours=1))),
|
||||
],
|
||||
)
|
||||
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(path), "--older-than-days", "0", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["future"]
|
||||
|
||||
def test_vacuum_with_apply_rewrites_the_file(self, tmp_path):
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(path),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "VACUUM" in result.stdout
|
||||
assert _rows(path) == ["fresh-1", "fresh-2"]
|
||||
|
||||
def test_deleting_from_a_db_without_the_table_is_a_backend_failure(self, tmp_path):
|
||||
"""连得上但没有 llm_calls: 属"目标不可用",退出码 2 且**不**静默当成 0 行。"""
|
||||
path = tmp_path / "empty.db"
|
||||
sqlite3.connect(path).close()
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "llm_calls" in result.stderr
|
||||
|
||||
def test_missing_db_file_exits_two(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(tmp_path / "nope.db"), "--older-than-days", "7"
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "nope.db" in result.stderr
|
||||
|
||||
|
||||
class TestUsageErrors:
|
||||
"""参数层的一切错误都是退出码 1(argparse 默认的 2 已被本脚本改写,2 留给连接失败)。"""
|
||||
|
||||
def test_sqlite_with_dsn_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--dsn" in result.stderr
|
||||
|
||||
def test_sqlite_without_path_exits_one(self):
|
||||
result = _run("--backend", "sqlite", "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--path" in result.stderr
|
||||
|
||||
def test_sqlite_with_batch_size_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--batch-size",
|
||||
"10",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--batch-size" in result.stderr
|
||||
|
||||
def test_postgres_with_vacuum_exits_one(self):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--vacuum" in result.stderr
|
||||
|
||||
def test_vacuum_without_apply_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--apply" in result.stderr
|
||||
|
||||
def test_missing_older_than_days_exits_one(self, tmp_path):
|
||||
result = _run("--backend", "sqlite", "--path", str(tmp_path / "x.db"))
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
def test_negative_older_than_days_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(tmp_path / "x.db"), "--older-than-days", "-1"
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--older-than-days" in result.stderr
|
||||
|
||||
def test_unknown_backend_exits_one(self, tmp_path):
|
||||
result = _run("--backend", "mysql", "--path", str(tmp_path / "x.db"))
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
|
||||
class TestHelp:
|
||||
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
||||
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
|
||||
result = _run("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "维护角色" in result.stdout
|
||||
assert "REVOKE" in result.stdout
|
||||
assert "PARTITION" in result.stdout
|
||||
+880
-46
File diff suppressed because it is too large
Load Diff
@@ -398,3 +398,132 @@ class TestSourceConfigExtraBody:
|
||||
"""
|
||||
with pytest.raises(TypeError):
|
||||
hash(_make_source())
|
||||
|
||||
|
||||
class TestCallerDimensionsValidation:
|
||||
"""调用方自定义维度的入口校验(issue #11 设计 §4.2)。
|
||||
|
||||
这些红线全部要求**报错**而非静默丢弃: Langfuse 对超长 value 的做法是
|
||||
直接丢掉,本库不抄——P5 严禁默认值掩盖错误。且报错点必须在进洋葱之前,
|
||||
洋葱内的一切失败都会被遥测层降级成 warning,校验放那里等于没有校验。
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"", # 空串是哨兵值的地盘(老行/未归属)
|
||||
" ", # 纯空白 strip 后为空
|
||||
" t1", # 首尾空白: 与 "t1" 在 RLS 等值比较下是两个租户
|
||||
"t1 ",
|
||||
"x" * 129, # 上限 128
|
||||
123, # 非 str
|
||||
],
|
||||
)
|
||||
def test_bad_tenant_id_rejected(self, bad):
|
||||
"""租户标识形态错误必须当场报错,而非带着走到落库。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError) as exc:
|
||||
validate_caller_dimensions(bad, None, origin="chat(tenant_id=...)")
|
||||
assert "chat(tenant_id=...)" in str(exc.value) # 信息须能定位来源
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"Batch", # 大写不合字符集
|
||||
"batch-id", # 连字符不合字符集
|
||||
"b" * 65, # 键长上限 64
|
||||
"pg_internal", # 保留前缀
|
||||
"", # 空键
|
||||
],
|
||||
)
|
||||
def test_bad_meta_key_rejected(self, key):
|
||||
"""键集合被假定为低基数且稳定,形态必须收紧(OTel semconv 字符集)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="meta"):
|
||||
validate_caller_dimensions(None, {key: "v"}, origin="test")
|
||||
|
||||
def test_non_str_meta_key_rejected(self):
|
||||
"""非 str 键无法进 JSON 对象,须先于值校验报出键的问题。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="str"):
|
||||
validate_caller_dimensions(None, {1: "a"}, origin="test")
|
||||
|
||||
def test_too_many_meta_keys_rejected(self):
|
||||
"""上限 16: 容器是审计维度,不是给调用方塞整个请求体的地方。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="16"):
|
||||
validate_caller_dimensions(None, {f"k{i}": "v" for i in range(17)}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a"], {"a": 1}, None, object()])
|
||||
def test_non_scalar_meta_value_rejected(self, bad):
|
||||
"""只收扁平标量(OTel AnyValue 的可移植子集);嵌套让调用方自己序列化。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError, match="值"):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_float_rejected(self, bad):
|
||||
"""json.dumps 会把它们写成 NaN/Infinity 字面量——非合法 JSON,PG JSONB 拒收。
|
||||
|
||||
放行则调用方的输入错误会在写入层失败、被遥测降级吞成 warning,
|
||||
即"输入错误"静默变成"丢遥测"(Codex 审查推翻了初稿的"不可达"论断)。
|
||||
"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": bad}, origin="test")
|
||||
|
||||
def test_too_long_meta_value_rejected(self):
|
||||
"""字符串值上限 256(对齐 Sentry tag / Langfuse 的量级)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
validate_caller_dimensions(None, {"k": "v" * 257}, origin="test")
|
||||
|
||||
def test_absent_dimensions_pass_through(self):
|
||||
"""两者都不传是绝大多数调用点的现状,必须零摩擦放行。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
assert validate_caller_dimensions(None, None, origin="test") == (None, {})
|
||||
|
||||
@pytest.mark.parametrize("good", ["s", 1, 1.5, True, False, 0])
|
||||
def test_scalar_meta_values_accepted(self, good):
|
||||
"""bool 是 int 子类,两者都合法;0/False 不得被真值判断误杀。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
_, meta = validate_caller_dimensions(None, {"k": good}, origin="test")
|
||||
assert meta == {"k": good}
|
||||
|
||||
def test_returns_independent_copy(self):
|
||||
"""调用方复用同一 dict 逐次改值是预期模式,不拷贝会有竞态(同 overlay 决策 E)。"""
|
||||
from polygateway.types import validate_caller_dimensions
|
||||
|
||||
caller_dict = {"batch": "b-42"}
|
||||
_, meta = validate_caller_dimensions("t1", caller_dict, origin="test")
|
||||
caller_dict["batch"] = "b-99"
|
||||
assert meta == {"batch": "b-42"}
|
||||
|
||||
|
||||
class TestChatRequestDimensions:
|
||||
"""ChatRequest 承载维度的字段契约(issue #11)。"""
|
||||
|
||||
def test_defaults_are_absent_dimensions(self):
|
||||
"""新字段必须带默认值——三项目逐字段构造的 fake 才能零改动(ARCH §5.1 约定①)。"""
|
||||
request = ChatRequest(messages=[{"role": "user", "content": "x"}])
|
||||
assert request.tenant_id is None
|
||||
assert request.meta == {}
|
||||
|
||||
def test_dimensions_are_carried(self):
|
||||
"""维度随请求在洋葱内流转,是缓存 key 之外三个遥测入口的共同读取点。"""
|
||||
request = ChatRequest(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
tenant_id="t1",
|
||||
meta={"batch": "b-42"},
|
||||
)
|
||||
assert request.tenant_id == "t1"
|
||||
assert request.meta == {"batch": "b-42"}
|
||||
|
||||
@@ -254,7 +254,7 @@ def _resp(usage_source):
|
||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||
async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_attempt(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_src(),
|
||||
call_id="cid",
|
||||
@@ -268,7 +268,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||
async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_attempt(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_src(),
|
||||
call_id="cid",
|
||||
@@ -282,14 +282,16 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||
async def test_emit_cache_hit_stays_in_domain(emitted):
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_cache_hit(request=_REQ, response=_resp(emitted))
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit(
|
||||
request=_REQ, response=_resp(emitted)
|
||||
)
|
||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||
|
||||
|
||||
async def test_emit_terminal_failure_stays_in_domain():
|
||||
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_terminal_failure(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure(
|
||||
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
|
||||
)
|
||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||
|
||||
@@ -127,7 +127,7 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
|
||||
env = _merged_env()
|
||||
run_id = args.run_id
|
||||
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
|
||||
recorder = SQLiteRecorder(telemetry_path)
|
||||
recorder = SQLiteRecorder(telemetry_path, auto_migrate=True)
|
||||
if args.scenario == "P7":
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""遥测表 `llm_calls` 的保留期清理脚本(issue #12;独立运维工具,库本体不 import 它)。
|
||||
|
||||
**为什么是脚本而不是库能力**: 库对下游数据库只做 SELECT/INSERT 加可选建表,一切
|
||||
改结构与删数据的操作交给下游(ARCHITECTURE D15)。库若持有 DELETE 权限,就与生产
|
||||
部署模板推荐的 `REVOKE UPDATE, DELETE ON llm_calls FROM app` 直接冲突。
|
||||
|
||||
**默认 dry-run**: 本脚本会永久删除审计数据,故不带 `--apply` 时只统计不删,并把
|
||||
行数、`created_at` 窗口、`tenant_id` 分布三样一并打出——运维据此判断"删掉的是不是
|
||||
我想删的",判断不了就不该按下 `--apply`。
|
||||
|
||||
**失败方向与库相反**: 这是运维工具,缺依赖/连不上/表不存在一律明确报错退出,绝不
|
||||
静默降级成"删了 0 行"——静默的 0 行会被当成"已清理干净"。
|
||||
|
||||
用法见 `--help`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, NoReturn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
TABLE = "llm_calls"
|
||||
|
||||
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
||||
EXIT_OK = 0
|
||||
EXIT_USAGE = 1
|
||||
EXIT_BACKEND = 2
|
||||
EXIT_PARTITIONED = 3
|
||||
|
||||
# 库写 SQLite 的 created_at 是 UTC 文本(DEFAULT (datetime('now'))),故截止时刻
|
||||
# 也必须是同格式文本——该格式定长且高位在前,字符串比较与时间序等价。
|
||||
# PG 的 created_at 是 TIMESTAMPTZ,直接传 aware datetime,两端口径不可互换。
|
||||
_SQLITE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_EPILOG = """\
|
||||
退出码:
|
||||
0 正常完成(含 dry-run)
|
||||
1 参数错误
|
||||
2 连接/权限/目标表不可用(含缺少 asyncpg)
|
||||
3 目标是 PostgreSQL 分区表 —— 请改用 DETACH/DROP PARTITION,脚本不会 DELETE
|
||||
|
||||
权限: 请用**维护角色**(表属主)跑本脚本,不要用应用账号 —— 生产部署模板已对应用
|
||||
账号 REVOKE UPDATE, DELETE ON llm_calls(遥测表按不可变审计表对待)。
|
||||
|
||||
推荐路径(本脚本是存量兜底,不是首选):
|
||||
PostgreSQL 把 llm_calls 建成按 created_at 的 RANGE 分区表,过期靠
|
||||
ALTER TABLE ... DETACH PARTITION + DROP TABLE 做 O(1) 清理。
|
||||
SQLite 按天/按实验轮转库文件(如 runs/<date>.db),到期直接删文件。
|
||||
|
||||
时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
|
||||
--older-than-days 0 即"删除此刻之前的全部行"。
|
||||
|
||||
示例:
|
||||
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
|
||||
--older-than-days 90 # dry-run,只看会删什么
|
||||
python tools/telemetry_retention.py --backend postgres --dsn "$DSN" \\
|
||||
--older-than-days 90 --apply --batch-size 1000
|
||||
"""
|
||||
|
||||
|
||||
class _Parser(argparse.ArgumentParser):
|
||||
"""把 argparse 的参数错误退出码从 2 改成 1。
|
||||
|
||||
2 在本脚本的契约里留给"连接/权限失败",两者混用会让调度器分不清"我写错了参数"
|
||||
与"数据库连不上"——后者要告警重试,前者不该重试。
|
||||
"""
|
||||
|
||||
def error(self, message: str) -> NoReturn:
|
||||
self.print_usage(sys.stderr)
|
||||
print(f"{self.prog}: 参数错误: {message}", file=sys.stderr)
|
||||
raise SystemExit(EXIT_USAGE)
|
||||
|
||||
|
||||
def _build_parser() -> _Parser:
|
||||
"""构造 CLI 解析器(参数契约见设计 §6.2)。"""
|
||||
parser = _Parser(
|
||||
prog="telemetry_retention.py",
|
||||
description="按 created_at 清理 PolyGateway 遥测表 llm_calls 的过期行(默认 dry-run)。",
|
||||
epilog=_EPILOG,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--backend", required=True, choices=("sqlite", "postgres"))
|
||||
parser.add_argument("--path", help="SQLite 库文件路径(--backend sqlite 必填)")
|
||||
parser.add_argument("--dsn", help="PostgreSQL DSN(--backend postgres 必填)")
|
||||
parser.add_argument(
|
||||
"--older-than-days",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="N",
|
||||
help="删除 created_at 早于 N 天前的行;N >= 0",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="真正执行删除;不给则只统计不删(默认)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
metavar="N",
|
||||
help="仅 postgres: 每批删除的行数,每批一个事务(默认 1000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vacuum",
|
||||
action="store_true",
|
||||
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _validate(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""校验参数组合;任何不合法组合以退出码 1 结束(P5: 不给默认值掩盖错误)。
|
||||
|
||||
**校验链的顺序就是错误消息的优先级**: 先两端通用,再按 backend 分支——同时给出
|
||||
多个错误参数时,报出的是链上最先命中的那条。
|
||||
"""
|
||||
_validate_shared(parser, args)
|
||||
if args.backend == "sqlite":
|
||||
_validate_sqlite(parser, args)
|
||||
return
|
||||
_validate_postgres(parser, args)
|
||||
|
||||
|
||||
def _validate_shared(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""两端通用的校验。
|
||||
|
||||
`--vacuum` 与 `--apply` 的联动归在这里(而不是 SQLite 分支): 它是"别在只想看看的
|
||||
时候重写整个库"这条安全约束,先于"这个参数属于哪个 backend"成立。
|
||||
"""
|
||||
if args.older_than_days < 0:
|
||||
parser.error("--older-than-days 必须 >= 0")
|
||||
if args.vacuum and not args.apply:
|
||||
parser.error("--vacuum 会重写整个库文件,必须与 --apply 同时给")
|
||||
|
||||
|
||||
def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""SQLite 分支: 必须有 --path,且拒绝一切 postgres 专属参数(不静默忽略)。"""
|
||||
if args.path is None:
|
||||
parser.error("--backend sqlite 需要 --path")
|
||||
if args.dsn is not None:
|
||||
parser.error("--backend sqlite 不接受 --dsn")
|
||||
if args.batch_size is not None:
|
||||
parser.error("--batch-size 仅用于 --backend postgres")
|
||||
|
||||
|
||||
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
|
||||
if args.dsn is None:
|
||||
parser.error("--backend postgres 需要 --dsn")
|
||||
if args.path is not None:
|
||||
parser.error("--backend postgres 不接受 --path")
|
||||
if args.vacuum:
|
||||
parser.error("--vacuum 仅用于 --backend sqlite")
|
||||
if args.batch_size is None:
|
||||
args.batch_size = 1000
|
||||
elif args.batch_size < 1:
|
||||
parser.error("--batch-size 必须 >= 1")
|
||||
|
||||
|
||||
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
|
||||
"""打印将删除行数、created_at 窗口与按 tenant_id 的分布。
|
||||
|
||||
tenant_id 用 repr 打: 空串是"未归属"的哨兵(不是 NULL),裸打会与缺失混淆。
|
||||
"""
|
||||
print(f"将删除行数: {total}")
|
||||
print(f"created_at 范围: {low} ~ {high}" if total else "created_at 范围: (无匹配行)")
|
||||
print("按 tenant_id 分布:")
|
||||
if not tenants:
|
||||
print(" (无匹配行)")
|
||||
for tenant, count in tenants:
|
||||
print(f" {tenant!r}: {count}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- SQLite
|
||||
|
||||
|
||||
def _run_sqlite(path: str, cutoff: str, apply_: bool, vacuum: bool) -> int:
|
||||
"""SQLite 分支: 单条 DELETE(本地文件无长事务与锁膨胀问题),VACUUM 须显式要。"""
|
||||
file = Path(path)
|
||||
if not file.is_file():
|
||||
print(f"SQLite 库文件不存在: {file}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{file}?mode=rw", uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
print(f"打开 SQLite 库失败: {file}: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (TABLE,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
print(f"目标库里没有表 {TABLE}: {file}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
print(f"目标表: {file}::{TABLE}")
|
||||
total, low, high = conn.execute(
|
||||
f"SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM {TABLE} WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
).fetchone()
|
||||
tenants = conn.execute(
|
||||
f"SELECT tenant_id, COUNT(*) FROM {TABLE} WHERE created_at < ? "
|
||||
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
_print_stats(total, low, high, tenants)
|
||||
if not apply_:
|
||||
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
|
||||
return EXIT_OK
|
||||
cursor = conn.execute(f"DELETE FROM {TABLE} WHERE created_at < ?", (cutoff,))
|
||||
conn.commit()
|
||||
print(f"已删除 {cursor.rowcount} 行。")
|
||||
if vacuum:
|
||||
print("执行 VACUUM(重写整个库文件,需要与库等量的空闲磁盘)…")
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
print("VACUUM 完成。")
|
||||
except sqlite3.Error as exc:
|
||||
print(f"SQLite 操作失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
finally:
|
||||
conn.close()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- PostgreSQL
|
||||
|
||||
|
||||
def _quote(identifier: str) -> str:
|
||||
"""把 catalog 取回的 schema/表名包成合法标识符(库名含大写或特殊字符时必需)。"""
|
||||
escaped = identifier.replace('"', '""')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as exc:
|
||||
print(
|
||||
f"--backend postgres 需要 asyncpg,当前不可用({exc});"
|
||||
"请 pip install 'polygateway[postgres]' 或 pip install asyncpg 后重试。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
except (OSError, asyncpg.PostgresError) as exc:
|
||||
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
return await _purge_postgres(conn, cutoff, apply_, batch_size)
|
||||
except asyncpg.PostgresError as exc:
|
||||
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
|
||||
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
|
||||
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
||||
target = await conn.fetchrow(
|
||||
"SELECT n.nspname AS schema, c.relname AS name, "
|
||||
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
|
||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.oid = to_regclass($1)",
|
||||
TABLE,
|
||||
)
|
||||
if target is None:
|
||||
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
schema, name = target["schema"], target["name"]
|
||||
qualified = f"{_quote(schema)}.{_quote(name)}"
|
||||
print(f"目标表: {schema}.{name}")
|
||||
if target["partitioned"]:
|
||||
print(
|
||||
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
|
||||
"请改用 DETACH/DROP PARTITION —— ALTER TABLE ... DETACH PARTITION <子表> 后 "
|
||||
"DROP TABLE <子表>(或交给 pg_partman 的 retention)。\n"
|
||||
"那是 O(1) 的,而 DELETE 会全表扫描并留下等量膨胀。"
|
||||
)
|
||||
return EXIT_PARTITIONED
|
||||
|
||||
stats = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS total, MIN(created_at) AS low, MAX(created_at) AS high "
|
||||
f"FROM {qualified} WHERE created_at < $1",
|
||||
cutoff,
|
||||
)
|
||||
tenants = await conn.fetch(
|
||||
f"SELECT tenant_id, COUNT(*) AS total FROM {qualified} WHERE created_at < $1 "
|
||||
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
|
||||
cutoff,
|
||||
)
|
||||
_print_stats(
|
||||
stats["total"], stats["low"], stats["high"], [(r["tenant_id"], r["total"]) for r in tenants]
|
||||
)
|
||||
if not apply_:
|
||||
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
|
||||
return EXIT_OK
|
||||
|
||||
# 分批: 一条大 DELETE 会撑出长事务(阻塞 autovacuum、堆积 WAL、锁膨胀),
|
||||
# 中断后还得整批回滚重来。每批独立提交,中断只影响未删批次。
|
||||
deleted = 0
|
||||
batches = 0
|
||||
statement = (
|
||||
f"DELETE FROM {qualified} WHERE ctid IN "
|
||||
f"(SELECT ctid FROM {qualified} WHERE created_at < $1 ORDER BY created_at LIMIT $2)"
|
||||
)
|
||||
while True:
|
||||
async with conn.transaction():
|
||||
status = await conn.execute(statement, cutoff, batch_size)
|
||||
count = int(status.rsplit(" ", 1)[-1])
|
||||
if count == 0:
|
||||
break
|
||||
deleted += count
|
||||
batches += 1
|
||||
print(f" 批次 {batches}: 删除 {count} 行(已提交)")
|
||||
print(f"已删除 {deleted} 行,共 {batches} 批。")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- 入口
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""解析参数并分派到对应后端;返回值即进程退出码。"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
_validate(parser, args)
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(days=args.older_than_days)
|
||||
print(f"后端: {args.backend}")
|
||||
print(
|
||||
f"截止时间(UTC): {cutoff.strftime(_SQLITE_TIME_FORMAT)}"
|
||||
f"(--older-than-days {args.older_than_days};删除 created_at 早于该时刻的行)"
|
||||
)
|
||||
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
|
||||
if args.backend == "sqlite":
|
||||
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
|
||||
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user