PostgreSQL checks the schema CREATE privilege before the IF NOT EXISTS existence test, so an account with only table-level INSERT was denied on CREATE TABLE IF NOT EXISTS even though the table was right there and writable. The denial set _failed and the whole recorder went no-op for the process lifetime, silently: 150+ calls downstream lost their latency, token and cost rows with nothing but one warning to show for it. The probe is the direct fix. The larger fix is the criterion: structural degradation now means "provably cannot write" (pool creation failed, or the table is absent and cannot be created), not "something threw during init" -- a probe or acquire failure just skips the row and retries on the next call. SQLite stays as it is on purpose. Measured: it short-circuits the statement at parse time, so it passes even under another connection's EXCLUSIVE lock or on a read-only file. A probe there would buy nothing; the docstring now says so to keep symmetry-minded future edits away.
4.7 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| design | design:issue9-telemetry-ddl-probe | 建表前先探测,判死只认「确定写不进去」 | 2026-08-07 |
建表前先探测,判死只认「确定写不进去」
来源: Gitea issue #9(CHSAnalyzer3 现场)|范围: telemetry/postgres.py 单模块,无独立 plan(小改动自判)|相关: design:response-observability-fields(issue #3 修的是同一个坑的另一半)
问题
应用账号有表级 INSERT、表也已存在,但没有 schema 的 CREATE 权限时,_ensure_ready() 的 CREATE TABLE IF NOT EXISTS 被拒 → _failed = True → 整个进程遥测永久 no-op。业务调用一切正常,只留一行 warning,从外部完全看不出异常;下游 CHSAnalyzer3 首次端到端跑的 150+ 次调用数据因此全丢且无法补回。
根因
PostgreSQL 对 schema 的 CREATE 权限检查早于 IF NOT EXISTS 的存在性判断(RangeVarGetAndCheckCreationNamespace() 先 aclcheck 后查 relid)。这与 issue #3 里 ALTER TABLE 的 ownership 检查早于 IF NOT EXISTS 是同一类问题——当时只修了补列那一半,建表这一半原样留着,于是同一账号形态下"补列失败只丢一行日志接着干活,建表失败却把整个 recorder 判死"。
实测(PostgreSQL 16.14,临时角色只授 SELECT, INSERT ON llm_calls):
| 语句 | 结果 |
|---|---|
SELECT to_regclass('llm_calls') |
非 NULL(表就在那儿) |
CREATE TABLE IF NOT EXISTS llm_calls (...) |
被拒 InsufficientPrivilegeError: permission denied for schema |
INSERT INTO llm_calls ... |
通过 |
ALTER TABLE ... ADD COLUMN IF NOT EXISTS |
被拒 must be owner(即 issue #3 那条) |
选定方案
两条,第二条才是治本的那条:
- 表存在就绝不发 DDL。探测走
to_regclass(不需要任何权限,且与INSERT走同一套 search_path 解析——裸CREATE TABLE落在首个可建的 schema,可能与写入命中的不是同一张表,故探测优先反而更准)。表不存在才建;新建表列已齐全,顺带跳过补列。 - "结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」:
| 情形 | 处置 | 理由 |
|---|---|---|
| 建池失败 | 永久 no-op | 重试要在业务调用路径上内联吞掉 connect 超时 |
| 表存在 | 不发 DDL,只补列(失败仅 warning) | 本 issue 的直接修复 |
| 表不存在 → 建表成功 | 就绪,跳过补列 | 新建表列已齐 |
| 表不存在 → 建表失败 | 永久 no-op | 后续 INSERT 必然全败,重试无意义、日志纯噪音 |
| 探测/取连接失败 | 只跳过本条,下次调用重试 | 瞬时抖动,判死代价远大于多一次往返 |
SQLite 侧有意不对称:实测其对已存在的表在解析期就把 CREATE TABLE IF NOT EXISTS 短路掉——另一连接持 BEGIN EXCLUSIVE、或文件 chmod 444 时该语句均通过(同条件下 INSERT 与新表名建表分别报 database is locked / readonly database),既不抢写锁也不检查可写性。故 PG 侧的坑在此不存在,加探测零收益。需要对称的是保证(表存在就不该因建表失败而失能),不是代码;结论已钉进 sqlite.py 模块 docstring,防止后人为"对称"加回来。
被否决的备选
| 备选 | 否决理由 |
|---|---|
只加探测,_failed 语义不动(issue 原方案) |
治标。初始化瞬间的 DB 抖动、一次 pool.acquire 失败、search_path 配错仍会让整个进程永久失遥测——同一个开关,换个触发口 |
| 除建池外一律不判死 | 方向最统一,但表真的不存在时每次调用都发一条注定失败的 INSERT + 一条 warning(150 次调用 = 150 行噪音),而这种情形是可确定判定的,没必要留活路 |
捕获 InsufficientPrivilegeError 特判放行 |
按异常类型打补丁,漏一种错误码就复发;探测是把"该不该发这条 DDL"判断在前,与错误面无关 |
| SQLite 侧同步加探测 | 实测证明零收益,属为对称而对称的 gold-plating |
遗留
SQLite 的窄缝:表不存在 + 构造瞬间库被排他锁(多进程共库)→ __init__ 里的建表失败 → recorder 永久失能。修它要把 SQLite 也改成 lazy 重试结构,超出本 issue 范围,记此备查。
测试证据
- 单测
TestPostgresTableProbe(5 例,fake conn):表存在不发 DDL / DDL 被拒仍照常 INSERT 且_failed不置位 / 表缺失则建表且不补列 / 表缺失且建不出来才判死 / 探测失败下次重试。 - 集成
TestLeastPrivilegeDeployment(真实 PG,临时 schema + 临时角色,teardown 删净):先钉死"该角色确实建不了表"这条库外事实,再验两行记录照常落库。修复前该用例复现 issue 原文那行 warning 并失败。