diff --git a/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md b/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md new file mode 100644 index 0000000..8609ee0 --- /dev/null +++ b/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md @@ -0,0 +1,232 @@ +--- +type: design +node_id: design:2026-08-26-issue18-pg-test-isolation +title: "issue #18: 隔离靠权限强制,目标靠显式声明" +date: 2026-08-26 +--- + +# issue #18:隔离靠**权限强制**,目标靠**显式声明** + +> 类型:design|日期:2026-08-26|状态:待 Codex 审 → 人类审 +> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`(本文所有实测引用均出自该文)。 +> 两处需人类拍板的取舍已于 2026-08-26 会话中确认:`--table` **纳入**;7 条写真表的用例**全迁**;`public.llm_calls` 里那 11 行历史孤儿行**不清理**。 + +## 1. issue #18 的诊断只对了一半 + +issue 判定"行数断言依赖共享实例的当下状态",方向对;它推荐的首选处置(标 `slow`,交发布清单统一跑)**不解决问题**——标 `slow` 只是把假红挪出日常关卡,而这条断言还有另一半失效: + +| 失效方向 | 表现 | 标 `slow` 之后 | +|---|---|---| +| 假红 | 外部进程写/删共享表 → 断言红,脚本无辜 | 挪到发布关卡,**照样红**,只是红得更少人看见 | +| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** | + +这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价,而 `slow` 对假阴毫无作用。 + +## 2. 根因三层 + +| 层 | 事实 | 后果 | +|---|---|---| +| L1 | `_public_count` 是全套件唯一一处**全表口径**断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥,偶发红是必然而非意外 | +| L2 | 一个**安全属性**(脚本不越界)被编码成对**全局可变量**(真表行数)的观测 | 假红 + 假阴,结论既不可靠也不可否证 | +| L3 | 之所以只能这么写:`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定(`to_regclass('llm_calls')`),**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表",只好退回事后观测 | + +L3 不是测试的问题,是脚本契约的问题——它同时是生产风险:`search_path` 默认首项是 `"$user"`,换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。脚本现有的应对是把解析结果打印出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。 + +## 3. 设计主张 + +1. **安全属性由数据库权限强制,不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限,越界不是"会被发现",而是"做不到"。 +2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后,目标不再经 `search_path` 推断。 +3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰,隔离手法收敛为"临时 schema"一种,并由 lint 门机械化守住。 + +## 4. 变更 A:`telemetry_retention.py` 新增 `--table SCHEMA.NAME` + +### 4.1 语义:声明即目标,不是"声明后比对" + +两种可能的实现要先分清: + +| | 做法 | 结果 | +|---|---|---| +| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 | +| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 | + +选定做法的实现落点只有一处——`_purge_postgres` 里 `to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名,分区探测、统计、分批 DELETE 全部不变(它们本就用解析结果拼 `qualified`)。 + +三条支撑它的 PG 语义已实测(PostgreSQL 16.14,见 finding §7):`to_regclass('"schema"."llm_calls"')` 正常解析;**schema 不存在时返回 NULL 而不抛错**;引号限定名**区分大小写**(`"PGWPROBE_S_X"."llm_calls"` → NULL)。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支,第三条决定了 §4.2 的"逐字比较"是可实现的。 + +### 4.2 参数与校验 + +| 规则 | 行为 | 理由 | +|---|---|---| +| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 | +| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 | +| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" | +| 两段均非空、均不含 `.` 与 `"` | 不合法 → 退出 **1** | 复杂标识符(含点/引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help` | +| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 | +| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 | +| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 | + +退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。 + +### 4.3 目标白名单:为什么表名段不可变 + +`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at` 与 `tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的,它从未声称自己是通用清理器;让参数悄悄扩大作用域,是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。 + +故 `--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**:cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标,看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余,它本身就是"本脚本的作用域到此为止"的显式声明,且错误消息可以当场把边界告诉用户。 + +### 4.4 未声明时的提示 + +`--apply` 且未给 `--table` 时,在"目标表: x.y"之后补一行: + +``` +注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table .<表名>。 +``` + +只在 `--apply` 时打:dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途,多一行提示是噪音。 + +## 5. 变更 B:测试角色化——把安全网换成权限边界 + +### 5.1 模型 + +**凡是启动 `telemetry_retention.py` 子进程的用例,一律用临时登录角色跑,无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺:正向用例才是带 `--apply` 真删数据的那些,它们若仍用 `.env` 的 superuser DSN 跑,一旦 `search_path` 或 `--table` 出问题,删的就是真表,而新设计里已经没有行数快照会发现它。 + +每个这样的用例临时建一个**登录角色** `tmp`,并 `CREATE SCHEMA s AUTHORIZATION tmp`,表由 `tmp` 自己建。于是: + +- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场 +- `tmp` 对 `public.llm_calls` 一无所有:实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`,无 PUBLIC 授权 + +**必须换角色的原因**:`.env` 里的 `app` 实测 `rolsuper = true`,superuser 无视一切权限检查,用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`(项目既有惯例,见 `least_privilege_dsn`)。 + +防线已实测:临时角色裸连(`search_path = "$user", public`)对真表执行 `COUNT` 与 `DELETE`,两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`。 + +**约束:角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时,`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护,却让 §5.3 的最坏情况用例根本走不到 public,等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_` / `pgw_r_` 两套名字。 + +### 5.2 最坏情况从"事后观测"变成"确定性红灯" + +| 情形 | 旧 | 新 | +|---|---|---| +| `search_path` 失效,脚本落到 `public` | 事后数行数,可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红,**且一行都删不掉** | +| 外部进程并发读写 `public` | 直接假红 | 与测试无关(不再读 `public`) | + +`_public_count` / `before_public` / 那条 `assert` 整体删除。 + +### 5.3 新增一条"最坏情况"用例,替代被删掉的安全网 + +用属主角色的 DSN **不挂 search_path** 跑脚本(于是解析走 `"$user", public`,角色同名 schema 不存在 → 落到 `public.llm_calls`),不给 `--table`: + +- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变 +- **不断言 PG 的英文错误原文**(服务端 `lc_messages` 不由测试掌握),也**不出现 `public.llm_calls` 字面量**(见 §7 的 lint 门) +- 库里没有 `public.llm_calls` 的环境上,脚本报"找不到表"同样退出 2 —— 两条路都绿,用例不因环境而摇摆 + +这条用例把"最坏情况"钉成确定性的红/绿,且完全不观测共享状态。 + +## 6. 变更 C:7 条用例迁出 `public` + +| 用例 | 迁移后验的东西 | +|---|---| +| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**:现在验的是本机那张被历史 `_BACKFILL` 补过列的老表,迁到 fresh schema 后验的是**库当前 DDL 建出来的表** | +| `TestObservabilityColumns::test_values_round_trip` | 不变(只要求表存在) | +| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变(与表在哪无关) | +| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 | +| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变(验的是连接数),但**必须保留唯一 `application_name`**,见下 | + +### 6.1 `_RUN_PREFIX` 有两个职责,只能删掉其中一个 + +| 职责 | 落点 | 处置 | +|---|---|---| +| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 | +| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接,再查 `pg_stat_activity` 数连接数 | **保留**(就地生成 uuid)——连接是**实例级**共享资源,schema 隔离对它无效;改成固定名字会把并行进程的连接数进来,等于把偶发红从表层搬到连接层 | + +删除行隔离用途时调用点做**机械替换**(`_cid("c1")` → `"c1"`),不改任何断言语义;5 处 `LIKE` 过滤逐条在计划里列出并单独验证。 + +### 6.2 顺带封掉一个仓库自己已记载的隐患 + +`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`,**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测:`production_template` 的 `except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。 + +**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。 + +## 7. 变更 D:`conftest.py` 收敛 + lint 门 + +### 7.1 一个沙箱工厂取代七处样板 + +`tests/integration/conftest.py` 新增: + +| fixture | 职责 | +|---|---| +| `pg_admin_dsn`(session) | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 | +| `pg_sandbox`(function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass(`schema` / `dsn` / `role`);teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` | + +三条硬约束(缺一条工厂就会自己变成污染源): + +1. **资源逐步登记,`except BaseException` 清理**:建角色成功、建 schema 失败时不会走到 `yield`,普通 teardown 不执行,角色就永久留在实例上(角色是**全局**对象,不随库消失)。`production_template` 已有同款先例,工厂必须继承它而不是简化掉。 +2. **uuid 后缀取 12 位十六进制**:8 位在并行会话下碰撞概率虽低却非零,而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。 +3. **admin DSN 不做成 fixture**:改为模块私有函数,只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前,"用例不该直接用"只是纪律不是机制。 + +今天这套样板在两个文件里重复**七处**(`legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`,加 retention 侧两处)。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏,残留都落在共享库里。 + +### 7.2 机械化执法 + +`make lint` / `make check` 各加一步: + +``` +tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1 +``` + +§5.3 的用例已按"不出现该字面量"设计,故门无需豁免名单——**注释与 docstring 同样不例外**,现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口,门就退化成建议。 + +**这道门是烟雾报警器,不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处:工厂 API 不把 admin DSN 交出去(§7.1 约束 3),以及脚本以无权角色运行(§5.1)。文档里必须这样写,否则下一个人会拿这道门当"tests 零触碰 public"的证明。 + +## 8. 明确不做 + +| 不做 | 理由 | +|---|---| +| 标 `slow` | §1:对假阴无效;改完之后这条用例的成败不再取决于外部服务状态,它**应该**留在日常关卡里 | +| 建临时数据库(而非 schema) | PG 的 schema 对 DML/DDL 已是完备隔离;建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 | +| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策:那是与迁移项目共用的表,本次不动 | +| 给 SQLite 分支加 `--table` | 库文件即目标,无歧义(§4.2) | +| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope,无全表口径断言,不属同类 | +| 把 `--table` 做成必填 | 会打断下游既有 cron,属破坏性契约变更 | + +## 9. 残余风险(本设计**不**覆盖,需明写而非默认解决) + +| 风险 | 为什么不在本设计覆盖范围 | 缓解 | +|---|---|---| +| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 | +| 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 | +| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 | + +## 10. 版本号与发布 + +**1.3.2**(patch)。需在 CHANGELOG 里如实写明:`tools/` 与 `tests/` **都不在 pip 包内**(README 已声明脚本随仓库分发),故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版的对外内容是**运维脚本的契约扩展**与测试确定性,不是库能力更新。不得包装成库更新。 + +发布按 CLAUDE.md §4.4.1 九步全走,其中与本变更直接相关的:README 需补 `--table` 用法与安装版本约束核对;`make wiki-check` 需在合并前跑过;合并后在 main 上补跑 `pytest -m slow`。 + +## 11. 验收标准 + +| # | 判据 | 验证方式 | +|---|---|---| +| 1a | `--table` 的**参数分类**:sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测(`tests/unit/test_retention_tool.py`,无需 PG) | +| 1b | `--table` 的**真实解析行为**:显式指向 sandbox 表成功删除;指向不存在的 schema → 2;指向无权表 → 2;指向分区表 → 仍 3 | **集成用例(必须真连 PG)**——单测只能验参数分类与拼出的目标字符串,验不了 `to_regclass` 的真实语义 | +| 2 | 未给 `--table` 且 `--apply` 时打印推断提示 | 单测断言 stdout | +| 3 | 最坏情况(search_path 落到 public)**删不掉任何行**且退出 2 | §5.3 新用例 | +| 4 | 整套 `tests/integration` 连跑三次全绿,其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 | +| 5 | `tests/` 下 `public.llm_calls` 零命中 | `make lint` | +| 6 | 迁移未削弱任何用例:7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表,verifier 复核 | +| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 | +| 6c | `test_schema_has_frozen_columns_in_order` 带 `table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 | +| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles` 无 `pgw_%` 残留 | +| 7 | 全套件 + `-m slow` 全绿 | 合并前 | + +## 12. 审查留痕(Codex,2026-08-26) + +报 6 项实质问题,**全部采纳**,其中两项为阻断级: + +| # | 意见 | 处置 | +|---|---|---| +| 1 | **阻断**:`--table` 未限定表名段,会把脚本扩成"任意同形表删除工具"(`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据) | 采纳,见 §4.2 新增规则与 §4.3 | +| 2 | **阻断**:只给"最坏情况"用例换低权限角色,正向 apply 用例仍用 superuser 跑,则新安全网对最危险的那条路径不生效 | 采纳,§5.1 改为"凡启动脚本的用例一律用临时角色,无一例外" | +| 3 | `_RUN_PREFIX` 有第二个职责(`application_name` 唯一),机械删除会让连接池用例失去并发隔离 | 采纳,§6.1;本会话的独立清点也得出同一结论 | +| 4 | `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询不带 schema 过滤 | 采纳,§6.2;核实属实,且仓库注释已记载该坑 | +| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳,§7.1 三条硬约束 | +| 6 | lint 门只防字面量,不能当"零触碰"的证明;`--table` 的验收不能只靠 unit | 采纳,§7.2 定位改写 + §11 拆出 1a/1b | + +**一处处置与建议不同**:Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类",建议归 1。本设计维持 2,理由写在 §4.2——该参数格式合法,能否解析到是环境事实;归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。 diff --git a/research-wiki/findings/2026-08-26-issue18-shared-pg-test-isolation.md b/research-wiki/findings/2026-08-26-issue18-shared-pg-test-isolation.md new file mode 100644 index 0000000..7882364 --- /dev/null +++ b/research-wiki/findings/2026-08-26-issue18-shared-pg-test-isolation.md @@ -0,0 +1,91 @@ +--- +type: finding +node_id: finding:2026-08-26-issue18-shared-pg-test-isolation +title: "issue #18 实测: 偶发红的是安全网本身,不是被测脚本" +date: 2026-08-26 +--- + +# issue #18 实测:偶发红的是**安全网本身**,不是被测脚本 + +> 类型:finding|日期:2026-08-26|实例 `polygateway` 库(PostgreSQL 16.14,共享) +> 本文是 `designs/2026-08-26-issue18-pg-test-isolation-design.md` 的事实基础。 +> 实测与推断在 §5 明确分界——推断部分未做复现实验,不当作既定事实使用。 + +## 1. 失败断言的唯一归属 + +`assert 12 == 61` 只能对应 `test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 的最后一行: + +| 断言 | 形态 | +|---|---| +| `_call_ids(schema_dsn) == ["fresh-1", "fresh-2"]` | 列表比较,失败会打印列表 | +| `"将删除行数: 5" in result.stdout` 等五条 | 子串判定,失败不打印数字对 | +| `await _public_count(dsn) == before_public` | **整型比较,唯一能报出 `12 == 61`** | + +`before_public` 在 seed 之前取,`12` 是脚本跑完后的复测值。 + +## 2. 被测脚本没有越界 + +失败发生在最后一条,意味着它前面全部通过:`_call_ids(schema_dsn)` 恰为 `["fresh-1","fresh-2"]`(临时 schema 里 5 行过期行被删、2 行新鲜行留下)、stdout 里出现 `<临时schema>.llm_calls`、`将删除行数: 5`、三条批次行齐全。 + +若 `search_path` 曾失效、脚本打到了 `public.llm_calls`,那么临时表 7 行一行不少,第二条断言就会先红。**故本次失败与 `telemetry_retention.py` 的行为无关**。 + +## 3. 共享表的实测现状 + +以 `.env` 的 `PGW_TELEMETRY_PG_DSN` 直连查得(2026-08-26): + +| 项 | 实测值 | +|---|---| +| `public.llm_calls` 行数 | **11**,非分区普通表 | +| 这 11 行的 `created_at` | 全部落在 `2026-07-22 14:00 ~ 14:26` | +| 这 11 行的 `call_id` 形态 | 裸 hex 前缀(`3c915c04`、`c8071b6a` …)与一个 `c1`,**不是** `pgwtest-` 前缀 | +| 表属主 / ACL | `app` / `{app=arwdDxt/app, chs3_test=ar/app}`(无 PUBLIC 授权) | +| `.env` 里那个角色 | `app`,`rolsuper = true`、`rolcreatedb = true`、`rolcreaterole = true` | +| 服务端版本 / 连接 | PostgreSQL 16.14;`max_connections = 100`,查时 54 个连接在用 | +| 残留临时 schema / 角色 | 无(`pgw%` 命名下均为空) | + +失败时的 `12` 与这个 `11` 行基线同量级;`61` 意味着取快照那一刻库里另有约 49 行,随后消失。那 11 行是一个多月前留下的**孤儿行**:它们早于 7 天截止线,任何一次带 `--apply` 的存量清理都会删掉它们——这本身说明真实共享表上确实存在"测试/工具写完没清干净"的历史。 + +## 4. 本仓库自己就是共享表的写入方 + +`tests/integration/test_postgres_telemetry.py` 存在两套并行的隔离手法: + +| 手法 | 用在哪 | 是否触碰 `public.llm_calls` | +|---|---|---| +| 临时 schema(`legacy_schema`、`fresh_schema`、`pre_tenant_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`) | 需要特定表形态的用例 | 否,teardown 走 `DROP SCHEMA CASCADE` | +| `_RUN_PREFIX` 前缀(模块级 `pgwtest-`) | `TestObservabilityColumns::test_values_round_trip`、`TestSchema` 三条、`TestDegradation` 两条、`TestPoolFootprint` 一条,**共 7 条** | **是**,写入真表,`dsn` fixture teardown 执行 `DELETE ... WHERE call_id LIKE '<前缀>-%'` | + +前缀隔离对**读**是完备的(每个进程只看自己的行),对**全表口径的观测**不设防——而 `_public_count` 正是全套件里唯一一处全表口径。 + +## 5. 实测与推断的分界 + +**实测(本会话工具输出)**:§1 的断言归属、§2 的失败顺序推理、§3 的全部数字、§4 的用例清单。 + +**推断(未做复现实验)**:那 49 行的来源。同一 pytest 进程内 `test_postgres_telemetry.py` 排在 `test_retention_tool_pg.py` 之前(文件名序),且其 `dsn` fixture 是函数级、每条用例后立即清理,故同进程解释不成立;最合理的解释是**另一个进程**在同一秒窗口内完成了一轮"写 7 条 → teardown 删掉"的循环——并行的另一个开发会话,或 `~/Projects/m4-worktrees/` 下迁移项目的批跑(三个迁移项目正是用本库往这张表写遥测)。 + +这条推断不影响结论:无论那 49 行由谁写删,`public.llm_calls` 的行数都是**不归本测试控制的全局可变量**,把它当断言基线在设计上就不成立。 + +## 6. 与 `_public_count` 的设计意图的落差 + +该断言的注释写明它要防的是"`search_path` 没生效导致静默删库"。行数快照防不住这件事: + +- **假红**:任何外部写/删都让它红(本次即是),而脚本完全正常 +- **假阴**:外部并发的增减可以与脚本的误删互相抵消,行数相等则静默放行——它守的是删库,这一半失效才是真正的代价 + +一个安全属性被编码成对全局可变量的观测,两个方向都不成立。 + +## 7. 方案可行性的实测(2026-08-26,同一实例) + +用一次性角色/schema 做的证伪实验(建 `pgwprobe_r_*` 角色 + `pgwprobe_s_*` schema,跑完全部 `DROP`,实例上无残留): + +| # | 探针 | 结果 | +|---|---|---| +| 1 | 角色以自己身份建表 | 属主为该角色(与"用维护角色跑"的现场一致) | +| 2 | `to_regclass('""."llm_calls"')` | 正常解析到该表 | +| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** | +| 4 | `to_regclass('""."llm_calls"')` | **返回 NULL** —— 引号限定名区分大小写 | +| 5 | 临时角色**裸连**(不挂 search_path) | `SHOW search_path` = `"$user", public`,`to_regclass('llm_calls')` 命中真表 | +| 6 | 裸连对真表 `SELECT COUNT(*)` | `InsufficientPrivilegeError: permission denied for table llm_calls` | +| 7 | 裸连对真表 `DELETE ... WHERE created_at < now()` | `InsufficientPrivilegeError: permission denied for table llm_calls` | +| 8 | 角色名与 schema **同名**时裸连 | `"$user"` 命中自有 schema,**遮蔽 public** | + +第 6、7 条是新方案的核心防线:最坏情况下脚本连数都数不出来,更谈不上删。第 8 条是一条必须写进设计的约束——今天 `least_privilege_dsn` 的角色与 schema 恰好同名,若沿用该形态,"search_path 落到 public"的最坏情况用例会走到自有 schema 上,测出来的是个假现场。