Both open issues ask the same question from opposite sides: how much power the library holds over a downstream database. #13 wants the structural writes back, #12 wants the data retention back. The two designs share one boundary -- the library does SELECT and INSERT plus an optional CREATE, and everything that alters structure or deletes rows belongs to the downstream, with the library obliged to print the exact SQL they need to run. Two findings shape #13 beyond what the issue argues. The precedents it cites (Hangfire's lock queue, Prefect's multi-instance race, Alembic's audit trail) all live on a shared production Postgres, while the SQLite side is a local file with no DBA and no migration tool, so the defaults split by backend rather than uniformly. And turning ALTER off only works together with trimming the INSERT to the columns that exist: without it a stale table drops every row instead of two columns, which breaks the telemetry rule harder than the automatic ALTER ever did. For #12 only the body cap touches library code; retention and access control land in the README, because the sdist carries src and the README alone -- a template that lives in the wiki is one a downstream pip install cannot reach.
11 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 *(库只写不读)。它同时是 issue #12 分区方案能成立的前提——下游把 llm_calls 建成分区表后,库的 to_regclass 探测与 INSERT 路由都照常工作。
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 三态配置 |
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
无有意放弃项。
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) | 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)。