The plan's one irreversible risk is the worst-case test itself. It deliberately lets the script fall through to the shared table, and the account in .env is a superuser, so running it before the sandbox role exists would delete every expired row in there. All eleven rows on that table predate any cutoff the tests use. That forces the order: factory, then the retention tests move onto an owner role, and only then does the case get written. Review caught that the original order also made the --table integration cases impossible to fail first, since the tool would already be implemented by the time they were written. Same fix resolves both. The worst-case case has no red-first path at all. Turning it red means running it as the superuser, which is the thing being prevented, so its evidence is the probe in the finding instead, and the plan says so rather than calling it verified. One acceptance criterion in the design turned out to be unrunnable: the hint line only prints on the Postgres branch, so no unit test that never connects can assert it. Corrected in place.
21 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| plan | plan:2026-08-26-issue18-pg-test-isolation | issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标 | 2026-08-26 |
issue #18 实现计划
类型:plan|日期:2026-08-26|分支
fix/issue-18-pg-test-isolation实现设计designs/2026-08-26-issue18-pg-test-isolation-design.md(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。 本计划不涉及参考实现迁移,保真校验不适用。
Caution
执行期唯一的不可逆风险,写在最前面。 设计 §5.3 的"最坏情况"用例故意让脚本以裸
search_path跑到共享表上。它只有在沙箱角色就位之后才可以跑——若在角色化之前用.env的app(实测 superuser)跑它,--older-than-days 7 --apply会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。 这条风险决定了下面的任务顺序:沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例。它没有常规意义上的"先红"路径,见 Task 2 的说明。
目标
让 tests/integration 不再依赖也不再污染共享表 llm_calls,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
方案概述
先建 tests/integration/conftest.py 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 telemetry_retention.py 加 --table SCHEMA.llm_calls(目标由参数精确解析、绕开 search_path,表名段锁死);随后把 test_postgres_telemetry.py 的 7 条用例迁出真表、拆分 _RUN_PREFIX 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
涉及技术
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 search_path 语义 / argparse。
文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
tests/integration/conftest.py |
新建 | PgSandbox 与 pg_sandbox 工厂;admin DSN 私有化 |
tests/integration/test_pg_sandbox.py |
新建 | 工厂自身的行为测试(含 setup 中途失败不留残留) |
tests/integration/test_retention_tool_pg.py |
修改 | 全部用例角色化;删行数快照;补 --table 与最坏情况用例 |
tools/telemetry_retention.py |
修改 | 新增 --table;PG 分支目标解析改为"显式限定名优先" |
tests/unit/test_retention_tool.py |
修改 | --table 的参数分类用例(不连库) |
tests/integration/test_postgres_telemetry.py |
修改 | 7 条用例迁出真表;_RUN_PREFIX 双职责拆分;其余 fixture 收敛到工厂 |
Makefile |
修改 | lint / check 各加一道字面量门 |
README.md / CHANGELOG.md / pyproject.toml / src/polygateway/__init__.py |
修改 | --table 用法与 1.3.2 定版 |
跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
tests/integration/conftest.py 对外只有一个 fixture 与一个返回类型:
@dataclass(frozen=True)
class PgSandbox:
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
schema: str
role: str | None
dsn: str # 已挂 options=-csearch_path=<schema>
bare_dsn: str | None # 同角色但不挂 search_path;role is None 时为 None
async def pg_sandbox(
*,
ddl: str | None = None,
extra: Sequence[str] = (),
role: Literal["none", "owner", "grantee"] = "none",
grants: Sequence[str] = ("SELECT", "INSERT"),
) -> PgSandbox: ...
三种 role 的语义
覆盖现有全部六个 fixture 的需求,不得再加第四种:
role |
schema 属主 | ddl/extra 由谁执行 |
返回 DSN 的身份 | 对应今天的 fixture |
|---|---|---|---|---|
"none" |
admin | admin | admin | fresh_schema / legacy_schema / pre_tenant_schema / partitioned_schema |
"owner" |
临时角色 | 临时角色自己(故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
"grantee" |
admin | admin(故表属主 = admin,与最小权限现场一致) | 临时角色(只被 GRANT USAGE ON SCHEMA + 表级 grants,绝不 GRANT CREATE) |
least_privilege_dsn / least_privilege_pre_tenant_dsn |
ddl / extra 的执行契约
- 调用方传的 DDL 一律不带 schema 限定(
CREATE TABLE llm_calls (...),不是CREATE TABLE {schema}.llm_calls)。工厂在执行前对该连接SET search_path = <schema>,由 search_path 定位。这条统一了两种今天并存的写法——PG_DDL本就是裸表名,而_LEGACY_DDL/_PRE_TENANT_DDL今天带{schema}占位,Task 5 要把这两个常量的{schema}.前缀去掉。 extra在同一连接、同一 search_path 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。ddl is None时只建空 schema,不执行任何建表语句。
临时角色的 DSN 构造
- 密码:模块级常量(测试专用,非机密),沿用今天
_PROBE_PASSWORD的做法。 bare_dsn:把 admin DSN 里的//user:pass@段整体替换为//<role>:<密码>@(re.sub(r"//[^@/]+@", ...),count=1),不追加任何options参数——它的用途就是让search_path回落到"$user", public。dsn:在bare_dsn基础上追加options=-csearch_path%3D<schema>,分隔符按 DSN 里是否已有?选?或&。role="none"时dsn用 admin 身份加同样的 options,bare_dsn为None——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
- schema 名
pgw_s_<12 位 hex>、角色名pgw_r_<12 位 hex>,两者前缀有意不同——同名会让"$user"遮蔽真表,最坏情况用例就测不到真现场。 - 资源逐步登记:每建成一个对象就把它的清理动作入栈,
except BaseException时逆序执行并 re-raise;yield之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为DROP SCHEMA IF EXISTS <s> CASCADE→DROP OWNED BY <r>→DROP ROLE IF EXISTS <r>(DROP OWNED BY必须在DROP ROLE之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。 role != "none"时先查rolcreaterole OR rolsuper,在建任何对象之前pytest.skip(production_template的教训:pytest.skip抛的是BaseException,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
Task 1:沙箱工厂
- 文件:
tests/integration/conftest.py(新建)、tests/integration/test_pg_sandbox.py(新建)
行为:实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(dotenv_values(".env") 合并 os.environ,剥掉 +driver,缺则 skip,库名不以 /polygateway 结尾则 pytest.fail)——这段逻辑今天重复两份,本任务收敛为一份私有函数。
测试要求(先红后绿的路径明确):先写 test_pg_sandbox.py 再写 conftest.py——此时 pg_sandbox fixture 不存在,pytest 报 fixture 'pg_sandbox' not found,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
| 用例 | 断言 |
|---|---|
role="none" 建表 |
表落在 sandbox.schema 下;sandbox.bare_dsn is None |
role="owner" 建表 |
表属主 = sandbox.role;sandbox.role != sandbox.schema 且两者前缀不同 |
role="owner" 的 bare_dsn |
SHOW search_path 为 "$user", public;用它解析 llm_calls 得到的不是沙箱里那张表 |
role="grantee" |
该角色 CREATE TABLE 被拒(asyncpg.exceptions.InsufficientPrivilegeError),INSERT 正常 |
| setup 中途失败 | 传一段必然报错的 ddl(如 CREATE TABLE llm_calls (bad_type NOT_A_TYPE)),捕获异常后查 pg_namespace / pg_roles:本次 uuid 对应的 schema 与角色都不存在 |
| teardown 后无残留 | 在用例内部记下 sandbox.schema / sandbox.role,用一个更外层的 fixture(在 pg_sandbox 之后销毁)回查两者均已消失 |
验证:
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
预期全绿;随后手工查实例:SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%' 与 pg_roles 同款查询均为空。
Task 2:retention 测试角色化,删除行数快照
- 文件:
tests/integration/test_retention_tool_pg.py(修改)
必须在 Task 3 之前完成——见文首 CAUTION。
行为:
- 删除
_public_count、before_public与那条行数断言;删除本地的_make_schema/_drop_schema/_search_path_dsn/dsnfixture,全部改用pg_sandbox。 - 凡启动脚本的用例一律
role="owner"(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。 - 现有三条用例的其余断言逐条保留:
将删除行数: 5、'acme': 3、批次 1/3 存在而批次 4 不存在、已删除 5 行、剩余fresh-1/fresh-2、分区表退出 3 且含DROP PARTITION/DETACH、缺 asyncpg 退出 2。 - 新增设计 §5.3 的最坏情况用例:用
sandbox.bare_dsn、不给--table、--older-than-days 7 --apply。断言退出 2、stderr 非空且含llm_calls、沙箱表一行不少。不断言 PG 的英文错误原文(lc_messages不由测试掌握),测试代码里不得出现public.llm_calls字面量。
测试证据(这条用例没有常规先红路径,如实记录):让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 findings/2026-08-26-issue18-shared-pg-test-isolation.md §7 的探针 6/7 提供:同款临时角色对真表的 COUNT 与 DELETE 均返回 InsufficientPrivilegeError。提交说明里必须写明这一点,不得含糊成"已验证"。
其余改动的先红路径正常:删掉 _public_count 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
验证:
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
预期全绿;连跑三次结果一致。
Task 3:--table 参数与精确解析
- 文件:
tools/telemetry_retention.py(修改)、tests/unit/test_retention_tool.py(修改)、tests/integration/test_retention_tool_pg.py(追加用例)
顺序:先写测试再改脚本——四条集成用例与五条单测在脚本未改时全部先红(--table 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
脚本行为(设计 §4):
| 项 | 要求 |
|---|---|
| 参数 | --table SCHEMA.NAME,仅 --backend postgres 接受 |
| 校验(全部退出 1) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 . 或 ";表名段不等于 llm_calls |
| 解析 | 给了 --table 时用 to_regclass($1) 传 "<schema>"."llm_calls"(_quote 包裹),绕开 search_path;未给时维持今天的裸 TABLE 解析 |
| 解析不到 | 退出 2,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
| 无权限 | 后续 COUNT 抛 PostgresError,走既有 except → 退出 2(不新增分支) |
| 分区表 | 仍退出 3,逻辑不动 |
| 提示行 | --apply 且未给 --table 时,在"目标表: x.y"之后打印一行,指出目标由 search_path 推断、可用 --table 钉死;dry-run 不打 |
--help 的 epilog 补两句:本脚本只清理 llm_calls;含点或引号的复杂标识符不支持,此时退回不给 --table 的路径。
单测(tests/unit/test_retention_tool.py,不连库):TestUsageErrors 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 --table;TestHelp 加一条断言 epilog 点明表名固定为 llm_calls。
集成用例(test_retention_tool_pg.py,全部 role="owner"):
| 用例 | 构造 | 预期 |
|---|---|---|
| 显式指定成功 | --table <sandbox.schema>.llm_calls + --apply |
退出 0,删除结果与不给 --table 时逐条一致 |
| 指向不存在的 schema | --table pgw_s_nosuchxxxxxxxx.llm_calls |
退出 2,stderr 点名该表;沙箱表一行不少 |
| 指向无权的表 | 建两个 role="owner" 沙箱,用 A 的 DSN 指 B 的表 |
退出 2;A、B 两张表都不变 |
| 指向分区表 | 分区沙箱 + --table |
仍退出 3,含 DROP PARTITION / DETACH 字样 |
| 提示行(设计验收 #2) | 沙箱 DSN + --apply,不给 --table |
stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
验证:
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
Task 4:7 条用例迁出真表,_RUN_PREFIX 拆职责
- 文件:
tests/integration/test_postgres_telemetry.py(修改)
行为:
- 七条用例改用
pg_sandbox(role="none"):TestObservabilityColumns::test_values_round_trip、TestSchema三条、TestDegradation::test_row_failure_does_not_poison_later_rows与test_aclose_idempotent、TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max。 test_schema_has_frozen_columns_in_order的information_schema查询补table_schema = $1(设计 §6.2;仓库注释已记载该隐患)。TestPoolFootprint保留唯一application_name,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。- 删除
_RUN_PREFIX的行隔离用途:_cid()的 63 处调用机械替换为字面量(_cid("c1")→"c1");5 处LIKE逐条处置——dsnfixture teardown 的DELETE整条删除,test_concurrent_writes_all_land的计数改COUNT(*),其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。 - 删除已无引用的本地
dsnfixture 与其 teardown。
测试证据:判据 6c 有明确先红路径——先在库里手工留一个残留同名表(CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)),此时 test_schema_has_frozen_columns_in_order 因少了 table_schema 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——这是回归门不是先红门,提交说明里如实这么写。
验证:
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
判据 6b 另做:两个 shell 同时跑 TestPoolFootprint 那一条,两边都绿。
Task 5:其余 fixture 收敛到工厂
- 文件:
tests/integration/test_postgres_telemetry.py(修改)
行为:
legacy_schema、pre_tenant_schema、fresh_schema、partitioned_schema改为role="none";least_privilege_dsn、least_privilege_pre_tenant_dsn改为role="grantee"。- 按接口契约,
_LEGACY_DDL与_PRE_TENANT_DDL两个常量去掉{schema}.前缀与.format(schema=...)调用,改为裸表名由工厂的 search_path 定位。 production_template不收敛:它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的_cid()调用一并处理掉。
测试证据:这些 fixture 的既有用例断言一行不改——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
验证:同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 least_privilege 系列仍能正确 skip。
Task 6:lint 门与字面量清理
- 文件:
Makefile(修改)、tests/integration/*.py(注释措辞)
行为:lint 与 check 各加一步——tests/ 下命中字面量 public.llm_calls 即 exit 1 并打印命中行。注释与 docstring 同样不豁免,现有"共享的 public.llm_calls"改写为"共享表 llm_calls"。
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):烟雾报警器,不是隔离证明——它拦不住 f"{schema}.{table}" 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
测试证据:故意加一行含该字面量的注释 → make lint 失败并打印该行;移除后 → 通过。
验证:
make lint && make check
Task 7:独立验证(合并前硬门)
- 派全新上下文的 verifier subagent(
verification-before-completion),交给它设计 §11 的判据表逐条核对,重点:- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
- 判据 4:整套
tests/integration连跑三次,其间由 verifier 手工改动真表行数(插入若干行再删掉),全程应无任何用例受影响 - 判据 6:7 条用例迁移前后断言逐条对照
--table的五种退出 1 与三种退出 2/3 是否都有用例覆盖tests/与实例上是否留下任何pgw_%残留
Task 8:文档与版本号
-
文件:
README.md、CHANGELOG.md、pyproject.toml、src/polygateway/__init__.py -
README:
--table用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为llm_calls。安装约束是>=1.3.0,<2范围式,本版无需改(已核)。 -
CHANGELOG:按设计 §10 如实写明
tools/与tests/都不在 pip 包内,1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。 -
版本号两处一致改
1.3.2。 -
make wiki-check WIKI=<路径>跑过(公共行为变更须同步用户文档站,docs-convention.md§2)。
Task 9:发布 1.3.2
- 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(
--no-ff)→ 在 main 上重跑make lint与全套件 → 显式跑pytest -m slow→ 打 tag 并 push →rm -rf dist && python -m build && twine check→ 上传 registry(token 走TWINE_PASSWORD,不进命令行)→pip download验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。 - 关闭 issue #18,正文指向本计划与设计。
审查留痕(Codex,2026-08-26)
报 5 项,全部采纳:
| # | 意见 | 处置 |
|---|---|---|
| 1 | ddl/extra 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 |
新增《ddl/extra 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 {schema} 占位"这一步 |
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
| 3 | Task 1 先实现 --table、Task 3 才写集成用例,先红路径不可能成立 |
采纳,任务重排:沙箱工厂 → retention 角色化 → --table(测试先写)。重排同时让 --table 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 test_pg_sandbox.py,此时 fixture 'pg_sandbox' not found 全红 |
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是设计写错了——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
Wiki 注册
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ \
--from "plan:2026-08-26-issue18-pg-test-isolation" \
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
.claude/tools/research_wiki.py rebuild_index research-wiki/