-
1.3.0 Stable
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,是人工比对"日志里的完成里程碑条数 vsllm_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: int与telemetry_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 argument。dataclasses.replace(settings, ...)一路不受影响。遥测失败的三分判据
判据两句话:致命 = 失败原因完全在进程内部且不可变;行级 vs 环境级看"失败与这一行的数据有没有关系"。
档 覆盖 处置 配置级致命 DSN 不可解析( ClientConfigurationError)、建池参数非法永久 no-op + 一条 error(这是人配错了,不是 warning) 环境级不可用 连接类 08/ 资源不足53(含 53300 too many connections)/ 管理干预57/ 认证28/ 库不存在3D,以及42501无权限、42P01表不存在;网络类异常;超时类异常仅在准备期路径可达(写入期的超时先被record_llm_call的except TimeoutError接住,按行级丢弃);表确定不存在且建不出来冷却 60s 后自动重试一次,成功即恢复。DBA 建完表、放开权限、PG 重启完毕,进程都不必重启 行级拒绝 其余数据与约束类错误( 22/23等),外加唯一具名例外42703(缺列)逐条 warning 丢弃,不降级 42703之所以是例外: issue #13 定了更高优先级的承诺——manual 档缺列时按现有列裁剪INSERT继续写、缺列以逐行 warning 暴露,"部分列写进去了"这件事本身有价值,不该被冷却掉。新增公共 API
名字 内容 GatewayClient.telemetry_status/EmbeddingClient.telemetry_status/OcrClient.telemetry_statusTelemetryStatus | 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的调用点必须补这两个参数,见"请先读这一条(四)"。PostgresRecorder的pool_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 共用。 - 对账请同时看
degraded与dropped_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
- 两个新配置键