8f792bc697
The plan review caught three mistakes that would have gone red in the tests rather than in the implementation. Column counts: COLUMNS is the insert field list and excludes the database-filled created_at, so a stale table has 23 physical columns and a current one 25, not 22 and 24. Warning capture: the library logs through loguru, which never reaches caplog, so that assertion would have passed forever without seeing a single line. And the stale-table-under-least-privilege fixture is least_privilege_pre_tenant_dsn -- the other one builds a complete table and never reaches the missing-column path at all. Three more: make lint rewrites files, so verification uses make check; the recorder signature change now ships with its only call site instead of leaving a TypeError between two commits; and the backfill statements the library runs are not the ones it prints -- the library probes first to dodge the exclusive lock, while a script handed to a DBA has to carry IF NOT EXISTS or it cannot be run twice. On the cap side, all three clients build their emitter inside __init__, so a required parameter there would strand anyone constructing a client directly. The emitter stays required, the clients take a defaulted one.
16 KiB
16 KiB
实现计划: 遥测 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 与公共函数共用):
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 在自己选的时机执行,锁风险是他的职责。
两个语句构造函数:
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 必填,无默认值):
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 之后:
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 文本与搬迁前逐字节相同(列名、列序、类型、默认值);
COLUMNS24 项且顺序未变。 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。
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);
- 测试(
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)。
- 两个 recorder 的
- 验收: 见测试。
- 测试(单元,真实临时 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_warningsfixture 形态(logger.add(messages.append, level="WARNING")+ teardownlogger.remove),在tests/unit/test_telemetry.py内新建同款 fixture;别命名为warnings,那会遮蔽标准库模块名。 - 缺
call_id的畸形表 → warning 升级措辞,不抛异常。 - 先失败证据: 新参数不存在时
TypeError;裁剪未实现时 manual 旧表用例因no column named tenant_id全行丢弃而读不回。
- manual + 手工建的旧表(22 个 INSERT 字段 +
- 验证:
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/ postgresFalse/ noneFalse;显式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_dsnfixture 的隔离纪律(临时 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 + 22 列旧表 →
- 测试: 即上述两例。先失败证据: 改动前 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: 新配置键与两端不对称缺省及理由;
- 验收: 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
完成判据
- 七个任务的提交点全部落地,
make ci全绿。 - 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
- 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
- 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。