• v1.2.3 296c765337

    1.2.3 Stable

    iomgaa released this 2026-08-20 10:39:58 +08:00 | 103 commits to main since this release

    遥测表 llm_calls 的结构变更从此由下游掌控(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 ALTER TABLE ADD COLUMN,而补列没有任何开关——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:ALTER 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 await),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。

    同一版里,issue #12 补上这条边界的另一半——删数据,并把它落成三样手段: 遥测正文的可配置上限、tools/ 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样没有一样改变缺省行为——不设 PGW_TELEMETRY_TEXT_CAP 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 DELETE/DROP 权限: 保留期是 tools/ 下的独立脚本,库不 import 它。

    请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的

    1.2.1 的 README 给的 RLS 模板把写侧也绑在了 app.tenant_id 这个 GUC 上:

    -- 1.2.1 的模板,有缺陷,勿用
    CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
      USING      (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
      WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
    

    PostgresRecorder一个连接池给所有租户写遥测,源码里从不发 set_config('app.tenant_id', ...)——库既拿不到也不该猜租户上下文该怎么设。于是 WITH CHECK 里的 current_setting 恒为 NULL、等值比较恒不为真,库的每一条 INSERT 都被 policy 拒绝。而遥测的失败方向是静默降级,所以表现不是报错,是整张表零行——业务调用一切正常,不看日志根本发现不了。

    照抄过就请现在查这两条:

    查什么 中招的样子
    SELECT count(*) FROM llm_calls;,且必须用能绕过 RLS 的角色(superuser 或带 BYPASSRLS 属性的角色)——FORCE 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" 启用 RLS 之后一直是 0,或从某个时刻起不再增长
    应用日志里遥测写入的降级告警,前缀 Postgres 遥测写入失败(丢弃该行): 每次调用刷一条,附带的 PG 原话是 new row violates row-level security policy for table "llm_calls"

    本版的新模板把写侧改为 WITH CHECK (true),隔离交由读侧USING 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 tenant_id,可把写侧收紧成 WITH CHECK (tenant_id <> ''),代价是漏传 tenant_id 的调用点会丢遥测行(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。

    破坏性变更(五项)

    # 变更 影响与应对
    Postgres 侧不再自动补列(缺省转为 manual 档) 库升级带来新列时,旧表不会被自动 ALTER:库改为发一条 warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 INSERT 继续写入——缺的那几列静默不落库,直到有人执行那几条 SQL。要恢复旧行为设 PGW_TELEMETRY_SCHEMA_MODE=auto。SQLite 侧缺省不变(仍 auto),理由见下
    两个 recorder 新增 keyword-only 必填参数 auto_migrate SQLiteRecorder(db_path, *, auto_migrate)PostgresRecorder(dsn, *, pool=None, auto_migrate);直接构造 recorder 的调用点必须补这个参数,不传即 TypeError故意不给默认值:缺省规则只写在 config 一处,不与类签名漂移
    GatewaySettings 新增必填字段 telemetry_auto_migrate: bool 只影响「构造函数全量注入」这条装配路(测试/高级用法);from_env() / from_settings() 的用户零改动。telemetry_backend="none" 时该字段在 __post_init__ 归一为 False
    GatewaySettings 再新增必填字段 telemetry_text_cap: int | None 同 ③,只影响直接构造这条路。None(不截断)是取值而不是默认值——字段本身没有默认值;<= 0__post_init__ 直接 ValueError,不会被当成"不截断"
    TelemetryEmitter 新增 keyword-only 必填参数 text_cap 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 TypeError。同样故意不给默认值: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 text_cap 全汇流到这里,而 GatewaySettings 那道只管 env 一条路

    新增

    • PGW_TELEMETRY_SCHEMA_MODE(可选键,值域 auto / manual),三态:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则有意不对称——postgresmanual,sqliteauto。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 runs/*.db),没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
    • 公共函数 telemetry_schema_sql(backend) -> str(已进顶层 __all__):返回可直接粘进迁移文件的完整脚本——注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句。PG 变体带 ADD COLUMN IF NOT EXISTS,整段可重复执行;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 backendValueError
    • manual 档的缺列告警逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且只在准备期发一次,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。

    issue #12 交付的三样手段列在下表——它们改变的是能做什么,不是默认做什么:

    手段 内容
    PGW_TELEMETRY_TEXT_CAP(可选正整数键) 遥测落库正文的字符上限;不设 = 不截断(缺省)。作用面正好四处: messages 里每条消息的字符串 content、多模态 content 数组中 type == "text" 的 part 的 text,以及 responsethinking 两列;超出部分头部保留、尾部换成 …(略 N 字)按每条文本切,而不是切整串 JSON——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。覆盖面到此为止: 调用方塞进 tool_calls.function.argumentsnamecontent 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留
    tools/telemetry_retention.py(独立运维脚本) created_at 清理过期行。默认 dry-run: 先打出将删行数、created_at 窗口与按 tenant_id 的分布,让运维先判断"要删的是不是我想删的",给了 --apply 才真动手。退出码是与调度器(cron/systemd)的契约: 0 正常(含 dry-run)、1 参数错误、2 连接/权限/目标表不可用(含缺 asyncpg——明确报错退出,绝不静默变成"删了 0 行")、3 目标是 PostgreSQL 分区表,此时脚本拒绝 DELETE,让路给 O(1) 的 DETACH + DROP PARTITION。请用维护角色跑,不要用应用账号(模板已对它 REVOKE UPDATE, DELETE)
    README 新增「生产部署 DDL 模板(PostgreSQL)」一节 三角色、created_at RANGE 分区与 pg_partman retention、REVOKE UPDATE, DELETE 加触发器兜底、RLS、库自己需要的最小权限、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 <!-- pg-template:* --> 锚点,由 tests/integration/test_postgres_telemetry.py 从 README 解析出来在真实 PG 上逐条执行——模板只有这一份,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物

    变更

    • Postgres 的写入去掉了冲突目标:ON CONFLICT (call_id) DO NOTHINGON CONFLICT DO NOTHING。普通表上语义逐字等价(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 (call_id) 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 created_at 分区后主键变成 (call_id, created_at),该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 INSERT OR IGNORE 本就无目标,未动。
    • manual 档按现有列裁剪 INSERT。这不是可选增强而是关掉 ALTER 的前提:旧表缺列时若仍发全量 INSERT,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
    • schema 常量收敛为单一事实源 telemetry/schema.py(内部模块):列序、两端 DDL、两端补列语句、INSERT 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是正确性而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。

    不变

    • manual 档仍然建表。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 to_regclass 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
    • auto 档行为与从前逐字相同,包括补列失败时不裁剪:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
    • 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
    • 遥测缺省不截断: 不设 PGW_TELEMETRY_TEXT_CAP 时落库正文与今天逐字节相同。digest_messages(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断只产出新对象、绝不就地修改——digest_messages 对非 list 的 content 是原样透传同一个 dict 对象,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 build_cache_key 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
    • embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是"取更严者"的关系;多模态 image_url 早已是 sha256 摘要,不受 cap 影响。

    库对下游数据库的承诺(Expand/Contract,本版成文)

    以下五条此前已被实现满足,但从未写成承诺。本版起它们是承诺:新列只增不删不改名且一律追加在既有列之后;新列必可空或带非易失常量默认值(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);INSERT 永远显式写出列名;库从不 SELECT *、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的冲突处理不绑定具体约束

    合起来它们保证:你可以自行给 llm_calls 加列、加索引、挂 RLS,乃至把它建成 PARTITION BY RANGE (created_at) 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,research-wiki/ 不在 sdist 内。

    同一条边界的另一半是删数据: 库不持有 DELETE/DROP 权限,保留期与访问控制以 README 模板加 tools/ 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 REVOKE UPDATE, DELETE ON llm_calls(按不可变审计表对待),那么过期清理就不可能再由应用角色的 DELETE 完成,只能是属主对 created_at RANGE 分区的 DETACH + DROP PARTITION(那是 DDL,同样不触发不可变性触发器)。分区在这里不可替代,不是性能偏好。

    升级提示

    • from_env() / from_settings() 装配的下游无需改代码;Postgres 下游升级后建议执行一次 python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
    • 直接构造 SQLiteRecorder / PostgresRecorder 或直接构造 GatewaySettings 的调用点必须补上新参数/新字段,否则 TypeError
    • 截断不需要任何升级动作: 不设 PGW_TELEMETRY_TEXT_CAP 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
    • 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
    • README 的安装 pin 由 >=1.2.1,<2 收紧为 >=1.2.3,<2。按旧 pin 装的下游不会被锁死(仍会拿到本版),但显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关,而包内那份 README 描述的正是它们。
    Downloads