diff --git a/src/polygateway/telemetry/postgres.py b/src/polygateway/telemetry/postgres.py index f26b223..101f240 100644 --- a/src/polygateway/telemetry/postgres.py +++ b/src/polygateway/telemetry/postgres.py @@ -183,9 +183,10 @@ class PostgresRecorder: 裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行 都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。 - 探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集会构造出 - `INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句(`insert_sql` 拦 - 不住——空集技术上是子集),必须在交给它之前拦下。 + 探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法 + INSERT,`insert_sql` 会 ValueError,而 `_prepare_schema` 里那次调用在 try + **之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡) + ——回落必须发生在把空列集交给它之前。 """ effective = tuple(column for column in COLUMNS if column in existing) if not effective: diff --git a/src/polygateway/telemetry/schema.py b/src/polygateway/telemetry/schema.py index a45c98e..dd8f34e 100644 --- a/src/polygateway/telemetry/schema.py +++ b/src/polygateway/telemetry/schema.py @@ -149,10 +149,12 @@ _COLUMN_SET = frozenset(COLUMNS) def insert_sql(backend: str, columns: Sequence[str]) -> str: - """按给定列构造 INSERT;列必须是 `COLUMNS` 的子集,否则 ValueError。 + """按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。 子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部 - 字符串拼进 SQL(占位符只保护值,保护不了列名)。sqlite 用 `?`、postgres 用 `$n`, + 字符串拼进 SQL(占位符只保护值,保护不了列名)。空集同样来自探测结果,而 + `INSERT INTO llm_calls () VALUES ()` 两端都语法非法——本函数自己拒,不把这个 + 不变量押在调用方身上。sqlite 用 `?`、postgres 用 `$n`, 两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)。 **PG 的 `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求 @@ -170,11 +172,13 @@ def insert_sql(backend: str, columns: Sequence[str]) -> str: 完整的 INSERT 语句。 Raises: - ValueError: backend 不在取值域内,或 columns 含 `COLUMNS` 之外的列名。 + ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名。 """ if backend not in _BACKENDS: raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}") selected = tuple(columns) + if not selected: + raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL") unknown = [column for column in selected if column not in _COLUMN_SET] if unknown: raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}") diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index 15f0b18..9b68ab8 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -87,9 +87,9 @@ class SQLiteRecorder: 裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行 都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。 - 探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集会构造出 - `INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句(`insert_sql` 拦 - 不住——空集技术上是子集),必须在交给它之前拦下。 + 探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法 + INSERT,`insert_sql` 会 ValueError,而遥测构造期抛异常就是把"初始化失败静默 + 降级"的铁律破成崩溃——回落必须发生在把空列集交给它之前。 """ effective = tuple(column for column in COLUMNS if column in existing) if not effective: diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index 48e7a95..a04fb0d 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -214,6 +214,19 @@ class TestSchemaModule: with pytest.raises(ValueError, match="mysql"): insert_sql("mysql", COLUMNS) + def test_insert_sql_rejects_an_empty_column_set(self): + """空列集合两端都拼出语法非法的 SQL,构造器自己拒,不押在调用方的不变量上。 + + 入参来自数据库探测结果: 探测到一张与本库毫无共同列的同名表,`effective` + 就是空的。真放行会产出 `INSERT OR IGNORE INTO llm_calls () VALUES ()`, + 错误要到执行时才由数据库报,离真因很远。 + """ + from polygateway.telemetry.schema import insert_sql + + for backend in ("sqlite", "postgres"): + with pytest.raises(ValueError, match="至少需要一列"): + insert_sql(backend, []) + def test_schema_sql_is_paste_ready_and_same_source(self): """打印给下游的脚本与库执行的 DDL 同源,且对人可重复执行。""" from polygateway.telemetry.schema import PG_BACKFILL, SQLITE_BACKFILL, telemetry_schema_sql @@ -628,8 +641,9 @@ class TestSQLiteSchemaMode: ): """探测结果与 COLUMNS 毫无交集视同探测异常: 保守回落全量列。 - 空列集会构造出 `INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句 - (`insert_sql` 不拒空列表——空集技术上是子集),故必须在交给它之前拦住。 + `insert_sql` 自己拒空列集合(见 `test_insert_sql_rejects_an_empty_column_set`), + 故这里回落不发生就不是"拼出空语句",而是 ValueError 逃出 `__init__` —— + 遥测初始化失败必须静默降级,崩溃比丢维度严重得多。 """ from polygateway.telemetry.schema import COLUMNS @@ -645,6 +659,30 @@ class TestSQLiteSchemaMode: recorder.close() assert captured_warnings # 沉默地退化成空语句是最坏结果,必须有声 + async def test_empty_probe_result_degrades_instead_of_raising( + self, tmp_path, captured_warnings + ): + """探测返回空集合时走回落,绝不让 `insert_sql` 的 ValueError 逃出去。 + + SQLite 建不出零列的表,故直接喂空探测结果调那条分支——它正是 + `insert_sql` 拒空之后唯一可能把"静默降级"变成崩溃的入口。 + """ + from polygateway.telemetry.schema import COLUMNS + + db = tmp_path / "empty_probe.db" + recorder = SQLiteRecorder(db, auto_migrate=False) + recorder._adopt_existing_columns(set()) # 不得抛 + assert recorder._columns == COLUMNS + await _record_minimal(recorder, call_id="c-after") # 写入照常 + recorder.close() + + conn = sqlite3.connect(db) + assert conn.execute( + "SELECT response FROM llm_calls WHERE call_id = 'c-after'" + ).fetchone() == ("ok",) + conn.close() + assert [m for m in captured_warnings if "没有任何本库认识的列" in m] # 只有 warning + async def test_auto_migrate_is_required_keyword_only(self, tmp_path): """关键行为参数不给默认值(P4): 缺省规则只写在 config 一处,不与类签名漂移。""" with pytest.raises(TypeError): @@ -877,6 +915,25 @@ class TestPostgresSchemaMode: assert [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")] assert recorder._columns == COLUMNS + async def test_no_recognizable_column_falls_back_to_the_full_column_set( + self, captured_warnings + ): + """PG 侧同款回落(SQLite 侧对称用例见 TestSQLiteSchemaMode)。 + + 表存在(`to_regclass` 非空)但列与 `COLUMNS` 毫无交集: 裁剪结果为空, + 必须回落全量而不是把空列集交给 `insert_sql`——`_prepare_schema` 里那次 + 调用在 try 之外,ValueError 会顺着 `record_llm_call` 冒给业务调用方。 + """ + from polygateway.telemetry.schema import COLUMNS + + conn = _FakePgConn(["foo", "bar"]) + recorder = self._recorder(conn, auto_migrate=False) + await _record_minimal(recorder) # 不得抛 + + assert recorder._columns == COLUMNS + assert not [s for s in conn.statements if s.startswith("ALTER TABLE")] + assert [m for m in captured_warnings if "没有任何本库认识的列" in m] + async def test_auto_mode_still_backfills(self): """auto 档现状回归: 缺列照补,补完写全量列。""" from polygateway.telemetry.schema import COLUMNS, PG_BACKFILL