docs: design issue #18 around what a safety net can actually prove
The failing assertion was never testing the script. It compared the row count of a table three other projects also write to, before and after the run, and the failure it reported (61 rows became 12) landed while the script under test was demonstrably confined to its own schema. A row count cannot carry the property that assertion stood in for. It goes red when anyone else writes, and green when an outside insert happens to cancel out a wrong delete. The second half is the one that was guarding against a dropped audit table. So the property moves to where the database enforces it: the script runs as a role that owns its scratch table and holds no grant at all on the shared one. Falling back to public stops being something a later assertion might notice and becomes permission denied. Measured, along with the rest of the Postgres semantics this rests on. The tool grows --table so the target stops being whatever search_path resolves to. The table half is pinned to llm_calls: without that, one typo turns a telemetry cleaner into a general purpose row deleter. Codex raised six problems and all are folded in. The one place this still disagrees with it is recorded with the reason.
This commit is contained in:
@@ -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-<uuid8>`) | `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('"<schema>"."llm_calls"')` | 正常解析到该表 |
|
||||
| 3 | `to_regclass('"nosuch_schema_xyz"."llm_calls"')` | **返回 NULL,不抛错** |
|
||||
| 4 | `to_regclass('"<SCHEMA 大写>"."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 上,测出来的是个假现场。
|
||||
Reference in New Issue
Block a user