遥测建表撞权限就把整个 recorder 判死:CREATE TABLE IF NOT EXISTS 的权限检查早于存在性检查 #9

Closed
opened 2026-08-07 22:21:49 +08:00 by iomgaa · 1 comment
Owner

症状

应用账号对表有 INSERT 权限、表也已经存在,遥测仍然整个降级成 no-op,只留一行 warning:

polygateway.telemetry.postgres:_ensure_ready:136 -
Postgres 遥测初始化失败,后续记录降级为 no-op: permission denied for schema public

之后这个进程里所有调用记录一条都不落库。业务调用一切正常,所以从外面完全看不出异常——
等到要用这批数据做分析时才发现整段历史是空的,那时候补不回来了。

复现

一个只被授予表级写权限、没有 schema 建表权的账号:

-- 表已经存在,由别的账号建的
GRANT SELECT, INSERT ON llm_calls TO app_user;
-- 但没有 GRANT CREATE ON SCHEMA public TO app_user;

实测(PostgreSQL 16,chs3_test 账号):

能不能读 llm_calls: True
能不能往 llm_calls 写: True
llm_calls 现在有多少行: 11
CREATE TABLE IF NOT EXISTS:被拒 — permission denied for schema public

根因

telemetry/postgres.py_ensure_ready() 无条件执行建表 DDL:

async with self._pool.acquire() as conn:
    await conn.execute(_DDL)          # ← CREATE TABLE IF NOT EXISTS ...
    await self._backfill_columns(conn)

PostgreSQL 检查 schema 的 CREATE 权限早于 IF NOT EXISTS 的存在性判断
所以表明明就在那儿、账号也明明写得进去,这一句照样被拒。异常落进外层的
except Exceptionself._failed = True,recorder 永久 no-op。

这个坑库里已经修过一次,只是没修在这一行

紧接着被调用的 _backfill_columns() 处理的是同一类问题,它的 docstring 写得很清楚:

① 不置 _failed: 应用账号只有 INSERT 权限时,ALTER TABLE 的 ownership
检查早于 IF NOT EXISTS 的存在性判断——列明明齐全也会失败。置位会让
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
② 先探测: ADD COLUMN IF NOT EXISTS 即便列已存在,也会先取 ACCESS EXCLUSIVE 锁

CREATE TABLE 这一行需要的正是同样这两条,而它两条都没有。所以现在的行为是:
补列失败只丢一行日志接着干活,建表失败却把整个 recorder 判死——
而两者失败的原因、失败的账号、失败的机理完全一样。

建议的修法

_backfill_columns 对称:先探测表在不在,在就跳过建表。

# to_regclass 尊重 search_path,和 _EXISTING_COLUMNS 那条一致
exists = await conn.fetchval("SELECT to_regclass('llm_calls')") is not None
if not exists:
    await conn.execute(_DDL)

顺带一条:即便建表真的失败了(表不存在、也建不出来),是不是也该像补列那样只
warning 而不置 _failed?那种情况下后续 INSERT 反正会逐行失败,行为一样,
但少一个「一次初始化失败就永久关掉」的开关。这一条不确定,看你怎么权衡。

SQLite 那一侧同款:sqlite.py 也是构造期无条件建表。它一般不会撞上权限问题
(文件权限是另一回事),但两侧守卫按你们的规矩要对称。

影响面

任何按最小权限授权的部署都会中招,而且中招的方式是静默的——这正是最难发现的那一类。
我们这边(CHSAnalyzer3)第一次端到端真跑,150 多次模型调用的耗时、token、成本全丢了,
是翻日志才发现的。

环境:polygateway 1.1.1,PostgreSQL 16,asyncpg。

