共享 PG 上的保留期批量删除测试偶发红,污染合并前信号 #18

Closed
opened 2026-08-26 16:35:32 +08:00 by iomgaa · 1 comment
Owner

现象

tests/integration/test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches整套件连跑时偶发失败,报 assert 12 == 61;单独重跑与再次连跑均绿

发现于 1.3.1(issue #16/#17)合并前的独立验证:同一次会话里 pytest tests/integration -q 连跑三次,第 1 次该条红,第 2、3 次全绿(54 passed, 6 skipped,EXIT=0)。

与 1.3.1 无关

git diff main...feat/issue-16-17-thinking-observability -- tests/integration/test_retention_tool_pg.py tools/ 为空——该分支根本没碰这个测试与它测的工具。

为什么值得单独处理

CLAUDE.md §4.6 定了一条判据:"成败取决于外部服务当下状态的测试一律标 slow",判据是"重跑一次可能就绿了"。这一条正好落在该定义里——它打的是与其他项目共用的那台 PostgreSQL(max_connections=100),行数断言依赖当时库里的数据状态。

留在日常关卡里的代价不是这一次红,而是信号污染:同一份文档里已经写过这个机制——"pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把'测试红了先怀疑网关'变成惯性,真 bug 也会被当成抖动重试掉"。一个会偶发的 PG 测试放在合并前关卡上,培养的是同一种惯性。

可能的方向(不主张任何一种)

  • slow,交由发布清单第 4 步统一跑——与四个 e2e 文件、Redis 时间语义变体同款处置
  • 让用例自建隔离数据(独立表名/schema 或事务回滚),使断言不依赖共享实例的当下状态
  • 保留在日常套件但把断言从精确行数改成不依赖并发状态的口径

判断哪种合适是维护者的事。前两种的区别在于:标 slow 承认它依赖外部状态,隔离数据则是消除这个依赖——后者更彻底,但要看这个用例本来想验的是不是"真实共享库上的批量删除"。

## 现象 `tests/integration/test_retention_tool_pg.py::TestPlainTableBatches::test_apply_deletes_only_expired_rows_in_batches` 在**整套件连跑**时偶发失败,报 `assert 12 == 61`;**单独重跑与再次连跑均绿**。 发现于 1.3.1(issue #16/#17)合并前的独立验证:同一次会话里 `pytest tests/integration -q` 连跑三次,第 1 次该条红,第 2、3 次全绿(`54 passed, 6 skipped`,EXIT=0)。 ## 与 1.3.1 无关 `git diff main...feat/issue-16-17-thinking-observability -- tests/integration/test_retention_tool_pg.py tools/` **为空**——该分支根本没碰这个测试与它测的工具。 ## 为什么值得单独处理 CLAUDE.md §4.6 定了一条判据:**"成败取决于外部服务当下状态的测试一律标 `slow`"**,判据是"重跑一次可能就绿了"。这一条正好落在该定义里——它打的是与其他项目共用的那台 PostgreSQL(`max_connections=100`),行数断言依赖当时库里的数据状态。 留在日常关卡里的代价不是这一次红,而是**信号污染**:同一份文档里已经写过这个机制——"pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把'测试红了先怀疑网关'变成惯性,真 bug 也会被当成抖动重试掉"。一个会偶发的 PG 测试放在合并前关卡上,培养的是同一种惯性。 ## 可能的方向(不主张任何一种) - 标 `slow`,交由发布清单第 4 步统一跑——与四个 e2e 文件、Redis 时间语义变体同款处置 - 让用例自建隔离数据(独立表名/schema 或事务回滚),使断言不依赖共享实例的当下状态 - 保留在日常套件但把断言从精确行数改成不依赖并发状态的口径 判断哪种合适是维护者的事。前两种的区别在于:标 `slow` 承认它依赖外部状态,隔离数据则是消除这个依赖——后者更彻底,但要看这个用例本来想验的是不是"真实共享库上的批量删除"。
Author
Owner

已在 1.3.2 修复。

诊断修正: issue 判定"行数断言依赖共享实例的当下状态",方向对,但它推荐的首选处置(标 slow)不解决问题。那条断言的失效是双向的——别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿,后一半守的正是"审计表被删空"。标 slow 只把假红挪出日常关卡,对假阴毫无作用。

根因三层:

  1. _public_count 是全套件唯一一处全表口径断言,而同文件的 _RUN_PREFIX 机制从设计上就假定多进程并行写同一张表——两套前提互斥。
  2. 一个安全属性(脚本不越界)被编码成对全局可变量(真表行数)的观测。
  3. 之所以只能这么写: telemetry_retention.py 的目标表由连接的 search_path 隐式决定,调用点无法声明"我要删哪张表"。

处置:

  • tools/telemetry_retention.py 新增 --table <schema>.llm_calls,目标由参数精确解析、绕开 search_path;表名段锁死为 llm_calls(否则一次手误就把遥测清理器变成通用行删除器)。
  • 跑脚本的测试改用拥有自有临时表、对共享表无任何授权的角色。search_path 万一落空,得到的是 permission denied 而不是"但愿有断言发现"。
  • test_postgres_telemetry.py 的 7 条用例迁出共享表,_RUN_PREFIX 只删行隔离用途(连接池用例的 application_name 唯一性保留——连接是实例级资源,schema 隔离对它无效)。
  • 顺带封掉一个仓库注释里早已记载的隐患: test_schema_has_frozen_columns_in_order 查 information_schema 不带 schema 过滤。

未做: 没有标 slow(改完之后这条用例的成败不再取决于外部服务状态);没有清理共享表里那 11 行历史孤儿行。

独立验证: 全新上下文的 verifier 复现了两条关键判据而非采信文档——最坏情况下脚本确实走到共享表面前才被权限拒绝(解析到的 oid 与共享表相同、has_table_privilege 五项全 False);并用 PG 的 pg_stat_all_tables 计数器测得,跑完全部 45 条 PG 用例前后共享表的 seq_scan/n_tup_ins/n_tup_del 一个单位都没动——现在是零访问,不是"访问了但没改坏"。

合并前的两道审查各自独立撞上同一处阻断: 沙箱的凭据替换在 DSN 无内联 user:pass 时会静默退回管理连接,那会让最坏情况用例以超级用户身份真删共享表。已修(拒绝 + 连上去比对 current_user 两层)。

设计 research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md
计划 research-wiki/plans/2026-08-26-issue18-pg-test-isolation.md
实测 research-wiki/findings/2026-08-26-issue18-shared-pg-test-isolation.md

已在 1.3.2 修复。 **诊断修正**: issue 判定"行数断言依赖共享实例的当下状态",方向对,但它推荐的首选处置(标 slow)不解决问题。那条断言的失效是双向的——别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿,后一半守的正是"审计表被删空"。标 slow 只把假红挪出日常关卡,对假阴毫无作用。 **根因三层**: 1. `_public_count` 是全套件唯一一处全表口径断言,而同文件的 `_RUN_PREFIX` 机制从设计上就假定多进程并行写同一张表——两套前提互斥。 2. 一个安全属性(脚本不越界)被编码成对全局可变量(真表行数)的观测。 3. 之所以只能这么写: `telemetry_retention.py` 的目标表由连接的 search_path 隐式决定,调用点无法声明"我要删哪张表"。 **处置**: - `tools/telemetry_retention.py` 新增 `--table <schema>.llm_calls`,目标由参数精确解析、绕开 search_path;表名段锁死为 llm_calls(否则一次手误就把遥测清理器变成通用行删除器)。 - 跑脚本的测试改用**拥有自有临时表、对共享表无任何授权**的角色。search_path 万一落空,得到的是 permission denied 而不是"但愿有断言发现"。 - `test_postgres_telemetry.py` 的 7 条用例迁出共享表,`_RUN_PREFIX` 只删行隔离用途(连接池用例的 application_name 唯一性保留——连接是实例级资源,schema 隔离对它无效)。 - 顺带封掉一个仓库注释里早已记载的隐患: `test_schema_has_frozen_columns_in_order` 查 information_schema 不带 schema 过滤。 **未做**: 没有标 slow(改完之后这条用例的成败不再取决于外部服务状态);没有清理共享表里那 11 行历史孤儿行。 **独立验证**: 全新上下文的 verifier 复现了两条关键判据而非采信文档——最坏情况下脚本确实走到共享表面前才被权限拒绝(解析到的 oid 与共享表相同、`has_table_privilege` 五项全 False);并用 PG 的 `pg_stat_all_tables` 计数器测得,跑完全部 45 条 PG 用例前后共享表的 seq_scan/n_tup_ins/n_tup_del **一个单位都没动**——现在是零访问,不是"访问了但没改坏"。 合并前的两道审查各自独立撞上同一处阻断: 沙箱的凭据替换在 DSN 无内联 user:pass 时会静默退回管理连接,那会让最坏情况用例以超级用户身份真删共享表。已修(拒绝 + 连上去比对 current_user 两层)。 设计 `research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md` 计划 `research-wiki/plans/2026-08-26-issue18-pg-test-isolation.md` 实测 `research-wiki/findings/2026-08-26-issue18-shared-pg-test-isolation.md`
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#18