Files
PolyGateway/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md
iomgaa 39fcf2631d docs: fix the partitioning conflict the review caught
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.
2026-08-19 08:59:30 -04:00

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_callself._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 无该语法,以注释标明"仅当列不存在时执行")。非法 backendValueError(公共入口显式校验,先例同 issue #11 的维度校验)。

这不是锦上添花而是正确性要求: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 _DDL / _BACKFILL / _COLUMNSsqlite.pypostgres.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_sqlCOLUMNS 同源(输出含全部列名且顺序一致)、非法 backend 报 ValueError
unit config 派生: 未设键 → sqlite True / postgres False;显式设置覆盖两侧;非法值报错;backend=noneFalse
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)。