## 症状 应用账号对表有 `INSERT` 权限、表也已经存在,遥测仍然整个降级成 no-op,只留一行 warning: ``` polygateway.telemetry.postgres:_ensure_ready:136 - Postgres 遥测初始化失败,后续记录降级为 no-op: permission denied for schema public ``` 之后这个进程里所有调用记录一条都不落库。业务调用一切正常,所以从外面完全看不出异常—— 等到要用这批数据做分析时才发现整段历史是空的,那时候补不回来了。 ## 复现 一个只被授予表级写权限、没有 schema 建表权的账号: ```sql -- 表已经存在,由别的账号建的 GRANT SELECT, INSERT ON llm_calls TO app_user; -- 但没有 GRANT CREATE ON SCHEMA public TO app_user; ``` 实测(PostgreSQL 16,`chs3_test` 账号): ``` 能不能读 llm_calls: True 能不能往 llm_calls 写: True llm_calls 现在有多少行: 11 CREATE TABLE IF NOT EXISTS:被拒 — permission denied for schema public ``` ## 根因 `telemetry/postgres.py` 的 `_ensure_ready()` 无条件执行建表 DDL: ```python async with self._pool.acquire() as conn: await conn.execute(_DDL) # ← CREATE TABLE IF NOT EXISTS ... await self._backfill_columns(conn) ``` **PostgreSQL 检查 schema 的 `CREATE` 权限早于 `IF NOT EXISTS` 的存在性判断**, 所以表明明就在那儿、账号也明明写得进去,这一句照样被拒。异常落进外层的 `except Exception`,`self._failed = True`,recorder 永久 no-op。 ## 这个坑库里已经修过一次,只是没修在这一行 紧接着被调用的 `_backfill_columns()` 处理的是**同一类问题**,它的 docstring 写得很清楚: > ① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership > 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让 > 整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖 > ② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会先取 ACCESS EXCLUSIVE 锁 `CREATE TABLE` 这一行需要的正是同样这两条,而它两条都没有。所以现在的行为是: **补列失败只丢一行日志接着干活,建表失败却把整个 recorder 判死**—— 而两者失败的原因、失败的账号、失败的机理完全一样。 ## 建议的修法 和 `_backfill_columns` 对称:先探测表在不在,在就跳过建表。 ```python # to_regclass 尊重 search_path,和 _EXISTING_COLUMNS 那条一致 exists = await conn.fetchval("SELECT to_regclass('llm_calls')") is not None if not exists: await conn.execute(_DDL) ``` 顺带一条:即便建表真的失败了(表不存在、也建不出来),是不是也该像补列那样只 warning 而不置 `_failed`?那种情况下后续 INSERT 反正会逐行失败,行为一样, 但少一个「一次初始化失败就永久关掉」的开关。这一条不确定,看你怎么权衡。 SQLite 那一侧同款:`sqlite.py` 也是构造期无条件建表。它一般不会撞上权限问题 (文件权限是另一回事),但两侧守卫按你们的规矩要对称。 ## 影响面 任何按最小权限授权的部署都会中招,而且中招的方式是静默的——这正是最难发现的那一类。 我们这边(CHSAnalyzer3)第一次端到端真跑,150 多次模型调用的耗时、token、成本全丢了, 是翻日志才发现的。 环境:polygateway 1.1.1,PostgreSQL 16,asyncpg。
Author
Owner

成立,已在 1.1.2 修复并发布。感谢报告——尤其是把 _backfill_columns 那段 docstring 翻出来对照,那正是同一个坑修了一半的证据。

复现确认

在实验室实例上按你的场景造了临时角色(只授 SELECT, INSERT ON llm_calls,跑完删净),PostgreSQL 16.14 实测:

