test: prove old telemetry tables gain the tenant column safely
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -285,6 +286,131 @@ class TestSQLiteColumnBackfill:
|
||||
recorder.close()
|
||||
|
||||
|
||||
# issue #11 之前的表形态: 22 个 recorder 字段 + created_at = 23 个物理列,没有任何租户维度
|
||||
_PRE_TENANT_DDL = """
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER
|
||||
);
|
||||
"""
|
||||
|
||||
_PRE_TENANT_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms) "
|
||||
"VALUES ('old-row', 'm', 'p', 's1', '[]', 'contract text', 1, 2, 'measured', 10)"
|
||||
)
|
||||
|
||||
|
||||
def _make_pre_tenant_db(path: Path) -> None:
|
||||
"""造一个 issue #11 之前的库: 22 字段旧表 + 一行没有租户归属的历史数据。"""
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute(_PRE_TENANT_DDL)
|
||||
conn.execute(_PRE_TENANT_INSERT)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestSQLiteCallerDimensionsAcceptance:
|
||||
"""issue #11 的机械化验收(SQLite 侧,真实临时文件): 新建库 / 旧表补列 / 补列失败方向。"""
|
||||
|
||||
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
|
||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||
db = tmp_path / "fresh.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
await _record_minimal(
|
||||
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
)
|
||||
recorder.close()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
|
||||
row = conn.execute(
|
||||
"SELECT tenant_id, meta FROM llm_calls WHERE call_id = 'c-dim'"
|
||||
).fetchone()
|
||||
assert row[0] == "tenant-a"
|
||||
assert json.loads(row[1]) == {"batch": "b7"}
|
||||
|
||||
async def test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable(self, tmp_path):
|
||||
"""22 字段旧表补列后,新行带维度,而**老行的 tenant_id 是空串而非 NULL**。
|
||||
|
||||
这条直接验收 issue #11 的核心论点(先启用落库、后加列,补列之前的行没有
|
||||
租户归属)。断言方向必须是空串: PG 的 RLS `USING` 表达式对返回 false **或
|
||||
NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",而是对
|
||||
所有人永久不可见的黑洞;哨兵空串则能被一条 `COUNT(*) WHERE tenant_id = ''`
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
db = tmp_path / "pre_tenant.db"
|
||||
_make_pre_tenant_db(db)
|
||||
|
||||
recorder = SQLiteRecorder(db)
|
||||
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
|
||||
recorder.close()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||
assert cols == _EXPECTED_COLUMNS # 22 → 24 个 recorder 字段(+ created_at 共 25 物理列)
|
||||
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
||||
assert rows["new-row"] == "tenant-a"
|
||||
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||
assert (
|
||||
conn.execute("SELECT meta FROM llm_calls WHERE call_id = 'old-row'").fetchone()[0]
|
||||
== "{}"
|
||||
)
|
||||
|
||||
async def test_readonly_file_backfill_failure_keeps_the_recorder_alive(self, tmp_path):
|
||||
"""补列失败的降级方向(SQLite 等价构造: 文件只读)。
|
||||
|
||||
SQLite 没有角色权限模型,与 PG「只有 SELECT/INSERT 权限的角色」等价的构造
|
||||
是文件本身只读。库文件必须**预先置为 WAL 且干净关闭**,否则 `__init__` 的
|
||||
`PRAGMA journal_mode=WAL` 会先撞上只读而让失败点跑到补列之前,测不到本用例
|
||||
要测的那条分支(实测: 非 WAL 库 chmod 444 后该 PRAGMA 报 readonly database)。
|
||||
只读库连 INSERT 都做不了,故这里**只断言**补列失败不清空 `_conn`、不抛出
|
||||
`__init__`(sqlite.py `_backfill_columns` 那条纪律),不断言"写入仍成功"。
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
pytest.skip("root 无视文件权限位,只读构造不成立")
|
||||
db = tmp_path / "readonly.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("PRAGMA journal_mode=WAL") # 预置 WAL: 让只读连接不必改日志模式
|
||||
conn.execute(_PRE_TENANT_DDL)
|
||||
conn.execute(_PRE_TENANT_INSERT)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db.chmod(0o444)
|
||||
|
||||
recorder = SQLiteRecorder(db) # 不得抛
|
||||
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
|
||||
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
|
||||
recorder.close()
|
||||
|
||||
db.chmod(0o644) # 还原,让 tmp_path 清理不受阻
|
||||
stale = sqlite3.connect(db)
|
||||
assert [r[1] for r in stale.execute("PRAGMA table_info(llm_calls)")] == (
|
||||
_EXPECTED_COLUMNS[:-2]
|
||||
) # 补列确实没成功,用例不是在只读库上空转
|
||||
|
||||
|
||||
class _FakePgConn:
|
||||
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user