Files
PolyGateway/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md
T
iomgaa 58c4af28ea fix: refuse the sandbox rather than quietly running it as the superuser
Both reviews landed on the same line independently. _as_role swaps the
credentials in the DSN with a regex, and when the pattern does not match
it returned the string unchanged. Two shapes miss it: no inline
credentials, and a unix socket URL. Either one is a legal DSN.

What that costs is not a broken test. The sandbox builds, every
assertion still passes, and bare_dsn is now the admin connection, so the
worst-case case runs the real script with --apply as a superuser against
the shared table. The verifier ran that command as a dry run to see what
it would have done: target public.llm_calls, 11 rows to delete. The case
would still have gone red on the exit code, after the rows were gone.

It raises now. There is also a second check that connects and compares
current_user, because a successful string substitution is not the same
as connecting as that role -- PGUSER and friends still override. The
whole design rests on that connection having no grant on the shared
table; a string comparison is too thin a thing to rest it on.

That check has to stay inside the try. Past it the cleanup statements
have already been merged into the fixture-level stack, and unwinding
again runs DROP OWNED BY twice, which has no IF EXISTS.

The catalog probe took any SQL and ran it on the admin connection. The
design claims withholding the DSN makes the boundary structural; that
was only true of the connection string, not of the capability. It takes
SELECT now.

--table's schema half is restricted to plain identifiers. Not a
security fix, since the name goes through a parameter and _quote: the
help text says complex identifiers are unsupported and the code was
accepting them anyway.
2026-08-26 11:59:51 -04:00

22 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
design design:2026-08-26-issue18-pg-test-isolation issue #18: 隔离靠权限强制,目标靠显式声明 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. 变更 Atelemetry_retention.py 新增 --table SCHEMA.NAME

4.1 语义:声明即目标,不是"声明后比对"

两种可能的实现要先分清:

做法 结果
否决 仍按 search_path 解析,再与声明比对,不符则退出 目标仍然由环境决定,--table 只是一道确认;且要为"不符"发明第四个退出码语义
选定 给了 --table 就用 to_regclass('"schema"."name"') 精确解析,绕开 search_path 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义

选定做法的实现落点只有一处——_purge_postgresto_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 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具"
两段均非空;schema 段须为普通标识符([A-Za-z_][A-Za-z0-9_$]*) 不合法 → 退出 1 复杂标识符(含引号的表名)不支持,此时退回不给 --table 的路径;写进 --help本行原写作"均不含 .\"",实现阶段核出"段内含 ."是不可达分支——按 . 切分后恰好两段是前置条件,a.b.c 走的是"不是恰好两段"那条消息,故删去该半句
逐字比较,不做大小写折叠 _quote() 包裹的限定名给 to_regclass catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架
解析不到 退出 2,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" search_path 找不到的消息分开写:诊断方向不同。退出码维持 2 而非 1Public.llm_calls 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码
无权限 后续 COUNTPostgresError → 既有 except → 退出 2 无需新增分支

退出码不新增。1 留给"参数写错了,重试也没用",2 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 _Parser.error 的注释),本变更沿用。

4.3 目标白名单:为什么表名段不可变

--table 若只校验"两段、非空、无点无引号",一次手误 --table audit.events 就会让脚本对一张恰好也有 created_attenant_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 <schema>.<表名>。

只在 --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 是那张表的属主——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
  • tmppublic.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)对真表执行 COUNTDELETE,两者均 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_<uuid> / pgw_r_<uuid> 两套名字。

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. 变更 C7 条用例迁出 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_templateexcept BaseException 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 table_schema = $1,把它从"靠别人不留残留"改成"自己只看自己"。

用函数级而非 module 级 sandbox:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。

7. 变更 Dconftest.py 收敛 + lint 门

7.1 一个沙箱工厂取代七处样板

tests/integration/conftest.py 新增:

fixture 职责
pg_admin_dsnsession .env、缺失 skip、库名守卫(只许 polygateway)。命名下划线语义上属内部,用例不该直接用
pg_sandboxfunction,工厂) await pg_sandbox(ddl=..., extra=(), owner_role=False) → 返回 frozen dataclassschema / 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_schemapre_tenant_schemafresh_schemapartitioned_schemaleast_privilege_dsnleast_privilege_pre_tenant_dsnproduction_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,比残留本身更危险
共享实例上其他项目往真表写/删 不归本库管 改完之后本仓库测试对它完全不敏感,这正是本设计的目的
production_template 仍以管理身份执行不带限定名的 DELETE / DROP TABLE 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 独立验证实测:它的连接 search_path 只有自己那个 schemapublic 不在路径里),故 to_regclass('llm_calls') 返回 None——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表
pg_catalog_probe 持管理连接 工厂自测需要查 catalog 核对残留,这个能力删不掉 探针只接受 SELECT 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询

10. 版本号与发布

1.3.2patch)。需在 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 —— 该提示行只在 PG 分支打印,不连库的单测触发不到它(本行原写作"单测断言 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_ordertable_schema 过滤 故意在库里留一个残留同名表,用例仍绿
6d 沙箱工厂 setup 中途失败不留角色/schema 注入一个会失败的 DDL,跑完查 pg_namespace / pg_rolespgw_% 残留
7 全套件 + -m slow 全绿 合并前

12. 审查留痕(Codex2026-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_orderinformation_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 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。