语句 结果
SELECT to_regclass('llm_calls') 非 NULL
INSERT INTO llm_calls ... 通过
CREATE TABLE IF NOT EXISTS llm_calls (...) 被拒 — permission denied for schema
ALTER TABLE ... ADD COLUMN IF NOT EXISTS 被拒 — must be owner(即 issue #3 那条)

机理与你判断的一致:建表走 RangeVarGetAndCheckCreationNamespace(),schema 的 aclcheck 在查 relid 之前且无条件执行。

采纳了探测,但判死那条走得比建议更远

你提的 to_regclass 探测已按原样采纳——补充一个当时没提到的好处:它与 INSERT 走同一套 search_path 解析,而裸 CREATE TABLE 落在首个可建的 schema,两者可能不是同一张表。所以探测优先不只是绕权限,口径也更准。

你末尾那条"是不是也该像补列那样只 warning"我没有直接照做,而是换了个判据:判死只认「确定写不进去」,不认「初始化时出过错」

情形 处置
建池失败 永久 no-op(重试要在业务调用路径上内联吞掉 connect 超时)
表存在 不发 DDL,只补列(失败仅 warning)
表不存在 → 建表成功 就绪,跳过补列(新表列已齐)
表不存在 → 建表失败 永久 no-op(后续 INSERT 必然全败,重试无意义、日志纯噪音)
探测 / 取连接失败 只跳过本条并 warning,下次调用重新准备

理由是:只加探测的话,那个"一次异常永久关掉"的开关还在,只是换了触发口——初始化瞬间的 DB 抖动、一次 pool.acquire 失败、search_path 配错,仍然会让整个进程静默失遥测。而全都不判死的话,表真的不存在时每次调用都要发一条注定失败的 INSERT 加一条 warning,而这种情形是可以确定判定的。

SQLite 侧:实测后有意不对称

按你说的"两侧守卫要对称"去测了,结论是不该加:SQLite 对已存在的表在解析期就把 CREATE TABLE IF NOT EXISTS 短路掉,既不抢写锁也不检查可写性——另一连接持 BEGIN EXCLUSIVE、或文件 chmod 444 时该语句均通过(同条件下 INSERT 与新表名建表分别报 database is locked / readonly database)。所以 PG 那个坑在这边不存在,加探测零收益。需要对称的是保证(表存在就不该因建表失败而失能),不是代码;实测结论已钉进 sqlite.py 的模块 docstring,防止后来的人为对称加回来。

遗留一条窄缝备查:表不存在 + 构造瞬间库被排他锁(多进程共库)→ SQLite 侧仍会失能。修它要把 SQLite 也改成 lazy 重试结构,不在本 issue 范围内。

测试

  • 单测 5 例(表存在不发 DDL / DDL 被拒仍照常 INSERT 且不置位 / 表缺失则建表且不补列 / 表缺失且建不出来才判死 / 探测失败下次重试)。
  • 集成 2 例走真实 PG 的临时 schema + 临时角色:先钉死"该角色确实建不了表"这条库外事实(PG 语义哪天变了这里先红),再验记录照常落库。回退实现后该用例复现了你贴的那行 warning 并失败

升级

pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
    "polygateway[redis,postgres,structured]==1.1.*"

已上传 registry 并 pip download 解包验证过。若之前为绕开本问题给应用账号授了 CREATE ON SCHEMA,现在可以收回——表存在时库不再需要该权限。日志措辞也细分了:建池失败 / 建表探测失败(跳过本条,下次重试) / 建表失败(表不存在,记录无处可落)

决策全文见 research-wiki/designs/issue9-telemetry-ddl-probe.md(含被否决的四个备选及理由)。

成立,已在 **1.1.2** 修复并发布。感谢报告——尤其是把 `_backfill_columns` 那段 docstring 翻出来对照,那正是同一个坑修了一半的证据。 ## 复现确认 在实验室实例上按你的场景造了临时角色(只授 `SELECT, INSERT ON llm_calls`,跑完删净),PostgreSQL **16.14** 实测: | 语句 | 结果 | |---|---| | `SELECT to_regclass('llm_calls')` | 非 NULL | | `INSERT INTO llm_calls ...` | 通过 | | `CREATE TABLE IF NOT EXISTS llm_calls (...)` | **被拒 — permission denied for schema** | | `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | 被拒 — must be owner(即 issue #3 那条) | 机理与你判断的一致:建表走 `RangeVarGetAndCheckCreationNamespace()`,schema 的 aclcheck 在查 relid 之前且无条件执行。 ## 采纳了探测,但判死那条走得比建议更远 你提的 `to_regclass` 探测已按原样采纳——补充一个当时没提到的好处:它与 `INSERT` 走同一套 search_path 解析,而裸 `CREATE TABLE` 落在首个**可建**的 schema,两者可能不是同一张表。所以探测优先不只是绕权限,口径也更准。 你末尾那条"是不是也该像补列那样只 warning"我没有直接照做,而是换了个判据:**判死只认「确定写不进去」,不认「初始化时出过错」**。 | 情形 | 处置 | |---|---| | 建池失败 | 永久 no-op(重试要在业务调用路径上内联吞掉 connect 超时) | | 表存在 | 不发 DDL,只补列(失败仅 warning) | | 表不存在 → 建表成功 | 就绪,跳过补列(新表列已齐) | | 表不存在 → 建表失败 | 永久 no-op(后续 INSERT 必然全败,重试无意义、日志纯噪音) | | 探测 / 取连接失败 | 只跳过本条并 warning,**下次调用重新准备** | 理由是:只加探测的话,那个"一次异常永久关掉"的开关还在,只是换了触发口——初始化瞬间的 DB 抖动、一次 `pool.acquire` 失败、search_path 配错,仍然会让整个进程静默失遥测。而全都不判死的话,表真的不存在时每次调用都要发一条注定失败的 INSERT 加一条 warning,而这种情形是可以确定判定的。 ## SQLite 侧:实测后有意不对称 按你说的"两侧守卫要对称"去测了,结论是**不该加**:SQLite 对已存在的表在**解析期**就把 `CREATE TABLE IF NOT EXISTS` 短路掉,既不抢写锁也不检查可写性——另一连接持 `BEGIN EXCLUSIVE`、或文件 `chmod 444` 时该语句均通过(同条件下 `INSERT` 与新表名建表分别报 database is locked / readonly database)。所以 PG 那个坑在这边不存在,加探测零收益。需要对称的是**保证**(表存在就不该因建表失败而失能),不是代码;实测结论已钉进 `sqlite.py` 的模块 docstring,防止后来的人为对称加回来。 遗留一条窄缝备查:表不存在 + 构造瞬间库被排他锁(多进程共库)→ SQLite 侧仍会失能。修它要把 SQLite 也改成 lazy 重试结构,不在本 issue 范围内。 ## 测试 - 单测 5 例(表存在不发 DDL / DDL 被拒仍照常 INSERT 且不置位 / 表缺失则建表且不补列 / 表缺失且建不出来才判死 / 探测失败下次重试)。 - 集成 2 例走真实 PG 的临时 schema + 临时角色:先钉死"该角色确实建不了表"这条库外事实(PG 语义哪天变了这里先红),再验记录照常落库。**回退实现后该用例复现了你贴的那行 warning 并失败**。 ## 升级 ```bash pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ "polygateway[redis,postgres,structured]==1.1.*" ``` 已上传 registry 并 `pip download` 解包验证过。若之前为绕开本问题给应用账号授了 `CREATE ON SCHEMA`,现在可以收回——表存在时库不再需要该权限。日志措辞也细分了:`建池失败` / `建表探测失败(跳过本条,下次重试)` / `建表失败(表不存在,记录无处可落)`。 决策全文见 `research-wiki/designs/issue9-telemetry-ddl-probe.md`(含被否决的四个备选及理由)。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#9