From 72b6b54719e8cf6ac1ee65ff150dbbb532ef6a21 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 19 Aug 2026 08:48:27 -0400 Subject: [PATCH 1/5] docs: design the telemetry schema gate and the retention boundary 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. --- ...8-19-issue12-telemetry-retention-design.md | 130 +++++++++++++++++ .../2026-08-19-issue13-schema-mode-design.md | 133 ++++++++++++++++++ .../designs/issue12-telemetry-retention.md | 22 +++ research-wiki/designs/issue13-schema-mode.md | 20 +++ research-wiki/graph/edges.json | 10 ++ research-wiki/index.md | 8 +- research-wiki/log.md | 3 + 7 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md create mode 100644 research-wiki/designs/2026-08-19-issue13-schema-mode-design.md create mode 100644 research-wiki/designs/issue12-telemetry-retention.md create mode 100644 research-wiki/designs/issue13-schema-mode.md diff --git a/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md b/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md new file mode 100644 index 0000000..2e712ae --- /dev/null +++ b/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md @@ -0,0 +1,130 @@ +# issue #12 设计: 遥测表的正文体量、保留期与访问控制 + +> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例) +> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md) + +## 1. 问题 + +`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白: + +1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。 +2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。 + +这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。 + +## 2. 已定决策(人类,2026-08-19) + +| # | 决策 | 选择 | +|---|---|---| +| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) | +| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 | +| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 | + +E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。 + +## 3. 三个子问题的边界 + +| 子问题 | 库能做什么 | 性质 | +|---|---|---| +| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 | +| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP | +| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 | + +(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。 + +## 4. 备选方案对比 + +| 方案 | 内容 | 权衡 | 结论 | +|---|---|---|---| +| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ | +| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 | +| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 | +| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 | +| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 | + +## 5. 设计: (a) 正文截断 + +### 5.1 配置与装配 + +| 层 | 形态 | +|---|---| +| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) | +| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0` 报 `ValueError` | +| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值) | + +### 5.2 作用面与切法 + +截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"` 的 `text` 字段)、`response`、`thinking`。 + +**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。 + +**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。 + +非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。 + +**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。 + +### 5.3 红线 + +**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。 + +## 6. 设计: (b) 保留期 + +**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。 + +分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13 的 `telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。 + +**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则): + +| 项 | 设计 | +|---|---| +| 参数 | `--backend sqlite\|postgres`、`--path/--dsn`、`--older-than-days N`、`--apply`(**默认 dry-run**)、`--batch-size`、`--vacuum`(仅 SQLite,显式) | +| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 | +| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE | +| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) | +| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) | + +## 7. 设计: (c) 访问控制与不可变性(纯文档) + +README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。 + +| 内容 | 要点 | +|---|---| +| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) | +| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 | +| 分区 | 与 §6 的 retention 模板同一段落 | +| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 | + +**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。 + +## 8. 非功能维度 + +| 维度 | 结论 | +|---|---| +| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 | +| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 | +| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 | +| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 | + +## 9. 错误处理与测试策略 + +| 层 | 用例 | +|---|---| +| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 | +| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" | +| unit | config: 未设 → `None`;`<= 0` → `ValueError`;合法值透传到 emitter | +| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 | +| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 | + +遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。 + +## 10. 兼容性、文档与发布 + +**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。 + +文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。 + +## 11. 开放问题 + +1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。 +2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。 diff --git a/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md b/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md new file mode 100644 index 0000000..112332f --- /dev/null +++ b/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md @@ -0,0 +1,133 @@ +# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 + +> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来) +> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md) + +## 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) + +```python +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. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。 +2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。 diff --git a/research-wiki/designs/issue12-telemetry-retention.md b/research-wiki/designs/issue12-telemetry-retention.md new file mode 100644 index 0000000..2733064 --- /dev/null +++ b/research-wiki/designs/issue12-telemetry-retention.md @@ -0,0 +1,22 @@ +--- +type: design +node_id: design:issue12-telemetry-retention +title: "issue #12: 遥测表的正文体量、保留期与访问控制" +date: 2026-08-19 +--- + +# issue #12: 遥测表的正文体量、保留期与访问控制 + + +正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。 + +- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。 +- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。 +- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。 +- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。 +- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。 +- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。 +- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。 +- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。 +- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder` 加 `purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。 +- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。 diff --git a/research-wiki/designs/issue13-schema-mode.md b/research-wiki/designs/issue13-schema-mode.md new file mode 100644 index 0000000..93502c2 --- /dev/null +++ b/research-wiki/designs/issue13-schema-mode.md @@ -0,0 +1,20 @@ +--- +type: design +node_id: design:issue13-schema-mode +title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位" +date: 2026-08-19 +--- + +# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 + + +正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。 + +- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。 +- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。 +- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。 +- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。 +- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。 +- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。 +- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。 +- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 9a8d13b..798da40 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -165,6 +165,16 @@ "id": "plan:issue11-caller-dimensions", "label": "调用方自定义维度实现计划(issue #11)", "type": "plan" + }, + { + "id": "design:issue13-schema-mode", + "label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位", + "type": "design" + }, + { + "id": "design:issue12-telemetry-retention", + "label": "issue #12: 遥测表的正文体量、保留期与访问控制", + "type": "design" } ], "links": [ diff --git a/research-wiki/index.md b/research-wiki/index.md index 8fc186d..4b841fa 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-17 10:09 UTC +> 自动生成,更新时间:2026-08-19 12:45 UTC -## design (30) +## design (34) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -17,10 +17,14 @@ - [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design` - [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design` - [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design` +- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design` +- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention` +- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention` +- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` diff --git a/research-wiki/log.md b/research-wiki/log.md index 6e7d5d7..fec1205 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -106,3 +106,6 @@ - [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions) - [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions - [2026-08-17 10:09 UTC] 重建索引: 70 篇页面 +- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode) +- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention) +- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面 From 39fcf2631d7ec328527ff3486fdfa3a40feaa848 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 19 Aug 2026 08:59:30 -0400 Subject: [PATCH 2/5] 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. --- ...-08-19-issue12-telemetry-retention-design.md | 17 ++++++++++++++++- .../2026-08-19-issue13-schema-mode-design.md | 15 ++++++++++++++- .../designs/issue12-telemetry-retention.md | 1 + research-wiki/designs/issue13-schema-mode.md | 1 + 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md b/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md index 2e712ae..bd42ba8 100644 --- a/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md +++ b/research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md @@ -50,7 +50,7 @@ E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无 |---|---| | 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) | | `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0` 报 `ValueError` | -| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值) | +| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) | ### 5.2 作用面与切法 @@ -64,6 +64,8 @@ E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无 **覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。 +**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73` 与 `ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `` 占位、`response` 走 `_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。 + ### 5.3 红线 **`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。 @@ -72,6 +74,16 @@ E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无 **README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。 +### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级) + +PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。 + +修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。 + +**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。 + +### 6.2 模板与工具 + 分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13 的 `telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。 **`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则): @@ -114,6 +126,8 @@ README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节 | unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" | | unit | config: 未设 → `None`;`<= 0` → `ValueError`;合法值透传到 emitter | | unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 | +| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 | +| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 | | integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 | 遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。 @@ -128,3 +142,4 @@ README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节 1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。 2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。 +3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。 diff --git a/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md b/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md index 112332f..92f7325 100644 --- a/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md +++ b/research-wiki/designs/2026-08-19-issue13-schema-mode-design.md @@ -79,7 +79,17 @@ polygateway.telemetry_schema_sql(backend: str) -> str ### 4.5 Expand/Contract 成文化(零代码) -库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)**。它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测与 INSERT 路由都照常工作。 +库已满足前三条,但从未文档化为承诺。本次写进 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. 旧版行为审计 @@ -95,6 +105,8 @@ polygateway.telemetry_schema_sql(backend: str) -> str | 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` | 保留(本就无冲突目标) | | 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 | 无有意放弃项。 @@ -117,6 +129,7 @@ polygateway.telemetry_schema_sql(backend: str) -> str | 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) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 | | integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning | 每条行为变更须有先失败后通过的证据(测试结果门)。 diff --git a/research-wiki/designs/issue12-telemetry-retention.md b/research-wiki/designs/issue12-telemetry-retention.md index 2733064..dbe8dc0 100644 --- a/research-wiki/designs/issue12-telemetry-retention.md +++ b/research-wiki/designs/issue12-telemetry-retention.md @@ -20,3 +20,4 @@ date: 2026-08-19 - **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。 - **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder` 加 `purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。 - **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。 +- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。 diff --git a/research-wiki/designs/issue13-schema-mode.md b/research-wiki/designs/issue13-schema-mode.md index 93502c2..2af1caa 100644 --- a/research-wiki/designs/issue13-schema-mode.md +++ b/research-wiki/designs/issue13-schema-mode.md @@ -18,3 +18,4 @@ date: 2026-08-19 - **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。 - **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。 - **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。 +- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。 From 5b2e3ba82db4b77181daefa9cdf05f075810bd1c Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 19 Aug 2026 09:13:20 -0400 Subject: [PATCH 3/5] docs: plan both telemetry changes down to the task level 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. --- research-wiki/graph/edges.json | 24 +++ research-wiki/index.md | 8 +- research-wiki/log.md | 5 + .../2026-08-19-issue12-telemetry-retention.md | 167 +++++++++++++++++ .../plans/2026-08-19-issue13-schema-mode.md | 172 ++++++++++++++++++ .../plans/plan-issue12-telemetry-retention.md | 17 ++ .../plans/plan-issue13-schema-mode.md | 15 ++ 7 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 research-wiki/plans/2026-08-19-issue12-telemetry-retention.md create mode 100644 research-wiki/plans/2026-08-19-issue13-schema-mode.md create mode 100644 research-wiki/plans/plan-issue12-telemetry-retention.md create mode 100644 research-wiki/plans/plan-issue13-schema-mode.md diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 798da40..a5c7776 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -175,6 +175,16 @@ "id": "design:issue12-telemetry-retention", "label": "issue #12: 遥测表的正文体量、保留期与访问控制", "type": "design" + }, + { + "id": "plan:plan-issue13-schema-mode", + "label": "实现计划: issue13-schema-mode", + "type": "plan" + }, + { + "id": "plan:plan-issue12-telemetry-retention", + "label": "实现计划: issue12-telemetry-retention", + "type": "plan" } ], "links": [ @@ -310,6 +320,20 @@ "relation": "implements", "evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路", "added": "2026-08-17T10:09:08.967997+00:00" + }, + { + "source": "plan:plan-issue13-schema-mode", + "target": "design:issue13-schema-mode", + "relation": "implements", + "evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md", + "added": "2026-08-19T13:10:55.616264+00:00" + }, + { + "source": "plan:plan-issue12-telemetry-retention", + "target": "design:issue12-telemetry-retention", + "relation": "implements", + "evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md", + "added": "2026-08-19T13:10:57.986963+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 4b841fa..0ad29e7 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-19 12:45 UTC +> 自动生成,更新时间:2026-08-19 13:10 UTC ## design (34) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -52,7 +52,7 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (25) +## plan (29) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -65,6 +65,8 @@ - [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget` - [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention` - [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions` +- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention` +- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` @@ -74,6 +76,8 @@ - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` +- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention` +- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` diff --git a/research-wiki/log.md b/research-wiki/log.md index fec1205..9f1e182 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -109,3 +109,8 @@ - [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode) - [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention) - [2026-08-19 12:45 UTC] 重建索引: 74 篇页面 +- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode) +- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode +- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention) +- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention +- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面 diff --git a/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md new file mode 100644 index 0000000..3c0ec07 --- /dev/null +++ b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md @@ -0,0 +1,167 @@ +# 实现计划: 遥测正文体量、保留期与访问控制(issue #12) + +- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。 +- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。 +- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。 +- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。 +- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。 +- **前置依赖**: **issue #13 的计划须先合并**。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。 + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter` 增 `text_cap` 必填 | +| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings` 增 `telemetry_text_cap` | +| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 | +| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) | +| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) | +| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import | +| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 | +| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 | +| `tests/unit/test_config.py` | 修改 | 配置校验 | +| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) | +| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 | +| `README.md`、`CHANGELOG.md`、`.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 | + +**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。 + +--- + +## 关键接口(跨任务消费,此处定稿) + +截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`): + +```python +def _cap_text(text: str, cap: int | None) -> str: + """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" + +def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]: + """对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。 + + 非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。 + """ +``` + +`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值): + +```python +class TelemetryEmitter: + def __init__( + self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None, + text_cap: int | None, + ) -> None: ... +``` + +`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后: + +```python +telemetry_text_cap: int | None +``` + +`tools/telemetry_retention.py` 的 CLI 契约: + +```text +--backend sqlite|postgres 必填 +--path PATH | --dsn DSN 按 backend 二选一,必填 +--older-than-days N 必填,N >= 0 +--apply 缺省不带即 dry-run(只统计不删) +--batch-size N 仅 postgres,缺省 1000 +--vacuum 仅 sqlite,须与 --apply 同时给 +退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION) +``` + +--- + +## Task 1: 正文截断与 emitter 参数 + +- [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。 +- **行为**: + - 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。 + - `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;测试内十余处构造点一并补齐。 + - **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。 + - **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。 + - `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。 +- **验收**: + - `cap=None` → 落库正文与今天逐字节相同。 + - `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。 + - 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。 + - 非字符串 content(如 `123`、`None`、嵌套 dict)不抛异常。 + - `response`/`thinking` 同样受 cap。 + - OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。 +- **测试**: + - 上述六条各一例(`tests/unit/test_telemetry.py`)。 + - **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。 + - **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。 + - 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。 +- **提交**: `feat: cap telemetry bodies at a configurable length` + +## Task 2: 配置与装配 + +- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。 +- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings` 增 `telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0` 报 `ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。 +- **验收**: 未设 → `None`;`"0"` 与 `"-1"` 报 `ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。 +- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。 +- **提交**: `feat: wire the telemetry text cap through settings` + +## Task 3: 保留期脚本 + +- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`。 +- **行为**: 按上文 CLI 契约实现。 + - **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。 + - SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。 + - PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。 + - 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。 + - 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。 +- **验收**: 见测试。 +- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite): + - dry-run 后行数不变,stdout 含将删行数与时间范围。 + - `--apply` 后仅超期行被删,未超期行完好。 + - `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。 + - 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。 + - `--vacuum` 不带 `--apply` 时退出码 1。 + - 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py -v` → PASS。 +- **提交**: `feat: add a retention script downstreams can schedule` + +## Task 4: 生产部署模板与其机械化验收 + +- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`。 +- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含: + - **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。 + - **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。 + - **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。 + - **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。 + - **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。 + - 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。 +- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。 +- **测试**(集成,真实 PG,沿用 `least_privilege_dsn` 同款临时 schema + 临时角色隔离,teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言: + - `app` 角色能 INSERT、**不能** DELETE(报权限错)。 + - `report` 角色能读、不能写。 + - 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。 + - 分区表上写入成功且落进当月分区。 + - 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。 +- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。 +- **提交**: `docs: ship a production deployment template with its own test` + +## Task 5: CHANGELOG 与 wiki + +- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`。 +- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。 +- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。 +- **测试**: 无自动化测试。 +- **验证**: `conda run -n PolyGateway make ci` → 全绿。 +- **提交**: `docs: record the retention boundary and its knobs` + +--- + +## 完成判据 + +1. 五个任务的提交点全部落地,`make ci` 全绿。 +2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。 +3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。 +4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。 diff --git a/research-wiki/plans/2026-08-19-issue13-schema-mode.md b/research-wiki/plans/2026-08-19-issue13-schema-mode.md new file mode 100644 index 0000000..470e878 --- /dev/null +++ b/research-wiki/plans/2026-08-19-issue13-schema-mode.md @@ -0,0 +1,172 @@ +# 实现计划: 遥测 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 九步。 diff --git a/research-wiki/plans/plan-issue12-telemetry-retention.md b/research-wiki/plans/plan-issue12-telemetry-retention.md new file mode 100644 index 0000000..37345ab --- /dev/null +++ b/research-wiki/plans/plan-issue12-telemetry-retention.md @@ -0,0 +1,17 @@ +--- +type: plan +node_id: plan:plan-issue12-telemetry-retention +title: "实现计划: issue12-telemetry-retention" +date: 2026-08-19 +--- + +# 实现计划: issue12-telemetry-retention + + +正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。 + +五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。 + +**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。 + +写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。 diff --git a/research-wiki/plans/plan-issue13-schema-mode.md b/research-wiki/plans/plan-issue13-schema-mode.md new file mode 100644 index 0000000..ed6c0f0 --- /dev/null +++ b/research-wiki/plans/plan-issue13-schema-mode.md @@ -0,0 +1,15 @@ +--- +type: plan +node_id: plan:plan-issue13-schema-mode +title: "实现计划: issue13-schema-mode" +date: 2026-08-19 +--- + +# 实现计划: issue13-schema-mode + + +正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。 + +七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。 + +`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。 From 8f792bc69752a76b23130ff5e07c81cf17bf50f5 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 19 Aug 2026 09:25:33 -0400 Subject: [PATCH 4/5] 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-issue12-telemetry-retention.md | 23 +++++++++--- .../plans/2026-08-19-issue13-schema-mode.md | 37 +++++++++++-------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md index 3c0ec07..3dc6113 100644 --- a/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md +++ b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md @@ -5,7 +5,7 @@ - **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。 - **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。 - **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。 -- **前置依赖**: **issue #13 的计划须先合并**。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。 +- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451` 的 `_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。 --- @@ -55,6 +55,16 @@ class TelemetryEmitter: ) -> None: ... ``` +三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性): + +```python +class GatewayClient: # client.py:130 起的构造签名 + def __init__(self, *, ..., text_cap: int | None = None) -> None: ... +# EmbeddingClient / OcrClient 同款 +``` + +**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。 + `GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后: ```python @@ -80,7 +90,7 @@ telemetry_text_cap: int | None - [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。 - **行为**: - 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。 - - `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;测试内十余处构造点一并补齐。 + - `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。 - **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。 - **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。 - `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。 @@ -124,8 +134,9 @@ telemetry_text_cap: int | None - `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。 - 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。 - `--vacuum` 不带 `--apply` 时退出码 1。 - - 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`。 -- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py -v` → PASS。 + - **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。 + - 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。 - **提交**: `feat: add a retention script downstreams can schedule` ## Task 4: 生产部署模板与其机械化验收 @@ -137,9 +148,11 @@ telemetry_text_cap: int | None - **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。 - **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。 - **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。 + - **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"` 的 `text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。 + - **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。 - 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。 - **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。 -- **测试**(集成,真实 PG,沿用 `least_privilege_dsn` 同款临时 schema + 临时角色隔离,teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言: +- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言: - `app` 角色能 INSERT、**不能** DELETE(报权限错)。 - `report` 角色能读、不能写。 - 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。 diff --git a/research-wiki/plans/2026-08-19-issue13-schema-mode.md b/research-wiki/plans/2026-08-19-issue13-schema-mode.md index 470e878..2fe2e21 100644 --- a/research-wiki/plans/2026-08-19-issue13-schema-mode.md +++ b/research-wiki/plans/2026-08-19-issue13-schema-mode.md @@ -33,13 +33,17 @@ `schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用): ```python -COLUMNS: tuple[str, ...] # 24 列,顺序即物理列序(call_id 起、meta 止) +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], ...] # (列名, 完整 ALTER 语句) +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 在自己选的时机执行,锁风险是他的职责。 + 两个语句构造函数: ```python @@ -80,9 +84,9 @@ telemetry_auto_migrate: bool - 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。 - `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。 - `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。 - - `telemetry_schema_sql` 输出包含全部 24 个列名,且列名出现顺序与 `COLUMNS` 一致;未知 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;`conda run -n PolyGateway make lint` → 通过(import-linter 契约不得报新违规: schema.py 只依赖标准库)。 +- **验证**: `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 写入去掉冲突目标 @@ -95,9 +99,10 @@ telemetry_auto_migrate: bool - **验证**: `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` 与裁剪写入 +## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传) -- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;`tests/unit/test_telemetry.py`。 +- [ ] **文件**: `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` 取值。 @@ -109,9 +114,10 @@ telemetry_auto_migrate: bool - 建表(`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 列齐全(建表未被停掉)。 + - 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_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。 @@ -119,11 +125,10 @@ telemetry_auto_migrate: bool ## Task 4: 配置派生与装配 -- [ ] **文件**: `src/polygateway/config.py`、`src/polygateway/client.py`、`.env.example`;`tests/unit/test_config.py`。 +- [ ] **文件**: `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`。 - - `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`。 @@ -142,10 +147,10 @@ telemetry_auto_migrate: bool ## Task 6: 真实 Postgres 集成验收 - [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。 -- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。 +- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。 - **验收**: - manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。 - - `least_privilege_dsn`(只授 `SELECT, INSERT`,不授 schema CREATE)+ manual → 不再出现 ALTER 失败的 warning,写入照常。 + - **`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` @@ -158,8 +163,8 @@ telemetry_auto_migrate: bool - 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` → 全绿。 +- **测试**(集成,真实 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` --- From 172f3180e54b3df8482356c37087410ae7ced317 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 19 Aug 2026 09:27:54 -0400 Subject: [PATCH 5/5] docs: record what the plan review changed --- research-wiki/plans/plan-issue12-telemetry-retention.md | 1 + research-wiki/plans/plan-issue13-schema-mode.md | 1 + 2 files changed, 2 insertions(+) diff --git a/research-wiki/plans/plan-issue12-telemetry-retention.md b/research-wiki/plans/plan-issue12-telemetry-retention.md index 37345ab..492ef20 100644 --- a/research-wiki/plans/plan-issue12-telemetry-retention.md +++ b/research-wiki/plans/plan-issue12-telemetry-retention.md @@ -15,3 +15,4 @@ date: 2026-08-19 **前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。 写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。 +- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter` 的 `text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient` 的 `__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py` 挂 `PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。 diff --git a/research-wiki/plans/plan-issue13-schema-mode.md b/research-wiki/plans/plan-issue13-schema-mode.md index ed6c0f0..0c80595 100644 --- a/research-wiki/plans/plan-issue13-schema-mode.md +++ b/research-wiki/plans/plan-issue13-schema-mode.md @@ -13,3 +13,4 @@ date: 2026-08-19 七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。 `insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。 +- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS` 是 **INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint` 带 `--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。