5b2e3ba82d
Twelve tasks across the two plans, each with the files it touches, the evidence it has to produce, and the command that proves it. #13 goes first: both branches edit config.py and client.py, and #12's partitioning template leans on the schema SQL helper and the untargeted conflict clause that #13 introduces. Writing the cap plan surfaced a trap worth its own guard. digest_messages appends the very same dict when a message's content is not a list, so the telemetry copy, the caller's messages and the cache key all share one object -- capping in place would poison the caller's request and the cache key at once, silently. Two red-line tests now pin that down, and the plan asks for an in-place version to be written and run first, to prove the tests actually catch it.
173 lines
14 KiB
Markdown
173 lines
14 KiB
Markdown
# 实现计划: 遥测 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 与公共函数共用):
|
|
|
|
```python
|
|
COLUMNS: tuple[str, ...] # 24 列,顺序即物理列序(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], ...] # (列名, 完整 ALTER 语句)
|
|
```
|
|
|
|
两个语句构造函数:
|
|
|
|
```python
|
|
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 必填**,无默认值):
|
|
|
|
```python
|
|
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` 之后:
|
|
|
|
```python
|
|
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 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
|
|
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
|
|
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。
|
|
- `telemetry_schema_sql` 输出包含全部 24 个列名,且列名出现顺序与 `COLUMNS` 一致;未知 backend 抛 `ValueError`。
|
|
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
|
|
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway make lint` → 通过(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` 与裁剪写入
|
|
|
|
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;`tests/unit/test_telemetry.py`。
|
|
- **行为**:
|
|
- 两个 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)。
|
|
- **验收**: 见测试。
|
|
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
|
|
- manual + 手工建的 22 列旧表 → 写入成功且能读回、`PRAGMA table_info` 列数**保持 22**(证明未 ALTER)、`caplog` 中恰有一条 warning 且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。
|
|
- auto + 同款 22 列旧表 → 列数变 24(现状回归)。
|
|
- manual + 全新库 → 建表且 24 列齐全(建表未被停掉)。
|
|
- 缺 `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`、`src/polygateway/client.py`、`.env.example`;`tests/unit/test_config.py`。
|
|
- **行为**:
|
|
- `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`。
|
|
- `client.py` 的 `_build_telemetry` 把它透传给两个 recorder。
|
|
- `.env.example` 在 `PGW_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__.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_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
|
|
- **验收**:
|
|
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
|
|
- `least_privilege_dsn`(只授 `SELECT, INSERT`,不授 schema CREATE)+ manual → 不再出现 ALTER 失败的 warning,写入照常。
|
|
- **测试**: 即上述两例。先失败证据: 改动前 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 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
|
|
- **测试**: 无自动化测试;人工核对 README 片段在真实 PG 上可执行(Task 6 的环境里跑一遍)。
|
|
- **验证**: `conda run -n PolyGateway 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 九步。
|