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