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
|
||||
|
||||
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
|
||||
|
||||
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(
|
||||
|
||||
@@ -131,6 +131,9 @@ class GatewaySettings:
|
||||
telemetry_backend: str
|
||||
telemetry_sqlite_path: str | None
|
||||
telemetry_pg_dsn: str | None
|
||||
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);
|
||||
# 派生规则只写在 `_load_pgw` 一处,不与 recorder 的类签名漂移
|
||||
telemetry_auto_migrate: bool
|
||||
redis_url: str | None
|
||||
pricing_path: str | None
|
||||
structured_max_retries: int
|
||||
@@ -444,6 +447,11 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
if telemetry_backend == "sqlite"
|
||||
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,
|
||||
"pricing_path": env.get("PGW_PRICING_PATH") or None,
|
||||
"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"
|
||||
)
|
||||
|
||||
_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:
|
||||
"""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:
|
||||
import asyncpg # noqa: F401 - 仅探测 extra 是否安装
|
||||
except ImportError as exc:
|
||||
@@ -51,6 +98,10 @@ class PostgresRecorder:
|
||||
self._dsn = dsn
|
||||
self._pool: asyncpg.Pool | None = pool
|
||||
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._failed = False # 结构性降级标志: 置位后所有写入短路
|
||||
self._init_lock = asyncio.Lock()
|
||||
@@ -93,7 +144,7 @@ class PostgresRecorder:
|
||||
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
writable = await self._prepare_table(conn)
|
||||
columns = await self._prepare_table(conn)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -101,14 +152,20 @@ class PostgresRecorder:
|
||||
# 只跳过本次记录,下次调用重新准备
|
||||
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
|
||||
return None
|
||||
if not writable:
|
||||
if columns is None:
|
||||
self._failed = True
|
||||
return None
|
||||
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||
self._columns = columns
|
||||
self._insert = insert_sql("postgres", columns)
|
||||
self._schema_ready = True
|
||||
return pool
|
||||
|
||||
async def _prepare_table(self, conn: object) -> bool:
|
||||
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
|
||||
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||
|
||||
返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||
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]
|
||||
if exists:
|
||||
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
|
||||
return True
|
||||
return await self._resolve_columns(conn) # 旧表可能缺列
|
||||
try:
|
||||
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
||||
return False
|
||||
return True # 新建表列已齐全,无需再走补列
|
||||
return None
|
||||
return COLUMNS # 新建表列已齐全,无需再走补列
|
||||
|
||||
async def _backfill_columns(self, conn: object) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
||||
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
|
||||
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
|
||||
|
||||
两条纪律各有实测理由:
|
||||
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
||||
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。
|
||||
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
||||
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
||||
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
|
||||
**先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
|
||||
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
if column not in existing:
|
||||
await conn.execute(statement) # type: ignore[attr-defined]
|
||||
@@ -156,14 +255,18 @@ class PostgresRecorder:
|
||||
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。"""
|
||||
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
"""
|
||||
pool = await self._ensure_ready()
|
||||
if pool is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in COLUMNS)
|
||||
row = tuple(fields[col] for col in self._columns)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(_INSERT, *row)
|
||||
await conn.execute(self._insert, *row)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -24,15 +24,65 @@ from loguru import logger
|
||||
|
||||
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:
|
||||
"""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._conn: sqlite3.Connection | None = None
|
||||
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
||||
self._columns: tuple[str, ...] = COLUMNS
|
||||
self._insert = insert_sql("sqlite", COLUMNS)
|
||||
try:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -44,23 +94,61 @@ class SQLiteRecorder:
|
||||
self._conn = conn
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||
self._backfill_columns()
|
||||
self._prepare_columns()
|
||||
|
||||
def _backfill_columns(self) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||
def _prepare_columns(self) -> None:
|
||||
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
||||
|
||||
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。
|
||||
无守卫的探测会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
if self._conn is None:
|
||||
return
|
||||
try:
|
||||
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
||||
logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
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:
|
||||
if column in existing:
|
||||
continue
|
||||
@@ -74,10 +162,14 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
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:
|
||||
return
|
||||
row = tuple(fields[col] for col in COLUMNS)
|
||||
row = tuple(fields[col] for col in self._columns)
|
||||
try:
|
||||
await asyncio.to_thread(self._write, row)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -86,7 +178,7 @@ class SQLiteRecorder:
|
||||
def _write(self, row: tuple) -> None:
|
||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||
with self._lock:
|
||||
self._conn.execute(_INSERT, row)
|
||||
self._conn.execute(self._insert, row)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user