Postgres requires a partitioned table's unique constraints to cover the partition key, so ranging on created_at forces the primary key to (call_id, created_at) -- and ON CONFLICT (call_id) DO NOTHING then matches no constraint at all. The retention design claimed INSERT stays transparent under partitioning; that holds for the routing, not for the conflict target, and telemetry would have failed outright on any partitioned deployment. The write drops its conflict target, which is byte-equivalent on a plain table and legal on both. The cap design gains the three emitter construction sites it has to touch and the relationship to the 200-char caps embed and OCR already carry: they stay, and the new cap is the stricter of the two. Covering all three call paths is deliberate -- their rows land in one table, and issue #11 settled that argument already.
13 KiB
issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来) 同批交付: issue #12 遥测保留期与访问控制
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)
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.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
- manual 档是否也该停
CREATE TABLE(本设计: 否,理由见 §4.2)。