--- 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 上,测出来的是个假现场。