Files
PolyGateway/research-wiki/plans/2026-08-19-issue13-schema-mode.md
iomgaa 8f792bc697 docs: correct the plans against what the code actually does
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.
2026-08-19 09:25:33 -04:00

16 KiB
Raw Permalink Blame History

实现计划: 遥测 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 并派生;GatewaySettingstelemetry_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.exampleREADME.mdCHANGELOG.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_COLUMNScreated_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.pysrc/polygateway/telemetry/postgres.py;修改 tests/integration/test_postgres_telemetry.py(它 from polygateway.telemetry.postgres import _DDL,改为从 schema.py 取)。
  • 行为: 把 sqlite.py_DDL/_BACKFILL_COLUMNS/_COLUMNSpostgres.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.pysrc/polygateway/telemetry/postgres.pysrc/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._columnsself._insert = insert_sql(backend, effective);record_llm_callself._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_idmetaALTER TABLE
    • auto + 同款旧表 → 物理列数变 25(24 个 INSERT 字段 + created_at,现状回归)。
    • manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
    • warning 捕获不能用 caplog: 库用 loguru,它不经标准 logging,caplog 一条也抓不到(那条断言会静默永远绿)。照搬 tests/integration/test_postgres_telemetry.py:436captured_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"派生只写在这一处
    • GatewaySettingstelemetry_auto_migrate: bool(无默认值),telemetry_backend == "none" 时恒 False
    • .env.examplePGW_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__.pytests/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.mdCHANGELOG.mdresearch-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 九步。