Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec9ec7056 | |||
| 5255f68900 | |||
| 58c4af28ea | |||
| bc0fcc4719 | |||
| ea9e5062e8 | |||
| c8746b1ca1 | |||
| 503c06327e | |||
| 064f22a0a0 | |||
| ea791c9f30 | |||
| 965938230a |
@@ -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:
|
||||
# 两条路的诊断方向不同,消息分开写: 显式指定找不到多半是名字/大小写写错了,
|
||||
# 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