feat: gate the automatic ALTER behind an explicit mode
两个 recorder 的 `__init__` 增 keyword-only 必填 `auto_migrate`(设计 D-c: 缺省规则只写在 config 一处,不与类签名漂移),并把写入语句从模块级常量改为 实例级: manual 档探测到旧表缺列时一条 ALTER 都不发,改按现有列裁剪 INSERT, 准备期发一次 warning(逐列点名 + "以下维度不会被记录" + 可直接执行的补列 SQL)。 裁剪是关掉 ALTER 的前提而非增强: 旧表缺列时若既不 ALTER 又不裁剪,每一行 INSERT 都撞 `no column named tenant_id` 被整行丢弃,比自动 ALTER 更严重地 违反"遥测必录"。auto 档行为逐字不变(先探测后 ALTER、duplicate column 视为 成功、失败只 warning 不判死、写入沿用全量列)。 探测失败、或探测结果与 COLUMNS 毫无交集,两档都保守回落全量列——空列集会让 `insert_sql` 产出 `INSERT INTO llm_calls () VALUES ()`(它不拒空列表,空集 技术上是子集)。PG 侧 `_columns`/`_insert` 与 `_schema_ready` 在同一处一起 赋值,不留"已就绪但语句还是旧的"窗口。 同批改 `GatewaySettings.telemetry_auto_migrate`(按后端派生: PG False、 SQLite True)与 `client._build_telemetry` 透传: 签名变更与其唯一调用点必须 落在同一次提交,否则该提交点整条装配路 TypeError。env 键留给下一步。
This commit is contained in:
@@ -400,11 +400,15 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
|||||||
from polygateway.telemetry.postgres import PostgresRecorder
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
return PostgresRecorder(
|
||||||
|
settings.telemetry_pg_dsn, auto_migrate=settings.telemetry_auto_migrate
|
||||||
|
)
|
||||||
from polygateway.telemetry.sqlite import SQLiteRecorder
|
from polygateway.telemetry.sqlite import SQLiteRecorder
|
||||||
|
|
||||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
return SQLiteRecorder(
|
||||||
|
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_structured(
|
def _build_structured(
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ class GatewaySettings:
|
|||||||
telemetry_backend: str
|
telemetry_backend: str
|
||||||
telemetry_sqlite_path: str | None
|
telemetry_sqlite_path: str | None
|
||||||
telemetry_pg_dsn: str | None
|
telemetry_pg_dsn: str | None
|
||||||
|
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);
|
||||||
|
# 派生规则只写在 `_load_pgw` 一处,不与 recorder 的类签名漂移
|
||||||
|
telemetry_auto_migrate: bool
|
||||||
redis_url: str | None
|
redis_url: str | None
|
||||||
pricing_path: str | None
|
pricing_path: str | None
|
||||||
structured_max_retries: int
|
structured_max_retries: int
|
||||||
@@ -444,6 +447,11 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
|||||||
if telemetry_backend == "sqlite"
|
if telemetry_backend == "sqlite"
|
||||||
else None,
|
else None,
|
||||||
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
|
||||||
|
# 按后端不对称派生(issue #13): SQLite 是下游自己的本地文件(无 DBA、无迁移
|
||||||
|
# 工具),补列是毫秒级元数据操作;PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE
|
||||||
|
# 锁会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。
|
||||||
|
# backend=none 时无 recorder 消费该值,派生结果恒 False。
|
||||||
|
"telemetry_auto_migrate": telemetry_backend == "sqlite",
|
||||||
"redis_url": redis_url,
|
"redis_url": redis_url,
|
||||||
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
||||||
"structured_max_retries": _load_structured_retries(env),
|
"structured_max_retries": _load_structured_retries(env),
|
||||||
|
|||||||
@@ -35,13 +35,60 @@ _EXISTING_COLUMNS = (
|
|||||||
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
|
||||||
)
|
)
|
||||||
|
|
||||||
_INSERT = insert_sql("postgres", COLUMNS)
|
# 缺列 warning 要打印可直接执行的补列语句,与库内 ALTER 同源(不许两份)
|
||||||
|
_BACKFILL_STATEMENTS = dict(PG_BACKFILL)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_columns_message(missing: list[str], *, alien_table: bool) -> str:
|
||||||
|
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
|
||||||
|
|
||||||
|
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
|
||||||
|
看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
missing: 缺失的列名(按 `COLUMNS` 保序)。
|
||||||
|
alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
单条 warning 的完整文本(库只在准备期发一次,不逐行发)。
|
||||||
|
"""
|
||||||
|
statements = [
|
||||||
|
f"{_BACKFILL_STATEMENTS[column]};" for column in missing if column in _BACKFILL_STATEMENTS
|
||||||
|
]
|
||||||
|
unknown = [column for column in missing if column not in _BACKFILL_STATEMENTS]
|
||||||
|
if unknown:
|
||||||
|
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
|
||||||
|
statements.append(
|
||||||
|
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
|
||||||
|
'polygateway.telemetry_schema_sql("postgres")'
|
||||||
|
)
|
||||||
|
head = (
|
||||||
|
"Postgres 遥测表 llm_calls 缺主键列 call_id,很可能不是本库的遥测表"
|
||||||
|
"(库不做二次判定,仍照常尝试写入)"
|
||||||
|
if alien_table
|
||||||
|
else "Postgres 遥测表 llm_calls 缺列,且 auto_migrate=False(库不发任何 DDL)"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{head};以下维度不会被记录: {', '.join(missing)}。"
|
||||||
|
"补列请自行执行(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁):\n" + "\n".join(statements)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PostgresRecorder:
|
class PostgresRecorder:
|
||||||
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
|
||||||
|
|
||||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None) -> None:
|
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None:
|
||||||
|
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dsn: asyncpg 连接串(已剥驱动后缀)。
|
||||||
|
pool: 外部注入的池;注入方自己负责关闭。
|
||||||
|
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
|
||||||
|
则一条 ALTER 都不发——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE
|
||||||
|
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
||||||
|
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
|
||||||
|
签名漂移(设计 D-c)。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
@@ -51,6 +98,10 @@ class PostgresRecorder:
|
|||||||
self._dsn = dsn
|
self._dsn = dsn
|
||||||
self._pool: asyncpg.Pool | None = pool
|
self._pool: asyncpg.Pool | None = pool
|
||||||
self._external_pool = pool is not None
|
self._external_pool = pool is not None
|
||||||
|
self._auto_migrate = auto_migrate
|
||||||
|
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
|
||||||
|
self._columns: tuple[str, ...] = COLUMNS
|
||||||
|
self._insert = insert_sql("postgres", COLUMNS)
|
||||||
self._schema_ready = False
|
self._schema_ready = False
|
||||||
self._failed = False # 结构性降级标志: 置位后所有写入短路
|
self._failed = False # 结构性降级标志: 置位后所有写入短路
|
||||||
self._init_lock = asyncio.Lock()
|
self._init_lock = asyncio.Lock()
|
||||||
@@ -93,7 +144,7 @@ class PostgresRecorder:
|
|||||||
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
|
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
writable = await self._prepare_table(conn)
|
columns = await self._prepare_table(conn)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -101,14 +152,20 @@ class PostgresRecorder:
|
|||||||
# 只跳过本次记录,下次调用重新准备
|
# 只跳过本次记录,下次调用重新准备
|
||||||
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
|
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
|
||||||
return None
|
return None
|
||||||
if not writable:
|
if columns is None:
|
||||||
self._failed = True
|
self._failed = True
|
||||||
return None
|
return None
|
||||||
|
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||||
|
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||||
|
self._columns = columns
|
||||||
|
self._insert = insert_sql("postgres", columns)
|
||||||
self._schema_ready = True
|
self._schema_ready = True
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
async def _prepare_table(self, conn: object) -> bool:
|
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||||
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
|
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||||
|
|
||||||
|
返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。
|
||||||
|
|
||||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||||
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
||||||
@@ -121,32 +178,74 @@ class PostgresRecorder:
|
|||||||
"""
|
"""
|
||||||
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
|
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
|
||||||
if exists:
|
if exists:
|
||||||
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
|
return await self._resolve_columns(conn) # 旧表可能缺列
|
||||||
return True
|
|
||||||
try:
|
try:
|
||||||
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
||||||
return False
|
return None
|
||||||
return True # 新建表列已齐全,无需再走补列
|
return COLUMNS # 新建表列已齐全,无需再走补列
|
||||||
|
|
||||||
async def _backfill_columns(self, conn: object) -> None:
|
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
|
||||||
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
|
||||||
|
|
||||||
两条纪律各有实测理由:
|
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
|
||||||
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
**先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
|
||||||
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||||
(SQLite 侧同款守卫,两侧必须对称)。
|
|
||||||
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||||
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
|
||||||
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
|
||||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||||
|
return COLUMNS
|
||||||
|
if self._auto_migrate:
|
||||||
|
await self._backfill_columns(conn, existing)
|
||||||
|
return COLUMNS
|
||||||
|
return self._trim_columns(existing)
|
||||||
|
|
||||||
|
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
|
||||||
|
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
|
||||||
|
|
||||||
|
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||||
|
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||||
|
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集会构造出
|
||||||
|
`INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句(`insert_sql` 拦
|
||||||
|
不住——空集技术上是子集),必须在交给它之前拦下。
|
||||||
|
"""
|
||||||
|
effective = tuple(column for column in COLUMNS if column in existing)
|
||||||
|
if not effective:
|
||||||
|
logger.warning(
|
||||||
|
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||||
|
"现有列: {}",
|
||||||
|
sorted(existing),
|
||||||
|
)
|
||||||
|
return COLUMNS
|
||||||
|
missing = [column for column in COLUMNS if column not in existing]
|
||||||
|
if missing:
|
||||||
|
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
|
||||||
|
logger.warning(
|
||||||
|
"{}", _missing_columns_message(missing, alien_table="call_id" not in existing)
|
||||||
|
)
|
||||||
|
return effective
|
||||||
|
|
||||||
|
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
||||||
|
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不置 `_failed`**。
|
||||||
|
|
||||||
|
不置 `_failed` 的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
||||||
|
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||||
|
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||||
|
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
||||||
|
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
||||||
|
请显式选 manual。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
for column, statement in PG_BACKFILL:
|
for column, statement in PG_BACKFILL:
|
||||||
if column not in existing:
|
if column not in existing:
|
||||||
await conn.execute(statement) # type: ignore[attr-defined]
|
await conn.execute(statement) # type: ignore[attr-defined]
|
||||||
@@ -156,14 +255,18 @@ class PostgresRecorder:
|
|||||||
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。
|
||||||
|
|
||||||
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
|
"""
|
||||||
pool = await self._ensure_ready()
|
pool = await self._ensure_ready()
|
||||||
if pool is None:
|
if pool is None:
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in COLUMNS)
|
row = tuple(fields[col] for col in self._columns)
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute(_INSERT, *row)
|
await conn.execute(self._insert, *row)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -24,15 +24,65 @@ from loguru import logger
|
|||||||
|
|
||||||
from polygateway.telemetry.schema import COLUMNS, SQLITE_BACKFILL, SQLITE_DDL, insert_sql
|
from polygateway.telemetry.schema import COLUMNS, SQLITE_BACKFILL, SQLITE_DDL, insert_sql
|
||||||
|
|
||||||
_INSERT = insert_sql("sqlite", COLUMNS)
|
# 缺列 warning 要打印可直接执行的补列语句,列定义与库内 ALTER 同源(不许两份)
|
||||||
|
_BACKFILL_DECLS = dict(SQLITE_BACKFILL)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_columns_message(missing: list[str], *, alien_table: bool) -> str:
|
||||||
|
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
|
||||||
|
|
||||||
|
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
|
||||||
|
看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
missing: 缺失的列名(按 `COLUMNS` 保序)。
|
||||||
|
alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
单条 warning 的完整文本(库只在准备期发一次,不逐行发)。
|
||||||
|
"""
|
||||||
|
statements = [
|
||||||
|
f"ALTER TABLE llm_calls ADD COLUMN {column} {_BACKFILL_DECLS[column]};"
|
||||||
|
for column in missing
|
||||||
|
if column in _BACKFILL_DECLS
|
||||||
|
]
|
||||||
|
unknown = [column for column in missing if column not in _BACKFILL_DECLS]
|
||||||
|
if unknown:
|
||||||
|
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
|
||||||
|
statements.append(
|
||||||
|
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
|
||||||
|
'polygateway.telemetry_schema_sql("sqlite")'
|
||||||
|
)
|
||||||
|
head = (
|
||||||
|
"SQLite 遥测表 llm_calls 缺主键列 call_id,很可能不是本库的遥测表"
|
||||||
|
"(库不做二次判定,仍照常尝试写入)"
|
||||||
|
if alien_table
|
||||||
|
else "SQLite 遥测表 llm_calls 缺列,且 auto_migrate=False(库不发任何 DDL)"
|
||||||
|
)
|
||||||
|
return f"{head};以下维度不会被记录: {', '.join(missing)}。补列请自行执行:\n" + "\n".join(
|
||||||
|
statements
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SQLiteRecorder:
|
class SQLiteRecorder:
|
||||||
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
|
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
|
||||||
|
|
||||||
def __init__(self, db_path: Path | str) -> None:
|
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None:
|
||||||
|
"""建连接与表,并按探测到的列定型本实例的 INSERT 语句。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: 库文件路径;父目录不存在会自动创建。
|
||||||
|
auto_migrate: True 则给已存在的旧表自动补列(SQLite 侧的缺省档:
|
||||||
|
下游本地文件,无 DBA 无迁移工具);False 则一条 ALTER 都不发,
|
||||||
|
改为按现有列裁剪写入。keyword-only **必填**: 缺省规则只写在
|
||||||
|
config 一处,不与本类签名漂移(设计 D-c)。
|
||||||
|
"""
|
||||||
|
self._auto_migrate = auto_migrate
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._conn: sqlite3.Connection | None = None
|
self._conn: sqlite3.Connection | None = None
|
||||||
|
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
||||||
|
self._columns: tuple[str, ...] = COLUMNS
|
||||||
|
self._insert = insert_sql("sqlite", COLUMNS)
|
||||||
try:
|
try:
|
||||||
path = Path(db_path)
|
path = Path(db_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -44,23 +94,61 @@ class SQLiteRecorder:
|
|||||||
self._conn = conn
|
self._conn = conn
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||||
self._backfill_columns()
|
self._prepare_columns()
|
||||||
|
|
||||||
def _backfill_columns(self) -> None:
|
def _prepare_columns(self) -> None:
|
||||||
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
||||||
|
|
||||||
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||||
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
无守卫的探测会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||||
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||||
比逐行丢弃严重得多。
|
|
||||||
"""
|
"""
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||||
return
|
return
|
||||||
|
if self._auto_migrate:
|
||||||
|
self._backfill_columns(existing)
|
||||||
|
return
|
||||||
|
self._adopt_existing_columns(existing)
|
||||||
|
|
||||||
|
def _adopt_existing_columns(self, existing: set[str]) -> None:
|
||||||
|
"""manual 档: 不发任何 DDL,按现有列裁剪 INSERT,并把缺列一次讲清楚。
|
||||||
|
|
||||||
|
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||||
|
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||||
|
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集会构造出
|
||||||
|
`INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句(`insert_sql` 拦
|
||||||
|
不住——空集技术上是子集),必须在交给它之前拦下。
|
||||||
|
"""
|
||||||
|
effective = tuple(column for column in COLUMNS if column in existing)
|
||||||
|
if not effective:
|
||||||
|
logger.warning(
|
||||||
|
"SQLite 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||||
|
"现有列: {}",
|
||||||
|
sorted(existing),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._columns = effective
|
||||||
|
self._insert = insert_sql("sqlite", effective)
|
||||||
|
missing = [column for column in COLUMNS if column not in existing]
|
||||||
|
if missing:
|
||||||
|
# 单参数传入: 补列 SQL 里带 `'{}'` 字面量,拼进 format 模板会被当占位符
|
||||||
|
logger.warning(
|
||||||
|
"{}", _missing_columns_message(missing, alien_table="call_id" not in existing)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _backfill_columns(self, existing: set[str]) -> None:
|
||||||
|
"""auto 档: 给已存在的旧表补新列(issue #3);逐列独立 try,失败只降级为逐行丢弃。
|
||||||
|
|
||||||
|
补列失败绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||||
|
比逐行丢弃严重得多。失败后写入沿用全量列(今天的行为): auto 档承诺的是
|
||||||
|
"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||||
|
"""
|
||||||
|
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||||
for column, decl in SQLITE_BACKFILL:
|
for column, decl in SQLITE_BACKFILL:
|
||||||
if column in existing:
|
if column in existing:
|
||||||
continue
|
continue
|
||||||
@@ -74,10 +162,14 @@ class SQLiteRecorder:
|
|||||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。"""
|
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
||||||
|
|
||||||
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
|
"""
|
||||||
if self._conn is None:
|
if self._conn is None:
|
||||||
return
|
return
|
||||||
row = tuple(fields[col] for col in COLUMNS)
|
row = tuple(fields[col] for col in self._columns)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self._write, row)
|
await asyncio.to_thread(self._write, row)
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
@@ -86,7 +178,7 @@ class SQLiteRecorder:
|
|||||||
def _write(self, row: tuple) -> None:
|
def _write(self, row: tuple) -> None:
|
||||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._conn.execute(_INSERT, row)
|
self._conn.execute(self._insert, row)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class TestBreakerRecoveryFullChain:
|
|||||||
|
|
||||||
class TestCancellationThroughStack:
|
class TestCancellationThroughStack:
|
||||||
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
|
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
entered = asyncio.Event()
|
entered = asyncio.Event()
|
||||||
|
|
||||||
async def hanging_handler(request):
|
async def hanging_handler(request):
|
||||||
@@ -144,7 +144,7 @@ class TestCancellationThroughStack:
|
|||||||
|
|
||||||
class TestTelemetryAcrossPaths:
|
class TestTelemetryAcrossPaths:
|
||||||
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
|
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
|
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
|
||||||
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
|
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
|
||||||
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
|
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
|
||||||
@@ -155,7 +155,7 @@ class TestTelemetryAcrossPaths:
|
|||||||
assert hits == 1 and total == 2
|
assert hits == 1 and total == 2
|
||||||
|
|
||||||
async def test_transient_attempts_each_recorded(self, tmp_path):
|
async def test_transient_attempts_each_recorded(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|
||||||
def flaky(request):
|
def flaky(request):
|
||||||
@@ -193,7 +193,7 @@ class TestRejectionReasonIsQueryable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
client = _full_client(
|
client = _full_client(
|
||||||
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
||||||
)
|
)
|
||||||
@@ -257,7 +257,7 @@ class TestSamplingThroughStack:
|
|||||||
return _sse()
|
return _sse()
|
||||||
|
|
||||||
db = tmp_path / "t.db"
|
db = tmp_path / "t.db"
|
||||||
recorder = SQLiteRecorder(db)
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
client = _full_client(handler, telemetry=recorder)
|
client = _full_client(handler, telemetry=recorder)
|
||||||
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
||||||
recorder.close()
|
recorder.close()
|
||||||
@@ -276,7 +276,7 @@ class TestSamplingThroughStack:
|
|||||||
|
|
||||||
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
|
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
|
||||||
db = tmp_path / "t.db"
|
db = tmp_path / "t.db"
|
||||||
recorder = SQLiteRecorder(db)
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
client = GatewayClient(
|
client = GatewayClient(
|
||||||
scope="llm",
|
scope="llm",
|
||||||
sources=[src],
|
sources=[src],
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class TestObservabilityColumns:
|
|||||||
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||||
|
|
||||||
async def test_values_round_trip(self, dsn):
|
async def test_values_round_trip(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||||
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||||
@@ -213,7 +213,7 @@ class TestObservabilityColumns:
|
|||||||
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
||||||
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
||||||
schema_dsn, schema = legacy_schema
|
schema_dsn, schema = legacy_schema
|
||||||
recorder = PostgresRecorder(schema_dsn)
|
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(
|
await _record_minimal(
|
||||||
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||||
@@ -238,7 +238,7 @@ class TestObservabilityColumns:
|
|||||||
|
|
||||||
class TestSchema:
|
class TestSchema:
|
||||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder)
|
await _record_minimal(recorder)
|
||||||
rows = await _fetch(
|
rows = await _fetch(
|
||||||
@@ -251,7 +251,7 @@ class TestSchema:
|
|||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
|
|
||||||
async def test_call_id_idempotent(self, dsn):
|
async def test_call_id_idempotent(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("dup"))
|
await _record_minimal(recorder, call_id=_cid("dup"))
|
||||||
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
|
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
|
||||||
@@ -263,7 +263,7 @@ class TestSchema:
|
|||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
|
|
||||||
async def test_concurrent_writes_all_land(self, dsn):
|
async def test_concurrent_writes_all_land(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
||||||
@@ -281,14 +281,14 @@ class TestSchema:
|
|||||||
class TestDegradation:
|
class TestDegradation:
|
||||||
async def test_unreachable_server_degrades_silently(self):
|
async def test_unreachable_server_degrades_silently(self):
|
||||||
"""结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。"""
|
"""结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。"""
|
||||||
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x")
|
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
|
||||||
await _record_minimal(recorder) # 不抛
|
await _record_minimal(recorder) # 不抛
|
||||||
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
|
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
|
||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
|
|
||||||
async def test_row_failure_does_not_poison_later_rows(self, dsn):
|
async def test_row_failure_does_not_poison_later_rows(self, dsn):
|
||||||
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
|
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
|
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
|
||||||
await _record_minimal(recorder, call_id=_cid("good"))
|
await _record_minimal(recorder, call_id=_cid("good"))
|
||||||
@@ -302,7 +302,7 @@ class TestDegradation:
|
|||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
|
|
||||||
async def test_aclose_idempotent(self, dsn):
|
async def test_aclose_idempotent(self, dsn):
|
||||||
recorder = PostgresRecorder(dsn)
|
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||||
await _record_minimal(recorder)
|
await _record_minimal(recorder)
|
||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
await recorder.aclose()
|
await recorder.aclose()
|
||||||
@@ -374,7 +374,7 @@ class TestLeastPrivilegeDeployment:
|
|||||||
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
|
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
|
||||||
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
|
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
|
||||||
low_dsn, schema = least_privilege_dsn
|
low_dsn, schema = least_privilege_dsn
|
||||||
recorder = PostgresRecorder(low_dsn)
|
recorder = PostgresRecorder(low_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("lp1"))
|
await _record_minimal(recorder, call_id=_cid("lp1"))
|
||||||
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
|
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
|
||||||
@@ -534,7 +534,7 @@ class TestCallerDimensionsAcceptance:
|
|||||||
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
|
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
|
||||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||||
fresh_dsn, schema = fresh_schema
|
fresh_dsn, schema = fresh_schema
|
||||||
recorder = PostgresRecorder(fresh_dsn)
|
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(
|
await _record_minimal(
|
||||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||||
@@ -568,7 +568,7 @@ class TestCallerDimensionsAcceptance:
|
|||||||
审计出来,历史欠账是可见、可量化、可补录的。
|
审计出来,历史欠账是可见、可量化、可补录的。
|
||||||
"""
|
"""
|
||||||
schema_dsn, schema = pre_tenant_schema
|
schema_dsn, schema = pre_tenant_schema
|
||||||
recorder = PostgresRecorder(schema_dsn)
|
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(
|
await _record_minimal(
|
||||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||||
@@ -620,7 +620,7 @@ class TestCallerDimensionsAcceptance:
|
|||||||
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
|
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
|
||||||
且一旦 DBA 补上列也不会自愈——必须等重启。
|
且一旦 DBA 补上列也不会自愈——必须等重启。
|
||||||
"""
|
"""
|
||||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn)
|
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
||||||
assert recorder._failed is False
|
assert recorder._failed is False
|
||||||
@@ -716,7 +716,7 @@ class TestConflictTargetFreeInsert:
|
|||||||
断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。
|
断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。
|
||||||
"""
|
"""
|
||||||
fresh_dsn, _ = fresh_schema
|
fresh_dsn, _ = fresh_schema
|
||||||
recorder = PostgresRecorder(fresh_dsn)
|
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("nodup"))
|
await _record_minimal(recorder, call_id=_cid("nodup"))
|
||||||
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
|
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
|
||||||
@@ -737,7 +737,7 @@ class TestConflictTargetFreeInsert:
|
|||||||
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
|
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
|
||||||
"""
|
"""
|
||||||
part_dsn, _ = partitioned_schema
|
part_dsn, _ = partitioned_schema
|
||||||
recorder = PostgresRecorder(part_dsn)
|
recorder = PostgresRecorder(part_dsn, auto_migrate=True)
|
||||||
try:
|
try:
|
||||||
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
|
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
|
||||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||||
|
|||||||
+200
-14
@@ -115,6 +115,21 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_warnings():
|
||||||
|
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
|
||||||
|
|
||||||
|
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
|
||||||
|
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
|
||||||
|
"""
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
yield messages
|
||||||
|
logger.remove(sink_id)
|
||||||
|
|
||||||
|
|
||||||
# 搬迁前(1.2.1)两个 recorder 各自持有的 INSERT 常量原文,逐字冻结在此。
|
# 搬迁前(1.2.1)两个 recorder 各自持有的 INSERT 常量原文,逐字冻结在此。
|
||||||
# 这两条字符串是"纯搬迁不改行为"的机械证据: 构造逻辑换了地方,产物必须一字不差。
|
# 这两条字符串是"纯搬迁不改行为"的机械证据: 构造逻辑换了地方,产物必须一字不差。
|
||||||
_FROZEN_SQLITE_INSERT = (
|
_FROZEN_SQLITE_INSERT = (
|
||||||
@@ -249,7 +264,7 @@ class TestBackendColumnParity:
|
|||||||
|
|
||||||
class TestSQLiteRecorder:
|
class TestSQLiteRecorder:
|
||||||
async def test_schema_has_frozen_columns(self, tmp_path):
|
async def test_schema_has_frozen_columns(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await _record_minimal(recorder)
|
await _record_minimal(recorder)
|
||||||
recorder.close()
|
recorder.close()
|
||||||
cols = [
|
cols = [
|
||||||
@@ -258,7 +273,7 @@ class TestSQLiteRecorder:
|
|||||||
assert cols == _EXPECTED_COLUMNS
|
assert cols == _EXPECTED_COLUMNS
|
||||||
|
|
||||||
async def test_call_id_idempotent(self, tmp_path):
|
async def test_call_id_idempotent(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await _record_minimal(recorder, call_id="dup")
|
await _record_minimal(recorder, call_id="dup")
|
||||||
await _record_minimal(recorder, call_id="dup", response="second")
|
await _record_minimal(recorder, call_id="dup", response="second")
|
||||||
recorder.close()
|
recorder.close()
|
||||||
@@ -270,7 +285,7 @@ class TestSQLiteRecorder:
|
|||||||
assert rows == [("ok",)] # INSERT OR IGNORE: 第二次静默忽略
|
assert rows == [("ok",)] # INSERT OR IGNORE: 第二次静默忽略
|
||||||
|
|
||||||
async def test_concurrent_writes_all_land(self, tmp_path):
|
async def test_concurrent_writes_all_land(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
|
await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
|
||||||
recorder.close()
|
recorder.close()
|
||||||
(count,) = (
|
(count,) = (
|
||||||
@@ -279,12 +294,12 @@ class TestSQLiteRecorder:
|
|||||||
assert count == 50
|
assert count == 50
|
||||||
|
|
||||||
async def test_unwritable_path_degrades_silently(self):
|
async def test_unwritable_path_degrades_silently(self):
|
||||||
recorder = SQLiteRecorder(Path("/nonexistent-root/deep/t.db"))
|
recorder = SQLiteRecorder(Path("/nonexistent-root/deep/t.db"), auto_migrate=True)
|
||||||
await _record_minimal(recorder) # 不抛
|
await _record_minimal(recorder) # 不抛
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
async def test_observability_columns_round_trip(self, tmp_path):
|
async def test_observability_columns_round_trip(self, tmp_path):
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await _record_minimal(recorder, call_id="c-hit", cached_prompt_tokens=64)
|
await _record_minimal(recorder, call_id="c-hit", cached_prompt_tokens=64)
|
||||||
await _record_minimal(recorder, call_id="c-zero", cached_prompt_tokens=0)
|
await _record_minimal(recorder, call_id="c-zero", cached_prompt_tokens=0)
|
||||||
await _record_minimal(recorder, call_id="c-none", model_reported="MiniMax-Text-01")
|
await _record_minimal(recorder, call_id="c-none", model_reported="MiniMax-Text-01")
|
||||||
@@ -300,7 +315,7 @@ class TestSQLiteRecorder:
|
|||||||
|
|
||||||
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
|
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
|
||||||
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
|
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
|
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
|
||||||
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
|
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
|
||||||
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
|
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
|
||||||
@@ -316,7 +331,7 @@ class TestSQLiteRecorder:
|
|||||||
|
|
||||||
async def test_sampling_column_round_trips(self, tmp_path):
|
async def test_sampling_column_round_trips(self, tmp_path):
|
||||||
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
|
||||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||||
await _record_minimal(recorder, call_id="c-s", sampling='{"seed": 42, "temperature": 0}')
|
await _record_minimal(recorder, call_id="c-s", sampling='{"seed": 42, "temperature": 0}')
|
||||||
await _record_minimal(recorder, call_id="c-plain")
|
await _record_minimal(recorder, call_id="c-plain")
|
||||||
recorder.close()
|
recorder.close()
|
||||||
@@ -363,7 +378,7 @@ class TestSQLiteColumnBackfill:
|
|||||||
legacy.commit()
|
legacy.commit()
|
||||||
legacy.close()
|
legacy.close()
|
||||||
|
|
||||||
recorder = SQLiteRecorder(db)
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
await _record_minimal(recorder, cached_prompt_tokens=7, model_reported="m-real")
|
await _record_minimal(recorder, cached_prompt_tokens=7, model_reported="m-real")
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
@@ -388,7 +403,7 @@ class TestSQLiteColumnBackfill:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
recorder = SQLiteRecorder(db) # 不得抛
|
recorder = SQLiteRecorder(db, auto_migrate=True) # 不得抛
|
||||||
assert recorder._conn is not None # 补列失败 ≠ recorder 失能(D1 纪律)
|
assert recorder._conn is not None # 补列失败 ≠ recorder 失能(D1 纪律)
|
||||||
await _record_minimal(recorder) # 不得抛
|
await _record_minimal(recorder) # 不得抛
|
||||||
recorder.close()
|
recorder.close()
|
||||||
@@ -445,7 +460,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
|
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
|
||||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||||
db = tmp_path / "fresh.db"
|
db = tmp_path / "fresh.db"
|
||||||
recorder = SQLiteRecorder(db)
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
await _record_minimal(
|
await _record_minimal(
|
||||||
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
|
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||||
)
|
)
|
||||||
@@ -471,7 +486,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
db = tmp_path / "pre_tenant.db"
|
db = tmp_path / "pre_tenant.db"
|
||||||
_make_pre_tenant_db(db)
|
_make_pre_tenant_db(db)
|
||||||
|
|
||||||
recorder = SQLiteRecorder(db)
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
|
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
@@ -510,7 +525,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
# finally 还原权限位: 任一断言先失败时,不还原会让 tmp_path 清理连带报错,
|
# finally 还原权限位: 任一断言先失败时,不还原会让 tmp_path 清理连带报错,
|
||||||
# 把"某条断言失败"的真因盖成一个无关的 PermissionError
|
# 把"某条断言失败"的真因盖成一个无关的 PermissionError
|
||||||
try:
|
try:
|
||||||
recorder = SQLiteRecorder(db) # 不得抛
|
recorder = SQLiteRecorder(db, auto_migrate=True) # 不得抛
|
||||||
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
|
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
|
||||||
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
|
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
|
||||||
recorder.close()
|
recorder.close()
|
||||||
@@ -523,6 +538,119 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
) # 补列确实没成功,用例不是在只读库上空转
|
) # 补列确实没成功,用例不是在只读库上空转
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLiteSchemaMode:
|
||||||
|
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
||||||
|
|
||||||
|
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
||||||
|
补齐后 24 + `created_at` = 25。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _physical_columns(self, db: Path) -> list[str]:
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
return [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_manual_mode_trims_the_insert_instead_of_altering(
|
||||||
|
self, tmp_path, captured_warnings
|
||||||
|
):
|
||||||
|
"""manual + 22 字段旧表: 一条 ALTER 都不发,写入按现有列裁剪后照样落库。
|
||||||
|
|
||||||
|
裁剪是关掉 ALTER 的前提: 不裁剪的话每行 INSERT 都撞 `no column named
|
||||||
|
tenant_id` 而被整行丢弃——那是把自动补列换成静默全失能。
|
||||||
|
"""
|
||||||
|
db = tmp_path / "manual_legacy.db"
|
||||||
|
_make_pre_tenant_db(db)
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
assert len(self._physical_columns(db)) == 23 # 未 ALTER: 物理列数原封不动
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT response, model FROM llm_calls WHERE call_id = 'new-row'"
|
||||||
|
).fetchone() == ("ok", "m") # 裁剪后的列值仍对得上位
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert len(captured_warnings) == 1 # 缺列只讲一次,不逐行刷屏
|
||||||
|
message = captured_warnings[0]
|
||||||
|
assert "tenant_id" in message and "meta" in message # 逐列点名
|
||||||
|
assert "不会被记录" in message # 讲清后果
|
||||||
|
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
||||||
|
|
||||||
|
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
||||||
|
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 25。"""
|
||||||
|
db = tmp_path / "auto_legacy.db"
|
||||||
|
_make_pre_tenant_db(db)
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a")
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
||||||
|
assert len(self._physical_columns(db)) == 25
|
||||||
|
|
||||||
|
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
||||||
|
"""manual 只管 ALTER,不管 CREATE: 全新库照建,25 个物理列齐全(设计 §4.2)。"""
|
||||||
|
db = tmp_path / "manual_fresh.db"
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert (
|
||||||
|
conn.execute("SELECT tenant_id FROM llm_calls WHERE call_id = 'c-fresh'").fetchone()[0]
|
||||||
|
== "tenant-a"
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_table_without_call_id_escalates_the_wording(self, tmp_path, captured_warnings):
|
||||||
|
"""缺主键列 call_id = 该表压根不是本库的 llm_calls: 措辞升级,但库不做二次判定。"""
|
||||||
|
db = tmp_path / "alien.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("CREATE TABLE llm_calls (model TEXT, provider TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False) # 不得抛
|
||||||
|
await _record_minimal(recorder) # 照常尝试写入
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
message = "\n".join(captured_warnings)
|
||||||
|
assert "call_id" in message
|
||||||
|
assert "不是本库" in message
|
||||||
|
|
||||||
|
async def test_no_recognizable_column_falls_back_to_the_full_column_set(
|
||||||
|
self, tmp_path, captured_warnings
|
||||||
|
):
|
||||||
|
"""探测结果与 COLUMNS 毫无交集视同探测异常: 保守回落全量列。
|
||||||
|
|
||||||
|
空列集会构造出 `INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句
|
||||||
|
(`insert_sql` 不拒空列表——空集技术上是子集),故必须在交给它之前拦住。
|
||||||
|
"""
|
||||||
|
from polygateway.telemetry.schema import COLUMNS
|
||||||
|
|
||||||
|
db = tmp_path / "foreign.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute("CREATE TABLE llm_calls (foo TEXT, bar TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False) # 不得抛
|
||||||
|
assert recorder._columns == COLUMNS
|
||||||
|
await _record_minimal(recorder) # 写不进去,但只逐行 warning,不抛
|
||||||
|
recorder.close()
|
||||||
|
assert captured_warnings # 沉默地退化成空语句是最坏结果,必须有声
|
||||||
|
|
||||||
|
async def test_auto_migrate_is_required_keyword_only(self, tmp_path):
|
||||||
|
"""关键行为参数不给默认值(P4): 缺省规则只写在 config 一处,不与类签名漂移。"""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
SQLiteRecorder(tmp_path / "t.db") # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
class _FakePgConn:
|
class _FakePgConn:
|
||||||
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
|
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
|
||||||
|
|
||||||
@@ -601,7 +729,9 @@ class TestPostgresBackfillDiscipline:
|
|||||||
def _recorder(self, conn):
|
def _recorder(self, conn):
|
||||||
from polygateway.telemetry.postgres import PostgresRecorder
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
|
return PostgresRecorder(
|
||||||
|
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True
|
||||||
|
)
|
||||||
|
|
||||||
async def test_alter_failure_does_not_disable_the_recorder(self):
|
async def test_alter_failure_does_not_disable_the_recorder(self):
|
||||||
"""ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。"""
|
"""ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。"""
|
||||||
@@ -655,7 +785,9 @@ class TestPostgresTableProbe:
|
|||||||
def _recorder(self, conn):
|
def _recorder(self, conn):
|
||||||
from polygateway.telemetry.postgres import PostgresRecorder
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
|
return PostgresRecorder(
|
||||||
|
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True
|
||||||
|
)
|
||||||
|
|
||||||
def _created(self, conn):
|
def _created(self, conn):
|
||||||
return [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
|
return [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
|
||||||
@@ -703,6 +835,60 @@ class TestPostgresTableProbe:
|
|||||||
assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
|
assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostgresSchemaMode:
|
||||||
|
"""issue #13: PG 侧 manual 档一条 ALTER 都不发,改按现有列裁剪 INSERT。
|
||||||
|
|
||||||
|
真实 PG 的验收在 `tests/integration/test_postgres_telemetry.py`;这里用 fake 连接
|
||||||
|
锁住"发了哪些语句",无 DSN 环境下集成用例被 skip 时仍有回归保护。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LEGACY = ["call_id", "cost", "created_at"]
|
||||||
|
|
||||||
|
def _recorder(self, conn, *, auto_migrate):
|
||||||
|
from polygateway.telemetry.postgres import PostgresRecorder
|
||||||
|
|
||||||
|
return PostgresRecorder(
|
||||||
|
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=auto_migrate
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_manual_mode_trims_the_insert_instead_of_altering(self, captured_warnings):
|
||||||
|
conn = _FakePgConn(self._LEGACY)
|
||||||
|
recorder = self._recorder(conn, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder)
|
||||||
|
|
||||||
|
assert not [s for s in conn.statements if s.startswith("ALTER TABLE")]
|
||||||
|
assert "INSERT INTO llm_calls (call_id, cost) VALUES ($1, $2) ON CONFLICT DO NOTHING" in (
|
||||||
|
conn.statements
|
||||||
|
)
|
||||||
|
assert recorder._columns == ("call_id", "cost")
|
||||||
|
message = "\n".join(captured_warnings)
|
||||||
|
assert "tenant_id" in message and "meta" in message # 逐列点名
|
||||||
|
assert "不会被记录" in message # 讲清后果
|
||||||
|
assert "ALTER TABLE llm_calls ADD COLUMN tenant_id" in message # 可直接执行的 SQL
|
||||||
|
|
||||||
|
async def test_manual_mode_still_creates_a_missing_table(self):
|
||||||
|
"""manual 只管 ALTER 不管 CREATE: 新建表列已齐全,写入照发全量列。"""
|
||||||
|
from polygateway.telemetry.schema import COLUMNS
|
||||||
|
|
||||||
|
conn = _FakePgConn([])
|
||||||
|
recorder = self._recorder(conn, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder)
|
||||||
|
|
||||||
|
assert [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
|
||||||
|
assert recorder._columns == COLUMNS
|
||||||
|
|
||||||
|
async def test_auto_mode_still_backfills(self):
|
||||||
|
"""auto 档现状回归: 缺列照补,补完写全量列。"""
|
||||||
|
from polygateway.telemetry.schema import COLUMNS, PG_BACKFILL
|
||||||
|
|
||||||
|
conn = _FakePgConn(self._LEGACY)
|
||||||
|
recorder = self._recorder(conn, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder)
|
||||||
|
|
||||||
|
assert len([s for s in conn.statements if s.startswith("ALTER TABLE")]) == len(PG_BACKFILL)
|
||||||
|
assert recorder._columns == COLUMNS
|
||||||
|
|
||||||
|
|
||||||
class _MemoryRecorder:
|
class _MemoryRecorder:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rows = []
|
self.rows = []
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
|
|||||||
env = _merged_env()
|
env = _merged_env()
|
||||||
run_id = args.run_id
|
run_id = args.run_id
|
||||||
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
|
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
|
||||||
recorder = SQLiteRecorder(telemetry_path)
|
recorder = SQLiteRecorder(telemetry_path, auto_migrate=True)
|
||||||
if args.scenario == "P7":
|
if args.scenario == "P7":
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user