• v1.3.0 f5cf69a1ac

    1.3.0 Stable

    iomgaa released this 2026-08-25 01:21:42 +08:00 | 69 commits to main since this release

    遥测后端从此按需占用连接、失败可自愈、降级可查询(issue #15)。提交方在一个 max_connections=100 的共享 PostgreSQL 上跑多 worker × 多 scope,发现库悄悄占掉了 40 条常驻连接,且余量一紧张就整个进程再也不落一行遥测——19 次调用一行未落、成本少记约 $5,是人工比对"日志里的完成里程碑条数 vs llm_calls 行数"才发现的。

    根因不是"asyncpg 的默认 min_size=10 太大"这一条,而是四层叠加,只改默认值会留下三层:

    # 缺陷 本版
    库对自己的资源占用从未表态 —— create_pool(dsn, timeout=10) 继承第三方默认值,而 asyncpg 的 min_size 语义是"预连接"不是"下限":要么一次拿到 10 条,要么建池失败。这是全库唯一一处预占资源的组件 min_size=0 + max_size 可配(PGW_TELEMETRY_PG_POOL_MAX,缺省 4)+ 每次写入硬预算(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S,缺省 5.0s)
    判死判据挂在"哪一步失败"(建池失败即永久判死),而那一步里同时藏着 DSN 写错(进程内不可能改变)与 too many clients(下一秒可能就好) 判据改挂"失败是什么性质",永久失能收窄到只剩 DSN 不可解析一类,其余一律 60s 冷却后自动重试
    降级不可恢复也不可见 —— 全程只有一条 warning,SQLite 侧连 warning 都没有 进入/恢复各一条日志 + 降级期间节流复述 + client.telemetry_status 只读快照
    "多个 client 共享一个 recorder"这条正道是坏的(第一个 aclose() 就把共享的 recorder 弄死),所以下游只能退回"每个 client 各占一份" 全库统一"谁建的谁关"纪律,共享路径打通

    真实实验室 PG 上的连接数实测,一眼可见差别: 修复前建完 recorder 就是 10 条;修复后建完 recorder 0 条 → 一次写入后 1 条 → 20 行并发后 4 条(= pool_max)→ aclose() 后回到 0

    请先读这一条(一): 最低 Python 版本提到 3.12,3.11 的部署装不上

    requires-python>=3.11 改为 >=3.12。这是本版四条要点里唯一会让下游装不上的变更——仍在 3.11 上的部署执行 pip install 会被 pip 直接拒绝,不是运行时报错,是装不了。升级 Python 或钉住 polygateway<1.3 二选一。

    抬版本不是顺手做的: 本版的写入预算依赖 asyncio.timeout,而 3.11.0 / 3.11.1 的 uncancel 有已知缺陷,继续支持 3.11 就得退回 wait_for 并绕开那个缺陷。取舍是缩小支持面换掉一整块补丁代码。同批把三处泛型函数改成 PEP 695 语法(def f[T](...),该语法在 3.11 是 SyntaxError)。

    请先读这一条(二): 遥测的常驻连接数会从 10 × client 数 掉到 0,监控曲线会突变

    这是纯改善,但曲线会跳,不要误判为故障: 连接不再于装配期预占,而是第一次写入时才建、忙时最多 PGW_TELEMETRY_PG_POOL_MAX 条(缺省 4)、空闲超过回收期后归 0。代价是首次写入多付一次建连(实测 ≈390ms,相对一次秒级 LLM 调用可忽略),稳态写入无差异(实测 123ms)。

    pool_max 的调参口径请按实测折算,不要按 pool_max / RTT 估算——那会乐观一倍: 跨内网 RTT ≈ 123ms 的实验室 PG 上,pool_max=4 实测约 15.6 行/秒(50 行并发批耗时 3.2s),因为一次 INSERT 的实际往返比一次 SELECT 1 重。缺省 4 配缺省 5s 预算能吞下约 50 行的突发,余量约 1.5 倍;超预算的行被丢弃并计入 telemetry_status.dropped_rows——丢一条遥测好过拖垮业务调用。多个 client 共享同一个 recorder 时并发在这里汇聚,应相应放大。

    请先读这一条(三): aclose() 不再关闭注入进来的组件

    新纪律是谁建的谁关,注入的一律不碰: from_env() / from_settings() 自建的 transport / recorder / limiter / breaker / cache 照常被 aclose() 关掉;经构造函数注入进来的则一律不碰,由注入方自己关。RedisCache 同款(注入的 redis 客户端不再被误关)。

    这修正的是一次越权——共享同一个 recorder 的多个 client 里,第一个 aclose() 会把其他 client 还在用的 recorder 弄死。但若你的代码依赖了"注入之后由 client 代关",升级后会漏关,请自行补上关闭。同一批还修掉了反方向的泄漏: 自建的 redis limiter / breaker 客户端此前从来没有人关(aclose 压根不持有它们的引用),现在会被关。

    请先读这一条(四): 直接构造 GatewaySettings 的代码要补两个参数

    GatewaySettings 新增 telemetry_pg_pool_max: inttelemetry_pg_write_timeout_s: float 两个无默认值的必填字段。走 from_env() / from_settings() 的调用方不受影响(两个新键都是可选的,env 装配路给缺省 4 与 5.0);直接构造 GatewaySettings(...) 的代码——测试装配、配置改写脚本——升级后不补参数会当场 TypeError

    这不是疏忽而是既有纪律: 相邻的 telemetry_auto_migrate / telemetry_text_cap 同样无默认值,缺省规则只写在 _load_* 一处,不与字段签名漂移(P4 显式优于隐式)。写默认值在此也不可能——这两个字段后面还跟着四个无默认值字段,加了就是 TypeError: non-default argument follows default argumentdataclasses.replace(settings, ...) 一路不受影响。

    遥测失败的三分判据

    判据两句话:致命 = 失败原因完全在进程内部且不可变;行级 vs 环境级看"失败与这一行的数据有没有关系"

    覆盖 处置
    配置级致命 DSN 不可解析(ClientConfigurationError)、建池参数非法 永久 no-op + 一条 error(这是人配错了,不是 warning)
    环境级不可用 连接类 08 / 资源不足 53(含 53300 too many connections)/ 管理干预 57 / 认证 28 / 库不存在 3D,以及 42501 无权限、42P01 表不存在;网络类异常;超时类异常仅在准备期路径可达(写入期的超时先被 record_llm_callexcept TimeoutError 接住,按行级丢弃);表确定不存在且建不出来 冷却 60s 后自动重试一次,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启
    行级拒绝 其余数据与约束类错误(22/23 等),外加唯一具名例外 42703(缺列) 逐条 warning 丢弃,不降级

    42703 之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪 INSERT 继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。

    新增公共 API

    名字 内容
    GatewayClient.telemetry_status / EmbeddingClient.telemetry_status / OcrClient.telemetry_status TelemetryStatus | None 只读属性。None = 未启用遥测,或注入的 recorder 不提供状态
    polygateway.TelemetryStatus(顶层导出) frozen dataclass: degraded / fatal / reason / degraded_for_s / dropped_rows / retry_after_s。下游可据此对账或告警,不必再人工比对行数
    ports.TelemetryStatusProvider 新增的独立可选端口。TelemetryRecorder 逐字未变——它是 @runtime_checkable,往里加成员会让所有只实现 record_llm_call 的对象当场不再满足协议,下游的同款 isinstance 断言升级即断

    其他

    • 两个新配置键 PGW_TELEMETRY_PG_POOL_MAX(缺省 4,须 ≥ 1)与 PGW_TELEMETRY_PG_WRITE_TIMEOUT_S(缺省 5.0,须 > 0)。GatewaySettings 相应新增两个无默认值的必填字段,与相邻三个遥测键(telemetry_auto_migrate / telemetry_text_cap / telemetry_sqlite_path)完全一致——上面那两个"缺省"只存在于 env 装配路(_load_* 函数),直接构造 GatewaySettings 的调用点必须补这两个参数,见"请先读这一条(四)"。PostgresRecorderpool_max / write_timeout_s 是 keyword-only 必填参数(直接构造 recorder 的调用点需补,不传即 TypeError)。
    • PostgresRecorder.aclose() 现在是有界且终局的: 走 asyncio.wait_for + 超时 terminate()(Pool.close() 在 in-flight 连接未释放时会无限等,asyncpg 自己的文档就建议加 wait_for);关闭后写入短路且不再复活——此前关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入外部池的调用方以为自己管着全部连接、实际早已不是。
    • 降级日志的级别由是否致命决定: 配置级致命(DSN 写不对)发 ERROR——人配错了、本进程内不会自愈,运维必须看见;其余(后端挂了、权限被收、表被删)发 WARNING——外部状态,冷却到期会自己重试。级别只在 TelemetryStatusTracker 一处决定,两个 recorder 共用。
    • 对账请同时看 degradeddropped_rows: 写入因本地池饱和超出预算被丢时走的是行级丢弃,degraded 保持 False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按 degraded 配告警会完全看不见这一类丢行——而它恰是 PGW_TELEMETRY_PG_POOL_MAX 配小了的唯一信号。
    • SQLite 遥测初始化失败后终于有日志了。此前 sqlite.py 初始化失败直接 return,连一条 warning 都没有,整个进程零遥测且无任何痕迹。SQLite 侧本版只做可见性,不做 lazy 化与冷却重连(它的失败模式在装配期就会暴露,不是"跑到一半悄悄断")。
    • 写入路径不再用 async with pool.acquire(...)Pool.release() 是 shielded 且默认复用 acquire 时记录的 timeout,预算到期时那次释放会正常等到完成——业务路径的真实上界因此是 ≈ 2 × 预算而不是一个预算。改为显式 acquire/release 后,承诺精确为"主写入尝试 ≤ 预算,释放路径独立有界(1s,超时即 terminate)"。
    Downloads