Merge branch 'fix/issue-18-pg-test-isolation'
issue #18: the retention script can be told which table it may delete from, and the Postgres tests moved off the table three migration projects also write to. The assertion that was failing intermittently compared row counts on a shared table before and after the run. It could go red because someone else wrote, and green because an outside insert cancelled out a wrong delete. That property now belongs to the database: the tests run as a role that owns its scratch table and holds no grant on the shared one.
This commit is contained in:
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.2(2026-08-28)
|
||||
|
||||
**本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。
|
||||
|
||||
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
|
||||
|
||||
`tools/telemetry_retention.py` 此前删哪张表,取决于连接的 `search_path`——它的首项是 `"$user"`,所以**换个角色跑同一条命令,目标可能就换了一张表**。脚本会把解析到的限定名打出来,但那行打印与 `DELETE` 在同一次运行里,中间没有人。
|
||||
|
||||
新增可选参数 `--table <schema>.llm_calls`:给了它,目标由参数精确解析(`to_regclass` 走引号限定名),绕开 `search_path`。
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| 不给 `--table` | **与 1.3.1 完全一致**,现有 cron 不受影响;但 `--apply` 时会多打印一行,提示目标是推断来的 |
|
||||
| 表名段不是 `llm_calls` | 退出 **1**。本脚本只清理遥测表,不是通用清理器——一次 `--table audit.events` 的手误,会对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE |
|
||||
| 显式指定的表不存在/不可见 | 退出 **2**,消息附一句"PG 中未加引号建的标识符在 catalog 里是小写"(大小写手误是这里的高频原因) |
|
||||
| 显式指定的是分区表 | 仍退出 **3** 让路给 `DROP PARTITION`,语义未变 |
|
||||
|
||||
退出码契约未新增也未改动。**建议 cron 一律带上 `--table`**:那一行配置从此自己说明删的是哪张表。
|
||||
|
||||
### 测试隔离:从"事后观测共享表"改成"权限上做不到"
|
||||
|
||||
issue #18 报的是一条 PG 集成测试偶发红。查下来失败的断言并不在测被测脚本——它比对的是一张**三个迁移项目也在写**的表的前后行数,而报错时(61 行变 12 行)脚本本身被证明只动了自己的临时 schema。
|
||||
|
||||
行数快照承载不了它想守的属性:别人一写就假红,而外部插入恰好抵消掉一次误删时又会假绿——后一半守的正是"审计表被删空"。现在这条属性交给数据库强制:跑脚本的测试角色拥有自己的临时表、对共享表**没有任何授权**,`search_path` 万一落空就是 `permission denied` 而不是"但愿有断言发现"。共享表 `llm_calls` 至此不再被本仓库任何测试读写,killed 的测试也不会再往里留孤儿行。
|
||||
|
||||
对下游没有影响(测试不进包),列在这里是因为它解释了本版为何存在。
|
||||
|
||||
## 1.3.1(2026-08-26)
|
||||
|
||||
「这次调用到底推理没推理」从此是库的**一等返回值**(issue #16 + #17): `LLMResponse.thinking_observation` 三态如实作答,判不出来时说 `unknown` 而不是伪装成「没推理」,并与推理能力表持续对账。
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
.PHONY: install test lint format check ci wiki wiki-check
|
||||
.PHONY: install test lint format check ci wiki wiki-check shared-table-gate
|
||||
|
||||
ENV := PolyGateway
|
||||
|
||||
# 集成测试触碰共享表 llm_calls 的字面量门(issue #18)。
|
||||
# 这道门是**烟雾报警器,不是隔离证明**: 它拦不住 f"{schema}.{table}" 拼接、
|
||||
# 参数化查询,或不带限定名的 DELETE 配上 admin 的默认 search_path。真正的隔离
|
||||
# 来自两处——沙箱工厂不把管理连接交给用例,以及清理脚本以无权角色运行。
|
||||
# 留着它是因为字面量回归最常见、也最便宜拦。
|
||||
shared-table-gate:
|
||||
@if grep -rn --include='*.py' 'public\.llm_calls' tests/; then \
|
||||
echo ""; \
|
||||
echo "错误: 集成测试不得触碰共享表(见上面的命中行)。"; \
|
||||
echo "改用 tests/integration/conftest.py 的 pg_sandbox 工厂;注释里提到它请写「共享表 llm_calls」。"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
install:
|
||||
conda run -n $(ENV) pip install -e ".[redis,postgres,structured,dev]"
|
||||
|
||||
test:
|
||||
conda run -n $(ENV) pytest tests/ --cov=src/polygateway --cov-report=term-missing
|
||||
|
||||
lint:
|
||||
lint: shared-table-gate
|
||||
conda run -n $(ENV) ruff check src/ tests/ --fix
|
||||
conda run -n $(ENV) lint-imports
|
||||
|
||||
format:
|
||||
conda run -n $(ENV) ruff format src/ tests/
|
||||
|
||||
check:
|
||||
check: shared-table-gate
|
||||
conda run -n $(ENV) ruff format --check src/ tests/
|
||||
conda run -n $(ENV) ruff check src/ tests/
|
||||
conda run -n $(ENV) lint-imports
|
||||
|
||||
@@ -352,7 +352,7 @@ PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全
|
||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
|
||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`;**`--table <schema>.llm_calls` 把目标钉死**,不给则由连接的 `search_path` 推断) |
|
||||
|
||||
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
||||
|
||||
@@ -362,6 +362,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
||||
|
||||
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
||||
|
||||
**`--apply` 之前先把目标钉死。** 不给 `--table` 时,脚本删哪张表取决于连接的 `search_path`——它的首项是 `"$user"`,故换个角色跑同一条命令,只要库里存在同名 schema 下的 `llm_calls`,删的就是另一张表。`--table <schema>.llm_calls` 让目标由参数精确解析、不再经 `search_path` 推断;表名段固定为 `llm_calls`(本脚本只清理遥测表,不是通用清理器),写别的名字会以退出码 1 被拒。cron 里跑 `--apply` 尤其该给它:那一行配置从此自己说明删的是哪张表。
|
||||
|
||||
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
||||
|
||||
## 错误模型(四分类)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.3.1"
|
||||
version = "1.3.2"
|
||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:2026-08-26-issue18-pg-test-isolation
|
||||
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
|
||||
date: 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. 变更 A:`telemetry_retention.py` 新增 `--table SCHEMA.NAME`
|
||||
|
||||
### 4.1 语义:声明即目标,不是"声明后比对"
|
||||
|
||||
两种可能的实现要先分清:
|
||||
|
||||
| | 做法 | 结果 |
|
||||
|---|---|---|
|
||||
| 否决 | 仍按 `search_path` 解析,再与声明比对,不符则退出 | 目标**仍然**由环境决定,`--table` 只是一道确认;且要为"不符"发明第四个退出码语义 |
|
||||
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**,绕开 `search_path` | 目标真正由参数决定;不存在则落入既有的"目标表不可用"语义 |
|
||||
|
||||
选定做法的实现落点只有一处——`_purge_postgres` 里 `to_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 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
|
||||
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
|
||||
|
||||
退出码不新增。`1` 留给"参数写错了,重试也没用",`2` 留给"环境不对,值得告警"——这条分界是脚本已有的对调度器契约(见 `_Parser.error` 的注释),本变更沿用。
|
||||
|
||||
### 4.3 目标白名单:为什么表名段不可变
|
||||
|
||||
`--table` 若只校验"两段、非空、无点无引号",一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at` 与 `tenant_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` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致,测的不是一个失真的现场
|
||||
- `tmp` 对 `public.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`)对真表执行 `COUNT` 与 `DELETE`,两者均 `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. 变更 C:7 条用例迁出 `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_template` 的 `except BaseException` 分支注释里已经写明了这个坑("会被残留物在下一次运行里以列数不符的形态误伤"),当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`,把它从"靠别人不留残留"改成"自己只看自己"。
|
||||
|
||||
**用函数级而非 module 级 sandbox**:建/删一个 schema 是毫秒级,7 条用例的开销可忽略;module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。
|
||||
|
||||
## 7. 变更 D:`conftest.py` 收敛 + lint 门
|
||||
|
||||
### 7.1 一个沙箱工厂取代七处样板
|
||||
|
||||
`tests/integration/conftest.py` 新增:
|
||||
|
||||
| fixture | 职责 |
|
||||
|---|---|
|
||||
| `pg_admin_dsn`(session) | 读 `.env`、缺失 `skip`、库名守卫(只许 `polygateway`)。**命名下划线语义上属内部**,用例不该直接用 |
|
||||
| `pg_sandbox`(function,工厂) | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass(`schema` / `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_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_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` **只有**自己那个 schema(`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 |
|
||||
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 |
|
||||
|
||||
## 10. 版本号与发布
|
||||
|
||||
**1.3.2**(patch)。需在 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_order` 带 `table_schema` 过滤 | 故意在库里留一个残留同名表,用例仍绿 |
|
||||
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL,跑完查 `pg_namespace` / `pg_roles` 无 `pgw_%` 残留 |
|
||||
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |
|
||||
|
||||
## 12. 审查留痕(Codex,2026-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_order` 的 `information_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 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
|
||||
@@ -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 上,测出来的是个假现场。
|
||||
@@ -405,6 +405,13 @@
|
||||
"relation": "refines",
|
||||
"evidence": "复测确认 M3 can_disable 仍成立,并补记非流式不可观测、仅 reasoning_effort 有效两条限制",
|
||||
"added": "2026-08-26T04:49:18.648857+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:2026-08-26-issue18-pg-test-isolation",
|
||||
"target": "design:2026-08-26-issue18-pg-test-isolation",
|
||||
"relation": "implements",
|
||||
"evidence": "9 个任务逐条实现设计 §4-§11",
|
||||
"added": "2026-08-26T11:28:33.469681+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-26 04:49 UTC
|
||||
> 自动生成,更新时间:2026-08-26 11:28 UTC
|
||||
|
||||
## design (38)
|
||||
## design (39)
|
||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
|
||||
@@ -28,6 +28,7 @@
|
||||
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
|
||||
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
|
||||
- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle`
|
||||
- [issue #18: 隔离靠权限强制,目标靠显式声明](designs/2026-08-26-issue18-pg-test-isolation-design.md) `design:2026-08-26-issue18-pg-test-isolation`
|
||||
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
|
||||
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
|
||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||
@@ -42,13 +43,14 @@
|
||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||
|
||||
## finding (13)
|
||||
## finding (14)
|
||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||
@@ -57,7 +59,7 @@
|
||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||
|
||||
## plan (33)
|
||||
## plan (34)
|
||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||
@@ -74,6 +76,7 @@
|
||||
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
|
||||
- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle`
|
||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||
|
||||
@@ -144,3 +144,5 @@
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --supports--> design:2026-08-25-thinking-observability-design
|
||||
- [2026-08-26 04:49 UTC] 新增边: finding:2026-08-25-thinking-observability-regression --refines--> design:2026-08-02-thinking-capability-design
|
||||
- [2026-08-26 04:49 UTC] 重建索引: 88 篇页面
|
||||
- [2026-08-26 11:28 UTC] 新增边: plan:2026-08-26-issue18-pg-test-isolation --implements--> design:2026-08-26-issue18-pg-test-isolation
|
||||
- [2026-08-26 11:28 UTC] 重建索引: 91 篇页面
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:2026-08-26-issue18-pg-test-isolation
|
||||
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
|
||||
date: 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 与一个返回类型:
|
||||
|
||||
```python
|
||||
@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
|
||||
```
|
||||
|
||||
```python
|
||||
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` 的执行契约
|
||||
|
||||
1. **调用方传的 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}.` 前缀去掉**。
|
||||
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
|
||||
3. `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,逐条都是验收点)
|
||||
|
||||
1. schema 名 `pgw_s_<12 位 hex>`、角色名 `pgw_r_<12 位 hex>`,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
|
||||
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`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 清理。
|
||||
3. `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。
|
||||
|
||||
**行为**:
|
||||
|
||||
1. 删除 `_public_count`、`before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox`。
|
||||
2. **凡启动脚本的用例一律 `role="owner"`**(设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
|
||||
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5`、`'acme': 3`、批次 1/3 存在而批次 4 不存在、`已删除 5 行`、剩余 `fresh-1`/`fresh-2`、分区表退出 3 且含 `DROP PARTITION`/`DETACH`、缺 asyncpg 退出 2。
|
||||
4. 新增设计 §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`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
1. 七条用例改用 `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`。
|
||||
2. `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询补 `table_schema = $1`(设计 §6.2;仓库注释已记载该隐患)。
|
||||
3. `TestPoolFootprint` **保留唯一 `application_name`**,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
|
||||
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")` → `"c1"`);5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)`,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
|
||||
5. 删除已无引用的本地 `dsn` fixture 与其 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`(修改)
|
||||
|
||||
**行为**:
|
||||
|
||||
1. `legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema` 改为 `role="none"`;`least_privilege_dsn`、`least_privilege_pre_tenant_dsn` 改为 `role="grantee"`。
|
||||
2. 按接口契约,`_LEGACY_DDL` 与 `_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
|
||||
3. `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 注册
|
||||
|
||||
```bash
|
||||
.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/
|
||||
```
|
||||
@@ -42,7 +42,7 @@ from polygateway.types import (
|
||||
ThinkingObservation,
|
||||
)
|
||||
|
||||
__version__ = "1.3.1"
|
||||
__version__ = "1.3.2"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""PG 集成测试的一次性沙箱工厂(issue #18)。
|
||||
|
||||
**为什么把它收敛成一份**: 在此之前,"建临时 schema → 挂 search_path → teardown
|
||||
删净"这套样板在两个测试文件里重复了七处,清理逻辑各写各的——任何一处写漏,残留都
|
||||
落在与真实批跑共用的那个库上。工厂让清理只有一份实现,并让"用例拿不到管理连接"
|
||||
成为结构事实而不是纪律。
|
||||
|
||||
**admin DSN 不做成 fixture**: 它能对共享表执行任何语句。做成 fixture 等于把这个
|
||||
能力摆在每一条用例面前,"用例不该直接用"就只是一句提醒。故它是模块私有函数,
|
||||
只被工厂内部调用,`PgSandbox` 也不携带它。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
# 测试专用口令: 这些角色只在单条用例的生命周期内存在,且只对自建 schema 有权。
|
||||
# 它不是机密,写死在这里比走 .env 更清楚——.env 里的每一项都该是真实部署会用的。
|
||||
_SANDBOX_PASSWORD = "pgw-sandbox-not-a-secret" # noqa: S105
|
||||
|
||||
_Role = Literal["none", "owner", "grantee"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PgSandbox:
|
||||
"""一次性 PG 沙箱: 独立 schema + 可选独占登录角色。"""
|
||||
|
||||
schema: str
|
||||
role: str | None
|
||||
dsn: str
|
||||
"""已挂 `options=-csearch_path=<schema>`,用例默认用它。"""
|
||||
bare_dsn: str | None
|
||||
"""同角色但**不挂** search_path(回落 `"$user", public`);`role="none"` 时为 None。"""
|
||||
|
||||
|
||||
def _admin_dsn() -> str | None:
|
||||
"""读 `.env` 的 `PGW_TELEMETRY_PG_DSN` 并剥掉 SQLAlchemy 风格的 `+driver` 后缀。"""
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||
if not raw:
|
||||
return None
|
||||
scheme, sep, rest = raw.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _require_admin_dsn() -> str:
|
||||
"""取管理连接串;未配置则 skip,连错库则 fail(不是 skip)。
|
||||
|
||||
库名守卫不肯降级成 skip: 这个实例上还有 app/chs_prod 等在用库,把"连错库"
|
||||
悄悄跳过,等于让一次配置事故以"没跑那些测试"的形态过关。
|
||||
"""
|
||||
value = _admin_dsn()
|
||||
if value is None:
|
||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||
if not value.rstrip("/").endswith("/polygateway"):
|
||||
pytest.fail(f"PG 集成测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _with_search_path(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
def _as_role(dsn: str, role: str) -> str:
|
||||
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
|
||||
|
||||
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
|
||||
.pgpass / trust)与 `postgresql:///db?host=/var/run/postgresql`(unix socket)
|
||||
都是合法 DSN,却没有可替换的内联凭据段。静默返回原串的后果不是测试报错,而是
|
||||
沙箱以**管理身份**建成、用例照常绿,同时 `bare_dsn` 变成超级用户连接——最坏
|
||||
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红。
|
||||
这正是 P5"严禁默认值掩盖错误"要挡的形态。
|
||||
"""
|
||||
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
|
||||
if count != 1:
|
||||
raise RuntimeError(
|
||||
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
|
||||
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
|
||||
)
|
||||
return swapped
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pg_catalog_probe():
|
||||
"""只读地查 PG catalog,**仅供工厂自测核对残留**,不是通用查询入口。
|
||||
|
||||
它拿的是管理连接,故有意只暴露给 `test_pg_sandbox.py` 这一类"验证隔离本身
|
||||
是否成立"的用例;业务断言一律走 `PgSandbox.dsn`。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
dsn = _require_admin_dsn()
|
||||
|
||||
async def probe(sql: str, *args: object) -> list[tuple]:
|
||||
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
|
||||
# 管理能力"这句话降格成一句 docstring 里的请求。
|
||||
if not sql.lstrip().upper().startswith("SELECT"):
|
||||
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
return [tuple(r) for r in await conn.fetch(sql, *args)]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pg_sandbox():
|
||||
"""一次性沙箱工厂: `await pg_sandbox(ddl=..., role=...)`,清理由 fixture 兜底。
|
||||
|
||||
同一条用例可以要多个沙箱(如"A 的角色去动 B 的表"),它们按后进先出清理。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
admin_dsn = _require_admin_dsn()
|
||||
# 清理动作栈: 每建成一个对象就入栈一条,setup 中途失败与正常 teardown 共用
|
||||
# 同一条退栈路径——两处各写一份的话,失败那条永远是没被测过的那份。
|
||||
cleanups: list[str] = []
|
||||
|
||||
async def _run_as_admin(*statements: str) -> None:
|
||||
conn = await asyncpg.connect(admin_dsn, timeout=10)
|
||||
try:
|
||||
for statement in statements:
|
||||
await conn.execute(statement)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def _unwind(statements: list[str]) -> None:
|
||||
"""逆序执行清理并**逐条容错**: 一条失败不该拖累其余对象的清理。
|
||||
|
||||
吞掉异常是不行的(残留会静默累积),但让第一条失败中断整栈更糟——角色是
|
||||
全局对象,漏掉的每一个都要人手工去删。故全部试完再抛出第一个异常。
|
||||
"""
|
||||
first: BaseException | None = None
|
||||
for statement in reversed(statements):
|
||||
try:
|
||||
await _run_as_admin(statement)
|
||||
except Exception as exc: # noqa: BLE001 — 见 docstring: 收集而非吞没
|
||||
first = first or exc
|
||||
statements.clear()
|
||||
if first is not None:
|
||||
raise first
|
||||
|
||||
async def make(
|
||||
*,
|
||||
ddl: str | None = None,
|
||||
extra: Sequence[str] = (),
|
||||
role: _Role = "none",
|
||||
grants: Sequence[str] = ("SELECT", "INSERT"),
|
||||
) -> PgSandbox:
|
||||
# 权限门在建任何对象**之前**: pytest.skip 抛的是 BaseException,若它在
|
||||
# 已建对象之后触发,清理会去 DROP 从未建成的东西并把 skip 盖掉。
|
||||
if role != "none":
|
||||
conn = await asyncpg.connect(admin_dsn, timeout=10)
|
||||
try:
|
||||
can_create = await conn.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
if not can_create:
|
||||
pytest.skip("当前账号无权建临时角色,跳过需要独占角色的用例")
|
||||
|
||||
# schema 与角色的前缀有意不同: 同名会让 "$user" 命中自有 schema 并遮蔽
|
||||
# 共享表,于是"search_path 落到共享表"这个最坏情况就再也构造不出来。
|
||||
suffix = uuid4().hex[:12]
|
||||
schema = f"pgw_s_{suffix}"
|
||||
role_name = f"pgw_r_{suffix}" if role != "none" else None
|
||||
|
||||
# 本次调用自己的清理栈: 失败只回滚**本次**建成的对象。同一条用例常要两个
|
||||
# 沙箱(如"A 的角色去动 B 的表"),回滚整栈会把已通过断言依赖的对象也删掉。
|
||||
local: list[str] = []
|
||||
try:
|
||||
if role_name is not None:
|
||||
await _run_as_admin(f"CREATE ROLE {role_name} LOGIN PASSWORD '{_SANDBOX_PASSWORD}'")
|
||||
# DROP OWNED BY 必须排在 DROP ROLE 之前: 角色仍持有对象时删不掉
|
||||
local.append(f"DROP ROLE IF EXISTS {role_name}")
|
||||
local.append(f"DROP OWNED BY {role_name}")
|
||||
owner_clause = f" AUTHORIZATION {role_name}" if role == "owner" else ""
|
||||
await _run_as_admin(f"CREATE SCHEMA {schema}{owner_clause}")
|
||||
local.append(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
|
||||
bare = _as_role(admin_dsn, role_name) if role_name is not None else None
|
||||
# role="owner" 时 DDL 由角色自己执行,表属主才会是它;"grantee" 的现场
|
||||
# 恰恰相反——表由别的账号建好,角色只拿到表级权限。
|
||||
ddl_dsn = _with_search_path(bare if role == "owner" else admin_dsn, schema)
|
||||
if ddl is not None:
|
||||
conn = await asyncpg.connect(ddl_dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(ddl)
|
||||
for statement in extra:
|
||||
await conn.execute(statement)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if role == "grantee":
|
||||
await _run_as_admin(f"GRANT USAGE ON SCHEMA {schema} TO {role_name}")
|
||||
if ddl is not None:
|
||||
await _run_as_admin(
|
||||
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
|
||||
)
|
||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||
|
||||
used = bare if role_name is not None else admin_dsn
|
||||
sandbox = PgSandbox(
|
||||
schema=schema,
|
||||
role=role_name,
|
||||
dsn=_with_search_path(used, schema),
|
||||
bare_dsn=bare,
|
||||
)
|
||||
if role_name is not None:
|
||||
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
|
||||
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
|
||||
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
|
||||
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
|
||||
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
|
||||
# IF EXISTS,第二遍必报错)。
|
||||
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||
try:
|
||||
actual = await conn.fetchval("SELECT current_user")
|
||||
finally:
|
||||
await conn.close()
|
||||
if actual != role_name:
|
||||
raise RuntimeError(
|
||||
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
|
||||
"权限边界不成立,拒绝把这个沙箱交出去。"
|
||||
)
|
||||
except BaseException:
|
||||
await _unwind(local)
|
||||
raise
|
||||
cleanups.extend(local)
|
||||
|
||||
return sandbox
|
||||
|
||||
yield make
|
||||
await _unwind(cleanups)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""`conftest.py` 沙箱工厂自身的行为测试(issue #18 Task 1)。
|
||||
|
||||
工厂是本次一切隔离的地基: 它若在 setup 中途失败时漏掉清理、或让角色名与
|
||||
schema 名撞上,受害的不是这一个文件,而是此后每一条 PG 用例。故它必须先被测。
|
||||
|
||||
**这里的断言全部只看自建对象与 PG catalog**,不读任何共享数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.integration.conftest import _as_role
|
||||
|
||||
_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def assert_no_leftovers(pg_catalog_probe):
|
||||
"""收集沙箱名,在 `pg_sandbox` 清理之后回查它们是否真的没了。
|
||||
|
||||
必须比 `pg_sandbox` **先** setup: pytest 的 finalizer 是后进先出,先 setup
|
||||
的后 teardown——本 fixture 的检查因此发生在沙箱清理之后,而不是之前。
|
||||
"""
|
||||
seen: list[tuple[str, str | None]] = []
|
||||
yield seen
|
||||
for schema, role in seen:
|
||||
left = await pg_catalog_probe("SELECT nspname FROM pg_namespace WHERE nspname = $1", schema)
|
||||
assert left == [], f"沙箱 schema 未清理: {schema}"
|
||||
if role is not None:
|
||||
left = await pg_catalog_probe("SELECT rolname FROM pg_roles WHERE rolname = $1", role)
|
||||
assert left == [], f"沙箱角色未清理: {role}"
|
||||
|
||||
|
||||
async def _oid_of_llm_calls(dsn: str) -> int | None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
return await conn.fetchval("SELECT to_regclass('llm_calls')::oid")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestRoleDsnConstruction:
|
||||
"""凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。
|
||||
|
||||
这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱
|
||||
"看起来"建好了、用例照常绿,而 `bare_dsn` 其实是超级用户——最坏情况用例
|
||||
会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红。
|
||||
行已经没了。设计 §5.1 要的是"越界做不到",不是"越界会被发现"。
|
||||
"""
|
||||
|
||||
def test_inline_credentials_are_replaced(self):
|
||||
swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x")
|
||||
|
||||
assert swapped.startswith("postgresql://pgw_r_x:")
|
||||
assert "app:secret" not in swapped
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dsn",
|
||||
[
|
||||
"postgresql://h:5432/polygateway", # 口令走 PGPASSWORD / .pgpass / trust
|
||||
"postgresql:///polygateway?host=/var/run/postgresql", # unix socket
|
||||
],
|
||||
)
|
||||
def test_a_dsn_without_inline_credentials_is_refused(self, dsn):
|
||||
"""这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。"""
|
||||
with pytest.raises(RuntimeError, match="沙箱角色"):
|
||||
_as_role(dsn, "pgw_r_x")
|
||||
|
||||
|
||||
class TestCatalogProbeIsReadOnly:
|
||||
"""探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。"""
|
||||
|
||||
async def test_non_select_statements_are_refused(self, pg_catalog_probe):
|
||||
with pytest.raises(RuntimeError, match="只接受 SELECT"):
|
||||
await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'")
|
||||
|
||||
|
||||
class TestSchemaOnlySandbox:
|
||||
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
|
||||
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
|
||||
sandbox = await pg_sandbox(ddl=_DDL)
|
||||
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||
try:
|
||||
where = await conn.fetchval(
|
||||
"SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.oid = to_regclass('llm_calls')"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
assert where == sandbox.schema
|
||||
assert sandbox.role is None
|
||||
assert sandbox.bare_dsn is None
|
||||
|
||||
|
||||
class TestOwnerRoleSandbox:
|
||||
async def test_the_role_owns_its_own_table(self, pg_sandbox):
|
||||
"""`role="owner"`: 表由角色自己建,故属主是它——与"用维护角色跑"的现场一致。"""
|
||||
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||
try:
|
||||
owner = await conn.fetchval(
|
||||
"SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = to_regclass('llm_calls')"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
assert owner == sandbox.role
|
||||
# 名字必须错开: 同名会让 "$user" 命中自有 schema 并遮蔽真表,
|
||||
# 最坏情况用例就再也走不到那条真实路径上(设计 §5.1 实测)
|
||||
assert sandbox.role != sandbox.schema
|
||||
assert not sandbox.role.startswith("pgw_s_")
|
||||
assert not sandbox.schema.startswith("pgw_r_")
|
||||
|
||||
async def test_bare_dsn_falls_through_to_the_default_search_path(self, pg_sandbox):
|
||||
"""裸 DSN 必须真的回落到 `"$user", public`——最坏情况用例全靠它构造现场。"""
|
||||
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(sandbox.bare_dsn, timeout=10)
|
||||
try:
|
||||
path = await conn.fetchval("SHOW search_path")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
assert path == '"$user", public'
|
||||
# 裸 DSN 解析到的绝不能是沙箱里那张表(否则"落到共享表"的现场是假的)
|
||||
assert await _oid_of_llm_calls(sandbox.bare_dsn) != await _oid_of_llm_calls(sandbox.dsn)
|
||||
|
||||
|
||||
class TestGranteeRoleSandbox:
|
||||
async def test_grantee_can_write_but_cannot_create(self, pg_sandbox):
|
||||
"""`role="grantee"`: 表属主是 admin,角色只拿表级权限——最小权限部署的现场。"""
|
||||
import asyncpg
|
||||
|
||||
sandbox = await pg_sandbox(ddl=_DDL, role="grantee")
|
||||
|
||||
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute("INSERT INTO llm_calls (call_id) VALUES ('g1')")
|
||||
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 1
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("CREATE TABLE another (x TEXT)")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
async def test_setup_failure_leaves_nothing_behind(self, pg_sandbox, pg_catalog_probe):
|
||||
"""建到一半失败时也必须删净——角色是**全局**对象,残留不随库消失。"""
|
||||
before_schemas = await pg_catalog_probe(
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
|
||||
)
|
||||
before_roles = await pg_catalog_probe(
|
||||
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
|
||||
)
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
|
||||
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
|
||||
|
||||
assert (
|
||||
await pg_catalog_probe(
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw!_%' ESCAPE '!'"
|
||||
)
|
||||
== before_schemas
|
||||
)
|
||||
assert (
|
||||
await pg_catalog_probe(
|
||||
"SELECT rolname FROM pg_roles WHERE rolname LIKE 'pgw!_%' ESCAPE '!'"
|
||||
)
|
||||
== before_roles
|
||||
)
|
||||
|
||||
async def test_a_failure_does_not_roll_back_earlier_sandboxes(self, pg_sandbox):
|
||||
"""一次失败只回滚它自己建的东西——同一条用例里先建成的沙箱必须毫发无损。
|
||||
|
||||
"A 的角色去动 B 的表"这类用例一条要两个沙箱;若失败回滚把整栈清空,受害的
|
||||
是那些**已经通过**的断言所依赖的对象,而症状会以"表不见了"的形态出现在
|
||||
与真因无关的地方。
|
||||
"""
|
||||
good = await pg_sandbox(ddl=_DDL, role="owner")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017 — 工厂原样抛出 PG 的 DDL 错误
|
||||
await pg_sandbox(ddl="CREATE TABLE llm_calls (bad NOT_A_REAL_TYPE)", role="owner")
|
||||
|
||||
assert await _oid_of_llm_calls(good.dsn) is not None, "先前建成的沙箱被误清理"
|
||||
|
||||
async def test_teardown_removes_schema_and_role(self, assert_no_leftovers, pg_sandbox):
|
||||
"""正常路径的清理: 断言发生在 `pg_sandbox` teardown **之后**(见 fixture 说明)。"""
|
||||
sandbox = await pg_sandbox(ddl=_DDL, role="owner")
|
||||
assert_no_leftovers.append((sandbox.schema, sandbox.role))
|
||||
@@ -1,11 +1,15 @@
|
||||
"""PostgresRecorder 集成测试(M2 设计 §5;真实实验室 Postgres,polygateway 专用库)。
|
||||
|
||||
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。该实例上有 app/chs_prod 等
|
||||
在用库——本测试只允许连 polygateway 专用库(fixture 里守卫)。
|
||||
在用库——本测试只允许连 polygateway 专用库(`conftest.py` 的工厂里守卫)。
|
||||
|
||||
隔离纪律(M4 事故教训): `llm_calls` 是与真实批跑/迁移项目共享的表,
|
||||
**严禁 DROP/TRUNCATE**——本测试以 run 级 call_id 前缀隔离,断言只看
|
||||
自己写入的行,teardown 只删自己的行。
|
||||
隔离纪律(issue #18): 本文件对共享表 `llm_calls` **零触碰**——每条用例都在
|
||||
`pg_sandbox` 建的一次性 schema 里跑,建/删都只发生在自己的 schema 内。
|
||||
此前那套 run 级 call_id 前缀隔离已随之删除: schema 隔离完全取代了它,
|
||||
两套并存只会让"这一行归谁"重新变成需要论证的事。
|
||||
|
||||
**唯一的例外是连接**: 连接是实例级共享资源,schema 隔离对它无效,故
|
||||
`TestPoolFootprint` 仍靠一个就地生成的唯一 `application_name` 认领本池连接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -55,15 +59,9 @@ _EXPECTED_COLUMNS = [
|
||||
"thinking_observation",
|
||||
]
|
||||
|
||||
# run 级前缀: 同库并存的其他运行(迁移批跑/另一开发机)互不可见
|
||||
_RUN_PREFIX = f"pgwtest-{uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _cid(suffix: str) -> str:
|
||||
return f"{_RUN_PREFIX}-{suffix}"
|
||||
|
||||
|
||||
def _dsn() -> str | None:
|
||||
"""读 `.env` 的 DSN 并剥掉 SQLAlchemy 风格的 `+driver` 后缀;未配置返回 None。"""
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||
if not raw:
|
||||
@@ -73,23 +71,24 @@ def _dsn() -> str | None:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dsn():
|
||||
async def template_admin_dsn() -> str:
|
||||
"""管理连接串,**只服务 `production_template` 一个 fixture**。
|
||||
|
||||
它没有随其余六个 fixture 一起收敛到 `pg_sandbox`,是因为 `production_template`
|
||||
要自建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期
|
||||
清理都是它自己的(设计 §7.1 末段),工厂强行接管会把这些语义压扁。
|
||||
|
||||
名字不叫 `dsn`: 叫 `dsn` 等于把一个能动共享表的连接摆在每条用例的参数位上,
|
||||
而设计 §7.1 约束 3 要的正是"用例拿不到管理连接"。此处的窄命名是那条约束在
|
||||
本文件能做到的最接近的形态。
|
||||
"""
|
||||
value = _dsn()
|
||||
if value is None:
|
||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||
# 隔离守卫: 该实例有 app/chs_prod/mimiciv 等在用库,只许打 polygateway 专用库
|
||||
if not value.rstrip("/").endswith("/polygateway"):
|
||||
pytest.fail(f"遥测测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||
yield value
|
||||
# teardown: 只删本 run 写入的行;表可能尚不存在(全新库)则忽略
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(value, timeout=10)
|
||||
try:
|
||||
if await conn.fetchval("SELECT to_regclass('llm_calls')") is not None:
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id LIKE $1", f"{_RUN_PREFIX}-%")
|
||||
finally:
|
||||
await conn.close()
|
||||
return value
|
||||
|
||||
|
||||
async def _record_minimal(
|
||||
@@ -101,7 +100,7 @@ async def _record_minimal(
|
||||
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证。
|
||||
"""
|
||||
fields: dict[str, object] = {
|
||||
"call_id": call_id if call_id is not None else _cid("c1"),
|
||||
"call_id": call_id if call_id is not None else "c1",
|
||||
"parent_call_id": None,
|
||||
"session_id": "sess-1",
|
||||
"model": "m",
|
||||
@@ -176,8 +175,9 @@ async def _execute_script(dsn: str, sql: str) -> None:
|
||||
await conn.close()
|
||||
|
||||
|
||||
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
|
||||
_LEGACY_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
@@ -202,56 +202,43 @@ CREATE TABLE {schema}.llm_calls (
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def legacy_schema(dsn):
|
||||
"""在**自建的临时 schema** 里造一张 18 列旧表,验证补列(issue #3)。
|
||||
async def legacy_schema(pg_sandbox) -> tuple[str, str]:
|
||||
"""在一次性沙箱 schema 里造一张 18 列旧表,验证补列(issue #3)。
|
||||
|
||||
绝不碰共享的 public.llm_calls: 用 search_path 把 recorder 指向临时 schema,
|
||||
teardown 只 DROP 自己建的 schema。
|
||||
共享表 `llm_calls` 一个字节都不碰: recorder 由 search_path 指向沙箱 schema,
|
||||
清理由工厂统一兜底。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_LEGACY_DDL.format(schema=name))
|
||||
finally:
|
||||
await conn.close()
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
yield f"{dsn}{sep}options=-csearch_path%3D{name}", name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
sandbox = await pg_sandbox(ddl=_LEGACY_DDL)
|
||||
return sandbox.dsn, sandbox.schema
|
||||
|
||||
|
||||
class TestObservabilityColumns:
|
||||
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||
|
||||
async def test_values_round_trip(self, dsn):
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
async def test_values_round_trip(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||
await _record_minimal(recorder, call_id=_cid("model"), model_reported="MiniMax-01")
|
||||
await _record_minimal(recorder, call_id="hit", cached_prompt_tokens=64)
|
||||
await _record_minimal(recorder, call_id="zero", cached_prompt_tokens=0)
|
||||
await _record_minimal(recorder, call_id="model", model_reported="MiniMax-01")
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("samp"), sampling='{"seed": 42, "temperature": 0}'
|
||||
recorder, call_id="samp", sampling='{"seed": 42, "temperature": 0}'
|
||||
)
|
||||
rows = await _fetch(
|
||||
dsn,
|
||||
sandbox.dsn,
|
||||
"SELECT call_id, cached_prompt_tokens, model_reported, sampling FROM llm_calls "
|
||||
"WHERE call_id LIKE $1",
|
||||
f"{_RUN_PREFIX}-%",
|
||||
"WHERE call_id = ANY($1::text[])",
|
||||
["hit", "zero", "model", "samp"],
|
||||
)
|
||||
by_id = {r["call_id"]: r for r in rows}
|
||||
assert by_id[_cid("hit")]["cached_prompt_tokens"] == 64
|
||||
assert by_id[_cid("zero")]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
|
||||
assert by_id[_cid("model")]["cached_prompt_tokens"] is None
|
||||
assert by_id[_cid("model")]["model_reported"] == "MiniMax-01"
|
||||
assert by_id["hit"]["cached_prompt_tokens"] == 64
|
||||
assert by_id["zero"]["cached_prompt_tokens"] == 0 # 真实零命中 ≠ NULL
|
||||
assert by_id["model"]["cached_prompt_tokens"] is None
|
||||
assert by_id["model"]["model_reported"] == "MiniMax-01"
|
||||
# issue #4: PG 侧也须验非空 sampling 能读回原值(不只是列存在)
|
||||
assert json.loads(by_id[_cid("samp")]["sampling"]) == {"seed": 42, "temperature": 0}
|
||||
assert by_id[_cid("hit")]["sampling"] is None
|
||||
assert json.loads(by_id["samp"]["sampling"]) == {"seed": 42, "temperature": 0}
|
||||
assert by_id["hit"]["sampling"] is None
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
@@ -261,7 +248,7 @@ class TestObservabilityColumns:
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||
recorder, call_id="legacy", cached_prompt_tokens=7, model_reported="m-real"
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
@@ -274,7 +261,7 @@ class TestObservabilityColumns:
|
||||
rows = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT cached_prompt_tokens, model_reported FROM llm_calls WHERE call_id = $1",
|
||||
_cid("legacy"),
|
||||
"legacy",
|
||||
)
|
||||
assert (rows[0]["cached_prompt_tokens"], rows[0]["model_reported"]) == (7, "m-real")
|
||||
finally:
|
||||
@@ -282,42 +269,44 @@ class TestObservabilityColumns:
|
||||
|
||||
|
||||
class TestSchema:
|
||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
async def test_schema_has_frozen_columns_in_order(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder)
|
||||
rows = await _fetch(
|
||||
dsn,
|
||||
sandbox.dsn,
|
||||
# `table_schema = $1` 不可省: 不带它,库里任何一个残留 schema 下的同名表
|
||||
# 都会把自己的列拼进结果,这条断言于是以"列数不符"的形态被别人的残留误伤
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name='llm_calls' ORDER BY ordinal_position",
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
sandbox.schema,
|
||||
)
|
||||
assert [r["column_name"] for r in rows] == _EXPECTED_COLUMNS
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_call_id_idempotent(self, dsn):
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
async def test_call_id_idempotent(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("dup"))
|
||||
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
|
||||
await _record_minimal(recorder, call_id="dup")
|
||||
await _record_minimal(recorder, call_id="dup", response="second")
|
||||
rows = await _fetch(
|
||||
dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("dup")
|
||||
sandbox.dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "dup"
|
||||
)
|
||||
assert [r["response"] for r in rows] == ["ok"] # ON CONFLICT DO NOTHING
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_concurrent_writes_all_land(self, dsn):
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
async def test_concurrent_writes_all_land(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
||||
)
|
||||
rows = await _fetch(
|
||||
dsn,
|
||||
"SELECT count(*) AS n FROM llm_calls WHERE call_id LIKE $1",
|
||||
f"{_RUN_PREFIX}-c%",
|
||||
)
|
||||
await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
|
||||
# 沙箱 schema 里只有这一批行,故全表 COUNT 就是本用例写入的行数——
|
||||
# 前缀过滤在这里已无事可做(它当年存在只是为了从共享表里认领自己的行)
|
||||
rows = await _fetch(sandbox.dsn, "SELECT count(*) AS n FROM llm_calls")
|
||||
assert rows[0]["n"] == 50
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
@@ -341,7 +330,7 @@ class TestDegradation:
|
||||
"""服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。"""
|
||||
recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
|
||||
await _record_minimal(recorder) # 不抛
|
||||
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
|
||||
await _record_minimal(recorder, call_id="c2") # 已降级短路,同样不抛
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_refused_connection_cools_down_and_retries_after_cooldown(self):
|
||||
@@ -364,7 +353,7 @@ class TestDegradation:
|
||||
now=clock,
|
||||
)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("deg1"))
|
||||
await _record_minimal(recorder, call_id="deg1")
|
||||
first = recorder.telemetry_status
|
||||
# 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死,
|
||||
# 整进程从此一行遥测都不落、只有重启能恢复
|
||||
@@ -375,14 +364,14 @@ class TestDegradation:
|
||||
assert "建表探测失败" in (first.reason or "")
|
||||
|
||||
clock.advance(30.0)
|
||||
await _record_minimal(recorder, call_id=_cid("deg2"))
|
||||
await _record_minimal(recorder, call_id="deg2")
|
||||
mid = recorder.telemetry_status
|
||||
# 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路)
|
||||
assert mid.retry_after_s == pytest.approx(30.0)
|
||||
assert mid.dropped_rows == 2
|
||||
|
||||
clock.advance(30.1)
|
||||
await _record_minimal(recorder, call_id=_cid("deg3"))
|
||||
await _record_minimal(recorder, call_id="deg3")
|
||||
after = recorder.telemetry_status
|
||||
# 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈)
|
||||
assert after.retry_after_s == pytest.approx(60.0)
|
||||
@@ -391,23 +380,25 @@ class TestDegradation:
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_row_failure_does_not_poison_later_rows(self, dsn):
|
||||
async def test_row_failure_does_not_poison_later_rows(self, pg_sandbox):
|
||||
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
|
||||
await _record_minimal(recorder, call_id=_cid("good"))
|
||||
await _record_minimal(recorder, call_id="bad", response="nul\x00byte")
|
||||
await _record_minimal(recorder, call_id="good")
|
||||
rows = await _fetch(
|
||||
dsn,
|
||||
sandbox.dsn,
|
||||
"SELECT call_id FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
[_cid("bad"), _cid("good")],
|
||||
["bad", "good"],
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("good")]
|
||||
assert [r["call_id"] for r in rows] == ["good"]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_aclose_idempotent(self, dsn):
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
async def test_aclose_idempotent(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
recorder = _recorder(sandbox.dsn, auto_migrate=True)
|
||||
await _record_minimal(recorder)
|
||||
await recorder.aclose()
|
||||
await recorder.aclose()
|
||||
@@ -427,7 +418,7 @@ def _tagged(dsn: str, app_name: str) -> str:
|
||||
async def _pool_backend_count(dsn: str, app_name: str) -> int:
|
||||
"""数**本池**在服务端的连接数(只读查询,不改实例任何状态)。
|
||||
|
||||
只按 run 级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
|
||||
只按用例级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或
|
||||
用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例
|
||||
(CLAUDE.md §4.6)。本查询自己那条连接走未打 tag 的 DSN,故不会数到自己。
|
||||
"""
|
||||
@@ -458,73 +449,53 @@ class TestPoolFootprint:
|
||||
那么多连接"——两件事,只有真实 PG 能证后者。
|
||||
"""
|
||||
|
||||
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, dsn):
|
||||
app_name = f"{_RUN_PREFIX}-pool" # run 级唯一,与并跑的其他运行互不可见
|
||||
recorder = _recorder(_tagged(dsn, app_name), auto_migrate=True)
|
||||
async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, pg_sandbox):
|
||||
sandbox = await pg_sandbox()
|
||||
# `application_name` 的唯一性必须**就地**造,不能跟着行隔离前缀一起删掉:
|
||||
# 连接是实例级资源,schema 隔离对 `pg_stat_activity` 完全无效,换成固定名字
|
||||
# 会把并跑进程的连接数进来,等于把偶发红从表层搬到连接层(设计 §6.1)。
|
||||
app_name = f"pgwtest-pool-{uuid4().hex[:12]}"
|
||||
recorder = _recorder(_tagged(sandbox.dsn, app_name), auto_migrate=True)
|
||||
try:
|
||||
# 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前
|
||||
# `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10
|
||||
assert await _pool_backend_count(dsn, app_name) == 0
|
||||
assert await _pool_backend_count(sandbox.dsn, app_name) == 0
|
||||
|
||||
await _record_minimal(recorder, call_id=_cid("fp1"))
|
||||
await _record_minimal(recorder, call_id="fp1")
|
||||
# **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功),
|
||||
# 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。
|
||||
# 故这是个确定值,不是"某一刻恰好的采样"
|
||||
assert await _pool_backend_count(dsn, app_name) == 1
|
||||
assert await _pool_backend_count(sandbox.dsn, app_name) == 1
|
||||
|
||||
await asyncio.gather(
|
||||
*(_record_minimal(recorder, call_id=_cid(f"fp{i}")) for i in range(2, 22))
|
||||
*(_record_minimal(recorder, call_id=f"fp{i}") for i in range(2, 22))
|
||||
)
|
||||
steady = await _pool_backend_count(dsn, app_name)
|
||||
steady = await _pool_backend_count(sandbox.dsn, app_name)
|
||||
# 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池,
|
||||
# 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过
|
||||
assert 1 <= steady <= _POOL_MAX
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
assert await _settled_backend_count(dsn, app_name) == 0 # 关闭即归还全部连接
|
||||
# 关闭即归还全部连接
|
||||
assert await _settled_backend_count(sandbox.dsn, app_name) == 0
|
||||
|
||||
|
||||
_PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def least_privilege_dsn(dsn):
|
||||
"""临时 schema + 临时角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
|
||||
async def least_privilege_dsn(pg_sandbox) -> tuple[str, str]:
|
||||
"""一次性 schema + 独占角色: 只授表级 SELECT/INSERT,**不授 schema CREATE**。
|
||||
|
||||
这是 issue #9 的现场——最小权限部署的标准形态。fixture 建的一切
|
||||
(schema、表、角色)都在 teardown 里删净,共享的 public.llm_calls 不受影响;
|
||||
连不上或无权建角色(非超级用户)时 skip,不让 CI 假绿。
|
||||
这是 issue #9 的现场——最小权限部署的标准形态。`role="grantee"` 的语义恰是它:
|
||||
表由 admin 建好(属主不是应用账号),角色只拿到 `USAGE` 加表级 grants,
|
||||
唯独没有 `CREATE ON SCHEMA`——缺的正是这一项。
|
||||
无权建角色(非超级用户)时工厂自己 skip,不让 CI 假绿。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
name = f"pgwtest_lp_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
if not await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
):
|
||||
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(f"SET search_path = {name}")
|
||||
await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||
finally:
|
||||
await admin.close()
|
||||
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
sep = "&" if "?" in low else "?"
|
||||
yield f"{low}{sep}options=-csearch_path%3D{name}", name
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
|
||||
await admin.execute(f"DROP OWNED BY {name}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
sandbox = await pg_sandbox(ddl=PG_DDL, role="grantee")
|
||||
return sandbox.dsn, sandbox.schema
|
||||
|
||||
|
||||
class TestLeastPrivilegeDeployment:
|
||||
@@ -552,26 +523,28 @@ class TestLeastPrivilegeDeployment:
|
||||
low_dsn, schema = least_privilege_dsn
|
||||
recorder = _recorder(low_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lp1"))
|
||||
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
|
||||
await _record_minimal(recorder, call_id="lp1")
|
||||
await _record_minimal(recorder, call_id="lp2", cost=1.5)
|
||||
assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级
|
||||
rows = await _fetch(
|
||||
low_dsn,
|
||||
"SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
|
||||
f"{_RUN_PREFIX}-lp%",
|
||||
"SELECT call_id, cost FROM llm_calls "
|
||||
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
["lp1", "lp2"],
|
||||
)
|
||||
assert [(r["call_id"], r["cost"]) for r in rows] == [
|
||||
(_cid("lp1"), None),
|
||||
(_cid("lp2"), 1.5),
|
||||
("lp1", None),
|
||||
("lp2", 1.5),
|
||||
]
|
||||
assert schema # teardown 会连表带角色删净
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度。
|
||||
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
|
||||
_PRE_TENANT_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
@@ -598,10 +571,12 @@ CREATE TABLE {schema}.llm_calls (
|
||||
)
|
||||
"""
|
||||
|
||||
# 工厂的 `extra` 逐条裸执行、不接受查询参数,故这行历史数据的 call_id 直接内联成
|
||||
# 字面量('old' 是本文件固定的测试常量,不是外部输入)。
|
||||
_PRE_TENANT_INSERT = (
|
||||
"INSERT INTO {schema}.llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
|
||||
"VALUES ('old', 'm', 'p', 's1', '[]', 'old body', 1, 2, 'measured', 10)"
|
||||
)
|
||||
|
||||
|
||||
@@ -637,82 +612,36 @@ async def captured_warnings():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pre_tenant_schema(dsn):
|
||||
"""自建临时 schema 里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
|
||||
async def pre_tenant_schema(pg_sandbox) -> tuple[str, str]:
|
||||
"""一次性沙箱里造一张 **22 字段的 issue #11 之前的表**,并留一行历史数据。
|
||||
|
||||
绝不碰共享的 public.llm_calls——本机那张表早已被 `_BACKFILL` 真实补过列,
|
||||
指望它还是旧形态的测试第二次跑就会空转。schema 名带 uuid,可重复运行。
|
||||
共享表 `llm_calls` 一个字节都不碰——本机那张表早已被 `_BACKFILL` 真实补过列,
|
||||
指望它还是旧形态的测试第二次跑就会空转。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_pre_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PRE_TENANT_DDL.format(schema=name))
|
||||
await conn.execute(_PRE_TENANT_INSERT.format(schema=name), _cid("old"))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, extra=(_PRE_TENANT_INSERT,))
|
||||
return sandbox.dsn, sandbox.schema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_schema(dsn):
|
||||
async def fresh_schema(pg_sandbox) -> tuple[str, str]:
|
||||
"""空 schema: recorder 自己建表,验"新建库"这条路径而不依赖共享表的历史状态。"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_new_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
sandbox = await pg_sandbox()
|
||||
return sandbox.dsn, sandbox.schema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def least_privilege_pre_tenant_dsn(dsn):
|
||||
async def least_privilege_pre_tenant_dsn(pg_sandbox) -> str:
|
||||
"""22 字段旧表 + 只有 `SELECT, INSERT` 权限的角色: 补列必然失败的现场。
|
||||
|
||||
与 `least_privilege_dsn` 分开而非复用: 那个 fixture 建的是列已齐全的当前表
|
||||
(测的是 CREATE 被拒),这里必须是缺列的旧表,才能让 `ALTER TABLE` 真的发出去
|
||||
并撞上 ownership 检查(该检查早于 `IF NOT EXISTS` 的存在性判断)。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_lppre_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
if not await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
):
|
||||
pytest.skip("当前账号无权建临时角色,跳过最小权限用例")
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(_PRE_TENANT_DDL.format(schema=name)) # 表属主是 admin,不是应用账号
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
low = re.sub(r"//[^@/]+@", f"//{name}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
yield _search_path_dsn(low, name)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
|
||||
await admin.execute(f"DROP OWNED BY {name}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {name}")
|
||||
finally:
|
||||
await admin.close()
|
||||
`role="grantee"` 正是这个现场: 表由 admin 建好(属主不是应用账号),角色只拿到
|
||||
表级 SELECT/INSERT。
|
||||
"""
|
||||
sandbox = await pg_sandbox(ddl=_PRE_TENANT_DDL, role="grantee")
|
||||
return sandbox.dsn
|
||||
|
||||
|
||||
class TestCallerDimensionsAcceptance:
|
||||
@@ -724,7 +653,7 @@ class TestCallerDimensionsAcceptance:
|
||||
recorder = _recorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
recorder, call_id="dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
fresh_dsn,
|
||||
@@ -736,7 +665,7 @@ class TestCallerDimensionsAcceptance:
|
||||
rows = await _fetch(
|
||||
fresh_dsn,
|
||||
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = $1",
|
||||
_cid("dim"),
|
||||
"dim",
|
||||
)
|
||||
assert rows[0]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(rows[0]["meta"]) == {"batch": "b7"}
|
||||
@@ -757,9 +686,7 @@ class TestCallerDimensionsAcceptance:
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
await _record_minimal(recorder, call_id="new", tenant_id="tenant-a", meta='{"k": 1}')
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
@@ -772,13 +699,13 @@ class TestCallerDimensionsAcceptance:
|
||||
schema_dsn,
|
||||
"SELECT call_id, tenant_id, meta FROM llm_calls "
|
||||
"WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
[_cid("new"), _cid("old")],
|
||||
["new", "old"],
|
||||
)
|
||||
by_id = {r["call_id"]: r for r in rows}
|
||||
assert by_id[_cid("new")]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(by_id[_cid("new")]["meta"]) == {"k": 1}
|
||||
assert by_id[_cid("old")]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert json.loads(by_id[_cid("old")]["meta"]) == {}
|
||||
assert by_id["new"]["tenant_id"] == "tenant-a"
|
||||
assert json.loads(by_id["new"]["meta"]) == {"k": 1}
|
||||
assert by_id["old"]["tenant_id"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert json.loads(by_id["old"]["meta"]) == {}
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
@@ -809,7 +736,7 @@ class TestCallerDimensionsAcceptance:
|
||||
"""
|
||||
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
||||
await _record_minimal(recorder, call_id="lpp1") # 不得抛
|
||||
assert recorder.telemetry_status.degraded is False
|
||||
assert any("补列失败" in m for m in captured_warnings)
|
||||
# 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据
|
||||
@@ -821,8 +748,9 @@ class TestCallerDimensionsAcceptance:
|
||||
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
|
||||
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
|
||||
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
|
||||
# 裸表名: 由 `pg_sandbox` 在沙箱 schema 的 search_path 下执行(工厂的 DDL 执行契约)
|
||||
_PARTITIONED_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT NOT NULL,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
@@ -847,14 +775,14 @@ CREATE TABLE {schema}.llm_calls (
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (call_id, created_at)
|
||||
) PARTITION BY RANGE (created_at)
|
||||
"""
|
||||
|
||||
# 仍带 `.format`,但只为月份边界——表名两处都已是裸名,由 search_path 定位
|
||||
_PARTITION_DDL = (
|
||||
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls "
|
||||
"FOR VALUES FROM ('{start}') TO ('{end}')"
|
||||
"CREATE TABLE llm_calls_current PARTITION OF llm_calls FOR VALUES FROM ('{start}') TO ('{end}')"
|
||||
)
|
||||
|
||||
|
||||
@@ -868,29 +796,18 @@ def _current_month_bounds() -> tuple[str, str]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
|
||||
async def partitioned_schema(pg_sandbox) -> tuple[str, str]:
|
||||
"""一次性沙箱里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
|
||||
|
||||
与 legacy_schema 同款隔离: 绝不碰共享的 public.llm_calls,teardown 只 DROP
|
||||
自己建的 schema(CASCADE 连分区一并删)。
|
||||
与 `legacy_schema` 同款隔离: 共享表 `llm_calls` 一个字节都不碰,工厂的
|
||||
`DROP SCHEMA ... CASCADE` 连分区子表一并删。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_part_{uuid4().hex[:8]}"
|
||||
start, end = _current_month_bounds()
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PARTITIONED_DDL.format(schema=name))
|
||||
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
sandbox = await pg_sandbox(
|
||||
ddl=_PARTITIONED_DDL,
|
||||
extra=(_PARTITION_DDL.format(start=start, end=end),),
|
||||
)
|
||||
return sandbox.dsn, sandbox.schema
|
||||
|
||||
|
||||
class TestConflictTargetFreeInsert:
|
||||
@@ -905,11 +822,11 @@ class TestConflictTargetFreeInsert:
|
||||
fresh_dsn, _ = fresh_schema
|
||||
recorder = _recorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"))
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
|
||||
await _record_minimal(recorder, call_id="nodup")
|
||||
await _record_minimal(recorder, call_id="nodup", response="second")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
rows = await _fetch(
|
||||
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup")
|
||||
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", "nodup"
|
||||
)
|
||||
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
|
||||
finally:
|
||||
@@ -926,14 +843,14 @@ class TestConflictTargetFreeInsert:
|
||||
part_dsn, _ = partitioned_schema
|
||||
recorder = _recorder(part_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
|
||||
await _record_minimal(recorder, call_id="part", tenant_id="tenant-p")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
rows = await _fetch(
|
||||
part_dsn,
|
||||
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
|
||||
_cid("part"),
|
||||
"part",
|
||||
)
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")]
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [("part", "tenant-p")]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
@@ -958,7 +875,7 @@ class TestManualSchemaModeAcceptance:
|
||||
recorder = _recorder(schema_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
recorder, call_id="man", tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
@@ -971,7 +888,7 @@ class TestManualSchemaModeAcceptance:
|
||||
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man")
|
||||
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", "man"
|
||||
)
|
||||
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
|
||||
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
|
||||
@@ -999,9 +916,9 @@ class TestManualSchemaModeAcceptance:
|
||||
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}'
|
||||
recorder, call_id="manlp1", tenant_id="tenant-b", meta='{"k": 2}'
|
||||
)
|
||||
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5)
|
||||
await _record_minimal(recorder, call_id="manlp2", cost=2.5)
|
||||
|
||||
assert [m for m in captured_warnings if "补列失败" in m] == []
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
@@ -1031,10 +948,10 @@ class TestManualSchemaModeAcceptance:
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
least_privilege_pre_tenant_dsn,
|
||||
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
|
||||
f"{_RUN_PREFIX}-manlp%",
|
||||
f"SELECT {names} FROM llm_calls WHERE call_id = ANY($1::text[]) ORDER BY call_id",
|
||||
["manlp1", "manlp2"],
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")]
|
||||
assert [r["call_id"] for r in rows] == ["manlp1", "manlp2"]
|
||||
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
|
||||
assert rows[1]["cost"] == 2.5
|
||||
finally:
|
||||
@@ -1184,14 +1101,20 @@ async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def production_template(dsn):
|
||||
async def production_template(template_admin_dsn):
|
||||
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
|
||||
|
||||
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls`
|
||||
**有意不收敛到 `pg_sandbox`**(设计 §7.1 末段): 它要建三个角色、跑 README
|
||||
解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的,工厂
|
||||
强行接管会把这些语义压扁。故它是本文件唯一仍持管理连接的 fixture。
|
||||
|
||||
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享表 `llm_calls`
|
||||
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
dsn = template_admin_dsn
|
||||
|
||||
suffix = uuid4().hex[:8]
|
||||
schema = f"pgwtpl_{suffix}"
|
||||
roles = {
|
||||
@@ -1206,7 +1129,7 @@ async def production_template(dsn):
|
||||
f"README 的模板锚点与预期不符: {list(blocks)}"
|
||||
)
|
||||
|
||||
seeded = (_cid("tpl-a"), _cid("tpl-b"))
|
||||
seeded = ("tpl-a", "tpl-b")
|
||||
admin_dsn = _search_path_dsn(dsn, schema)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
|
||||
@@ -1228,9 +1151,9 @@ async def production_template(dsn):
|
||||
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
|
||||
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
|
||||
except BaseException:
|
||||
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
|
||||
# 而 `TestSchema` 那条按 table_name 查 information_schema 的用例不带
|
||||
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
|
||||
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,而三个
|
||||
# 角色是**全局**对象,不随库消失。`TestSchema` 那条用例如今自带 table_schema
|
||||
# 过滤已不再受残留影响,但残留本身仍是这个共享实例上的垃圾,该清还是要清。
|
||||
await admin.close()
|
||||
await _drop_template_objects(dsn, schema, roles)
|
||||
raise
|
||||
@@ -1303,17 +1226,17 @@ class TestProductionTemplate:
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.app_dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
|
||||
await conn.execute(_TEMPLATE_INSERT, "tpl-app", "tenant-a")
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", "tpl-app")
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("UPDATE llm_calls SET response = 'x'")
|
||||
finally:
|
||||
await conn.close()
|
||||
rows = await _fetch(
|
||||
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
|
||||
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", "tpl-app"
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
|
||||
assert [r["call_id"] for r in rows] == ["tpl-app"] # 写入真落库了
|
||||
|
||||
async def test_report_can_read_but_cannot_write(self, production_template):
|
||||
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
|
||||
@@ -1323,7 +1246,7 @@ class TestProductionTemplate:
|
||||
conn = await asyncpg.connect(env.report_dsn, timeout=10)
|
||||
try:
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
|
||||
await conn.execute(_TEMPLATE_INSERT, "tpl-rpt", "tenant-a")
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
|
||||
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
|
||||
|
||||
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。
|
||||
隔离纪律(issue #18): 本文件跑的是一个**会删数据的脚本**,而实例上的共享表 `llm_calls`
|
||||
与真实批跑共用。故**凡启动脚本的用例一律用 `pg_sandbox(role="owner")` 的临时角色跑**:
|
||||
该角色对共享表一无所有,越界不是"会被发现",而是数据库层面做不到。
|
||||
|
||||
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
|
||||
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path),
|
||||
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
|
||||
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
|
||||
这条纪律取代了此前那条"跑完对比共享表行数"的安全网——行数快照守的是安全属性,却把它
|
||||
编码成对全局可变量的观测: 外部进程一写就假红,外部插入与脚本误删互相抵消则假阴。
|
||||
权限边界两个方向都没有。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,10 +17,8 @@ import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
@@ -55,20 +54,6 @@ def _partitioned_ddl() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _dsn_value() -> str | None:
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||
if not raw:
|
||||
return None
|
||||
scheme, sep, rest = raw.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
def _stamp(delta: timedelta) -> datetime:
|
||||
return datetime.now(UTC) + delta
|
||||
|
||||
@@ -84,43 +69,6 @@ def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedP
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dsn():
|
||||
value = _dsn_value()
|
||||
if value is None:
|
||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
|
||||
if not value.rstrip("/").endswith("/polygateway"):
|
||||
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(f"SET search_path = {name}")
|
||||
await conn.execute(ddl)
|
||||
for statement in extra:
|
||||
await conn.execute(statement)
|
||||
finally:
|
||||
await conn.close()
|
||||
return name
|
||||
|
||||
|
||||
async def _drop_schema(dsn_value: str, name: str) -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
|
||||
import asyncpg
|
||||
|
||||
@@ -142,50 +90,31 @@ async def _call_ids(schema_dsn: str) -> list[str]:
|
||||
return [r["call_id"] for r in rows]
|
||||
|
||||
|
||||
async def _public_count(dsn_value: str) -> int:
|
||||
"""共享表的行数;本测试全程不得让它变动一行。"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
|
||||
return -1
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
async def partitioned_sandbox(pg_sandbox):
|
||||
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
|
||||
name = await _make_schema(
|
||||
dsn,
|
||||
"part",
|
||||
_partitioned_ddl(),
|
||||
return await pg_sandbox(
|
||||
ddl=_partitioned_ddl(),
|
||||
extra=(
|
||||
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
|
||||
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
|
||||
),
|
||||
role="owner",
|
||||
)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def plain_schema(dsn):
|
||||
async def plain_sandbox(pg_sandbox):
|
||||
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
|
||||
name = await _make_schema(dsn, "plain", PG_DDL)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
return await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||
|
||||
|
||||
class TestPartitionedTarget:
|
||||
async def test_partitioned_table_exits_three_without_deleting_anything(
|
||||
self, partitioned_schema
|
||||
self, partitioned_sandbox
|
||||
):
|
||||
schema_dsn, schema = partitioned_schema
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
partitioned_sandbox.dsn,
|
||||
[
|
||||
("part-old-1", "", _stamp(timedelta(days=-30))),
|
||||
("part-old-2", "acme", _stamp(timedelta(days=-20))),
|
||||
@@ -194,24 +123,28 @@ class TestPartitionedTarget:
|
||||
|
||||
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
|
||||
result = _run(
|
||||
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
partitioned_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
)
|
||||
|
||||
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "DROP PARTITION" in combined
|
||||
assert "DETACH" in combined
|
||||
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
|
||||
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1", "part-old-2"]
|
||||
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
assert f"{partitioned_sandbox.schema}.llm_calls" in result.stdout
|
||||
|
||||
|
||||
class TestPlainTableBatches:
|
||||
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
|
||||
schema_dsn, schema = plain_schema
|
||||
before_public = await _public_count(dsn)
|
||||
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_sandbox):
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
plain_sandbox.dsn,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-40))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||
@@ -227,7 +160,7 @@ class TestPlainTableBatches:
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
plain_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
@@ -236,8 +169,8 @@ class TestPlainTableBatches:
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
|
||||
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
|
||||
assert "将删除行数: 5" in result.stdout
|
||||
assert "'acme': 3" in result.stdout
|
||||
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
|
||||
@@ -245,30 +178,200 @@ class TestPlainTableBatches:
|
||||
assert "批次 3" in result.stdout
|
||||
assert "批次 4" not in result.stdout
|
||||
assert "已删除 5 行" in result.stdout
|
||||
assert await _public_count(dsn) == before_public
|
||||
|
||||
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_sandbox):
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
|
||||
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert "将删除行数: 1" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||
|
||||
|
||||
class TestExplicitTable:
|
||||
"""`--table SCHEMA.NAME` 的真实解析行为(issue #18 设计 §4.1;判据 1b)。
|
||||
|
||||
单测只能验参数分类,验不了 `to_regclass` 的语义——schema 不存在返 NULL 而非抛错、
|
||||
引号限定名区分大小写、无权限落在 `COUNT` 而非解析,这三条都必须真连库才成立。
|
||||
"""
|
||||
|
||||
async def test_explicit_table_deletes_exactly_like_the_implicit_path(self, plain_sandbox):
|
||||
await _seed(
|
||||
plain_sandbox.dsn,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-40))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||
("old-3", "acme", _stamp(timedelta(days=-20))),
|
||||
("old-4", "acme", _stamp(timedelta(days=-15))),
|
||||
("old-5", "", _stamp(timedelta(days=-10))),
|
||||
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||
],
|
||||
)
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
plain_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--batch-size",
|
||||
"2",
|
||||
"--table",
|
||||
f"{plain_sandbox.schema}.llm_calls",
|
||||
)
|
||||
|
||||
# 与不给 --table 的那条用例逐条同款: 显式声明只改"怎么找到表",不改任何行为
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["fresh-1", "fresh-2"]
|
||||
assert f"{plain_sandbox.schema}.llm_calls" in result.stdout
|
||||
assert "将删除行数: 5" in result.stdout
|
||||
assert "'acme': 3" in result.stdout
|
||||
assert "批次 1" in result.stdout
|
||||
assert "批次 3" in result.stdout
|
||||
assert "批次 4" not in result.stdout
|
||||
assert "已删除 5 行" in result.stdout
|
||||
|
||||
async def test_table_in_a_nonexistent_schema_exits_two(self, plain_sandbox):
|
||||
"""schema 不存在时 `to_regclass` 返 NULL(不抛错),故落进既有的"目标不可用"。"""
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
missing = "pgw_s_nosuchxxxxxxxx"
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
plain_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--table",
|
||||
f"{missing}.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert missing in result.stderr
|
||||
assert "llm_calls" in result.stderr
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||
|
||||
async def test_table_owned_by_another_role_exits_two(self, pg_sandbox):
|
||||
"""拿 A 的连接指 B 的表: 权限拒绝,两张表都不能少一行。"""
|
||||
sandbox_a = await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||
sandbox_b = await pg_sandbox(ddl=PG_DDL, role="owner")
|
||||
await _seed(sandbox_a.dsn, [("a-old", "acme", _stamp(timedelta(days=-30)))])
|
||||
await _seed(sandbox_b.dsn, [("b-old", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
sandbox_a.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--table",
|
||||
f"{sandbox_b.schema}.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert await _call_ids(sandbox_a.dsn) == ["a-old"]
|
||||
assert await _call_ids(sandbox_b.dsn) == ["b-old"]
|
||||
|
||||
async def test_explicit_partitioned_table_still_exits_three(self, partitioned_sandbox):
|
||||
await _seed(partitioned_sandbox.dsn, [("part-old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
partitioned_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--table",
|
||||
f"{partitioned_sandbox.schema}.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "DROP PARTITION" in combined
|
||||
assert "DETACH" in combined
|
||||
assert await _call_ids(partitioned_sandbox.dsn) == ["part-old-1"]
|
||||
|
||||
|
||||
class TestInferredTargetHint:
|
||||
async def test_apply_without_table_warns_that_the_target_was_inferred(self, plain_sandbox):
|
||||
"""未钉死目标时必须当场说清"这张表是猜出来的"(设计 §4.4;判据 2)。
|
||||
|
||||
该提示行只在 PG 分支打印,不连库的单测触发不到它,故验收落在集成层。
|
||||
"""
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
plain_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert "search_path" in result.stdout
|
||||
assert "--table" in result.stdout
|
||||
|
||||
async def test_dry_run_does_not_print_the_hint(self, plain_sandbox):
|
||||
"""dry-run 不可逆性为零,它本就以"看清楚再决定"为用途,多一行提示是噪音。"""
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run("--backend", "postgres", "--dsn", plain_sandbox.dsn, "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert "--table" not in result.stdout
|
||||
|
||||
|
||||
class TestSearchPathFallsThrough:
|
||||
async def test_bare_search_path_cannot_touch_the_shared_table(self, plain_sandbox):
|
||||
"""最坏情况: `search_path` 没生效,脚本落到共享表 `llm_calls` 上(设计 §5.3)。
|
||||
|
||||
用沙箱角色的**裸** DSN 跑(search_path 回落 `"$user", public`,而角色名与 schema
|
||||
名有意错开,故 `"$user"` 命不中沙箱),不给 `--table`,带 `--apply`。角色对共享表
|
||||
无任何权限,于是两条可能的路都收敛到退出码 2: 库里有那张表则 `COUNT` 被权限拒绝,
|
||||
没有则解析不到。**不断言 PG 的英文原文**——服务端 `lc_messages` 不由测试掌握。
|
||||
"""
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
plain_sandbox.bare_dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
)
|
||||
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert result.stderr.strip()
|
||||
assert "llm_calls" in result.stderr
|
||||
# 沙箱表一行不少: 脚本既没删共享表,也没绕回来删自己这张
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||
|
||||
|
||||
class TestMissingAsyncpg:
|
||||
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
|
||||
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_sandbox, tmp_path):
|
||||
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
|
||||
|
||||
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
|
||||
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
|
||||
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
|
||||
"""
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
await _seed(plain_sandbox.dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
stub = tmp_path / "stub"
|
||||
stub.mkdir()
|
||||
(stub / "asyncpg.py").write_text(
|
||||
@@ -285,7 +388,7 @@ class TestMissingAsyncpg:
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
plain_sandbox.dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
@@ -295,4 +398,4 @@ class TestMissingAsyncpg:
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert "asyncpg" in result.stderr
|
||||
assert "pip install" in result.stderr
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
assert await _call_ids(plain_sandbox.dsn) == ["old-1"]
|
||||
|
||||
@@ -261,6 +261,157 @@ class TestUsageErrors:
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
# --- --table 的参数分类(issue #18 设计 §4.2);真实解析行为在集成层验 ---
|
||||
|
||||
def test_sqlite_with_table_exits_one(self, tmp_path):
|
||||
"""SQLite 库文件即目标,无 schema 概念,故 `--table` 在该分支无歧义可消。"""
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"some_schema.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||
assert "unrecognized" not in result.stderr
|
||||
|
||||
def test_table_without_schema_qualifier_exits_one(self):
|
||||
"""单段等于没声明: 目标仍由 `search_path` 决定,隐式性原样保留,故拒绝。"""
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||
assert "unrecognized" not in result.stderr
|
||||
|
||||
def test_table_with_empty_segment_exits_one(self):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
".llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||
assert "unrecognized" not in result.stderr
|
||||
|
||||
def test_table_with_quote_in_a_segment_exits_one(self):
|
||||
"""含引号的复杂标识符不支持: 此时退回不给 `--table` 的路径(见 epilog)。"""
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
'sch"ema.llm_calls',
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||
assert "unrecognized" not in result.stderr
|
||||
|
||||
def test_table_naming_another_table_exits_one(self):
|
||||
"""表名段锁死: 不加这条,`--table` 会把本脚本扩成"任意同形表删除工具"。"""
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"audit.events",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
# 单看退出码与 "--table" 字样会被 argparse 的 "unrecognized arguments" 蒙混
|
||||
# 过去(它也是退出 1、也回显参数名)。必须钉住"参数已被识别、因规则被拒"。
|
||||
assert "unrecognized" not in result.stderr
|
||||
# 错误消息要当场把边界说清: 本脚本的作用域到 llm_calls 为止
|
||||
assert "llm_calls" in result.stderr
|
||||
|
||||
|
||||
class TestTableIdentifierWhitelist:
|
||||
"""schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。
|
||||
|
||||
这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在),
|
||||
是**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人。
|
||||
"""
|
||||
|
||||
def test_schema_with_a_space_exits_one(self):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"bad schema.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
|
||||
def test_schema_with_a_semicolon_exits_one(self):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"a;b.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--table" in result.stderr
|
||||
|
||||
def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self):
|
||||
"""收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。"""
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://127.0.0.1:1/nope",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--table",
|
||||
"pgw_s_a1b2c3.llm_calls",
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
|
||||
|
||||
class TestHelp:
|
||||
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
||||
@@ -271,3 +422,12 @@ class TestHelp:
|
||||
assert "维护角色" in result.stdout
|
||||
assert "REVOKE" in result.stdout
|
||||
assert "PARTITION" in result.stdout
|
||||
|
||||
def test_help_states_the_table_name_is_fixed(self):
|
||||
"""`--table` 只有 schema 一段可变,这条边界必须写在运维会读到的地方。"""
|
||||
result = _run("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "--table" in result.stdout
|
||||
assert "只清理" in result.stdout
|
||||
assert "llm_calls" in result.stdout
|
||||
|
||||
@@ -28,7 +28,7 @@ from polygateway import EmbeddingClient, GatewayClient, LLMResponse
|
||||
from polygateway.config import _SOURCE_FIELDS
|
||||
from polygateway.ocr import OcrClient
|
||||
from polygateway.providers import register_provider
|
||||
from polygateway.telemetry.sqlite import _COLUMNS as TELEMETRY_COLUMNS
|
||||
from polygateway.telemetry.sqlite import COLUMNS as TELEMETRY_COLUMNS
|
||||
|
||||
# 参数名允许在 wiki 里以别名出现的白名单(仅限确无歧义的自解释形参)
|
||||
_PARAM_ALIASES: dict[str, set[str]] = {"env": {"env"}}
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
|
||||
|
||||
TABLE = "llm_calls"
|
||||
|
||||
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
|
||||
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
|
||||
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
|
||||
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
||||
|
||||
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
||||
EXIT_OK = 0
|
||||
EXIT_USAGE = 1
|
||||
@@ -59,6 +65,11 @@ _EPILOG = """\
|
||||
时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
|
||||
--older-than-days 0 即"删除此刻之前的全部行"。
|
||||
|
||||
--table: 本脚本只清理表 llm_calls,故 --table 只有 schema 一段可变(写成
|
||||
--table <schema>.llm_calls)。给了它,目标就由参数精确解析、不再经
|
||||
search_path 推断。含点或引号的复杂标识符不支持,此时请不给 --table,
|
||||
退回 search_path 解析那条路径。
|
||||
|
||||
示例:
|
||||
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
|
||||
--older-than-days 90 # dry-run,只看会删什么
|
||||
@@ -114,6 +125,11 @@ def _build_parser() -> _Parser:
|
||||
action="store_true",
|
||||
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--table",
|
||||
metavar="SCHEMA.NAME",
|
||||
help=f"仅 postgres: 把目标钉死为 <schema>.{TABLE},绕开 search_path 推断",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -150,10 +166,16 @@ def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
parser.error("--backend sqlite 不接受 --dsn")
|
||||
if args.batch_size is not None:
|
||||
parser.error("--batch-size 仅用于 --backend postgres")
|
||||
if args.table is not None:
|
||||
parser.error("--table 仅用于 --backend postgres:SQLite 的库文件即目标,无 schema 可消歧")
|
||||
|
||||
|
||||
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
|
||||
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。
|
||||
|
||||
`--table` 在此解析成 `args.table_schema`(未给则 None): 校验与解析放在同一处,
|
||||
后面的执行路径就只面对一个已经合法的 schema 名,不必再重复判断。
|
||||
"""
|
||||
if args.dsn is None:
|
||||
parser.error("--backend postgres 需要 --dsn")
|
||||
if args.path is not None:
|
||||
@@ -164,6 +186,36 @@ def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
args.batch_size = 1000
|
||||
elif args.batch_size < 1:
|
||||
parser.error("--batch-size 必须 >= 1")
|
||||
args.table_schema = None if args.table is None else _parse_table(parser, args.table)
|
||||
|
||||
|
||||
def _parse_table(parser: _Parser, value: str) -> str:
|
||||
"""校验 `--table SCHEMA.NAME` 并返回 schema 段;任何不合法形态退出 1。
|
||||
|
||||
**表名段为什么不可变**: 只校验"两段、非空"的话,一次手误 `--table audit.events`
|
||||
就会让本脚本对一张恰好也有 `created_at` / `tenant_id` 的业务表跑同一套分批 DELETE。
|
||||
脚本的名字、退出码 3 的分区提示、README 的定位全都围绕遥测表写,它从未声称自己
|
||||
是通用清理器;把这条校验去掉等于在一个拿 DELETE 权限跑的脚本上开静默的口子。
|
||||
"""
|
||||
segments = value.split(".")
|
||||
if len(segments) != 2:
|
||||
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
|
||||
schema, name = segments
|
||||
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态
|
||||
if not schema or not name:
|
||||
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
|
||||
if not _PLAIN_IDENTIFIER.fullmatch(schema):
|
||||
parser.error(
|
||||
f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/"
|
||||
f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持,"
|
||||
"这种情形请不给 --table,退回 search_path 解析那条路径。"
|
||||
)
|
||||
if name != TABLE:
|
||||
parser.error(
|
||||
f"--table 的表名段必须逐字等于 {TABLE}:本脚本只清理遥测表 {TABLE},"
|
||||
f"不是通用清理器,当前: {value!r}"
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
|
||||
@@ -240,7 +292,9 @@ def _quote(identifier: str) -> str:
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
async def _run_postgres(
|
||||
dsn: str, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
|
||||
) -> int:
|
||||
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
|
||||
try:
|
||||
import asyncpg
|
||||
@@ -257,7 +311,7 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
|
||||
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
return await _purge_postgres(conn, cutoff, apply_, batch_size)
|
||||
return await _purge_postgres(conn, cutoff, apply_, batch_size, table_schema)
|
||||
except asyncpg.PostgresError as exc:
|
||||
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
@@ -265,23 +319,41 @@ async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: in
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
async def _purge_postgres(
|
||||
conn: Any, cutoff: datetime, apply_: bool, batch_size: int, table_schema: str | None
|
||||
) -> int:
|
||||
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
|
||||
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
|
||||
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
||||
# 给了 --table 就用引号限定名精确解析(绕开 search_path),否则维持裸表名解析——
|
||||
# 后者走连接自己的 search_path,故无论哪条路都必须把解析结果打出来:"我删的到底是
|
||||
# 哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
||||
lookup = TABLE if table_schema is None else f"{_quote(table_schema)}.{_quote(TABLE)}"
|
||||
target = await conn.fetchrow(
|
||||
"SELECT n.nspname AS schema, c.relname AS name, "
|
||||
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
|
||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.oid = to_regclass($1)",
|
||||
TABLE,
|
||||
lookup,
|
||||
)
|
||||
if target is None:
|
||||
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
||||
# 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
|
||||
# search_path 找不到则是连接配置的事。
|
||||
if table_schema is None:
|
||||
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
f"显式指定的表 {table_schema}.{TABLE} 不存在或当前角色不可见。"
|
||||
"注意: PG 中未加引号建的标识符在 catalog 里是小写。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_BACKEND
|
||||
schema, name = target["schema"], target["name"]
|
||||
qualified = f"{_quote(schema)}.{_quote(name)}"
|
||||
print(f"目标表: {schema}.{name}")
|
||||
if apply_ and table_schema is None:
|
||||
# 只在 --apply 时提示: dry-run 不可逆性为零,且它本就以"看清楚再决定"为用途。
|
||||
print(
|
||||
"注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。"
|
||||
)
|
||||
if target["partitioned"]:
|
||||
print(
|
||||
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
|
||||
@@ -347,7 +419,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
|
||||
if args.backend == "sqlite":
|
||||
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
|
||||
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
|
||||
return asyncio.run(
|
||||
_run_postgres(args.dsn, cutoff, args.apply, args.batch_size, args.table_schema)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user