Files
PolyGateway/research-wiki/designs/issue9-telemetry-ddl-probe.md
iomgaa 2e028d38f2 fix: probe for the telemetry table before creating it
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.
2026-08-07 11:21:33 -04:00

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 那条)

选定方案

两条,第二条才是治本的那条:

  1. 表存在就绝不发 DDL。探测走 to_regclass(不需要任何权限,且与 INSERT 走同一套 search_path 解析——裸 CREATE TABLE 落在首个可建的 schema,可能与写入命中的不是同一张表,故探测优先反而更准)。表不存在才建;新建表列已齐全,顺带跳过补列。
  2. "结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」:
情形 处置 理由
建池失败 永久 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 并失败