diff --git a/.env.example b/.env.example index 89938d2..923d3d9 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,26 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) # # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、 # # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。 # PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway) +# PGW_TELEMETRY_PG_POOL_MAX=4 # postgres 遥测池的连接上限,须 >= 1;缺省 4。**闲时占 0 条**—— +# # 池按需建连(min_size=0),不预占;这一格是忙时的天花板,不是常驻量。 +# # 调参口径(以实测为准,不要按 pool_max/RTT 估算):跨内网 RTT ≈ 123ms 的 +# # 实验室 PG 上,pool_max=4 实测约 **15.6 行/秒**(50 行并发批耗时 3.2s), +# # 即每条连接约 4 行/秒 —— 一次 INSERT 的实际往返比一次 `SELECT 1` 重一倍, +# # 按单次 RTT 估会乐观一倍。要放大就按这个实测值线性折算(pool_max=8 ≈ 31 行/秒)。 +# # 缺省 4 在缺省 5s 预算下能吞下约 50 行的突发(余量约 1.5 倍);超预算的行被丢弃 +# # 并计入 telemetry_status.dropped_rows —— 丢一条遥测好过拖垮业务调用。 +# # 注意告警口径: 池饱和丢的行走**行级丢弃**,telemetry_status.degraded 保持 +# # False(后端并没有挂,是本进程并发超了),只有 dropped_rows 增长。只按 +# # degraded 告警会完全看不见这一类丢行 —— 对账要两个字段一起看。 +# # 什么时候该调大: 单进程遥测写入并发经常超过 4(高频短调用、批量并发), +# # 或多个 client 显式共享同一个 recorder(并发在这里汇聚,应按 client 数放大)。 +# PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=5.0 # 一次遥测写入的硬预算(秒),须 > 0;缺省 5.0。同时用作建连、 +# # acquire 与「准备 + 取连接 + 执行」整段的上界:超时即丢弃该行, +# # 绝不让遥测无界地挂在业务路径上。实测参考: 稳态写入 123ms、 +# # 首次写入含建连 513ms —— 5s 对正常路径是极宽松的上限,它防的是 +# # 池满排队与后端假死这类"不会自己结束"的等待。 +# # 与之配套的两个不可配内部常量: 连接释放上界 1s(超时即 terminate)、 +# # 环境级降级的冷却期 60s(到期自动重试一次,成功即恢复)。 # PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。 # # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking; # # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 93efb7f..f4f79ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## 1.3.0(2026-08-24) + +遥测后端从此**按需占用连接、失败可自愈、降级可查询**(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: 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_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` 的调用点必须补这两个参数,见"请先读这一条(四)"。`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)"。 + + ## 1.2.4(2026-08-20) 熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。 diff --git a/CLAUDE.md b/CLAUDE.md index 94cc546..dbf5fa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。 - **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。 - **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。 -- **技术栈**: Python 3.11+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。 +- **技术栈**: Python 3.12+,核心仅依赖 `httpx` + `pydantic`,其余(redis/sqlite/postgres/json_repair/openai)一律 optional extras。conda 环境 `PolyGateway`。 ## 2. 常用命令 diff --git a/README.md b/README.md index 98ec21c..f73cb76 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,14 @@ | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | +| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` | | 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 | | OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` | | Embedding | 分批、维度校验、与 chat 同一治理栈 | -**降级方向是铁律**:缓存/遥测后端掉线 → 静默降级(warning);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放。 +**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。 ## 安装 @@ -33,7 +34,7 @@ ```bash pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ - "polygateway[redis,postgres,structured]>=1.2.4,<2" + "polygateway[redis,postgres,structured]>=1.3.0,<2" ``` 核心仅依赖 `httpx` + `pydantic`;按需选 extras: @@ -45,7 +46,7 @@ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/py | `structured` | json-repair | 结构化输出的修复策略 | | `sdk` | openai | 可选的 SDK transport(默认手写 httpx,不需要) | -要求 Python ≥ 3.11。 +要求 Python ≥ 3.12。 ## 快速开始 @@ -407,6 +408,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应** | `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) | | `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) | | `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text`、`response`、`thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) | +| `PGW_TELEMETRY_PG_POOL_MAX` | 可选正整数(缺省 4):Postgres 遥测池的连接**上限**。池按需建连,闲时占 0 条,这一格是忙时天花板而非常驻量。调参按实测折算而非按 `pool_max / RTT` 估算——跨内网 RTT ≈ 123ms 上 `pool_max=4` 实测约 15.6 行/秒(一次 `INSERT` 的往返比一次 `SELECT 1` 重一倍);多个 client 共享同一 recorder 时并发在此汇聚,应相应放大 | +| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 可选正数(缺省 5.0):**一次遥测写入的硬预算**,同时用作建连、`acquire` 与「准备 + 取连接 + 执行」整段的上界;超时即丢该行,绝不让遥测无界地挂在业务路径上 | | `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) | **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`** @@ -461,7 +464,7 @@ graph LR ## 开发 ```bash -conda create -n PolyGateway python=3.11 && conda activate PolyGateway +conda create -n PolyGateway python=3.12 && conda activate PolyGateway make install # editable 安装(dev + 全部 extras) make test # pytest + 覆盖率(目标 ≥80%) make lint # ruff + import-linter diff --git a/pyproject.toml b/pyproject.toml index f171ad8..1755ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.2.4" +version = "1.3.0" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" dependencies = [ "httpx>=0.27", "pydantic>=2.8", @@ -56,7 +56,7 @@ markers = [ ] [tool.ruff] -target-version = "py311" +target-version = "py312" line-length = 100 [tool.ruff.lint] diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 8c24cb5..059b7d4 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -325,6 +325,32 @@ flowchart TB 6. **开路/全源耗尽**: `CircuitOpenError` / `AllSourcesExhausted` → 按配置 wait(等待恢复,含 stall 判定)或 fail-fast 上抛。 7. **任意时刻取消**: `CancelledError` 穿透所有层;in-flight permit 与连接在 finally 释放。 +### 4.5 资源所有权纪律: 谁建的谁关,注入的一律不碰(2026-08-24,issue #15) + +这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态—— + +| 形态 | 位置(修复前) | 性质 | +|---|---|---| +| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 | +| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 | +| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 | +| 对照组: `RedisLimiter._owns_client` 的纪律**一直是对的** | `limiter.py:185-191, 318-322` | 正确先例 | + +纪律把已有的那个正确先例推广为全库唯一说法,分两层落地: + +| 层 | 所有权归属 | 落法 | +|---|---|---| +| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 | +| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) | + +三条实现细则各自都是"少写一条就等于纪律不成立": + +1. **默认必须是"不拥有"**。`__init__` 是全量注入路径,经它传入的一切组件一律 `_owns_* = False`,只有三个工厂在真正自建时置 True。默认若反过来,直接构造路径下共享 transport 仍会被第一个 client 关掉。 +2. **判定一律用 `is None` / `is not None`,不用 `or`**。工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好。 +3. **三个 client(chat/embedding/ocr)必须逐一持有 limiter/breaker 引用并各自被测试钉一次**。收敛成 helper 之后仍要三处各钉一次,否则下次有人把逻辑复制回去无人发现;`GatewayClient` 此前把 limiter/breaker 交给 `RetryMW` 后自己不留引用,`aclose` 因此触达不到自建的 redis 客户端,泄漏就是这么来的。 + +公共 API 面零变化(`_owns_*` 是私有属性)。**对下游的可见后果**只有一条,且必须显式声明: `aclose()` 不再关闭注入进来的组件,若有下游依赖了"注入后由 client 代关",升级后需自己关。 + --- ## 5. 核心类型 @@ -525,6 +551,39 @@ flowchart TB - **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 - 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。 +**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457` 的 `if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=, timeout=<预算>, command_timeout=<预算>)`,三条随之确立: + +| 语义 | 内容 | +|---|---| +| 建池零成本 | `min_size=0` 时 `_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 | +| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) | +| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 | + +两处实现纪律,都是"看起来完成了、其实资源还挂着"的形态,必须写下来否则会被改回去: ① **不得用 `async with pool.acquire(...)`**——`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`),外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行的那个 shielded release **会正常等到完成**,业务路径真实上界变成 ≈ 2 × 预算;故改为显式 `acquire(timeout=<完整写入预算>)` + `finally: release(con, timeout=1s)`(内层传完整预算而非剩余量: 真正的上界是外层那一层 `asyncio.timeout`),释放超时即 `con.terminate()`,承诺精确化为"主写入尝试 ≤ 预算,释放路径独立有界"。② **`aclose()` 必须有界且终局**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时无限等、60 秒只发一条 warning(`pool.py:939-948, 961-972`),故走 `asyncio.wait_for` + 超时 `terminate()`;同时置 `_closed`,此后写入短路且**不复活**——原实现关完池后下一次写入会拿 DSN 悄悄自建一个新池,注入方以为自己管着全部连接、实际早已不是(issue #15 实施期发现,是下面所有权根因的又一处表现)。 + +**遥测失败的三分判据(2026-08-24,issue #15)**: 判死判据此前挂在"**哪一步**失败"(`_open_pool` 失败即永久判死),而那一步里同时藏着两类性质完全不同的失败——DSN 非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。判据改挂"失败是**什么性质**",两句话说完: + +1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。 +2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。 + +| 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 | +|---|---|---| +| 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) | +| 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call` 的 `except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 | +| 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 | + +四点必须一起记住,否则后来人会把判据改回去: ① **致命档窄到只剩 DSN 一类是有意的**——认证失败、库不存在、表建不出来一律归环境级,因为 DBA 改完密码/建完表就该自动恢复,而永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形;②**`42703` 是唯一具名例外**,按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了优先级更高的承诺——manual 档缺列时按现有列裁剪 `INSERT` 继续写、缺列以逐行 warning 暴露,即"部分列写进去了"这件事本身有价值,不该被冷却掉;新增例外必须同款论证。③ **认不出的失败一律归最轻档(行级)**,这个保守缺省在建池路径上是安全的,理由是 `min_size=0` 让建池不触库(实测 0.000s),"下次调用重试建池"本身**零成本**——原实现注释担心的"每次重试内联吞一次 connect 超时"在新语义下不再成立;④ **表里那条 `TimeoutError` 规则只在准备期路径可达,写入期不可达**(2026-08-24 合并前审查发现,**本轮只记录不改行为**): `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的任何超时都在那里被按行级丢弃,不会走到分类函数。真实后果是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用内联付满一个写入预算(缺省 5s)、丢一行、`degraded` 保持 False、**不进 60s 冷却**——即"冷却把最坏成本压成每 60s 一次、上界一个预算"这句承诺只在准备期路径上成立。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行走行级、不置 degraded"本就是明确记下的有意取舍(见下一段中"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条)。是否给"连续超预算丢行"升档,留作后续议题。 + +**降级的可见性与可编程性(2026-08-24,issue #15)**: 铁律里"遥测后端挂 → 静默降级"的"静默"指的是**不向调用方冒泡**,不是"没有日志、没有状态"。此前它被实现成了后者——全程只有一条 warning,长跑进程里等同于消失(issue 是人工比对"日志里的完成里程碑条数 vs `llm_calls` 行数"才发现的,期间 19 次调用一行未落);SQLite 侧更糟,初始化失败后写入直接 `return`,连 warning 都没有。"遥测必录"铁律的实质要求是: **库做不到必录时,必须持续、可编程地让下游知道**。落法是 `telemetry/status.py` 的 `TelemetryStatusTracker`——两个 recorder 共用、不含任何后端知识(只接受"降级了/恢复了/丢了一行"三个事实),进入与恢复各一条日志(**进入那条的级别由 `fatal` 决定,且只在 tracker 这一处决定**: 致命档 error——人配错了、本进程内不会自愈,其余 warning——外部状态、会自愈;recorder 侧不得再复制一条,否则同一事实两条日志、级别两个源头),降级期间按行数(100 行)与时间(300s)双阈值节流复述,`snapshot()` 给只读 `TelemetryStatus`(`degraded`/`fatal`/`reason`/`degraded_for_s`/`dropped_rows`/`retry_after_s`),经三个 client 的 `telemetry_status` 属性出口。三条设计约束: + +- **不叫 `health`**: 该词在 `ports.py` 已被 `OcrTransport.check_health`(源探活)与 `SourceSelector.health(source_name) -> float`(成功率 EWMA)占用两次,库内 `health` 一律指"源的健康度";这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分(P2)。 +- **不并入 `TelemetryRecorder` 主 Protocol**,新起**独立**端口 `TelemetryStatusProvider`: 前者是 `@runtime_checkable`,而 runtime 检查按属性存在性做——加一个成员会让所有只实现 `record_llm_call` 的对象**当场不再是** `TelemetryRecorder`,库内与下游的同款 `isinstance` 断言升级即断。client 侧取值经**一处** `isinstance` 判定,不重演 `aclose` 那种三处复制的鸭子类型。 +- **`TelemetryStatus` 进顶层 `__all__`**(与 `SourceStats` 不同): 后者是端口内部快照、下游不消费,而本类型是 `client.telemetry_status` 的返回类型,下游要拿它做类型标注与对账——"顶层导出即公共 API 面"的约定要求它出现在那里。端口 `TelemetryStatusProvider` 则不导出(库外无实现者,导出即多一份永久承诺)。 +- **`degraded` 与 `dropped_rows` 覆盖的不是同一件事,下游对账必须两个都看**: `degraded` 只在**环境级/致命级**失败(服务端真的说了"不可用",如 53300)时置位;而写入因**本地池饱和**超出写入预算被丢时走的是行级丢弃——`degraded` 保持 False,只有 `dropped_rows` 增长。这是有意的(池满是本进程并发过高,不是后端挂了,冷却 60s 只会白丢更多行),但只按 `degraded` 配告警的下游会**完全看不见**这一类丢行,而它恰恰是 `pool_max` 配小了的唯一信号。 +- **SQLite 侧只做可见性**,不做 lazy 化与冷却重连: 它的失败模式(本地目录不可写、文件损坏)在装配期就暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确。这个不对称是已知且有理由的;tracker 与快照两侧共用,将来要对称时接口已就位。 + +**资源所有权在遥测侧的落点**: 通用纪律见 §4.5。对遥测的直接后果是 §7.7 R5 那条"共享必须显式注入"第一次真正可用——`PostgresRecorder(dsn, pool=<外部池>)` 与"多个 client 注入同一个 recorder"都不再被第一个 `aclose()` 弄死,issue #15 提的"共享池"方向由此以显式注入形态自然成立,不需要任何隐式全局注册表(那会违反"纯 asyncio 中立: 无全局状态、无模块级单例")。 + ### 7.9 结构化输出阶梯(D14) | 级 | 内容 | 成本 | @@ -587,6 +646,7 @@ src/polygateway/ - **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。 - **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。 - **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。 +- **`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(2026-08-24,issue #15)**: 两个可选键,**env 装配路缺省 4 与 5.0**。库必须对"自己该占多少资源"有一个可陈述的表态(不表态就等于继承第三方默认值,那正是 issue 的病根,见 §7.8),但**表态的落点是 `_load_pool_max`/`_load_write_timeout` 这条 env 装配路,不是字段默认值**: `GatewaySettings.telemetry_pg_pool_max` / `telemetry_pg_write_timeout_s` 与相邻三个遥测键**一样是无默认值的必填字段**,直接构造 `GatewaySettings` 的调用点需补两个参数(dataclass 语义上也只能如此——这两个字段后面跟着四个无默认值字段,就地加默认值即 `TypeError: non-default argument follows default argument`)。缺省写在 config 一处,`PostgresRecorder` 的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**参数(与 `auto_migrate` 同一纪律: 缺省规则不与类签名漂移)。值域校验(`pool_max >= 1`、`write_timeout_s > 0`)落 `GatewaySettings._validate_telemetry`,与 `telemetry_text_cap` 同一先例覆盖**三条装配路**(直接构造 / `dataclasses.replace` / env),报错文本同时点字段名与 env 键名。两键都带 `PG` 前缀与 `PGW_TELEMETRY_PG_DSN` 对齐: SQLite 侧的等价物(`busy_timeout=5000`)本次不动,这个不对称是已知且有理由的(§7.8 末)。**冷却期 60s 有意不给键**——无部署差异理由(P1 YAGNI)。`pool_max` 的调参口径必须按实测折算而非按 `pool_max / RTT` 估算: 跨内网 RTT ≈ 123ms 的实验室 PG 上 `pool_max=4` 实测约 **15.6 行/秒**(50 行并发批 3.2s),一次 `INSERT` 的实际往返比一次 `SELECT 1` 重一倍。 --- @@ -659,7 +719,7 @@ src/polygateway/ | # | 问题 | 建议 | |---|---|---| | Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 | -| Q2 | Python 最低版本 | 3.11(覆盖三项目: 3.11×2 + 3.13×1) | +| Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") | | Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 | | Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) | | Q4 | conda 环境名 | `PolyGateway` | diff --git a/research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md b/research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md new file mode 100644 index 0000000..ae6796b --- /dev/null +++ b/research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md @@ -0,0 +1,282 @@ +# 遥测连接池的资源语义与生命周期: 从"预占 10 条"到"按需 0 条" + +- **issue**: #15(共享 PostgreSQL 实例,`max_connections=100`,多 worker × 多 scope 部署) +- **核查基准**: HEAD 1.2.4。issue 按 1.1.2 运行环境提交并已自行复核 1.2.4,本文逐条重核**全部成立**: `postgres.py:100`(建池不传 min/max)、`postgres.py:106`(建池失败即永久判死)、`postgres.py:246-251`(`aclose` 不清 `_failed`)、`client.py:405-420`(每个 client 各 new 一个 recorder)。`min_size`/`max_size` 在整个包内**一次都没出现过**。 +- **状态**: **已实施**(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`,T0–T7 见实现计划末尾的提交表)。人类已确认方案与全部四组改动 + 缺省值;**Codex 已审,7 条全部处置完毕(§9)**;实施期的三处修订以 §10 标注 +- **实测环境**: asyncpg 0.31.0;真实实验室 PG(`polygateway` 专用库,跨内网 RTT ≈ 123ms) + +## 1. 问题的真实形状 + +issue 把问题命名为"asyncpg 默认 `min_size=10` 太大"。这个命名会把方案引向"改个默认值"。实际是**四层缺陷叠加**,只改默认值会留下三层,且下一次换个瞬时错误(PG 重启、DNS 抖动)照样全量失遥测。必须分开命名。 + +### 1.1 前提实测: `min_size` 的语义是"预连接",不是"下限" + +asyncpg `pool.py:457` 是 `if self._minsize:` ——为 0 时 `_initialize` 只创建 holder 对象,**一条连接都不连**。由此实测得到本设计的全部地基: + +| 实测项 | `min_size=0, max_size=2` | 默认 `10/10`(现状) | +|---|---|---| +| 建池指向**不可达**端口 | **立即成功**,0.000s,`size=0` | 立即抛 `ConnectionRefusedError` ← **issue 的失败点** | +| 建池连真实库 | 0.000s,`size=0` | 0.72s,**10 条常驻** | +| 首次写入 / 稳态写入 | 513ms(含建连 ≈390ms)/ **123ms**(一次 RTT) | 同(稳态无差异) | +| `acquire` 失败后再 `acquire` | 照常重试,池不进坏状态 | — | +| 空闲超 `max_inactive_connection_lifetime` | 连接归 0,下次写入重连 | 同 | + +**关键推论**: `min_size=0` 不只是"调小",它把建池从一次全有全无的重资源动作变成**零成本、不触库**的动作。这一步走出去,后面三层的性质全变。 + +**实施后在同一台真实实验室 PG 上的复测(T6,按唯一 `application_name` 过滤 `pg_stat_activity`)**,是全套证据里最直观的一条: 修复前建完 recorder 即 **10** 条连接;修复后 **0**(建 recorder)→ **1**(一次写入)→ **4**(20 行并发,恰为 `pool_max`)→ **0**(`aclose` 后)。四个数字逐一对应上表的四行推论。 + +### 1.2 缺陷一: 库对自己的资源占用从未表态——而这是全库唯一一处 + +`create_pool(self._dsn, timeout=10)` 继承第三方默认值(P4/P5: 默认参数掩盖关键逻辑)。横向扫过库内每一处外部资源: + +| 组件 | 建连方式 | 上限 | 预占? | +|---|---|---|---| +| httpx transport(`openai_compat.py:301`) | 按需 | 100(httpx 缺省) | 否 | +| RedisLimiter / RedisGate / RedisCache | 按需 | 无上限(redis-py 缺省) | 否 | +| **PostgresRecorder** | **预占 10 条,否则建池失败** | 10 | **是** | + +**库内每一处外部资源都是按需建立,唯独遥测池预占**。issue 那句"业务侧一条一条按需要,这个池要么一次拿到 10 条、要么建池失败,所以余量紧张时先倒下的必然是它"完全正确——它是链路上最脆的一环,承担的却是最不该悄悄失败的职责。issue 现场规模: 4 client × 10 = **40 条常驻专用于写遥测**,而实际写入并发是个位数。 + +### 1.3 缺陷二: 判死判据挂在"哪一步失败",而非"失败是什么性质" + +issue #9 已把判死收窄为"确定写不进去",但漏了一格: `_open_pool` 这一步里**同时藏着两类失败**——DSN 本身非法(进程内不可能改变)与 `too many clients` / 网络抖动(外部状态,随时可能好)。因为 `min_size=10` 让瞬时错误**发生在建池这一步**,它就被 `postgres.py:106` 一刀切成了永久判死。 + +判据错位的证据: `postgres.py:104-105` 的注释"池建不出来 = 确定写不进去"——这句话在 `min_size=10` 下是**假的**(连接耗尽不是确定写不进去,是这一秒写不进去);在 `min_size=0` 下才为真。**注释描述的是设计意图,代码实现的是另一件事**,中间的差额就是这次事故。 + +### 1.4 缺陷三: 降级不可恢复,且不可见 + +| 性质 | 现状 | 后果 | +|---|---|---| +| 不可恢复 | `_failed` 置位后无任何恢复路径;`aclose()`(`postgres.py:246-251`)只清 `_schema_ready` **不清 `_failed`** | 只有进程重启能恢复 | +| 不可见 | 全程只有**一条** warning(`postgres.py:107`) | 长跑进程里等同于静默 | + +issue 是**手工对账**(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的,期间 19 次调用一行未落、成本少记约 $5。这就是"遥测必录"铁律的实质破口: 库做不到必录时,必须**持续、可编程地**让下游知道。SQLite 侧更糟——`sqlite.py:138-139` 初始化失败后写入直接 `return`,**连 warning 都没有**。 + +### 1.5 缺陷四: 共享路径是坏的,所以每个 client 只能各占一份 + +issue 建议"让指向同一 DSN 的多个 recorder 共享一个池"。这条路今天走不通,而且不通的原因是一个**跨组件的所有权纪律缺口**: + +| 现象 | 位置 | 性质 | +|---|---|---| +| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry → 共享 recorder 被第一个关闭的 client 弄死 | `client.py:271-273`(`embedding.py:455-461`、`ocr.py:465-467` 各有一份复制) | 越权 | +| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 | +| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不碰 limiter/breaker) | `client.py:263-280` | **泄漏** | +| 对照组: `RedisLimiter._owns_client` 纪律**是对的** | `limiter.py:185-191, 318-322` | 正确先例 | + +**实施期挖出的第四个现象(T4,本设计原稿未预见)**: 注入外部池时,`aclose()` 之后的下一次写入会拿 DSN **偷偷自建一个池**——注入方以为自己管着全部连接,实际早已不是。它与上表三条同一根因(库不区分"这个资源是谁的"),只是表现在**关闭之后**而非关闭当时,故原稿按"谁关谁的"扫一遍时没看见。修法归入 §3.2 第 4 点的"关了就是关了": 置 `_closed` 后写入短路且不复活。 + +三个现象一个根因: **库对"谁建的、谁负责关"没有统一纪律**。ARCH §7.7 R5 规定"共享必须显式注入",但显式注入这条正道今天是坏的,下游只能退回"每 client 各占一份"——缺陷一的放大器由此长在架构里,而不是长在某个默认值里。 + +## 2. 备选方案与否决理由 + +| 备选 | 否决理由 | +|---|---| +| 只把默认值调小(issue 方向 1 单独做) | 脆点消失,但 §1.3 的判据错位仍在: 下次 PG 重启/DNS 抖动落在准备期,照样永久失能。治标 | +| 只加建池退避重试(issue 方向 3 单独做) | 在错的地方加复杂度。`min_size=0` 之后建池已不触库,**没有可重试的失败**;真正需要重试的是 acquire,而那里本来就有正确行为 | +| 隐式全局池注册表(DSN → 共享池) | 违反"纯 asyncio 中立: 无全局状态、无模块级单例"铁律,且解决的是 `min_size=0` 之后已不存在的问题(闲时占 0) | +| 暴露 `min_size` 配置项 | 它唯一的作用是把脆点装回来,换取首次 390ms。库没有理由提供一个只会伤人的旋钮(P1+P5) | +| 遥测改异步队列 + 后台 flush | 真正彻底消除"遥测拖慢业务",但引入进程崩溃时的丢数据窗口——与遥测被下游当**审计证据**用(§7.8/issue #12 决策 E-a)正面冲突;还要背负后台任务生命周期与背压策略。重大架构变更,不在本 issue 换取的收益内 | +| 把遥测失败塞进 `errors.py` 四分类 | 四分类的语义是"决定重试/换源/熔断"(ARCH §5.1)。遥测失败既不冒泡也不参与那套决策,塞进去会污染分类语义。改为在遥测子系统内定义自己的三分,收敛在一处(§3.2) | + +## 3. 设计 + +### 3.1 A 组 · 池语义: 显式声明,按需建连 + +`create_pool(dsn, min_size=0, max_size=<配置>, timeout=<写入预算>, command_timeout=<写入预算>)`。 + +- **只暴露 `max_size`**(理由见 §2)。稳态占用从"40 条常驻"变成"实际并发,闲时 0"。 +- 整次写入(`_ensure_ready` + `acquire` + `execute`)由 `asyncio.timeout` 包一层**硬预算**,超时按行级丢弃。这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证。 +- `acquire` 必须显式传 timeout。今天 `postgres.py:238` 的 `pool.acquire()` **无超时**(asyncpg 缺省 `timeout=None` = 无限等待),池满时会无限期挂在业务路径上——现状因 `max_size=10` 而未暴露,`max_size=4` 后必须补齐。 +- **不得用 `async with pool.acquire(...)`(Codex 审查,2026-08-24,已核实)**。`Pool.release()` 是 `await asyncio.shield(ch.release(timeout))`,且该 timeout **默认取 acquire 时记录的 `ch._timeout`**(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `async with` 的 `__aexit__`,此时**没有新的 cancel 投递**,那个 shielded release 会正常等到完成——于是业务路径的真实上界是 **≈ 2 × 预算**,而不是文档原先承诺的一个预算。故改为显式 `con = await pool.acquire(timeout=self._write_timeout_s)` + `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`。**acquire 传的是完整预算而非剩余预算**(实施期核定,T3): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍,徒增出错面;实测总耗时正好等于预算。承诺相应精确化为: **主写入尝试 ≤ 预算,释放路径独立有界**。 +- `CancelledError` 穿透由测试钉死: `asyncio.timeout` 只把自己触发的 cancel 转成 `TimeoutError`,外部取消照常以 `CancelledError` 冒出(实测确认,Codex 独立复现)。**实现纪律**: 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`(铁律"取消可穿透")。 + +### 3.2 B 组 · 失败三分与冷却降级 + +**判据(两句,写进 ARCH)**: + +1. **致命 = 失败原因完全在进程内部且不可变**;其余一切失败都可能被外部修好,故一律带冷却重试。 +2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。 + +第 2 句是 Codex 审查(2026-08-24)后补的,**原稿只有第 1 句,而分类表把 SQLSTATE `42` 整类归了行级——这与第 1 句自相矛盾**: 42501(账号被收走 INSERT 权限)、42P01(表被迁走/删掉)都是"能被外部修好"的持续性状态,却要在每次 LLM 调用上内联付一次 ≈123ms 往返并刷一条 warning,永远不会自愈也永远不停。按 SQLSTATE 前两位切太粗,必须切到具体码。 + +归档(asyncpg 0.31 异常层次 + PG SQLSTATE,**按 SQLSTATE 分类而非异常类白名单**——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移): + +| 档 | 判据 | 处置 | +|---|---|---| +| **配置级致命** | `ClientConfigurationError`(DSN 本身不可解析,`InterfaceError`/`ValueError` 子类);`create_pool` 抛的 `ValueError`/`TypeError`(参数非法) | 永久 no-op + 一条 **error**(人配错了,不是 warning) | +| **环境级不可用** | SQLSTATE `08`(连接)/`53`(资源不足,含 **53300 too many connections**)/`57`(管理干预)/`28`(认证)/`3D`(库不存在)/**`42501`(无权限)**/**`42P01`(表不存在)**;`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅准备期路径可达**——写入期的超时被 `record_llm_call` 的 `except TimeoutError` 先接住并按行级丢弃,见第 3 点);**表确定不存在且建不出来** | **冷却降级**(内部常量 60s),到期允许**一次**重新准备 | +| **行级拒绝** | 其余 `PostgresError`: 数据与约束类(`22`/`23` 等),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级(`postgres.py:242-244`),但**接入节流复述** | + +四点必须说清: + +1. **致命档收到极窄是有意的**。认证失败、库不存在、表建不出来一律归环境级——它们都是外部状态,DBA 改完密码/建完表就该自动恢复。永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形,而 DSN 是构造期固定的字符串,是唯一满足这条的东西。 +2. **`42703` 是判据的唯一具名例外,且必须写明理由**。按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了一条更高优先级的承诺: manual 档缺列时**按现有列裁剪 INSERT 继续写**,缺列以逐行 warning 暴露,好让下游发现 schema 漂移——即"部分列写进去了"这件事本身有价值,不该被冷却掉。代价(无限逐行 warning)由接入节流复述抵消。**例外只此一条,新增例外必须同款论证**。 +3. **带冷却正面回答了 `postgres.py:104-105` 的顾虑**。那条注释担心的是"每次调用都内联吞一次 connect 超时";冷却 + §3.1 的硬预算把最坏成本变成"每 60s 一次、上界一个预算",有界且可解释。**进程不再需要重启**。 + **这句承诺的适用范围是准备期路径**(2026-08-24 合并前审查校正,**只改文档不改行为**): `TimeoutError` 是 `OSError` 子类、本表据此归环境级,但 `record_llm_call` 的 `except TimeoutError` 排在 `except Exception` 之前,写入本体抛出的超时一律在那里按行级丢弃,`_handle_failure` 根本不会被调用——写入路径上这条分类规则是死代码。于是"后端 TCP 通但不回应(假死)且 schema 已就绪"时,每次业务调用仍内联付满一个预算(缺省 5s)、丢一行、`degraded` 保持 False、不进冷却。不改的理由: 相对改前的"无限期挂"仍是净改善,且"超预算丢行不置 degraded"是 §6"突发排队"与 ARCH §7.8"`degraded` 与 `dropped_rows` 覆盖的不是同一件事"那一条明确记下的有意取舍;升档议题见 §6 的"连续超预算丢行是否该升档"一格。 +4. **`aclose()` 的语义钉死为"关了就是关了"**: 置 `_closed`,此后写入短路且**不复活**。今天"关完还能自己重建池"的灰色状态取消。issue 提的"`aclose` 不清 `_failed`"由冷却机制解决,不由 `aclose` 解决——恢复是运行时行为,不是关闭动作的副作用。 + **关闭动作本身也必须有界(Codex 审查,已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(`pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着"advisable to use `asyncio.wait_for` to set a timeout"。故 `aclose()` 走 `asyncio.wait_for(pool.close(), ...)`,超时后 `pool.terminate()`,外部取消照常穿透——否则"遥测不得拖垮业务"在收尾路径上开了个口子。 + +分类函数是全库唯一一处 PG 失败分类,作 `postgres.py` 模块级私有函数(与 recorder 同文件、只服务 PG;不新起文件避免碎片化)。**认不出的失败归最轻档(行级)**是它的保守缺省,而这个缺省在**建池路径**上安全的理由比"最轻档代价最小"更强(实施期核实,T5): `min_size=0` 让建池不触库(实测 0.000s),所以"归行级 = 下次调用再重试一次建池"本身**零成本**——`postgres.py:104-105` 那条注释担心的"每次重试内联吞一次 connect 超时"是 `min_size=10` 语义下的顾虑,在新语义下**不成立**。这是 §1.1 那个关键推论的又一处红利: 地基一换,原本需要小心处理的保守缺省变成了白拿。 + +### 3.3 C 组 · 降级可见 + 可编程 + +新增 `telemetry/status.py` 的 `TelemetryStatusTracker`(两个 recorder **共用**,消除两侧不对称): + +| 能力 | 行为 | +|---|---| +| 进入降级 | 一条日志,含原因分档与恢复条件(冷却剩余 / "需重启");**级别由 `fatal` 决定且只在这一处决定**——致命档 error(人配错了,不会自愈)、其余 warning。recorder 侧不得再复制一条(实施期更正 #4) | +| 降级期间 | 按丢弃行数与时间**节流复述**(不刷屏,也不静默)——这一条是 §1.4 的直接钉子 | +| 恢复 | info 一条,报告"期间丢弃 N 行" | +| 快照 | `TelemetryStatus` frozen dataclass(放 `types.py`,与 `SourceStats` 同一先例): `degraded` / `fatal` / `reason` / `degraded_for_s` / `dropped_rows` / `retry_after_s` | + +**不叫 `health`,是因为这个词在 `ports.py` 里已经被占用两次**(本轮自查发现,Codex 未提): `OcrTransport.check_health`(`ports.py:90`,源探活)与 `SourceSelector` 侧的 `health(source_name) -> float`(`ports.py:234`,成功率 EWMA)。库内 `health` 一律指**源的健康度**,而这里描述的是"这个 recorder 现在能不能写、为什么不能、丢了多少",是状态不是评分。同一文件里一词两义会直接违反 P2(领域术语命名)。 + +**不并入 `TelemetryRecorder` 主 Protocol(Codex 审查,已核实)**: 该 Protocol 是 `@runtime_checkable`(`ports.py:246`),而 runtime 检查按属性存在性做——加一个 `status` 属性,会让所有只实现 `record_llm_call` 的实现**当场不再是** `TelemetryRecorder`。库内 `tests/unit/test_ports.py:137,141` 就有 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言,下游若用同款断言,升级即断。原稿"库外无第三方实现者故加属性零成本"的判断**只覆盖了静态类型,漏了运行时结构契约**。改为: + +- 独立可选端口 `TelemetryStatusProvider`(单方法/单属性,`@runtime_checkable`),两个内置 recorder 实现它;`TelemetryRecorder` 逐字不动。 +- 出口 `GatewayClient.telemetry_status -> TelemetryStatus | None`(None = 未启用遥测,或注入的 recorder 不提供)。取值经**一处** `isinstance(..., TelemetryStatusProvider)` 判定,不重演 `aclose` 那种三处复制的鸭子类型。 +- `types.py` 与 `ports.py` 同层且允许互 import(import-linter `ports : types : errors` 契约),分层不破。 +- 时钟经构造参数注入(`now: Callable[[], float] = time.monotonic`,与 `GatewayClient(now=...)` 同款),冷却与节流均可测。快照对外给 `degraded_for_s` **相对时长**而非绝对时间戳,避免 monotonic 与 wall clock 两个时钟并存的二义。 +- **SQLite 侧本次只做可见性**(补上缺失的 warning + 接入 tracker + 快照),**不做** lazy 化与冷却重连。理由: SQLite 的失败模式(本地目录不可写、文件损坏)在装配期就会暴露给下游,不是"跑到一半悄悄断",永久降级在那里语义基本正确;lazy 化是独立重构。tracker 与快照两侧共用,将来若要对称,接口已就位。 + +### 3.4 D 组 · 资源所有权纪律统一 + +把 `RedisLimiter._owns_client` 这个**库内已有的正确先例**推广为全库唯一纪律: **谁建的谁关,注入的一律不碰**。区分两类: + +| 类 | 所有权归属 | 落法 | +|---|---|---| +| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 → **`RedisCache` 补齐这条纪律** | +| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性(与 `RedisLimiter.from_url:190` 逐字同款模式),`aclose` 只关自建的 | + +- **默认必须是"不拥有"**: `__init__` 是全量注入路径(`client.py:126-149`),经它传入的一切组件一律视为**外部所有**(`_owns_* = False`),只有三个工厂在 `or _build_*` / `if telemetry is not None else _build_telemetry` 真正自建时才置 True。原稿只写了"工厂置位"没写死这条默认,Codex 据此指出直接构造路径下共享 transport 仍会被第一个 client 关掉——那是实现走偏的后果,但默认值本就该在设计里定死,故补。 +- 三处复制的 `getattr(..., "aclose")` 收敛为一个内部 helper;所有权修正必须三处一致,复制就是下一个 bug 的种子。 +- `aclose` 补关 limiter/breaker——修掉现存泄漏。这需要**三个 client 都新持引用**: 今天 `GatewayClient.__init__` 把 limiter/breaker 交给 `RetryMW` 后自己不留引用(`client.py:133-134`),embedding/ocr 同样(`embedding.py:497-498`、`ocr.py:510-511` 自建、`embedding.py:452-461`、`ocr.py:462-467` 的 `aclose` 触达不到)。内存后端无 `aclose`,helper 探测后跳过。 +- **判定一律用 `is None` / `is not None`,不得用 `or`**(实施期补,T1): 工厂里 `limiter or _build_limiter(...)` 这种写法在注入一个 falsy 后端时会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这是所有权判定能成立的**必要条件**,不是风格偏好,故写进设计而非留在代码里。 +- **零公共 API 面变化**: `_owns_*` 是私有属性,由工厂置位。 +- 有了 D 组,issue 的"共享池"方向以**显式注入**形态自然成立(`PostgresRecorder(dsn, pool=...)` 已支持且不关外部池),无需任何隐式全局。 + +### 3.5 新配置键与缺省值(人类已定) + +| 键 | 字段 | 缺省 | 依据 | +|---|---|---|---| +| `PGW_TELEMETRY_PG_POOL_MAX` | `telemetry_pg_pool_max: int` | **4** | 稳态吞吐**实测约 15.6 行/秒**(见 §6 的口径更正;原稿按 `max_size / RTT` 估的 32 行/秒偏乐观一倍),覆盖单 client 十余并发;闲时占 0,不构成常驻负担。issue 现场 4 client × 4 = 峰值 16、稳态趋近 0(今天是 40 条常驻) | +| `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | `telemetry_pg_write_timeout_s: float` | **5.0** | 实测稳态 123ms、首次含建连 513ms;5s 宽松且**有界**。同时用作 connect / acquire / 整次写入硬上界 | +| (无键) | 冷却期 | 60s,**内部常量** | 无部署差异理由(P1 YAGNI) | + +- 两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致,语义无歧义: SQLite 侧的等价物(`busy_timeout=5000`,`sqlite.py:58`)本次不动,这个不对称是**已知且有理由**的(见 §3.3 末)。 +- 校验落 `GatewaySettings._validate_telemetry`(与 `telemetry_text_cap` 同一先例,覆盖直接构造 / `dataclasses.replace` / env 三条路): `pool_max >= 1`、`write_timeout_s > 0`,报错文本同时点字段名与 env 键名。 +- 加字段的代价可控: `GatewaySettings(` 全库**只有 1 处**构造(`config.py` 的 `_load_pgw`),测试全走 `from_env`(74 处)+ `replace`(53 处),不重演 issue #13 那 35 处直接构造点的代价。三条链路(chat/embedding/ocr)因共用 `GatewaySettings` + `_build_telemetry` 自动覆盖。 + +## 4. 行为矩阵 + +| 场景 | 现状(1.2.4) | 本设计 | +|---|---|---| +| 建 client,共享实例余量 3 条 | 建池失败 → **整进程永久失遥测** | 建池成功(不触库),写入按需拿 1 条 → **正常落库** | +| 稳态写入 | 10 条常驻 | 闲时 0 条,忙时 ≤ `pool_max` | +| `too many clients` 落在首次准备期 | 永久判死 | 冷却降级 60s → 到期重试 → **自动恢复** | +| `too many clients` 落在稳态写入 | 丢一行,池自恢复(已正确) | 同,且进入降级态使其**可见** | +| PG 重启 / 网络抖动 | 视落点: 准备期 → 永久判死 | 一律冷却降级 → 自动恢复 | +| DSN 写错 | 永久 no-op + warning | 永久 no-op + **error**(措辞点明"配置错,需改 DSN 并重启") | +| 旧表缺列(42703) | 逐行 warning 丢弃 | 逐字不变(行级档) | +| 表不存在且建不出来 | 永久判死 | 冷却降级,DBA 建表后**自动恢复** | +| 遥测后端慢/挂 | `acquire` 无超时,可无限期挂在业务路径 | 硬预算封顶(5s),超时丢一行 | +| 降级期间下游想知道 | 只能人肉对账 | `client.telemetry_status` + 节流复述日志 | +| 多 client 注入同一 recorder | 第一个 `aclose` 把它弄死 | 各关自己的,共享 recorder 存活 | +| 自建 redis limiter | `aclose` 后**泄漏** | 被关 | +| 注入 redis 客户端给 RedisCache | 被 `aclose` 误关 | 不动 | + +## 5. 测试策略 + +行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。 + +**单元层**(`tests/unit/test_telemetry.py` 邻域,沿用既有假 asyncpg 模块): +建池参数断言 `min_size == 0` 且 `max_size == 配置值`(钉住"库对资源占用的表态",防回归到继承第三方默认值,这是本 issue 的**主回归钉子**);`too many clients`(53300)落在准备期 → 进冷却降级、**不** fatal → 假时钟推进 60s → 自动恢复;`ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`);**`42501`/`42P01` → 进冷却降级**、`42703` → 行级丢弃且**不**进降级(§3.2 的分档边界,两侧各钉一次);假 pool 的 acquire 挂住 → 硬预算生效、丢一行、耗时 ≤ 预算;外部 `CancelledError` 在 `asyncio.timeout` 内**不**被吞成 `TimeoutError`;状态快照六字段的状态机;节流复述(N 条丢弃只出 M 条 warning,loguru sink 断言);`aclose` 后写入不复活。 + +**收尾路径层**(Codex 审查新增,两条都是"看起来完成了、其实资源还在"的形态): +① `execute` 被硬预算取消后**连接不泄漏**——假 pool 记录 acquire/release 配对次数,断言超时路径上 release 照样发生且总耗时 ≤ 预算 + release 上限(钉 §3.1 那条 shielded release 的坑);② 持有连接不释放时 `aclose()` **不无限挂**——假 holder 永不 release,断言 `aclose` 在超时后走 `terminate()` 返回。 + +**所有权层**(`tests/unit/test_client.py` 邻域,假 recorder/transport 记 close 次数): +注入的 recorder/transport/limiter/breaker/cache 不被 `aclose` 关;自建的被关;自建 redis limiter/breaker 被关(泄漏钉子);注入给 `RedisCache` 的客户端不被关;三个 client(chat/embedding/ocr)**逐一**覆盖——收敛成 helper 后仍须三处各钉一次,否则下次复制回来无人发现。 + +**契约层**: `isinstance(只实现 record_llm_call 的对象, TelemetryRecorder)` 必须**仍为 True**(`tests/unit/test_ports.py:137,141` 现有断言保持绿即可,不需新增)——它是"没把 `status` 并进主 Protocol"这条决策的机械化执法点。 + +**集成层**(`tests/integration/test_postgres_telemetry.py`,真实 PG,沿用 run 级前缀隔离与"严禁 DROP/TRUNCATE"纪律,缺 DSN 则 skip、不标 slow): +`pg_stat_activity` 计数——建 recorder 后本池连接 **0** 条,一次写入后 **≤1** 条(issue 的直接回归钉子);稳态连接数 ≤ `pool_max`。**计数必须按唯一 `application_name` 过滤**(经 `server_settings` 设一个 run 级值): 该实例被多项目共用,按库名或用户名计数会被别人的连接污染,那样的用例是设计上就会间歇红的信号污染源(CLAUDE.md §4.6)。降级与恢复走**不可达 DSN** 的 recorder 验证,不去动共享实例的 `max_connections`。 + +## 6. 非功能与已知取舍 + +| 维度 | 结论 | +|---|---| +| 首次写入延迟 | `min_size=0` 把 ≈390ms 建连从"装配期"挪到"首次写入"。稳态无差异(实测 123ms);空闲超 `max_inactive_connection_lifetime`(asyncpg 缺省 300s,不暴露)后再付一次。相对一次秒级 LLM 调用可忽略 | +| 突发排队(**热池稳态**) | 业务并发 > `pool_max` 时遥测写入排队。按下一格更正后的实测口径(15.6 行/秒): 50 行同时到达 → 实测 3.2s,在 5s 预算内但**余量只剩约 1.5 倍**(原稿按 32 行/秒估算时以为余量有 3 倍);超出即丢行(铁律"丢一条 < 拖垮调用") | +| 突发排队(**冷启动/空闲后**) | 上一格的算术只在"schema 已就绪且连接已热"时成立。空闲超回收期后连接归 0,第一波要重新建连(实测 ≈390ms),且首次准备被 `_init_lock`(`postgres.py:75, 83-91`)串行保护——冷启动的最坏延迟不是 `64 / 32 ≈ 2s`。Codex 审查指出原稿这段易被读成两种情形通用,故拆开写。冷启动上界仍由硬预算封顶,超出即丢行 | +| Python 版本 | **本条取舍已消解**(人类决策,2026-08-24): 最低版本提到 **3.12**(`requires-python = ">=3.12"`、ruff `target-version = "py312"`),3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,`asyncio.timeout` 可直接用,不必退回 `wait_for`。代价见 §7 | +| `pool_max` 的调参口径(**实施期更正,T3 实测**) | 原稿的 `期望吞吐 ≈ pool_max / RTT`(4/0.123 ≈ 32 行/秒)**偏乐观一倍**: T3 实测 50 行并发批耗时 **3.2s**,即约 **15.6 行/秒**、每条连接约 4 行/秒——一次 `INSERT` 的实际往返比一次 `SELECT 1`(RTT 的测法)重。取舍方向不变(超预算丢行 < 拖垮业务),但 `.env.example` 与 README 的调参口径**必须写实测数字**,否则下游按错公式放大,以为 `pool_max=8` 能到 64 行/秒(实为约 31)。共享一个 recorder 给多 client 时并发在此汇聚,应按 client 数相应放大 | +| 冷却期的丢数 | 降级 60s 期间的行**确实丢了**,只是可见、可计数、且到期自动恢复。这是"遥测降级不得拖垮业务"的既有方向(ARCH 降级方向铁律),本设计不改方向,只改**可恢复性与可见性** | +| `42703` 缺列的持续逐行重试 | 缺列时每次调用付一次 acquire+execute(≈123ms 内联)且逐行 warning,不进冷却。**这是判据的唯一具名例外**(§3.2 第 2 点),由 issue #13 的"缺列须逐行暴露"承诺定死;代价由节流复述抵消。`42501`/`42P01` 原稿同归此格,经 Codex 审查已改判环境级 | +| 快照计数的线程安全 | `dropped_rows` 是单事件循环内的 int 自增。库不承诺跨线程共享同一 recorder("纯 asyncio 中立"),最坏是计数不准,不会崩 | +| SQLite 侧不对称 | 只做可见性,不做 lazy 化/冷却(理由见 §3.3)。tracker 与快照两侧共用,不产生第二套概念 | +| redis / httpx 的资源上限 | 两者均无上限或偏大(§1.2),但**按需建连、无预占脆点**,不是本 issue 的病灶。列为观察项,**本次不动**(反 gold-plating) | +| 连续超预算丢行是否该升档(**留作后续议题**) | 后端假死(TCP 通但不回应)且 schema 已就绪时,每次业务调用都内联付满一个预算并丢一行,`degraded` 恒 False、永不进冷却(成因见 §3.2 第 3 点)。本次不改行为——相对改前的"无限期挂"已是净改善,而升档需要新判据("连续 N 次超预算 = 后端不可用"),那是个有代价的猜测: 判错会把本地并发过高误判成后端挂了,冷却 60s 只会白丢更多行。要动就得先有实测依据,不在本 issue 范围内 | +| 端口签名 | `TelemetryRecorder` **逐字不变**(24 字段签名与 Protocol 成员集合都不动),不触碰迁移兼容约束(ARCH §5.1)、也不破坏 `runtime_checkable` 的既有 `isinstance` 语义;新增的是**独立**端口 `TelemetryStatusProvider` | + +## 7. 文档与发布 + +ARCH §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分判据(§3.2 那句判据是主要交付物之一)、**资源所有权纪律**(§3.4,应作为跨子系统的通用纪律成文,而非遥测局部约定)。§9 配置面登记两个新键。`.env.example`、README 能力表与配置表、Gitea wiki 按 `docs-convention.md` §2 同步。 + +**版号由人类在发布时定**,本文不预设: 按 semver 应是 **1.3.0**(端口新增只读属性 + 两处对下游可见的行为变更),但项目既有口径明显偏 patch——issue #11 扩遥测列(端口 22→24)落 1.2.1、issue #14 新增配置键 + `retry_after_s` 语义变更**设计文档写的是 1.3.0、实际发成了 1.2.4**。不核对这一条就照抄"1.3.0"会重演同一次不一致。 + +CHANGELOG 有四处需"请先读这一条"待遇(第 4 条是合并前审查补的): + +1. **最低 Python 提到 3.12**(人类决策,2026-08-24;`requires-python`、ruff `target-version`、README、CLAUDE.md 四处已同步)。这是四处里**唯一会让下游装不上**的变更: 仍在 3.11 的部署 `pip install` 直接被 pip 拒绝。这一条本身就足以把版号推到 **1.3.0**——它不是"新增能力",是缩小了支持面。 +2. 遥测常驻连接从 `10 × client 数` 变为按需(纯改善,但监控上会看到连接数曲线突变)。 +3. `aclose` 不再关闭注入的组件。这是修正越权,但若有下游**依赖**了"注入后由 client 代关",升级后会漏关——必须显式声明。 +4. **直接构造 `GatewaySettings` 需补两个参数**(合并前审查补,2026-08-24)。原稿漏了这一条,还把两个新字段写成"带缺省"——它们与相邻三个遥测键一样**无默认值**,缺省只在 env 装配路;直接构造的调用点升级即 `TypeError`,是货真价实的破坏性变更。 + +**版本提升的两项前置——已于 2026-08-24 执行完毕**(顺序不可颠倒,先改语法会当场把 import 全炸掉): + +| # | 前置 | 结果 | +|---|---|---| +| 1 | 重建 conda 环境(原 3.11.15 不满足新的 `requires-python`,`make install` 会被 pip 拒绝) | `PolyGateway` 重建为 **3.12.13**;`make install` 通过。比对新旧 `pip freeze` 发现重建**只**缺发布工具链(`build`/`twine` 及依赖,不在 `make install` 的 extras 里),已补装(twine 7.0.0) | +| 2 | `target-version = "py312"` 启用 UP047,3 处须改 PEP 695 语法(该语法在 3.11 是 **SyntaxError**) | `gather_bounded`(`client.py:443`)、`_anext_within`(`streaming.py:39`)、`stream_with_liveness_timeouts`(`streaming.py:60`)改为 `def f[T](...)`;两文件的模块级 `_T = TypeVar("_T")` 与 `TypeVar` import 随之删除 | + +验证: `make check` 全绿(ruff format + lint + import-linter 契约 KEPT),全套件 **973 passed / 23 skipped / 45 deselected(slow),覆盖率 94%**。这三处改动**不是本 issue 的重构**,是版本提升的直接后果,归入版本提升那个前置提交。 + +## 8. 已定决策(人类,2026-08-24) + +| # | 决策 | 随之固定的实施边界 | +|---|---|---| +| 1 | C 组只读状态快照**要做** | 新增**独立**端口 `TelemetryStatusProvider`(`TelemetryRecorder` 不动,理由见 §3.3);`types.py` 加 `TelemetryStatus`;client 侧一处 `isinstance` 判定。命名避开 `health`(该词在 `ports.py` 已两处占用) | +| 2 | D 组(所有权纪律)**一并做** | 改动面从 telemetry 扩到 client/embedding/ocr/backends。拆为**独立前置提交**(纪律统一 + 泄漏修复),验收标准"全套件绿 + 新增用例只在所有权层",该提交即回滚点 | +| 3 | 缺省 `POOL_MAX=4` / `WRITE_TIMEOUT=5.0` | 按 §3.5 落 config 校验;README 须给出调参口径,否则这两个旋钮等于不存在。**人类当时定的公式 `pool_max ≈ 期望吞吐 × RTT` 已被 §10 修订 #1 作废**(偏乐观一倍),文档一律写实测值 15.6 行/秒 | +| 4 | **最低 Python 提到 3.12**,版号定 **1.3.0** | 消解 §6 的 `asyncio.timeout` 版本取舍(可直接用,不退回 `wait_for`)。两项前置(重建环境、UP047 三处改 PEP 695)**已执行完毕并验证**,详见 §7。版号 1.3.0 的依据是缩小支持面,不是新增能力 | + +## 9. 审查留痕(Codex,2026-08-24) + +报 3 阻断 + 3 应改 + 1 可选,**逐条独立核实后 6 条采纳、1 条改判**。采纳的都不是措辞问题,而是"承诺比实现能给的更强"这同一类错误的不同实例。 + +| # | 档 | 结论 | 落点 | +|---|---|---|---| +| 1 | 阻断 | **采纳**。`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout,业务路径真实上界 ≈ 2 × 预算。核实于 `pool.py:886-889, 930-937` | §3.1 第 3 条;§5 收尾路径层① | +| 2 | 阻断 | **采纳,并回头改了判据本身**。SQLSTATE `42` 整类归行级与"能被外部修好的一律冷却重试"自相矛盾。补出第 2 句判据(行级 vs 环境级看"与本行数据有没有关系"),`42501`/`42P01` 改判环境级,`42703` 降为唯一具名例外 | §3.2 判据 2 与第 2 点;§5 单元层;§6 | +| 3 | 阻断 | **改判为实现约束**(非设计缺陷)。原稿"工厂置 `_owns_*`"已隐含"注入即不拥有",但确实没写死默认值。补为显式条款 | §3.4 第 1 条 | +| 4 | 应改 | **采纳,且原稿的理由本身是错的**。原稿称"库外无第三方实现者故加属性零成本"——这只覆盖静态类型,漏了 `TelemetryRecorder` 是 `@runtime_checkable`(`ports.py:246`),加属性会让 `tests/unit/test_ports.py:137,141` 的 `isinstance` 当场变 False。改为独立端口 | §3.3;§5 契约层;§6 | +| 5 | 应改 | **采纳**。`Pool.close()` 等 in-flight 释放会无限挂,60s 只 warning(`pool.py:939-948, 961-972`) | §3.2 第 4 点;§5 收尾路径层② | +| 6 | 应改 | **采纳**。limiter/breaker 引用要传穿三个 client,原稿只写了 chat | §3.4 第 3 条 | +| 7 | 可选 | **采纳**。吞吐算术只对热池稳态成立,冷启动另有口径 | §6 | + +**本轮自查另补两条 Codex 未发现的**: ① `health` 一词在 `ports.py` 已被 `check_health`(`:90`)与 `health(source_name) -> float`(`:234`)占用两次,故快照改名 `TelemetryStatus`(§3.3);② `asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一(§6)。 + +Codex 的取消穿透实测与本会话结论一致(外部 `task.cancel()` 在 `asyncio.timeout` 内冒出的是 `CancelledError` 而非 `TimeoutError`),两处独立验证互为佐证。 + +## 10. 实施期修订(2026-08-24,T0–T7 执行中发现) + +设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。 + +| # | 修订 | 落点 | +|---|---|---| +| 1 | **吞吐算术偏乐观一倍**。原稿按 `pool_max / RTT` 估 32 行/秒,T3 实测 50 行并发批 3.2s(≈15.6 行/秒)——`INSERT` 的实际往返比测 RTT 用的 `SELECT 1` 重。方向不变,但下游调参必须拿实测数字 | §3.5 表、§6 两格 | +| 2 | **原稿未预见的一处真 bug**: 注入外部池时 `aclose()` 之后的下一次写入会拿 DSN 偷偷自建一个池。与 §1.5 三条同根因,只是表现在关闭之后,T4 修掉 | §1.5 | +| 3 | **两条论证被补强**: ①"认不出的失败归行级"这个保守缺省在建池路径上安全,理由是 `min_size=0` 让重试建池零成本(T5);②所有权判定必须用 `is not None` 而非 `or`,否则注入 falsy 后端时自建分支与所有权标志漂移(T1) | §3.2 末、§3.4 | +| 4 | **日志级别的决策点收敛到 tracker**(独立验证发现)。原实现在 recorder 的 fatal 分支另发一条 `logger.error`,而 tracker 同时发一条语义重复的 warning——同一个事实两条日志,"级别"这个决策两个源头。改为 `enter_degraded` 按 `fatal` 选级别(error / warning),recorder 不再另发;SQLite 侧的致命档同步升为 error。**这条决策此前没有执法点**: 测试 fixture 挂 `level="WARNING"`,ERROR 与 WARNING 同池,删掉那条 error 用例照样绿。补 `captured_logs` fixture(连级别一起捕获)后三处补上级别断言 | §3.2 表、§3.3 表、§5 单元层 | +| 5 | **`acquire` 传的是完整预算,不是剩余预算**(独立验证发现,改文档不改代码): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍。行为无害,实测总耗时正好等于预算 | §3.1 | diff --git a/research-wiki/designs/issue15-telemetry-pool-lifecycle.md b/research-wiki/designs/issue15-telemetry-pool-lifecycle.md new file mode 100644 index 0000000..5eaf1ea --- /dev/null +++ b/research-wiki/designs/issue15-telemetry-pool-lifecycle.md @@ -0,0 +1,26 @@ +--- +type: design +node_id: design:issue15-telemetry-pool-lifecycle +title: "issue #15: 遥测连接池的资源语义与生命周期" +date: 2026-08-24 +--- + +# issue #15: 遥测连接池的资源语义与生命周期 + +正文: `2026-08-24-issue15-telemetry-pool-lifecycle-design.md`。状态: **已实施(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`)**——人类已确认方案、Codex 已审并逐条处置(正文 §9),T0–T7 全部完成;独立验证发现的 5 个问题已处置,实施期修订见正文 §10。前序: [[design:issue9-telemetry-ddl-probe]](判死判据的上一次收窄)、[[design:issue13-schema-mode]](schema 单一事实源)。 + +- **现象**: 共享 PG 实例余量紧张时,遥测**建池**失败 → `_failed` 永久置位 → 该 client 此后一行遥测都不落库,只有一条 warning,靠人肉对账才发现(19 次调用、成本少记约 $5)。 +- **选定方案(四组一次做完)**: A 池语义(`min_size=0` + `max_size` 可配,缺省 4 + 整次写入硬预算 5s);B 失败三分(配置级致命 / 环境级不可用 / 行级拒绝)+ 60s 冷却降级取代永久判死;C 降级可见(共用 `TelemetryStatusTracker` + 节流复述 + 只读快照 `TelemetryStatus`,走**独立**端口 `TelemetryStatusProvider`);D 资源所有权纪律统一(谁建的谁关)。 +- **地基是一条实测**: asyncpg `pool.py:457` 的 `if self._minsize:` ——`min_size=0` 时建池**零成本、不触库**(实测 0.000s、指向不可达端口照样成功)。这一步把"建池失败"从"混着瞬时错误的一刀切判死"变回真正的确定性失败,于是 issue 提的三个方向里,**方向 3(退避重试)大部分不必新建机制**(连接失败自动落到 `acquire`,那里本来就是"丢一行、池自恢复"的正确行为),**方向 2(共享池)从刚需降级为可选的显式能力**(闲时占 0)。 +- **判据是主要交付物(两句,经审查补全)**: ①**致命 = 失败原因完全在进程内部且不可变**,其余一切失败都可能被外部修好,故一律带冷却重试;②**行级 vs 环境级看失败与这一行的数据有没有关系**——只与本行数据有关(换一行可能成功)= 行级,与数据无关、每行都会同样失败 = 环境级。按此,致命档窄到只剩"DSN 本身不可解析";认证失败、库不存在、表建不出来、权限被收、表被迁走一律归环境级(修好即自动恢复)。分类按 **PG SQLSTATE**(切到具体码,非前两位整类)而非 asyncpg 异常类白名单,不随驱动版本漂移。 +- **`postgres.py:104-105` 的注释与代码不一致才是病灶**: 注释写"池建不出来 = 确定写不进去",这在 `min_size=10` 下是假的(连接耗尽只是这一秒写不进去)。冷却重试正面回应了该注释真正的顾虑("每次调用都内联吞一次 connect 超时"): 最坏成本变成"每 60s 一次、上界 5s"。 +- **D 组是范围扩展,理由是同一根因的另外三个表现**: `GatewayClient.aclose` 关掉**注入的** telemetry(共享 recorder 被第一个关闭的 client 弄死,三处复制)、`RedisCache.aclose` 关掉注入的 redis 客户端、自建的 limiter/breaker redis 客户端**从来没人关**(泄漏)。不修它,ARCH §7.7 R5 的"共享必须显式注入"这条正道就一直是坏的——缺陷的放大器长在架构里,不在某个默认值里。纪律推广自库内已有的正确先例 `RedisLimiter._owns_client`。 +- **被否决备选**: 只调默认值(判据错位仍在,下次 PG 重启照样永久失能);只加建池退避(`min_size=0` 后建池已无可重试的失败);隐式全局池注册表(违反"无全局状态、无模块级单例"铁律);暴露 `min_size`(唯一作用是把脆点装回来);**遥测改异步队列 + 后台 flush**(真正彻底消除"遥测拖慢业务",但引入进程崩溃丢数窗口,与遥测作为**审计证据**的定位正面冲突,见 [[design:issue12-telemetry-retention]] 决策 E-a);把遥测失败塞进 `errors.py` 四分类(那套语义是"决定重试/换源/熔断",遥测不冒泡也不参与,塞进去污染分类)。 +- **SQLite 侧有意只做一半**: 补可见性(今天初始化失败后写入连 warning 都没有),**不做** lazy 化与冷却。它的失败模式(本地目录不可写)在装配期就暴露,不是"跑到一半悄悄断",永久降级语义基本正确;tracker 与快照两侧共用,不产生第二套概念。与 [[design:issue9-telemetry-ddl-probe]] 的"两侧有意不对称"同一先例。 +- **发布**: 版号发布时由人类定(semver 指向 1.3.0,但项目既有口径偏 patch: issue #11 扩端口列落 1.2.1、issue #14 设计写 1.3.0 实际发成 1.2.4)。两处需"请先读这一条"待遇: 遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);`aclose` 不再关闭注入的组件(修正越权,但依赖过"注入后由 client 代关"的下游会漏关)。 + +- **审查留痕(Codex,2026-08-24)**: 报 3 阻断 + 3 应改 + 1 可选,核实后 6 条采纳、1 条改判为实现约束。三条最重的都是同一类错误——**承诺比实现能给的更强**: ① "写入墙钟上界 = 一个预算"不成立,`async with pool.acquire()` 的释放路径是 shielded 且复用 acquire 的 timeout(`pool.py:886-889, 930-937`),真实上界 ≈ 2 × 预算;② `Pool.close()` 等 in-flight 释放会**无限挂**,60s 只 warning(`pool.py:939-948, 961-972`),"关了就是关了"必须自己限时 + `terminate()`;③ 原稿"`TelemetryRecorder` 加 `health` 属性零成本"只覆盖静态类型,漏了它是 `@runtime_checkable`(`ports.py:246`)——加属性会让只实现 `record_llm_call` 的对象**当场不再满足协议**,库内 `tests/unit/test_ports.py:137,141` 的 isinstance 断言会红。 +- **审查还逼出判据本身的自相矛盾**: 原稿只有"致命 = 进程内不可变"一句,却把 SQLSTATE `42` 整类归了行级——而 42501(权限被收)、42P01(表被迁走)恰恰是"能被外部修好"的。补出第二句判据(**行级 vs 环境级看失败与这一行的数据有没有关系**),两者改判环境级,`42703` 缺列成为唯一具名例外(它由 [[design:issue13-schema-mode]] 的"缺列须逐行暴露"承诺定死)。 +- **自查另补两条 Codex 未发现的**: `health` 一词在 `ports.py` 已被占用两次(`check_health` 源探活、`health(source_name) -> float` 成功率 EWMA),故快照改名 `TelemetryStatus`(P2 领域术语);`asyncio.timeout` 是 3.11 新增而 `requires-python = ">=3.11"`,3.11.0/3.11.1 的 `uncancel` 有已知缺陷,实施时须在"抬最低版本"与"改用 `wait_for`"之间选一。 +- **最低 Python 提到 3.12(人类决策,2026-08-24)**: 顺带消解了原 §6 那条取舍(`asyncio.timeout` 是 3.11 新增、3.11.0/3.11.1 的 `uncancel` 有缺陷),现在可直接用、不必退回 `wait_for`。代价有两项且**顺序不可颠倒**: conda 环境 `PolyGateway` 当前是 3.11.15,须先重建;ruff `target-version = "py312"` 立刻启用 UP047,`gather_bounded`/`_anext_within`/`stream_with_liveness_timeouts` 三处要改 PEP 695 语法,而该语法在 3.11 是 **SyntaxError**——只能在 3.12 环境就位之后改。版号因此确定 **1.3.0 起步**: 缩小支持面(3.11 下游 `pip install` 会被 pip 直接拒绝)比新增能力更该进 minor。 + diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 8f77272..bc9cdf0 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -190,6 +190,16 @@ "id": "review:issue14-branch-review", "label": "整分支审查: issue #14 熔断等待档", "type": "review" + }, + { + "id": "design:issue15-telemetry-pool-lifecycle", + "label": "issue #15: 遥测连接池的资源语义与生命周期", + "type": "design" + }, + { + "id": "plan:plan-issue15-telemetry-pool-lifecycle", + "label": "实现计划: 遥测连接池的资源语义与生命周期(issue #15)", + "type": "plan" } ], "links": [ @@ -353,6 +363,27 @@ "relation": "informs", "evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述", "added": "2026-08-20T05:01:16.206639+00:00" + }, + { + "source": "design:issue15-telemetry-pool-lifecycle", + "target": "design:issue9-telemetry-ddl-probe", + "relation": "refines", + "evidence": "把 issue #9 的'确定写不进去'判据从'哪一步失败'改为'失败是什么性质': 建池失败不再一律判死", + "added": "2026-08-24T05:50:56.787791+00:00" + }, + { + "source": "design:issue15-telemetry-pool-lifecycle", + "target": "design:issue12-telemetry-retention", + "relation": "depends_on", + "evidence": "遥测作为审计证据的定位(决策 E-a)是否决'异步队列 + 后台 flush'备选的依据", + "added": "2026-08-24T05:50:57.954717+00:00" + }, + { + "source": "plan:plan-issue15-telemetry-pool-lifecycle", + "target": "design:issue15-telemetry-pool-lifecycle", + "relation": "implements", + "evidence": "八任务实现四组改动(池语义/失败三分/状态可见/所有权纪律)", + "added": "2026-08-24T12:05:46.300738+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 738bee6..5816096 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-20 05:01 UTC +> 自动生成,更新时间:2026-08-24 15:51 UTC -## design (35) +## design (37) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -20,12 +20,14 @@ - [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design` - [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design` - [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design` +- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention` - [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention` - [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode` +- [issue #15: 遥测连接池的资源语义与生命周期](designs/issue15-telemetry-pool-lifecycle.md) `design:issue15-telemetry-pool-lifecycle` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` @@ -53,7 +55,7 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (30) +## plan (32) - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -68,6 +70,7 @@ - [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions` - [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention` - [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode` +- [2026-08-24-issue15-telemetry-pool-lifecycle](plans/2026-08-24-issue15-telemetry-pool-lifecycle.md) `plan:2026-08-24-issue15-telemetry-pool-lifecycle` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` @@ -81,6 +84,7 @@ - [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention` - [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` +- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` diff --git a/research-wiki/log.md b/research-wiki/log.md index 68c6161..3296f4b 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -123,3 +123,20 @@ - [2026-08-20 05:01 UTC] 重建索引: 80 篇页面 - [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review) - [2026-08-20 05:01 UTC] 重建索引: 81 篇页面 +- [2026-08-24 05:49 UTC] 新增 design: issue #15: 遥测连接池的资源语义与生命周期 (design:issue15-telemetry-pool-lifecycle) +- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面 +- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --refines--> design:issue9-telemetry-ddl-probe +- [2026-08-24 05:50 UTC] 新增边: design:issue15-telemetry-pool-lifecycle --depends_on--> design:issue12-telemetry-retention +- [2026-08-24 05:50 UTC] 重建索引: 83 篇页面 +- [2026-08-24 10:14 UTC] 重建索引: 83 篇页面 +- [2026-08-24 10:15 UTC] design:issue15-telemetry-pool-lifecycle 经 Codex 审查: 3 阻断+3 应改+1 可选,核实后 6 采纳 1 改判,正文补 §9 审查留痕 +- [2026-08-24 10:20 UTC] 最低 Python 提到 3.12(pyproject/ruff/README/CLAUDE.md 四处);design:issue15 §6 版本取舍消解,§7 补两项实施前置 +- [2026-08-24 10:32 UTC] Python 3.12 迁移执行完毕: 环境重建 3.12.13、补装 build/twine、UP047 三处改 PEP 695;make check 绿、973 passed 覆盖率 94% +- [2026-08-24 12:05 UTC] 新增 plan: 实现计划: 遥测连接池的资源语义与生命周期(issue #15) (plan:plan-issue15-telemetry-pool-lifecycle) +- [2026-08-24 12:05 UTC] 新增边: plan:plan-issue15-telemetry-pool-lifecycle --implements--> design:issue15-telemetry-pool-lifecycle +- [2026-08-24 12:05 UTC] 重建索引: 85 篇页面 +- [2026-08-24 12:10 UTC] plan:issue15 经 Codex 审: 2 阻断已修(20 处构造点须同批改、application_name 改走 DSN 查询参数)、_failed 计数修正 +- [2026-08-24 15:48 UTC] issue15 T1-T7 实施完成: 池按需建连(min_size=0)+失败三分与 60s 冷却+TelemetryStatus 快照+所有权纪律统一,1038 passed +- [2026-08-24 15:48 UTC] issue15 独立验证 5 问题处置: 日志级别决策收敛到 tracker(fatal=error)并补执法用例、is not None 所有权纪律补 falsy 用例、更正两处过时吞吐数字、acquire 预算措辞对齐代码、登记页状态与行数校正 +- [2026-08-24 15:49 UTC] 重建索引: 85 篇页面 +- [2026-08-24 15:51 UTC] 重建索引: 85 篇页面 diff --git a/research-wiki/plans/2026-08-24-issue15-telemetry-pool-lifecycle.md b/research-wiki/plans/2026-08-24-issue15-telemetry-pool-lifecycle.md new file mode 100644 index 0000000..5008f97 --- /dev/null +++ b/research-wiki/plans/2026-08-24-issue15-telemetry-pool-lifecycle.md @@ -0,0 +1,380 @@ +# 实现计划: 遥测连接池的资源语义与生命周期(issue #15) + +- **设计**: `research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md`(已过 Codex 审 + 人类审) +- **涉及技术**: Python 3.12(PEP 695 已就位)、asyncpg 0.31 连接池、`asyncio.timeout`、PG SQLSTATE、frozen dataclass、`@runtime_checkable` Protocol、pytest(含真实 PG 的 integration) +- **版号**: 1.3.0(人类已定;**本计划不 bump 版本号**,那是发布清单第 3 步的事) +- **状态**: **已实施**(2026-08-24)。T0–T7 全部提交完成,提交表见文末;合并前的三道门(`pytest -m slow`、独立 verifier、整分支审查)见「完成判据」) + +## 目标 + +让遥测池的资源占用与真实负载挂钩,把"建池失败 → 整进程永久失遥测"这条路彻底拆掉,并让任何降级都可恢复、可见、可编程。 + +## 方案概述 + +`min_size=0` 让建池变成零成本动作(实测不触库),连接失败自动落到 `acquire` 那条本来就正确的"丢一行、池自恢复"路径;判死判据从"哪一步失败"改为"失败是什么性质",永久档窄到只剩"DSN 不可解析",其余一律 60s 冷却重试;降级状态升格为共用的一等对象(节流日志 + 只读快照);顺带把"谁建的谁关"统一为全库纪律,让 ARCH §7.7 R5 的显式共享真正可用。 + +## 保真校验适用性 + +**不适用**。遥测后端无参考实现蓝本(ARCHITECTURE.md §7.8 明记"参考仓无先例: 三项目遥测全 SQLite"),本计划不涉及 `reference/` 迁移。但有两条**同等强度的既有承诺**不得被本次改动破坏,各任务已挂检查点: + +1. issue #13 的"manual 档缺列时裁剪 INSERT 继续写、逐行 warning 暴露"(T5 的 `42703` 例外); +2. issue #9 的"表存在就绝不发 DDL"(`to_regclass` 先探测,T4/T5 不得碰这段控制流)。 + +## 起点状态(执行前必读) + +- **工作区有未提交改动且在 `main` 上**: Python 3.12 迁移已执行完毕(`pyproject.toml` `requires-python`/`target-version`、`README.md` 两处、`CLAUDE.md` 技术栈、`client.py` 与 `streaming.py` 的 UP047 三处改 PEP 695),conda 环境已重建为 3.12.13 并补装 `build`/`twine`。**T0 的第一件事就是把它们落到分支上**。 +- **建池路径今天零测试覆盖**: 全 `tests/` 目录对 `create_pool` 与 `_open_pool` 的引用数为 **0**(执行前可自行复核)。现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入,走的是 `_external_pool=True` 分支,**从不经过建池**。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要新建这一路的第一个用例。 + +## 提交门(每个提交点都受此约束) + +`.claude/scripts/hooks/pre-commit-guard.sh` 在检测到 `git commit` 时**阻塞式**执行: `ruff check src/`(任何问题即阻塞)、`radon cc src -n C`(圈复杂度 ≥ C 即阻塞)、`pytest tests/ --tb=line -q`(任一红即阻塞)。文件 > 200 行只是 warning,不阻塞。 + +两条由此而来的硬约束: + +- **不得留红态跨提交**——任务边界必须切在"全绿"处,不能把一个行为拆成"改实现"和"改测试"两次提交。 +- **圈复杂度是真实风险**: `record_llm_call` 本次要同时接入硬预算、失败分类与 tracker。一旦逼近 C 就必须抽私有方法,**这不算计划外重构**,是提交门的硬要求。 + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/types.py` | 改 | 新增 `TelemetryStatus` frozen dataclass(与 `SourceStats` 同一先例) | +| `src/polygateway/ports.py` | 改 | 新增**独立** `TelemetryStatusProvider` Protocol;`TelemetryRecorder` **一字不动** | +| `src/polygateway/telemetry/status.py` | **新建** | `TelemetryStatusTracker`: 降级状态机 + 节流日志 + 快照。两个 recorder 共用,不含任何后端知识 | +| `src/polygateway/telemetry/postgres.py` | 改 | 池语义、硬预算、失败三分、冷却降级、有界 `aclose`、接入 tracker | +| `src/polygateway/telemetry/sqlite.py` | 改 | **仅**接入 tracker(补上今天缺失的降级 warning);不做 lazy 化与冷却 | +| `src/polygateway/config.py` | 改 | 两个新键的加载与校验 | +| `src/polygateway/client.py` | 改 | 所有权纪律 + `aclose` helper + `telemetry_status` 出口 | +| `src/polygateway/embedding.py`、`ocr.py` | 改 | 同款所有权与出口(三处必须一致) | +| `src/polygateway/backends/redis_cache.py` | 改 | 补 `_owns_client` 纪律 | +| `tests/unit/test_telemetry.py` | 改 | `_FakePgPool` 改造 + 池语义/预算/分类/冷却/tracker 用例 | +| `tests/unit/test_client.py` | 改 | 所有权层用例(三个 client 各钉一次) | +| `tests/unit/test_config.py` | 改 | 两个新键的三条装配路 | +| `tests/integration/test_postgres_telemetry.py` | 改 | 真实 PG: 连接数计数、降级恢复 | +| `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md` | 改 | 配置面、能力表、发布说明、架构决策成文 | + +## 关键接口(跨任务消费,此处定死) + +`types.py` 新增(T2 建立,T4/T5/T6 消费): + +```python +@dataclass(frozen=True) +class TelemetryStatus: + """遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。""" + degraded: bool + fatal: bool # True = 本进程内不可恢复(仅 DSN 不可解析一类) + reason: str | None # 降级原因;未降级为 None + degraded_for_s: float | None # 已降级时长;未降级为 None + dropped_rows: int # 累计丢弃行数(进程生命周期内单调不减) + retry_after_s: float | None # 距下次重新准备;fatal 或未降级为 None +``` + +`ports.py` 新增(T2 建立)——**独立于 `TelemetryRecorder`**,理由见设计 §3.3: + +```python +@runtime_checkable +class TelemetryStatusProvider(Protocol): + """可自述可写状态的遥测后端;与 TelemetryRecorder 分开是为了不破坏后者的 + runtime_checkable 语义(加成员会让只实现 record_llm_call 的对象当场不满足协议)。""" + + @property + def telemetry_status(self) -> TelemetryStatus: ... +``` + +`telemetry/status.py` 新增(T2 建立,T4/T5 消费)。`now` 注入以便测试推进假时钟: + +```python +class TelemetryStatusTracker: + def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None: ... + def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None: ... + def recover(self) -> None: ... + def record_drop(self, reason: str) -> None: ... + def should_retry(self) -> bool: ... # fatal→False;冷却未到→False;到期→True + def snapshot(self) -> TelemetryStatus: ... +``` + +`PostgresRecorder.__init__` 新签名(T3 落地;`pool_max`/`write_timeout_s` keyword-only **必填**,与 `auto_migrate` 同一纪律——缺省只写在 config 一处): + +```python +def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool, + pool_max: int, write_timeout_s: float, + now: Callable[[], float] = time.monotonic) -> None: ... +``` + +`GatewaySettings` 新字段与 env 键(T3 落地): + +| 字段 | env 键 | 缺省 | 校验(落 `_validate_telemetry`) | +|---|---|---|---| +| `telemetry_pg_pool_max: int` | `PGW_TELEMETRY_PG_POOL_MAX` | 4 | `>= 1`,否则 ValueError 点出字段名与键名 | +| `telemetry_pg_write_timeout_s: float` | `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S` | 5.0 | `> 0`,同上 | + +失败三分(T5 落地,`postgres.py` 模块级私有函数,全库唯一一处 PG 失败分类): + +```python +_FATAL = "fatal" # 配置级致命 → 永久 no-op + 一条 error +_UNAVAILABLE = "unavailable" # 环境级 → 60s 冷却降级 +_ROW = "row" # 行级 → 逐条 warning 丢弃 + +def _classify_failure(exc: BaseException) -> str: ... +``` + +判据(设计 §3.2,两句): ①致命 = 原因完全在进程内部且不可变;②行级 vs 环境级看失败与**这一行的数据**有没有关系。落到具体码: + +| 归档 | 覆盖 | +|---|---| +| `_FATAL` | `asyncpg.ClientConfigurationError`;`create_pool` 抛的 `ValueError`/`TypeError` | +| `_UNAVAILABLE` | SQLSTATE 前两位 ∈ {`08`,`53`,`57`,`28`,`3D`} + 具体码 `42501`、`42P01`;`OSError`/`ConnectionError`/`TimeoutError`/其余 `InterfaceError` | +| `_ROW` | 其余 `PostgresError`(`22`/`23` 等)+ **具名例外 `42703`**(缺列,由 issue #13 承诺定死) | + +冷却期为模块级常量 `_DEGRADE_COOLDOWN_S = 60.0`(不暴露配置,设计 §3.5)。 + +## 任务清单 + +### T0 — 分支与基线(把已完成的 3.12 迁移落盘) + +- [x] 从 `main` 建分支 `feat/issue-15-telemetry-pool-lifecycle` +- [x] 把工作区现有改动分两次提交: ① `chore: 最低 Python 提到 3.12 并改用 PEP 695 泛型语法`(`pyproject.toml`/`README.md`/`CLAUDE.md`/`client.py`/`streaming.py`);② `docs: issue #15 设计文档与 wiki 登记`(`research-wiki/`) +- [x] 记录基线用例计数(执行时实测;2026-08-24 本机为 **973 passed / 23 skipped / 45 deselected**,覆盖率 94%)。该数只作**同环境**参照,不作硬验收——`addopts = "-m 'not slow'"` 与 Redis/PG 可达性都会改变它 + +**验证**: `make check` 全绿;`/home/iomgaa/miniconda3/envs/PolyGateway/bin/python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确。 + +> **不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check`。 +> **不要用 `conda run ... pytest` 取统计数字**——实测其输出缓冲会把结尾的 `N passed` 与覆盖率整段吞掉,只剩 exit code(2026-08-24 踩过)。用环境解释器绝对路径直跑。 + +--- + +### T1 — D 组: 资源所有权纪律统一(独立回滚点) + +**动**: `src/polygateway/client.py`、`embedding.py`、`ocr.py`、`backends/redis_cache.py`;测试 `tests/unit/test_client.py`。 + +**要实现的行为**: 全库唯一纪律 —— **谁建的谁关,注入的一律不碰**。分两层落: + +1. **组件内部自建的连接**归组件自己: `RedisCache` 补 `_owns_client`(构造注入 → False;`from_url` → True),`aclose` 自查后再关。这是照抄 `backends/redis/limiter.py:185-191, 318-322` 的既有正确先例,`backends/redis/breaker.py:437-441` 同款。 +2. **client 自建的整个组件**归 client: 三个 client 各持 `_owns_transport/_owns_telemetry/_owns_cache/_owns_limiter/_owns_breaker`,**默认全 False**(`__init__` 是全量注入路径,经它传入的一切都是外部的),只有三个工厂在真正自建时置 True。工厂里 `transport` 恒自建(三处工厂都没有 transport 注入参数),`limiter`/`breaker`/`cache`/`telemetry` 按 `xxx is None` 判定。 + +**执行留痕(T1)**: 工厂里既有的 `limiter or _build_limiter(...)` 一律改成了 `is not None` 判定。理由是注入一个 **falsy** 后端时 `or` 会走自建分支,而所有权标志按 `is None` 判成 False——两者一漂移就等于又造了一个 `aclose` 越权。这不是风格偏好,是所有权判定能成立的**必要条件**,已回写设计 §3.4。 + +**同时修掉的现存泄漏**: `GatewayClient.__init__` 今天把 limiter/breaker 交给 `RetryMW` 构造(`client.py:156-176`)后自己不留引用(`self._transport`/`_telemetry`/`_cache` 都存了,唯独这两个没存,见 `client.py:203-206`),`aclose` 因此**触达不到**自建的 redis 客户端。三个 client 都要新持 `self._limiter`/`self._breaker` 引用(仅为关闭)。embedding/ocr 的自建点在 `embedding.py:497-498`、`ocr.py:510-511`。 + +**收敛**: 三处复制的 `getattr(..., "aclose")` 探测(`client.py:268-280`、`embedding.py:452-461`、`ocr.py:462-467`)收敛为**一个**内部 helper。SQLite recorder 只有同步 `close()`,helper 须同时探测 `aclose`/`close`(今天 `client.py:274-277` 已有这个分支,embedding/ocr 也有,收敛后行为不变)。内存后端无 `aclose`,探测后跳过。 + +**测试要求**(先失败后通过): 假 recorder/transport/limiter/breaker/cache 各记 close 次数。 +- 注入的组件 `aclose` 后 close 次数 **0**;自建的为 **1**(工厂路径); +- 自建 redis limiter/breaker 被关(**泄漏钉子**,今天必红); +- 注入给 `RedisCache` 的客户端不被关; +- **三个 client 逐一覆盖**——收敛成 helper 之后仍须三处各钉一次,否则下次有人把逻辑复制回去无人发现; +- `aclose` 幂等(连调两次不重复关)。 + +**验证**: `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py -q` → PASS;`make check` 绿;全套件绿。 + +- [x] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏` + +--- + +### T2 — C 组基础设施: 状态快照 + tracker + 出口 + +**动**: `src/polygateway/types.py`、`ports.py`、**新建** `telemetry/status.py`、`telemetry/postgres.py`、`telemetry/sqlite.py`、`client.py`、`embedding.py`、`ocr.py`;测试 `tests/unit/test_telemetry.py`、`test_ports.py`、`test_client.py`。 + +**为什么排在 A/B 组之前**: T3/T5 的所有降级点都要向 tracker 报告。先建 tracker 则那两步直接写成最终形态,反之要返工一遍日志代码。 + +**要实现的行为**: +1. `TelemetryStatus` 与 `TelemetryStatusProvider` 按上文"关键接口"定死。**`TelemetryRecorder` 一字不动**。 +2. `TelemetryStatusTracker` 状态机: `enter_degraded` 打一条 warning(含原因与恢复条件: 冷却剩余秒数,或 fatal 时写明"需改配置并重启");降级期间 `record_drop` **节流复述**(按丢弃行数与时间双阈值,阈值为模块常量);`recover` 打一条 info 并报告"期间丢弃 N 行";`should_retry` 是纯查询(fatal → False,冷却未到 → False)。 +3. 两个 recorder 各持一个 tracker,把**今天已有的**降级点接上去: PG 的建池失败与判死、SQLite 的初始化失败。**SQLite 侧同时补上今天缺失的那条 warning**——`sqlite.py:138-139` 初始化失败后写入直接 `return`,连一条日志都没有。 +4. 出口 `telemetry_status` 属性加到三个 client,取值经**一处** `isinstance(self._telemetry, TelemetryStatusProvider)` 判定,不满足或无遥测则返回 `None`。 + +**本任务不改任何失败判据**: PG 侧仍是"建池失败即永久判死",只是这次判死会经 tracker 变得可见。判据在 T5 改。这样本任务的行为变更面收敛为"日志更可见 + 多一个只读出口"。 + +**过渡期状态并存(有意,且必须在 T5 收掉)**: 本任务结束时 PG 侧的 `_failed` 布尔与 tracker 的 fatal 状态**并存**——判死点两边都写。这是为了让 T2 能独立全绿提交,不是最终形态;T5 删除 `_failed`,状态收归 tracker 一处。两份状态只允许存活这一个任务的跨度,拖久了必然漂移。 + +**契约检查点**: `tests/unit/test_ports.py:137,141` 的 `isinstance(_DummyRecorder(), TelemetryRecorder)` 断言必须**保持绿**——它是"没把状态并进主 Protocol"这条决策的机械化执法点,新增用例不得替代它。 + +**测试要求**(先失败后通过): +- tracker 状态机六字段逐个钉: 未降级 → `degraded=False` 且三个可空字段为 None;进入降级 → `reason`/`retry_after_s` 正确;假时钟推进 → `degraded_for_s` 增长、`retry_after_s` 递减到 0;`recover` → 回到未降级且 `dropped_rows` **不清零**(进程生命周期内单调不减); +- 节流复述: 连续 N 次 `record_drop` 只产生 M 条 warning(loguru sink 捕获断言),且 N 与 M 的关系由常量决定而非硬编码数字; +- fatal 档: `should_retry()` 恒 False,`retry_after_s` 为 None; +- SQLite 初始化失败(指向不可写目录)→ 有 warning **且** `telemetry_status.degraded is True`(今天必红,连 warning 都没有); +- 三个 client 的 `telemetry_status`: 无遥测 → None;注入不实现该 Protocol 的假 recorder → None(不得抛 AttributeError);内置 recorder → 返回快照。 + +**验证**: `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_client.py -q` → PASS;`lint-imports` 绿(新文件 `telemetry/status.py` 在实现层,只许依赖 `types`/`ports`/标准库,**不得**被 `transports`/`backends` import);全套件绿。 + +- [x] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)` + +--- + +### T3 — A 组: 池语义与两个新配置键 + +**动**: `src/polygateway/config.py`、`client.py`(`_build_telemetry`)、`telemetry/postgres.py`;测试 `tests/unit/test_config.py`、`test_telemetry.py`、**`tests/integration/test_postgres_telemetry.py`**。 + +> **本任务必须一次改完全部 20 处 `PostgresRecorder(` 构造点**(Codex 审查,已实测复核): `src/polygateway/client.py` 1 处 + `tests/unit/test_telemetry.py` 3 处 + **`tests/integration/test_postgres_telemetry.py` 16 处**。新签名的 `pool_max`/`write_timeout_s` 是 keyword-only **必填**,漏一处就 `TypeError`,而提交门跑的是**全套件**——集成测试那 16 处不能拖到 T6,否则 T3 根本提交不了。这是 `auto_migrate` 当初(issue #13)踩过的同一形态: 必填 keyword-only 的代价就是所有构造点同批改。 + +**要实现的行为**: +1. 两个新配置键按"关键接口"那张表落地: `_load_pgw` 里读取(模板照 `config.py:524-543` 的 `_load_text_cap`),值域校验落 `_validate_telemetry`(与 `telemetry_text_cap` 同一先例,**一次覆盖直接构造 / `dataclasses.replace` / env 三条路**),报错文本同时点字段名与 env 键名。`_build_telemetry`(`client.py:405-420`)把两个值透传给 recorder。 +2. 建池改为 `create_pool(dsn, min_size=0, max_size=pool_max, timeout=write_timeout_s, command_timeout=write_timeout_s)`。 +3. **两处** `acquire` 都改为**显式** acquire/release,**不得**用 `async with pool.acquire(...)`——`_prepare_schema`(`postgres.py:114`)与 `record_llm_call`(`postgres.py:238`)。准备期同样在预算内、同样吃 shielded release 那一刀,只改一处等于留了半个坑: + - `con = await pool.acquire(timeout=write_timeout_s)`(传**完整**预算: 真正的上界是外层 `asyncio.timeout`,内层再算一次剩余量等于把同一个上界写两遍); + - `finally: await pool.release(con, timeout=<小的独立上限>)`,释放超时则 `con.terminate()`; + - 整次写入(准备 + acquire + execute)由 `asyncio.timeout(write_timeout_s)` 包一层。 + + **理由(设计 §3.1,已核实)**: `Pool.release()` 是 `await asyncio.shield(ch.release(timeout))` 且默认复用 acquire 记录的 `ch._timeout`(asyncpg `pool.py:886-889, 930-937`)。外层预算到期时 cancel 在 `execute` 处抛出,异常传播中执行 `__aexit__`,此时没有新的 cancel 投递,那个 shielded release 会**正常等到完成**——用 `async with` 的真实上界是 ≈ 2 × 预算。 + +**必须同步改造 `_FakePgPool`**(`tests/unit/test_telemetry.py:751`): 它今天的 `acquire()` **无参**且只返回一个 `_Ctx` 异步上下文管理器,没有 `release`。改造为接受 `timeout=` 并提供 `release(con, timeout=)`,同时记录 acquire/release 的配对次数(T3 与 T5 的用例都要用)。不改造则全部 PG 用例当场红。 + +**取消穿透的实现纪律**(铁律): 降级路径(节流日志、tracker 更新、release 收尾)一律不得 `except CancelledError` 而不 re-raise;`except TimeoutError` 必须排在 `except Exception` 之前;严禁裸 `except BaseException`。既有 `postgres.py:101-102` 的 `except asyncio.CancelledError: raise` 写法是对的,延续它。 + +**测试要求**(先失败后通过。注意: 建池路径**今天零覆盖**,这里要建立第一个用例): +- **主回归钉子**: monkeypatch `asyncpg.create_pool`,断言实参 `min_size == 0` 且 `max_size == 配置值`。这一条防的是回归到继承第三方默认值,是本 issue 的核心; +- 配置键三条装配路: env 路读取正确、缺省为 4 / 5.0、直接构造与 `replace` 同样被校验拦住(`pool_max=0`、`write_timeout_s=0` 各一条,断言报错文本含字段名与键名); +- 硬预算: 假 pool 的 acquire 挂住 → 丢一行且耗时 ≤ 预算(用假时钟或极小预算,**不要**在用例里真睡 5 秒); +- **release 不泄漏**(Codex 审查钉子): `execute` 被预算取消后,断言 `_FakePgPool` 记录的 acquire/release 次数**配对**; +- 外部 `CancelledError` 在预算内**不**被吞成 `TimeoutError`(直接钉铁律)。 + +**验证**: `pytest tests/unit/test_config.py tests/unit/test_telemetry.py -q` → PASS;`make check` 绿;全套件绿。 + +- [x] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算` + +--- + +### T4 — B 组之一: 有界关闭 + +**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。 + +**为什么单列一个任务**: 它与 T5 的失败判据无关,但同属"收尾路径的隐性无界等待",且能独立验证。合进 T5 会让那次提交同时动判据与关闭两件事,回滚粒度变粗。 + +**要实现的行为**: `aclose()` 语义钉死为"关了就是关了"——置 `_closed`,此后写入短路且**不复活**(取消今天"关完还能自己重建池"的灰色状态);关闭动作本身走 `asyncio.wait_for(pool.close(), timeout=...)`,超时后 `pool.terminate()`,外部取消照常穿透。 + +**理由(已核实)**: `Pool.close()` 会 `await` 每个 holder 的 `wait_until_released()`,in-flight 未释放时**无限等**,60 秒只发一条 warning(asyncpg `pool.py:939-948, 961-972`);asyncpg 自己的 docstring 就写着 "advisable to use `asyncio.wait_for` to set a timeout"。 + +**测试要求**(先失败后通过): +- 假 holder 永不 release → `aclose()` 在超时后走 `terminate()` 返回,**不无限挂**(今天必红/挂死,用例须自带超时保护); +- `aclose` 后再 `record_llm_call` → 直接短路,**不重建池**(断言 `create_pool` 未被再次调用); +- `aclose` 幂等;注入的外部池仍**不**被关(`_external_pool` 既有纪律不得破)。 + +**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;全套件绿。 + +- [x] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活` + +--- + +### T5 — B 组之二: 失败三分与冷却降级(本 issue 的核心) + +**动**: `src/polygateway/telemetry/postgres.py`;测试 `tests/unit/test_telemetry.py`。 + +**要实现的行为**: +1. 新增模块级 `_classify_failure`(按"关键接口"的三档表),全库唯一一处 PG 失败分类。 +2. 三个降级点改为按分类处置: `_open_pool`、`_prepare_schema`/`_prepare_table`、`record_llm_call`。 + - `_FATAL` → 永久 no-op + 一条 **error**(不是 warning: 这是人配错了),经 tracker 置 `fatal=True`; + - `_UNAVAILABLE` → `tracker.enter_degraded(cooldown_s=_DEGRADE_COOLDOWN_S)`,此后 `_ensure_ready` 开头零成本短路(只比较时间戳,不触库),到期 `should_retry()` 放行**一次**重新准备,成功即 `tracker.recover()`; + - `_ROW` → 逐条 warning 丢弃 + `tracker.record_drop()`,不降级。 +3. **删除 `_failed` 这个布尔**,状态收归 tracker 一处(否则两份状态必然漂移)。实测引用分布(执行时可自行复核): `src/polygateway/telemetry/postgres.py` **7 处**(74/79/84/106/124 是代码,209/211 在 `_backfill_columns` 的 docstring 里——**文档也要改**,否则留下指向已删字段的说明)、`tests/unit/test_telemetry.py` **6 处**、`tests/integration/test_postgres_telemetry.py` **6 处**,测试侧一并改为读 `telemetry_status` 快照。 +4. 判据的两条既有承诺不得破: + - **`42703` 仍走 `_ROW`**(issue #13: manual 档缺列时裁剪 INSERT 继续写、逐行暴露)。这是判据的**唯一具名例外**,代码里必须有注释写明它是例外及理由; + - **`_prepare_table` 的 `to_regclass` 先探测、表在就不发 DDL** 这段控制流(`postgres.py:147-157`)一行不动(issue #9)。 + +**圈复杂度检查点**: 本任务是三个降级点同时改,`record_llm_call` 与 `_ensure_ready` 最容易触到 radon 的 C 档而被提交门阻塞。逼近就抽私有方法(如 `_handle_failure(exc, *, stage)` 收敛三处处置)——这是提交门的硬要求,不算计划外重构。 + +**测试要求**(先失败后通过,分档逐个钉): +- **issue 场景直接回归**: 建池阶段抛 `TooManyConnectionsError`(53300)→ **不** fatal、进冷却降级 → 假时钟推进 60s → 下次调用自动恢复并成功写入。今天这一条必红(现状是永久判死); +- `ClientConfigurationError` → fatal + 一条 error + 此后零成本短路(断言不再调 `acquire`); +- **分档边界两侧各钉一次**: `42501`/`42P01` → 进冷却降级;`42703` → 行级丢弃且**不**进降级; +- `_prepare_table` 建表失败(表确定不存在)→ 冷却降级(不再是永久判死),DBA 建表后自动恢复; +- 探测失败(既有 `probe_errors` 路径)仍只跳过本次、下次重试,**不**降级(issue #9 既有行为不得回归); +- 全部现有 PG 用例保持绿(它们钉的是 issue #3/#9/#13 的承诺)。 + +**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;`radon cc src/polygateway/telemetry/postgres.py -n C -s` → 无输出;全套件绿。 + +- [x] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈` + +--- + +### T6 — 真实 PG 集成验证 + +**动**: `tests/integration/test_postgres_telemetry.py`。 + +**纪律(该文件既有,不得破)**: `llm_calls` 是与真实批跑共享的表,**严禁 DROP/TRUNCATE**;以 run 级 `call_id` 前缀隔离,teardown 只删自己的行;DSN 缺失则 skip;不标 `slow`(与该文件既有用例一致)。 + +**要实现的行为(用例)**: +1. **issue 的直接回归钉子**: 建 recorder 后本池连接数为 **0**,一次写入后 **≤1**,稳态 ≤ `pool_max`。 +2. 降级与恢复走**不可达 DSN** 的 recorder 验证(连接被拒 → 降级 → 假时钟/短冷却后重试),**不去动共享实例的 `max_connections`**。 + +**计数必须按唯一 `application_name` 过滤**,该实例被多项目共用,按库名或用户名计数会被别人的连接污染——那样的用例是**设计上就会间歇红**的信号污染源(CLAUDE.md §4.6)。 + +**怎么设这个 tag(Codex 指出原稿这里无法执行,已实测给出解法)**: recorder 的构造签名**没有** `server_settings`/`connect_kwargs` 入口,原稿那句"经 `server_settings=` 建池"落不了地。解法是走 **DSN 查询参数**——给 recorder 一个 `f"{dsn}?application_name={run级唯一值}"`,其余一切不变。 + +- 已实测(2026-08-24,真实实验室 PG): `create_pool(dsn + "?application_name=pgwtest-abc123", min_size=0, ...)` 后 `SHOW application_name` 返回该值,`pg_stat_activity` 按它过滤得连接数 1,`pool.close()` 后归零。 +- **不要**改用"测试自建池后以 `pool=` 注入": 那会走 `_external_pool=True` 分支、**完全绕过被测的建池路径**,而本任务要验的恰恰是自建池不预连接。 +- **不要**为此给 recorder 加 `server_settings` 入口: 纯测试便利不值得扩公共 API(P1)。 +- 注意 `config.py` 的 `_strip_dsn_driver` 只动 scheme 的 `+driver` 后缀,不碰查询参数;且集成测试直接构造 recorder、不经 config,两条路都不受影响。 + +**验证**: `pytest tests/integration/test_postgres_telemetry.py -q` → PASS(或无 DSN 时全 skip);全套件绿。 + +- [x] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈` + +--- + +### T7 — 文档、配置面与发布说明 + +**动**: `.env.example`、`README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`。 + +**要实现的行为**: +1. `.env.example`: 两个新键写在 `PGW_TELEMETRY_PG_DSN` 之后,沿用该文件既有的"键 + 缩进注释块讲清为什么"风格。`pool_max` 必须给**调参口径**: 写**实测值**而非 `pool_max / RTT`(T3 实测该公式乐观一倍,见设计 §10 修订 #1)——跨内网 RTT ≈ 123ms 上 `pool_max=4` 约 **15.6 行/秒**(50 行并发批 3.2s),并写明"共享一个 recorder 给多 client 时并发汇聚,应相应放大"。 +2. `README.md`: 配置表加两键;能力表反映"遥测降级可恢复 + 可查询状态";**核对安装命令里的版本约束**(发布清单第 1 步的老账: `==1.2.*` 这类极易漏改)。 +3. `ARCHITECTURE.md` §7.8 增补三条: 遥测池的资源语义(为何 `min_size=0`、为何不暴露 `min_size`)、失败三分的**两句判据**、**资源所有权纪律**(后者应作为跨子系统的通用纪律成文,而非遥测局部约定);§9 登记两个新键。 +4. `CHANGELOG.md`: 记在"未发布"下,三处"请先读这一条": ①最低 Python 提到 3.12(**唯一会让下游装不上**的变更);②遥测常驻连接从 `10 × client 数` 变按需(监控曲线会突变);③`aclose` 不再关闭注入的组件。 + +**验证**: `make check` 绿;人工通读 `.env.example` 两键注释,确认调参口径可执行。 + +- [x] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文` + +--- + +## 完成判据(合并前) + +- [x] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件) +- [ ] `pytest -m slow` 单独跑过一次(发布清单第 4 步;本次改动触及遥测写入路径,e2e 与 Redis 时间语义变体必须实测) +- [ ] 派**全新上下文**的 verifier subagent 独立验证(`verification-before-completion`,里程碑级/合并前 MANDATORY) +- [ ] 整分支审查(`requesting-code-review`,合并前 MANDATORY) +- [ ] 设计文档 §5 的每一条测试要求都能指到一个具体用例(逐条对照,不是"大致覆盖") + +## 审查留痕(Codex,2026-08-24) + +**Status: Issues Found → 2 条阻断级均已修订,2 条 Recommendation 采纳 1 条。** + +| # | 结论 | 落点 | +|---|---|---| +| 1 | **采纳(阻断)**。新签名的 `pool_max`/`write_timeout_s` 是必填 keyword-only,而 `PostgresRecorder(` 共 **20 处**构造点,其中 **16 处在集成测试**。原稿 T3 只列了两个单元测试文件,漏掉的那 16 处会让 T3 的提交门(跑全套件)当场红 | T3 "动"一节 | +| 2 | **采纳(阻断),并给出比建议更好的解法**。原稿 T6 写"经 `server_settings=` 建池"设唯一 `application_name`,但 recorder 签名根本没有这个入口,零上下文执行者会卡死。Codex 给的两条出路(注入外部池 / 加 recorder 入口)都有代价——前者绕过被测的建池路径,后者为测试便利扩公共 API。**实测发现第三条**: `?application_name=` 走 DSN 查询参数,asyncpg 认、PG 侧生效、关池后计数归零,**零 API 改动且真实覆盖建池路径** | T6 计数一节 | +| 3 | **采纳(建议)**。`_failed` 计数原稿写"测试 13 处"不准。实测: 源码 7 处(**含 2 处在 docstring 里**,文档也要改)、unit 6 处、integration 6 处 | T5 第 3 点 | +| 4 | 无需动作。Codex 复核确认了计划的两条硬断言: 建池路径零覆盖(`rg create_pool\|_open_pool tests` 无匹配)、`_FakePgPool` 定义于 `:751-765` 且只经三个 helper 注入(故改造类本身即可覆盖既有假池用例) | — | + +Codex 给的 `_failed` 分布数字(源码 5 处 / 测试断言 8 处)与本地实测(源码 7 / unit 6 / integration 6)不一致,以实测为准——它漏了 docstring 里那两处,而那两处恰恰是**必须改**的(留着就是指向已删字段的说明)。 + +## 实际提交(2026-08-24,分支 `feat/issue-15-telemetry-pool-lifecycle`) + +| 任务 | hash | message 首行 | +|---|---|---| +| T0 ① | `157a27f` | `chore: require python 3.12 and adopt PEP 695 type parameters` | +| T0 ② | `e7caa50` | `docs: plan the telemetry pool lifecycle rework for issue 15` | +| T1 | `e69ca4c` | `fix: make every client close what it built and nothing else` | +| T2 | `f958138` | `feat: make telemetry degradation a first-class state` | +| T3 | `84c2cc1` | `feat: make the telemetry pool declare what it costs` | +| T4 | `bc071c6` | `fix: make closing the telemetry pool bounded and final` | +| T5 | `eef2fdc` | `fix: judge telemetry failures by nature, not by step` | +| T6 | `bfeda5b` | `test: prove on real PG that the pool never preconnects` | +| T7 ⓪ | `69a5b5f` | `test: pin the cooldown assertion to a fake clock`(T5 留下的一处间歇红: 快照里的 `retry_after_s` 是时间差,却用真实时钟断言 60.0) | +| T7 ① | `7834d75` | `feat: export TelemetryStatus from the package root` | +| T7 ② | `4e1f09d` | `docs: record the telemetry pool semantics and ownership rule`(本表的 hash 由紧随其后的一次 bookkeeping 提交补齐) | +| T8 ① | `f90f7b0` | `test: give the log level and ownership rules real enforcement` | +| T8 ② | `6d6b3cf` | `docs: correct the stale throughput numbers and wiki state`(本行 hash 由紧随其后的 bookkeeping 提交补齐) | + +**T8 不在原计划内**: 它是合并前独立验证(全新上下文 verifier)报出的 5 个问题的处置——2 条"确证的假绿"(日志级别与所有权判定各自没有执法点)+ 2 处过时数字/措辞 + 1 处 wiki 状态漂移。详见设计 §10 修订 #4/#5。 + +T7 分两次提交是因为它含一处**公共 API 面**改动(`TelemetryStatus` 进顶层 `__all__`,决策见下),与纯文档的回滚粒度不同。 + +**T7 执行期追加的决策与发现**(计划原稿只列了四项文档任务): + +| # | 内容 | 落点 | +|---|---|---| +| 1 | `TelemetryStatus` 进 `polygateway.__all__`。issue #15 的核心诉求之一是下游能**编程对账**,而 `client.telemetry_status` 的返回类型若不能从顶层 import,下游做类型标注就得深入 `polygateway.types`——与"顶层导出即公共 API 面"的约定冲突。T2 参照的 `SourceStats` 先例**不适用**: 那是端口内部快照、下游不消费。端口 `TelemetryStatusProvider` 仍不导出 | `__init__.py`、`tests/unit/test_package.py`、ARCH §7.8 | +| 2 | 吞吐算术更正为实测值(15.6 行/秒),`.env.example` / README 的调参口径按实测写 | 设计 §3.5/§6/§10 | +| 3 | "重试建池已零成本"这条红利与"关闭后偷偷复活"这个 bug 分别补进设计 §3.2 / §1.5 | 设计 §10 | diff --git a/research-wiki/plans/plan-issue15-telemetry-pool-lifecycle.md b/research-wiki/plans/plan-issue15-telemetry-pool-lifecycle.md new file mode 100644 index 0000000..35b05c1 --- /dev/null +++ b/research-wiki/plans/plan-issue15-telemetry-pool-lifecycle.md @@ -0,0 +1,18 @@ +--- +type: plan +node_id: plan:plan-issue15-telemetry-pool-lifecycle +title: "实现计划: 遥测连接池的资源语义与生命周期(issue #15)" +date: 2026-08-24 +--- + +# 实现计划: 遥测连接池的资源语义与生命周期(issue #15) + +正文: `2026-08-24-issue15-telemetry-pool-lifecycle.md`(380 行)。实现 [[design:issue15-telemetry-pool-lifecycle]]。状态: **T0–T7 全部完成 + T8 处置独立验证发现的 5 个问题(2026-08-24)**,提交表见正文末尾。 + +- **八个任务**: T0 分支与基线(把已完成的 Python 3.12 迁移落盘)→ T1 D 组所有权纪律(独立回滚点)→ T2 C 组 tracker 与状态快照 → T3 A 组池语义与两个新配置键 → T4 有界关闭 → T5 B 组失败三分与冷却降级(核心)→ T6 真实 PG 集成验证 → T7 文档与发布说明。 +- **顺序的关键理由**: tracker(T2)排在池语义(T3)与失败判据(T5)**之前**——后两步的每个降级点都要向 tracker 报告,反过来做要把日志代码返工一遍。代价是 T2 结束时 `_failed` 与 tracker 状态**临时并存**(为了让 T2 能独立全绿提交),T5 必须收掉,两份状态只允许存活一个任务的跨度。 +- **执行前必读的两条事实**: ① Python 3.12 迁移的改动**还在 main 的工作区未提交**(T0 第一件事就是落到分支);② **建池路径今天零测试覆盖**——全 `tests/` 对 `create_pool`/`_open_pool` 的引用数为 0,现有 PG 用例一律经 `pool=_FakePgPool(...)` 注入、走 `_external_pool=True` 分支从不建池。这正是 `min_size=10` 潜伏至今的原因,也意味着 T3 要建这一路的**第一个**用例。 +- **提交门是任务边界的实际约束**: `.claude/scripts/hooks/pre-commit-guard.sh` 对每次 `git commit` 阻塞式跑 ruff + radon(圈复杂度 ≥C 即拦)+ 全套件。由此两条硬约束: 不得留红态跨提交(不能把一个行为拆成"改实现"和"改测试"两次);T5 同时改三个降级点,`record_llm_call` 逼近 C 时必须抽私有方法——这不算计划外重构,是提交门的硬要求。 +- **两条既有承诺挂了检查点,不得被本次改动破坏**: [[design:issue13-schema-mode]] 的"manual 档缺列时裁剪 INSERT 继续写、逐行暴露"(故 `42703` 是失败分类的唯一具名例外)、[[design:issue9-telemetry-ddl-probe]] 的"表存在就绝不发 DDL"(`to_regclass` 探测那段控制流一行不动)。 +- **保真校验不适用**: 遥测后端无 `reference/` 蓝本(ARCH §7.8 明记"参考仓无先例: 三项目遥测全 SQLite")。 + diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 1a53c4e..26b67f5 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -31,9 +31,10 @@ from polygateway.types import ( OcrLayoutResult, OcrTextResult, SourceConfig, + TelemetryStatus, ) -__version__ = "1.2.4" +__version__ = "1.3.0" __all__ = [ "DEFAULT_PROFILES", @@ -61,6 +62,7 @@ __all__ = [ "SourceConfig", "SourceDeadError", "SourceNotConfiguredError", + "TelemetryStatus", "TransientError", "__version__", "gather_bounded", diff --git a/src/polygateway/backends/redis_cache.py b/src/polygateway/backends/redis_cache.py index a755b34..67064e2 100644 --- a/src/polygateway/backends/redis_cache.py +++ b/src/polygateway/backends/redis_cache.py @@ -25,14 +25,20 @@ class RedisCache: "Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'" ) from _IMPORT_ERROR self._client = client + # 注入的客户端归注入方管理: 关掉它会弄死共享同一连接的其他组件 + # (与 RedisLimiter/RedisGate 同一纪律) + self._owns_client = False @classmethod def from_url(cls, url: str) -> RedisCache: + """自建并持有 Redis 客户端(aclose 时代关);共享后端请直接注入 client。""" if aioredis is None: raise ImportError( "Redis 缓存后端需要 redis 包: pip install 'polygateway[redis]'" ) from _IMPORT_ERROR - return cls(aioredis.from_url(url, decode_responses=True)) + cache = cls(aioredis.from_url(url, decode_responses=True)) + cache._owns_client = True + return cache async def get(self, key: str) -> str | None: return await self._client.get(key) @@ -41,4 +47,7 @@ class RedisCache: await self._client.set(key, value, ex=ttl_s) async def aclose(self) -> None: - await self._client.aclose() + """幂等释放自建客户端;注入的客户端归注入方管理。""" + if self._owns_client: + self._owns_client = False + await self._client.aclose() diff --git a/src/polygateway/client.py b/src/polygateway/client.py index dac5f5c..49319d6 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -13,7 +13,7 @@ import hashlib import json import random import time -from typing import TYPE_CHECKING, Any, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Literal from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.cache import InMemoryCache @@ -24,6 +24,7 @@ from polygateway.middleware.cache import CacheMW from polygateway.middleware.retry import RetryMW from polygateway.middleware.structured import StructuredMW from polygateway.middleware.telemetry import TelemetryEmitter, TelemetryMW +from polygateway.ports import TelemetryStatusProvider from polygateway.pricing import PricingTable from polygateway.providers import get_capability, get_provider, resolve_thinking from polygateway.sources import ( @@ -37,6 +38,7 @@ from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.types import ( ChatRequest, LLMResponse, + TelemetryStatus, validate_caller_dimensions, validate_request_overlay, ) @@ -63,8 +65,6 @@ if TYPE_CHECKING: SourceConfig, ) -_T = TypeVar("_T") - def _guard_thinking( sources: list[SourceConfig], @@ -119,6 +119,55 @@ def build_model_fingerprint(sources: Iterable[SourceConfig]) -> str: return fingerprint +async def _aclose_component(component: object | None) -> None: + """关闭一个**自建**组件: 优先 `aclose`,退到同步 `close`,两者皆无则跳过。 + + 退到 `close` 是给 SQLiteRecorder 的(它只有同步收尾);内存后端两者皆无, + 探测后静默跳过。三个 client 曾各持一份逐字复制的探测代码,收敛为一处是 + 所有权纪律能被维持的前提——复制即是下一个 bug 的种子(设计 §3.4)。 + """ + if component is None: + return + aclose = getattr(component, "aclose", None) + if aclose is not None: + await aclose() + return + close = getattr(component, "close", None) + if close is not None: + close() + + +def _telemetry_status_of(telemetry: TelemetryRecorder | None) -> TelemetryStatus | None: + """三个 client 共用的状态取值点: 不提供状态的 recorder 一律返回 None。 + + 判定写成 `isinstance(可选端口)` 而不是裸 `getattr`: 两者运行时都是结构检查 + (`@runtime_checkable` 按属性存在性判定),差别在**契约有没有名字**——端口是 + 写进 `ports.py` 的公开承诺,下游可以照着实现;散落的 `getattr` 不是,而 + `aclose` 当年正是被复制成三份鸭子类型探测才漂移出越权关闭(设计 §3.3/§3.4)。 + """ + if isinstance(telemetry, TelemetryStatusProvider): + return telemetry.telemetry_status + return None + + +def _mark_owned_components( + client: Any, + *, + limiter: RateLimiter | None, + breaker: ProviderGate | None, + telemetry: TelemetryRecorder | None, +) -> None: + """工厂置位所有权(三个 client 共用): 传进来的是 None,就说明这一件是工厂自建的。 + + 与 `RedisLimiter.from_url` 逐字同款——私有属性由工厂标记,公共 API 面不变。 + transport 单列: 三处工厂都没有 transport 注入入口,它恒是自建的。 + """ + client._owns_transport = True + client._owns_limiter = limiter is None + client._owns_breaker = breaker is None + client._owns_telemetry = telemetry is None + + class GatewayClient: """统一治理入口;构造函数全量注入(测试/高级),工厂覆盖 90% 场景。""" @@ -204,8 +253,29 @@ class GatewayClient: self._transport = transport self._telemetry = telemetry self._cache = cache + # limiter/breaker 交给 RetryMW 之后仍须自持引用,否则 aclose 触达不到 + # 自建的 redis 客户端(设计 §3.4 记录的现存泄漏) + self._limiter_backend = limiter + self._breaker_backend = breaker + # 所有权默认"不拥有": `__init__` 是全量注入路径,经它传入的一切都是 + # 外部资源,关掉别人的连接会弄死共享同一后端的其他 client(ARCH §7.7 R5)。 + # 只有工厂在真正自建时才置 True + self._owns_transport = False + self._owns_telemetry = False + self._owns_cache = False + self._owns_limiter = False + self._owns_breaker = False self._closed = False + @property + def telemetry_status(self) -> TelemetryStatus | None: + """遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。 + + 判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client + 的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。 + """ + return _telemetry_status_of(self._telemetry) + async def chat( self, messages: list[dict[str, Any]], @@ -261,23 +331,23 @@ class GatewayClient: return await self._handler(request) async def aclose(self) -> None: - """幂等释放: transport 连接池、遥测连接、缓存客户端。""" + """幂等释放**自建**资源: transport、遥测、缓存、限流/熔断后端。 + + 注入的组件一律不碰——它们可能被别的 client 共享,关掉即越权。 + """ if self._closed: return self._closed = True - transport_aclose = getattr(self._transport, "aclose", None) - if transport_aclose is not None: - await transport_aclose() - telemetry_aclose = getattr(self._telemetry, "aclose", None) - if telemetry_aclose is not None: - await telemetry_aclose() # Postgres 等异步后端 - else: - telemetry_close = getattr(self._telemetry, "close", None) - if telemetry_close is not None: - telemetry_close() - cache_aclose = getattr(self._cache, "aclose", None) - if cache_aclose is not None: - await cache_aclose() + if self._owns_transport: + await _aclose_component(self._transport) + if self._owns_telemetry: + await _aclose_component(self._telemetry) + if self._owns_cache: + await _aclose_component(self._cache) + if self._owns_limiter: + await _aclose_component(self._limiter_backend) + if self._owns_breaker: + await _aclose_component(self._breaker_backend) async def __aenter__(self) -> GatewayClient: return self @@ -305,12 +375,12 @@ class GatewayClient: profiles = [get_provider(s.provider, registry=registry) for s in sources] _guard_thinking(sources, profiles, capabilities) strategy, escalation = _build_structured(profiles) - return cls( + client = cls( scope=settings.scope, sources=sources, selector=_build_selector(settings.selector, rng=rng), - limiter=limiter or _build_limiter(settings, sources), - breaker=breaker or _build_breaker(settings), + limiter=limiter if limiter is not None else _build_limiter(settings, sources), + breaker=breaker if breaker is not None else _build_breaker(settings), transport=OpenAICompatTransport(registry=registry, capabilities=capabilities), retry=settings.retry, backpressure=settings.backpressure, @@ -328,6 +398,9 @@ class GatewayClient: structured_escalation=escalation, structured_max_retries=settings.structured_max_retries, ) + _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) + client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过 + return client @classmethod def from_env( @@ -410,7 +483,10 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None: assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证 return PostgresRecorder( - settings.telemetry_pg_dsn, auto_migrate=settings.telemetry_auto_migrate + settings.telemetry_pg_dsn, + auto_migrate=settings.telemetry_auto_migrate, + pool_max=settings.telemetry_pg_pool_max, + write_timeout_s=settings.telemetry_pg_write_timeout_s, ) from polygateway.telemetry.sqlite import SQLiteRecorder @@ -442,7 +518,7 @@ def _build_structured( return None, None -async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> list[_T]: +async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]: """有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。 语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。 @@ -451,7 +527,7 @@ async def gather_bounded(aws: Iterable[Awaitable[_T]], *, concurrency: int) -> l raise ValueError("concurrency 必须 ≥ 1") sem = asyncio.Semaphore(concurrency) - async def _run(aw: Awaitable[_T]) -> _T: + async def _run(aw: Awaitable[T]) -> T: async with sem: return await aw diff --git a/src/polygateway/config.py b/src/polygateway/config.py index 19bb13f..24062be 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -63,6 +63,15 @@ _SCHEMA_MODES = frozenset({"auto", "manual"}) _SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE" # 遥测正文字符上限(issue #12);二态键,未设 = 不截断 _TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP" +# 遥测池的资源占用与写入预算(issue #15);缺省只写在这里,recorder 侧是必填参数 +_POOL_MAX_KEY = "PGW_TELEMETRY_PG_POOL_MAX" +_WRITE_TIMEOUT_KEY = "PGW_TELEMETRY_PG_WRITE_TIMEOUT_S" +# 4 条实测约 15.6 行/秒(跨内网 RTT ≈ 123ms 的实验室 PG,50 行并发批耗时 3.2s)。 +# **不要按 `pool_max / RTT` 折算**——那会乐观一倍(一次 INSERT 的往返比一次 +# SELECT 1 重)。够单 client 十余并发;闲时占 0 条 +_DEFAULT_PG_POOL_MAX = 4 +# 实测稳态写入 123ms、首次含建连 513ms;5s 宽松且**有界** +_DEFAULT_PG_WRITE_TIMEOUT_S = 5.0 _REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend") # 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源) _DEFAULT_STALL_WINDOW_S = 300.0 @@ -152,6 +161,13 @@ class GatewaySettings: # 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、 # `dataclasses.replace` 与 env 三条路一并覆盖 telemetry_text_cap: int | None + # 遥测池对外声明的资源占用上限与整次写入的硬预算(issue #15)。库内每一处外部 + # 资源都按需建连,唯独遥测池此前预占 10 条(asyncpg 默认 `min_size`),共享实例 + # 余量紧张时先倒下的必然是它。这两个字段是库对自己占用的**显式表态**: + # 稳态并发上限 = `pool_max`,闲时 0 条;单次写入(准备+取连接+执行)≤ 预算。 + # 值域由 `_validate_telemetry` 把关,直接构造、`dataclasses.replace` 与 env 三条路一致 + telemetry_pg_pool_max: int + telemetry_pg_write_timeout_s: float redis_url: str | None pricing_path: str | None structured_max_retries: int @@ -248,6 +264,7 @@ class GatewaySettings: f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};" "不截断请不设该键(None),0 只会让每条正文退化成一个省略标记" ) + self._validate_telemetry_pool() if self.telemetry_backend == "none" and self.telemetry_auto_migrate: object.__setattr__(self, "telemetry_auto_migrate", False) if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path: @@ -266,6 +283,24 @@ class GatewaySettings: ) object.__setattr__(self, "telemetry_pg_dsn", stripped) + def _validate_telemetry_pool(self) -> None: + """遥测池两个标量的值域(issue #15);与 backend 无关,三条装配路一并覆盖。 + + 不按 `telemetry_backend == "postgres"` 才校验: 值域错就是错,提前拦住 + 比等到有人把 backend 切成 postgres 时才炸更接近"缺失关键配置直接报错"。 + 报错文本同时点字段名与 env 键名(两类调用方各看得懂自己那套)。 + """ + if self.telemetry_pg_pool_max < 1: + raise ValueError( + f"telemetry_pg_pool_max({_POOL_MAX_KEY})必须 >= 1: " + f"{self.telemetry_pg_pool_max};0 条上限等于永远取不到连接,遥测会全灭" + ) + if self.telemetry_pg_write_timeout_s <= 0: + raise ValueError( + f"telemetry_pg_write_timeout_s({_WRITE_TIMEOUT_KEY})必须 > 0: " + f"{self.telemetry_pg_write_timeout_s};预算 0 会让每一行当场超预算被丢弃" + ) + def _validate_lease(self) -> None: """调用超时须 ≤ permit 租约 TTL,防租约先于请求过期使并发超出配额。""" slowest = max(s.timeout_s for s in self.sources) @@ -486,6 +521,8 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]: "telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None, "telemetry_auto_migrate": auto_migrate, "telemetry_text_cap": _load_text_cap(env), + "telemetry_pg_pool_max": _load_pool_max(env), + "telemetry_pg_write_timeout_s": _load_write_timeout(env), "redis_url": redis_url, "pricing_path": env.get("PGW_PRICING_PATH") or None, "structured_max_retries": _load_structured_retries(env), @@ -543,6 +580,43 @@ def _load_text_cap(env: Mapping[str, str]) -> int | None: return int(_cast(found[1], "int", found[0])) +def _load_pool_max(env: Mapping[str, str]) -> int: + """读 `PGW_TELEMETRY_PG_POOL_MAX`(issue #15);未设即缺省 4。 + + 与 `_load_text_cap` 同为二态键,只是"未设"落到一个具体缺省而非 None: + 池上限没有"不设上限"这一档——不表态就是继承第三方默认值,而那正是本 issue + 的病灶。值域(>= 1)留给构造期守卫,它同时覆盖直接构造与 `dataclasses.replace`。 + + Args: + env: 已合并的环境映射。 + + Returns: + 遥测池允许的最大连接数。 + """ + found = _first(env, _POOL_MAX_KEY) + if found is None: + return _DEFAULT_PG_POOL_MAX + return int(_cast(found[1], "int", found[0])) + + +def _load_write_timeout(env: Mapping[str, str]) -> float: + """读 `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15);未设即缺省 5.0 秒。 + + 这个值同时是 connect、acquire 与整次写入的上界: 遥测是业务路径上的内联 + await,"不设预算"不是一个允许存在的档位(铁律"丢一条 < 拖垮调用")。 + + Args: + env: 已合并的环境映射。 + + Returns: + 单次遥测写入的硬预算(秒)。 + """ + found = _first(env, _WRITE_TIMEOUT_KEY) + if found is None: + return _DEFAULT_PG_WRITE_TIMEOUT_S + return float(_cast(found[1], "float", found[0])) + + def _strip_dsn_driver(dsn: str) -> str: """剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。""" scheme, sep, rest = dsn.partition("://") diff --git a/src/polygateway/embedding.py b/src/polygateway/embedding.py index f06b97c..068d547 100644 --- a/src/polygateway/embedding.py +++ b/src/polygateway/embedding.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Any from loguru import logger +from polygateway.client import _aclose_component, _telemetry_status_of from polygateway.config import EmbeddingSettings from polygateway.errors import ( AllSourcesExhausted, @@ -45,6 +46,7 @@ from polygateway.types import ( ChatRequest, EmbeddingResponse, LLMResponse, + TelemetryStatus, strip_unsupported_extra_body, validate_caller_dimensions, ) @@ -128,6 +130,15 @@ class EmbeddingClient: TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None ) self._telemetry = telemetry + # 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份 + # 引用才关得到自建的 redis 客户端(设计 §3.4) + self._limiter_backend = limiter + self._breaker_backend = breaker + # 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True + self._owns_transport = False + self._owns_telemetry = False + self._owns_limiter = False + self._owns_breaker = False self._pricing = pricing self._batch_size = batch_size self._normalize = normalize @@ -444,21 +455,28 @@ class EmbeddingClient: known = [c for c in costs if c is not None] return sum(known) if known else None + @property + def telemetry_status(self) -> TelemetryStatus | None: + """遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。 + + 判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client + 的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。 + """ + return _telemetry_status_of(self._telemetry) + async def aclose(self) -> None: - """幂等释放 transport 连接池与遥测连接(与 GatewayClient 对称)。""" + """幂等释放**自建**资源(与 GatewayClient 对称);注入的组件一律不碰。""" if self._closed: return self._closed = True - transport_aclose = getattr(self._transport, "aclose", None) - if transport_aclose is not None: - await transport_aclose() - telemetry_aclose = getattr(self._telemetry, "aclose", None) - if telemetry_aclose is not None: - await telemetry_aclose() - else: - telemetry_close = getattr(self._telemetry, "close", None) - if telemetry_close is not None: - telemetry_close() + if self._owns_transport: + await _aclose_component(self._transport) + if self._owns_telemetry: + await _aclose_component(self._telemetry) + if self._owns_limiter: + await _aclose_component(self._limiter_backend) + if self._owns_breaker: + await _aclose_component(self._breaker_backend) async def __aenter__(self) -> EmbeddingClient: return self @@ -484,18 +502,19 @@ class EmbeddingClient: _build_limiter, _build_selector, _build_telemetry, + _mark_owned_components, ) from polygateway.pricing import PricingTable from polygateway.transports.openai_compat import OpenAICompatTransport gw = settings.gateway sources = list(gw.sources) - return cls( + client = cls( scope=gw.scope, sources=sources, selector=_build_selector(gw.selector), - limiter=limiter or _build_limiter(gw, sources), - breaker=breaker or _build_breaker(gw), + limiter=limiter if limiter is not None else _build_limiter(gw, sources), + breaker=breaker if breaker is not None else _build_breaker(gw), transport=OpenAICompatTransport(registry=registry), retry=gw.retry, backpressure=gw.backpressure, @@ -512,6 +531,8 @@ class EmbeddingClient: normalize=settings.normalize, expected_dim=settings.expected_dim, ) + _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) + return client @classmethod def from_env( diff --git a/src/polygateway/ocr.py b/src/polygateway/ocr.py index 4f6da20..d2b486b 100644 --- a/src/polygateway/ocr.py +++ b/src/polygateway/ocr.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, Literal from loguru import logger +from polygateway.client import _aclose_component, _telemetry_status_of from polygateway.errors import ( AllSourcesExhausted, GovernanceBackendError, @@ -43,6 +44,7 @@ from polygateway.types import ( LLMResponse, OcrLayoutResult, OcrTextResult, + TelemetryStatus, Usage, strip_unsupported_extra_body, validate_caller_dimensions, @@ -126,6 +128,15 @@ class OcrClient: self._retry = retry self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None self._telemetry = telemetry + # 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份 + # 引用才关得到自建的 redis 客户端(设计 §3.4) + self._limiter_backend = limiter + self._breaker_backend = breaker + # 所有权默认"不拥有": `__init__` 是全量注入路径,只有工厂自建时才置 True + self._owns_transport = False + self._owns_telemetry = False + self._owns_limiter = False + self._owns_breaker = False self._now = now self._sleep = sleep self._rng = rng @@ -454,21 +465,28 @@ class OcrClient: # —— 生命周期 —— + @property + def telemetry_status(self) -> TelemetryStatus | None: + """遥测后端的可写状态;无遥测或注入的 recorder 不提供状态时为 None。 + + 判定收敛在 `_telemetry_status_of` 一处(不是三处各自探测): 三个 client + 的 `aclose` 曾各持一份逐字复制,漂移的结果就是越权关闭(设计 §3.3/§3.4)。 + """ + return _telemetry_status_of(self._telemetry) + async def aclose(self) -> None: - """幂等释放 transport 连接池与遥测连接(与 EmbeddingClient 对称)。""" + """幂等释放**自建**资源(与 EmbeddingClient 对称);注入的组件一律不碰。""" if self._closed: return self._closed = True - transport_aclose = getattr(self._transport, "aclose", None) - if transport_aclose is not None: - await transport_aclose() - telemetry_aclose = getattr(self._telemetry, "aclose", None) - if telemetry_aclose is not None: - await telemetry_aclose() - else: - telemetry_close = getattr(self._telemetry, "close", None) - if telemetry_close is not None: - telemetry_close() + if self._owns_transport: + await _aclose_component(self._transport) + if self._owns_telemetry: + await _aclose_component(self._telemetry) + if self._owns_limiter: + await _aclose_component(self._limiter_backend) + if self._owns_breaker: + await _aclose_component(self._breaker_backend) async def __aenter__(self) -> OcrClient: return self @@ -493,6 +511,7 @@ class OcrClient: _build_limiter, _build_selector, _build_telemetry, + _mark_owned_components, ) from polygateway.transports.monkey_ocr import MonkeyOcrTransport @@ -503,12 +522,12 @@ class OcrClient: alien = sorted({s.provider for s in sources if s.provider != "monkey"}) if alien: raise ValueError(f"OCR 装配仅支持 provider=monkey(D9 其余后端预留未实现): 发现 {alien}") - return cls( + client = cls( scope=gw.scope, sources=sources, selector=_build_selector(gw.selector), - limiter=limiter or _build_limiter(gw, sources), - breaker=breaker or _build_breaker(gw), + limiter=limiter if limiter is not None else _build_limiter(gw, sources), + breaker=breaker if breaker is not None else _build_breaker(gw), transport=MonkeyOcrTransport(), retry=gw.retry, backpressure=gw.backpressure, @@ -519,6 +538,8 @@ class OcrClient: # 一半不受控(issue #12) text_cap=gw.telemetry_text_cap, ) + _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) + return client @classmethod def from_env( diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 66c80c2..9429e4c 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -20,6 +20,7 @@ from .types import ( OcrTextTransportResult, SourceConfig, SourceStats, + TelemetryStatus, TransportResult, ) @@ -243,6 +244,20 @@ class StructuredOutputStrategy(Protocol): def parse(self, text: str) -> Any: ... +@runtime_checkable +class TelemetryStatusProvider(Protocol): + """可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。 + + 与 `TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable` + 而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call` 的 + 实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断 + (设计 §3.3)。消费方一律先 isinstance 再取值,取不到就当没有状态可报。 + """ + + @property + def telemetry_status(self) -> TelemetryStatus: ... + + @runtime_checkable class TelemetryRecorder(Protocol): """遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。 diff --git a/src/polygateway/streaming.py b/src/polygateway/streaming.py index 6a64820..ff315bb 100644 --- a/src/polygateway/streaming.py +++ b/src/polygateway/streaming.py @@ -14,13 +14,11 @@ from __future__ import annotations import asyncio import contextlib import time -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import AsyncIterator -_T = TypeVar("_T") - class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名 """流活性超时异常。 @@ -38,14 +36,14 @@ class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公 super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)") -async def _anext_within( - it: AsyncIterator[_T], +async def _anext_within[T]( + it: AsyncIterator[T], timeout_s: float, *, kind: str, start: float, first: bool, -) -> _T: +) -> T: """限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。 上游自抛的 TimeoutError 用 cm.expired() 区分,原样上抛不误吞。 @@ -59,13 +57,13 @@ async def _anext_within( raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None -async def stream_with_liveness_timeouts( - source: AsyncIterator[_T], +async def stream_with_liveness_timeouts[T]( + source: AsyncIterator[T], *, ttft_s: float, inter_token_s: float, total_s: float, -) -> AsyncIterator[_T]: +) -> AsyncIterator[T]: """逐项产出 source,并施加三层活性超时。 关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield——否则总时长 diff --git a/src/polygateway/telemetry/postgres.py b/src/polygateway/telemetry/postgres.py index 101f240..bb3f417 100644 --- a/src/polygateway/telemetry/postgres.py +++ b/src/polygateway/telemetry/postgres.py @@ -1,22 +1,27 @@ -"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 两级降级。 +"""Postgres 遥测后端(M2 设计 §5): asyncpg lazy 池 + 按失败性质三分的降级。 参考仓无先例(三项目遥测全 SQLite);asyncpg 工程写法取 GovDoc `taskrun/postgres_store.py`($n 占位、`ON CONFLICT DO NOTHING`),但其 -"失败冒泡"方向按遥测铁律**有意反转**: -① 结构性失败 → warning 一次后永久降级(所有写入短路); -② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由 -asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。 +"失败冒泡"方向按遥测铁律**有意反转**: 遥测失败一律不冒泡,只降级。 构造不连库(lazy),24 列 schema 与 SQLite 版同名同序。 -**"结构性"的判据是「确定写不进去」,不是「初始化时出过错」**(issue #9): -只有建池失败(重试要在业务路径上内联吞掉 connect 超时)与"表确定不存在 -且建不出来"(后续 INSERT 必然全败)才判死;探测失败、补列失败、取连接 -失败一律只 warning,让写入照常尝试或下次调用重试。 +**降级档位挂在"失败是什么性质",不挂"哪一步失败"**(issue #15)。挂步骤是 +issue 的病灶: `min_size=10` 把"连接耗尽"这种瞬时错误逼到建池那一步,于是它被 +一刀切成了永久判死,整进程从此一条遥测都不落,只有重启能恢复。判据两句: + +1. **致命 = 失败原因完全在进程内部且不可变**。DSN 是构造期定死的字符串,是唯一 + 满足这条的东西;认证失败、库不存在、表建不出来一律不算——DBA 改完就该好。 +2. **行级 vs 环境级看失败与"这一行的数据"有没有关系**: 只与本行数据有关(换一行 + 可能成功)= 行级,逐条丢弃;与数据无关、每一行都会同样失败 = 环境级,进冷却。 + +见 `_classify_failure`(全库唯一一处 PG 失败分类)与 `_handle_failure`(三个降级点 +唯一一处处置)。 """ from __future__ import annotations import asyncio +import time from typing import TYPE_CHECKING from loguru import logger @@ -28,24 +33,116 @@ from polygateway.telemetry.schema import ( insert_sql, missing_columns_warning, ) +from polygateway.telemetry.status import TelemetryStatusTracker if TYPE_CHECKING: + from collections.abc import Callable + import asyncpg + from polygateway.types import TelemetryStatus + # 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析 _TABLE_EXISTS = "SELECT to_regclass('llm_calls')" +# 归还连接的独立上限(issue #15)。**不**复用写入预算: 写入预算已经花在 +# acquire+execute 上,归还再给它一个同样大的额度,等于允许业务路径上的一次遥测 +# 写入吃掉 2 倍预算。归还是本地动作(reset 一次往返),1 秒足够;超时即断开, +# asyncpg 会在下次 acquire 时补一条新连接 +_RELEASE_TIMEOUT_S = 1.0 + +# 关闭池的独立上限(issue #15)。**不**复用写入预算: 关闭跑在收尾路径而非业务 +# 路径上,给它一个略宽的固定额度即可,但必须**有界**——asyncpg 的 +# `Pool.close()` 会 await 每个 holder 的 `wait_until_released()`,in-flight +# 连接不归还就无限等(`pool.py:939-948, 961-972`,60s 只发一条 warning), +# 其 docstring 自己写着 "advisable to use asyncio.wait_for to set a timeout" +_CLOSE_TIMEOUT_S = 5.0 + # 探测现有列;尊重 search_path(to_regclass 按当前 search_path 解析) _EXISTING_COLUMNS = ( "SELECT attname FROM pg_attribute " "WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped" ) +# 环境级降级的冷却期(issue #15)。**不暴露配置**: 它的取值只影响"多久重试一次" +# 这个内部节奏,任何取值都不改变对外承诺(降级可见、可自愈、有界成本),给出旋钮 +# 只会多一个下游要理解却调不对的东西(设计 §3.5) +_DEGRADE_COOLDOWN_S = 60.0 + +_FATAL = "fatal" +"""配置级致命: 原因完全在进程内部且不可变 → 永久 no-op + 一条 error。""" + +_UNAVAILABLE = "unavailable" +"""环境级不可用: 与本行数据无关、每行都会同样失败 → 冷却降级,到期重试一次。""" + +_ROW = "row" +"""行级拒绝: 只与本行数据有关 → 逐条 warning 丢弃,不降级。""" + +# 环境级的 SQLSTATE 类(前两位): 08 连接、53 资源不足(含 53300 too many +# connections)、57 管理干预、28 认证、3D 库不存在。共同点是"与这一行的数据无关, +# 换一行照样失败",且都能被外部修好 +_UNAVAILABLE_SQLSTATE_CLASSES = frozenset({"08", "53", "57", "28", "3D"}) + +# 类 42 整体归行级(见 `_classify_failure` 的默认档),但这两个码与本行数据无关: +# 42501 = 账号被收走 INSERT 权限,42P01 = 表被迁走/删掉。它们是持续性的环境状态, +# 按类归行级会让每次 LLM 调用都内联付一次往返、刷一条 warning,且永不自愈 +_UNAVAILABLE_SQLSTATES = frozenset({"42501", "42P01"}) + +# **判据的唯一具名例外**(issue #13 的更高优先级承诺): 42703 = 缺列。按判据第 2 句 +# 它本该是环境级(缺列时每一行都失败),归行级是因为 manual 档会按现有列裁剪 INSERT +# 继续写——"部分列写进去了 + 缺列逐行 warning 暴露"本身有价值,是下游发现 schema +# 漂移的唯一信号,不该被冷却掉。**新增例外必须同款论证**: 说清它为什么值得违反判据 +_ROW_SQLSTATES = frozenset({"42703"}) + + +def _classify_failure(exc: BaseException) -> str: + """按**失败的性质**分档(全库唯一一处 PG 失败分类);判据见模块 docstring。 + + 分类只认 SQLSTATE 与异常类型,不认"在哪一步失败"——后者正是 issue #15 的病灶。 + SQLSTATE 而非 asyncpg 异常类白名单: 前者是 PG 标准,不随驱动版本漂移。 + + **认不出来的失败一律给最轻的一档**(`_ROW`): 升档(冷却 60s)要有依据,没依据就 + 宁可每次调用多付一次内联往返,也不拿 60 秒的遥测去赌一个猜测。issue #9 定下的 + "探测抖动只跳过本次、下次重试"正是靠这条默认保住的。 + """ + if isinstance(exc, ValueError | TypeError): + # DSN 不可解析(实测: 端口写成非数字 → 裸 ValueError;scheme 不对 → + # ClientConfigurationError,它本身就是 ValueError 子类)与建池参数非法。 + # 这些是构造期就定死的进程内部事实,重试在任何时刻都不可能成功 + return _FATAL + sqlstate = getattr(exc, "sqlstate", None) + if isinstance(sqlstate, str): + if sqlstate in _ROW_SQLSTATES: + return _ROW + if sqlstate[:2] in _UNAVAILABLE_SQLSTATE_CLASSES or sqlstate in _UNAVAILABLE_SQLSTATES: + return _UNAVAILABLE + # 其余 PostgresError(22 数据异常、23 约束冲突等)都是这一行的数据问题 + return _ROW + # 没有 SQLSTATE = 话还没说到 PG 就断了: OSError(含 ConnectionError 与 + # TimeoutError)与 asyncpg 自己的 InterfaceError,都与本行数据无关 + return _UNAVAILABLE if isinstance(exc, OSError | _interface_error()) else _ROW + + +def _interface_error() -> type[BaseException]: + """asyncpg 的 `InterfaceError` 类型;延迟取用以免模块导入期硬依赖 extra。""" + import asyncpg + + return asyncpg.InterfaceError + class PostgresRecorder: """TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。""" - def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: + def __init__( + self, + dsn: str, + *, + pool: asyncpg.Pool | None = None, + auto_migrate: bool, + pool_max: int, + write_timeout_s: float, + now: Callable[[], float] = time.monotonic, + ) -> None: """记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。 Args: @@ -56,6 +153,12 @@ class PostgresRecorder: 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类 签名漂移(设计 D-c)。 + pool_max: 自建池的连接数上限(issue #15)。稳态吞吐**按实测折算,不要按 + `pool_max / RTT` 估**(那会乐观一倍): RTT ≈ 123ms 上 `pool_max=4` + 实测约 15.6 行/秒(50 行并发批 3.2s)。与 `auto_migrate` 同一纪律: + 必填,缺省只写在 config 一处。 + write_timeout_s: 单次写入的硬预算,同时用作 connect 与 acquire 的上限。 + now: 单调时钟,注入给降级 tracker(测试可推进冷却与节流窗口)。 """ try: import asyncpg # noqa: F401 - 仅探测 extra 是否安装 @@ -67,21 +170,40 @@ class PostgresRecorder: self._pool: asyncpg.Pool | None = pool self._external_pool = pool is not None self._auto_migrate = auto_migrate + self._pool_max = pool_max + self._write_timeout_s = write_timeout_s # 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为) self._columns: tuple[str, ...] = COLUMNS self._insert = insert_sql("postgres", COLUMNS) self._schema_ready = False - self._failed = False # 结构性降级标志: 置位后所有写入短路 + self._closed = False # 关了就是关了: 置位后写入短路且**不重建池** + # 降级状态**只此一份**: 是否短路写入、多久重试一次、下游查到什么, + # 全由 tracker 回答。两份状态(曾经的 `_failed` 布尔 + tracker)必然漂移 + self._status = TelemetryStatusTracker(backend="postgres", now=now) self._init_lock = asyncio.Lock() + @property + def telemetry_status(self) -> TelemetryStatus: + """当前可写状态快照(ports.TelemetryStatusProvider)。""" + return self._status.snapshot() + async def _ensure_ready(self) -> asyncpg.Pool | None: - """lazy 建池+备表;判死只认「确定写不进去」(issue #9),其余失败都留活路。""" - if self._failed: + """lazy 建池+备表;降级期间**零成本短路**,冷却到期放行一次重新准备。 + + `should_retry()` 是纯时间比较,不触库: 降级期间的调用因此既不内联吞 + connect 超时(`postgres.py` 老注释担心的正是这个),也不需要重启进程—— + 成本变成"每 60s 一次、上界一个写入预算",有界且可解释。 + + `_closed` 在锁内**必须复查**: 等锁期间发生的 `aclose` 否则会被这次 + 等待"绕过",等到锁时照旧建出一个没人负责关的池(注入档更隐蔽—— + 注入方以为自己管着全部连接,实际早已不是)。 + """ + if self._closed or not self._status.should_retry(): return None if self._schema_ready: return self._pool async with self._init_lock: - if self._failed: + if self._closed or not self._status.should_retry(): return None if self._schema_ready: return self._pool @@ -91,37 +213,59 @@ class PostgresRecorder: return await self._prepare_schema(pool) async def _open_pool(self) -> asyncpg.Pool | None: - """建池;失败即永久降级(唯一一处「无条件判死」)。""" + """建池;失败按性质分档处置(见 `_handle_failure`),不再一律判死。 + + **池的资源占用由本库显式声明**(issue #15): `min_size=0` 的语义是"不预 + 连接"(asyncpg `pool.py:457` 为 0 时只造 holder 对象,一条连接都不连), + 建池因此从"要么拿到 10 条、要么失败"的重资源动作变成零成本、不触库的 + 动作;连接失败自然落到 acquire 那条本来就正确的"丢一行、池自恢复"路径。 + `max_size` 是库对自己占用的表态——继承第三方默认值等于不表态(P4),而 + 那正是共享实例余量紧张时先倒下的原因。 + """ if self._pool is not None: return self._pool try: import asyncpg - self._pool = await asyncpg.create_pool(self._dsn, timeout=10) + self._pool = await asyncpg.create_pool( + self._dsn, + min_size=0, + max_size=self._pool_max, + timeout=self._write_timeout_s, + command_timeout=self._write_timeout_s, + ) except asyncio.CancelledError: raise except Exception as exc: - # 池建不出来 = 确定写不进去;且每次调用重试都要内联吞掉 connect - # 超时,而遥测是业务路径上的 await —— 此处必须永久降级 - self._failed = True - logger.warning("Postgres 遥测建池失败,后续记录降级为 no-op: {}", exc) + self._handle_failure(exc, stage="建池") return None return self._pool async def _prepare_schema(self, pool: asyncpg.Pool) -> asyncpg.Pool | None: - """备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。""" + """备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。 + + 取连接走显式 acquire/release(理由见 `_release`): 准备期同样跑在调用方的 + 写入预算里,`async with` 那条路的归还会把真实上界撑到 ≈2× 预算。 + """ try: - async with pool.acquire() as conn: + conn = await pool.acquire(timeout=self._write_timeout_s) + try: columns = await self._prepare_table(conn) + finally: + await self._release(pool, conn) except asyncio.CancelledError: raise except Exception as exc: - # 池已在手,取连接/探测失败多为瞬时抖动: 不判死也不标就绪, - # 只跳过本次记录,下次调用重新准备 - logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc) + self._handle_failure(exc, stage="建表探测") return None if columns is None: - self._failed = True + # 表确定不存在且建不出来: 与本行数据无关(每行都会同样失败)且能被 + # 外部修好(DBA 建了表就该自愈)—— 判据第 2 句下的环境级 + self._status.enter_degraded( + "表 llm_calls 不存在且建不出来(记录无处可落)", + fatal=False, + cooldown_s=_DEGRADE_COOLDOWN_S, + ) return None # 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready` # 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口 @@ -133,7 +277,7 @@ class PostgresRecorder: async def _prepare_table(self, conn: object) -> tuple[str, ...] | None: """备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。 - 返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。 + 返回 None 仅表示表确定不存在且建不出来(调用方据此进环境级冷却降级)。 `CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的 CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测: @@ -206,11 +350,11 @@ class PostgresRecorder: return effective async def _backfill_columns(self, conn: object, existing: set[str]) -> None: - """auto 档: 给已存在的旧表补新列(issue #3);**失败绝不置 `_failed`**。 + """auto 档: 给已存在的旧表补新列(issue #3);**失败绝不让 recorder 降级**。 - 不置 `_failed` 的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 - ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让 - 整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖 + 不降级的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 + ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。降级会让 + 整个 recorder 停写(环境级还要停满一个冷却期),与「补列失败只降级为逐行丢弃」的承诺相悖 (SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为): auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入 请显式选 manual。 @@ -224,28 +368,151 @@ class PostgresRecorder: except Exception as exc: logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc) + def _handle_failure(self, exc: BaseException, *, stage: str) -> None: + """按分档处置一次遥测失败;三个降级点(建池/建表探测/写入)共用这一处。 + + 收敛成一处不只是去重: 三处各写一遍处置,就是三处各自漂移一次判据的机会, + 而判据漂移正是 issue #15 的病灶(注释写着"确定写不进去",代码做的是别的事)。 + + `stage` 只进日志文案,**不参与分档**——挂步骤分档正是要被拆掉的错法。 + """ + verdict = _classify_failure(exc) + if verdict == _FATAL: + # 这里**不再**另发一条 error: 级别由 tracker 按 `fatal` 决定(致命档发 + # error——人配错了,本进程内不会自愈)。此处复制一条只会让同一个事实出 + # 两条语义重复的日志,并给"级别"这个决策造出第二个源头 + self._status.enter_degraded( + f"{stage}失败(配置有误): {exc}", fatal=True, cooldown_s=None + ) + elif verdict == _UNAVAILABLE: + # 每一行都会同样失败 → 冷却期内不再内联重试;`_schema_ready` 一并作废, + # 到期那次要重新走准备(表被删/权限被收回都得靠重新准备才能发现已修好) + self._schema_ready = False + self._status.enter_degraded( + f"{stage}失败: {exc}", fatal=False, cooldown_s=_DEGRADE_COOLDOWN_S + ) + else: + # 行级不进降级: 换一行可能就成了。逐条出声是 issue #13 的承诺 + # (缺列靠这条 warning 暴露 schema 漂移),不因刷屏而节流掉 + logger.warning("Postgres 遥测{}失败(丢弃该行,下次调用照常重试): {}", stage, exc) + + def _drop_reason(self) -> str: + """写不进去时说清是**哪一种**写不进去: 关了 / 降级中 / 本次没准备好。 + + 三者的处置完全不同(一个是调用方自己关了却还在写、一个等自愈、一个下次 + 就会重试),混成一句话会让对账的人分不清该等还是该修。 + """ + if self._closed: + return "遥测已关闭" + if self._status.snapshot().degraded: + return "遥测降级中" + return "后端本次未准备好(下次调用重试)" + async def record_llm_call(self, **fields: object) -> None: - """写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。 + """写一行遥测;整次写入受硬预算约束,失败逐条丢弃(两级降级之二),绝不冒泡。 + + **硬预算**(issue #15): 准备 + 取连接 + 执行合计不得超过 `write_timeout_s`。 + 这把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"变成一条可陈述、可测试的 + 保证——此前 `pool.acquire()` 无超时(asyncpg 缺省 `timeout=None` = 无限等), + 池满时会无限期挂在业务路径上。 + + 外部取消照常穿透: `asyncio.timeout` 只把**自己**触发的 cancel 转成 + TimeoutError,故 `CancelledError` 分支必须排在最前且原样 re-raise(铁律)。 + """ + try: + async with asyncio.timeout(self._write_timeout_s): + await self._write_row(fields) + except asyncio.CancelledError: + raise + except TimeoutError: + logger.warning( + "Postgres 遥测写入超预算 {}s(丢弃该行);后端慢不得拖垮业务调用", + self._write_timeout_s, + ) + self._status.record_drop("写入超预算") + except Exception as exc: + # 遥测铁律: 丢一条 < 拖垮调用。这一行无论如何都没了,区别只在于 + # **下一行还试不试**——那由失败的性质决定,不由这里决定 + self._handle_failure(exc, stage="写入") + self._status.record_drop("写入失败") + + async def _write_row(self, fields: dict[str, object]) -> None: + """预算内的写入本体: 准备 → 取连接 → 执行 → 归还。 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 占位符同序——两者必须一起改,分开改就是把值写进错位的列。 """ pool = await self._ensure_ready() if pool is None: + # 降级期间静默 return 就是 issue #15 的破口: 丢行必须计数且节流出声 + self._status.record_drop(self._drop_reason()) return row = tuple(fields[col] for col in self._columns) + conn = await pool.acquire(timeout=self._write_timeout_s) try: - async with pool.acquire() as conn: - await conn.execute(self._insert, *row) + await conn.execute(self._insert, *row) + finally: + await self._release(pool, conn) + # **恢复的唯一权威证据是一次真正写成功**(未降级时是廉价 no-op)。放在这里 + # 而不是准备期: 准备通过不代表写得进去(权限只到 SELECT 时正是如此) + self._status.recover() + + async def _release(self, pool: asyncpg.Pool, conn: object) -> None: + """归还连接;归还路径独立有界,失败即断开(下次 acquire 会补一条新的)。 + + **不用 `async with pool.acquire()`**(设计 §3.1,已核实): asyncpg 的 + `Pool.release` 是 `await asyncio.shield(ch.release(timeout))`,且那个 + timeout 默认复用 acquire 时记录的 `ch._timeout`(`pool.py:886-889, + 930-937`)。写入预算到期时 cancel 在 execute 处抛出,异常传播中执行 + `__aexit__`,此时没有新的 cancel 投递——那次 shielded release 会**正常 + 等到完成**,业务路径的真实上界因此变成 ≈2 × 预算。显式归还才能给它一个 + 独立的小上限,承诺才精确成立: 主写入尝试 ≤ 预算,归还路径独立有界。 + """ + try: + await pool.release(conn, timeout=_RELEASE_TIMEOUT_S) except asyncio.CancelledError: raise except Exception as exc: - # 遥测铁律: 丢一条 < 拖垮调用;仅记 warning(非 pass),池自恢复 - logger.warning("Postgres 遥测写入失败(丢弃该行): {}", exc) + # 含 TimeoutError: 归还超时与归还出错的处置相同——断开而不是留一条 + # 状态不明的连接在池里(asyncpg 的 reset 失败路径也是这么做的) + logger.warning("Postgres 遥测连接归还失败(强制断开): {}", exc) + self._terminate(conn, label="连接") + + @staticmethod + def _terminate(target: object, *, label: str) -> None: + """强制断开一条连接或整个池;断开本身再失败也只记 warning(遥测绝不冒泡)。 + + `label` 必填(不给默认值): 两个调用点的诊断价值全在"拆的是哪一层", + 默认值只会让其中一处悄悄报错成另一处。 + """ + try: + target.terminate() # type: ignore[attr-defined] + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("Postgres 遥测{}断开失败(交给上层自行回收): {}", label, exc) async def aclose(self) -> None: - """幂等关闭自建池;注入的池归注入方管理。""" + """幂等关闭自建池;**关了就是关了**,此后写入短路且不复活。注入的池归注入方管理。 + + 取消"关完还能自己重建池"的灰色状态(设计 §3.2 第 4 点): 关闭是所有权的 + 终结,而恢复是运行时行为(冷却重试),不该是关闭动作的副作用。 + + **关闭动作本身也有界**: `Pool.close()` 会 await 每个 holder 的 + `wait_until_released()`,in-flight 连接不归还就无限等——收尾路径上照样是 + "遥测拖垮业务"。超时即 `terminate()` 强拆: 关闭已在进行,留着一个关不掉的池 + 既不会自愈也没人再来收。外部取消照常穿透(铁律),不当成一次关闭超时。 + """ + self._closed = True pool, self._pool = self._pool, None self._schema_ready = False - if pool is not None and not self._external_pool: - await pool.close() + if pool is None or self._external_pool: + return + try: + await asyncio.wait_for(pool.close(), timeout=_CLOSE_TIMEOUT_S) + except asyncio.CancelledError: + raise + except Exception as exc: + # 含 TimeoutError: 关不掉与关出错的处置相同——强拆 + logger.warning("Postgres 遥测池关闭失败(强制断开): {}", exc) + self._terminate(pool, label="池") diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index 9b68ab8..e737dda 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -19,6 +19,7 @@ import asyncio import sqlite3 import threading from pathlib import Path +from typing import TYPE_CHECKING from loguru import logger @@ -29,6 +30,10 @@ from polygateway.telemetry.schema import ( insert_sql, missing_columns_warning, ) +from polygateway.telemetry.status import TelemetryStatusTracker + +if TYPE_CHECKING: + from polygateway.types import TelemetryStatus class SQLiteRecorder: @@ -45,6 +50,9 @@ class SQLiteRecorder: config 一处,不与本类签名漂移(设计 D-c)。 """ self._auto_migrate = auto_migrate + # SQLite 侧本次只做可见性: 它的失败模式(目录不可写、文件损坏)在装配期 + # 就暴露给下游,不是"跑到一半悄悄断",故降级恒为 fatal,不做冷却重连 + self._status = TelemetryStatusTracker(backend="sqlite") self._lock = threading.Lock() self._conn: sqlite3.Connection | None = None # 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为) @@ -60,9 +68,14 @@ class SQLiteRecorder: conn.commit() self._conn = conn except (OSError, sqlite3.Error) as exc: - logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc) + self._status.enter_degraded(f"初始化失败: {exc}", fatal=True, cooldown_s=None) self._prepare_columns() + @property + def telemetry_status(self) -> TelemetryStatus: + """当前可写状态快照(ports.TelemetryStatusProvider)。""" + return self._status.snapshot() + def _prepare_columns(self) -> None: """探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。 @@ -136,12 +149,28 @@ class SQLiteRecorder: 占位符同序——两者必须一起改,分开改就是把值写进错位的列。 """ if self._conn is None: + # 改前这里是**裸 return**: 初始化失败后每一行都无声消失,长跑进程里 + # 与"遥测正常"外观上完全一致(设计 §1.4 的直接钉子) + self._status.record_drop(self._drop_reason()) return row = tuple(fields[col] for col in self._columns) try: await asyncio.to_thread(self._write, row) except (OSError, sqlite3.Error) as exc: logger.warning("SQLite 遥测写入失败(降级不冒泡): {}", exc) + # 计数与出声是两件事,少了计数可见性在这条路径上就是假的: 磁盘满 / + # database is locked / 文件被外部改坏时行真的丢了,而 `dropped_rows` + # 恒 0、`degraded` 恒 False,下游读快照对账完全看不见(PG 侧两件都做) + self._status.record_drop("写入失败") + + def _drop_reason(self) -> str: + """连接为 None 时说清是**哪一种**写不进去: 降级中 / 调用方自己关了。 + + 不能写死为"已降级": `close()` 之后 `degraded` 是 False,固定文案会与 + 下游读到的快照互相矛盾,对账的人分不清该等自愈还是修自己的关闭时序。 + 本侧只有这两态(初始化失败必置降级,此外只剩关闭),故不照抄 PG 的三分。 + """ + return "遥测已降级" if self._status.snapshot().degraded else "遥测已关闭" def _write(self, row: tuple) -> None: assert self._conn is not None # 内部不变量: 调用方已判空 diff --git a/src/polygateway/telemetry/status.py b/src/polygateway/telemetry/status.py new file mode 100644 index 0000000..2665e05 --- /dev/null +++ b/src/polygateway/telemetry/status.py @@ -0,0 +1,185 @@ +"""遥测降级状态机(issue #15 C 组): 两个 recorder 共用的降级事实源。 + +存在的理由(设计 §1.4): 遥测降级过去只有**一条** warning,长跑进程里等同于 +静默——issue 是手工对账(日志里的完成里程碑条数 vs `llm_calls` 行数)才发现的, +期间 19 次调用一行未落。"遥测必录"铁律的实质要求是: 库做不到必录时,必须 +**持续、可编程地**让下游知道。故降级升格为一等对象,两条出路各走一边: + +- 人看: 进入/恢复各一条日志,降级期间按行数与时间**双阈值节流复述**(不刷屏, + 也不静默); +- 程序看: `snapshot()` 给只读 `TelemetryStatus`,下游可据此对账或告警。 + +本模块**不含任何后端知识**(不 import asyncpg/sqlite3,也不判失败性质): 失败 +分类是各 recorder 的事,tracker 只接受"降级了/恢复了/丢了一行"三个事实。 +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from loguru import logger + +from polygateway.types import TelemetryStatus + +if TYPE_CHECKING: + from collections.abc import Callable + +_DROP_REPEAT_EVERY_ROWS = 100 +"""降级期间每丢这么多行复述一次;首行必报。""" + +_DROP_REPEAT_EVERY_S = 300.0 +"""降级期间距上次复述超过这么久就再报一次——低频调用的进程不能因行数不够而静默。""" + + +class TelemetryStatusTracker: + """单个 recorder 的降级状态;非线程安全,由持有它的 recorder 在自己的时序内使用。 + + 时钟经构造参数注入(与 `GatewayClient(now=...)` 同款): 冷却窗口与节流窗口 + 都必须能用假时钟测,否则这些行为只能靠真睡验证,而真睡的用例是间歇红的源头。 + """ + + def __init__(self, *, backend: str, now: Callable[[], float] = time.monotonic) -> None: + """记下后端名(只用于日志前缀)与时钟;构造后即"未降级"。 + + Args: + backend: 后端名(如 `postgres`/`sqlite`),仅进日志文案。 + now: 单调时钟;测试可注入假时钟推进冷却与节流窗口。 + """ + self._backend = backend + self._now = now + self._degraded_since: float | None = None + self._fatal = False + self._reason: str | None = None + self._retry_at: float | None = None + self._dropped_rows = 0 + # 节流窗口: 本段降级里"自上次复述以来"丢了多少行、上次复述在什么时候 + self._dropped_since_report = 0 + self._last_report_at: float | None = None + self._dropped_at_entry = 0 + + def enter_degraded(self, reason: str, *, fatal: bool, cooldown_s: float | None) -> None: + """进入(或续期)降级;同一原因只讲一次,只刷新冷却窗口。 + + 不重复打日志是刚需而非优化: 冷却到期重试再失败会反复走到这里,每次都讲 + 就把"降级中"刷成噪音。原因变了才算新事实,值得再讲一遍。 + + Args: + reason: 降级原因(已含具体异常文本);同值视为同一次降级的续期。 + fatal: True = 本进程内不可恢复,此后 `should_retry()` 恒 False; + **同时决定日志级别**(见下方发日志处)。 + cooldown_s: 距下次允许重新准备的秒数;None 表示不自动重试。 + """ + if self._fatal: + return # 永久档不可被后来的失败覆盖,也不再刷屏 + now = self._now() + first_of_this_episode = self._degraded_since is None + announce = first_of_this_episode or reason != self._reason + if first_of_this_episode: + self._degraded_since = now + self._dropped_at_entry = self._dropped_rows + self._dropped_since_report = 0 + self._last_report_at = None + self._reason = reason + self._fatal = fatal + self._retry_at = None if fatal or cooldown_s is None else now + cooldown_s + if announce: + # 级别由 `fatal` 决定,且**只在这一处**决定(设计 §3.2): 致命档是"人把 + # 配置写错了、本进程内不会自愈",运维必须看见 → error;其余都是外部 + # 状态、会自愈 → warning。recorder 侧一度各自再发一条 error,同一个 + # 事实因此出两条语义重复的日志,"级别"这个决策也就有了两个源头——两个 + # 源头必然漂移,正是本 issue 反复踩的那类错 + emit = logger.error if fatal else logger.warning + emit( + "{} 遥测降级(后续记录将被丢弃): {};恢复条件: {}", + self._backend, + reason, + self._recovery_hint(cooldown_s, fatal=fatal), + ) + + def recover(self) -> None: + """退出降级并报告本段期间丢了多少行;未降级时是 no-op。 + + `dropped_rows` **不清零**: 它是进程生命周期内的累计量,下游靠它对账。 + """ + if self._degraded_since is None: + return + dropped = self._dropped_rows - self._dropped_at_entry + logger.info( + "{} 遥测已恢复(降级持续 {:.1f}s,期间丢弃 {} 行)", + self._backend, + self._now() - self._degraded_since, + dropped, + ) + self._degraded_since = None + self._fatal = False + self._reason = None + self._retry_at = None + self._dropped_since_report = 0 + self._last_report_at = None + + def record_drop(self, reason: str) -> None: + """记一行被丢弃;按行数与时间双阈值节流复述。 + + 双阈值缺一不可: 只按行数,低频调用的进程会长时间完全静默;只按时间, + 高频进程在窗口内丢几万行也只有一条日志,看不出量级。 + """ + self._dropped_rows += 1 + self._dropped_since_report += 1 + if not self._should_report(): + return + logger.warning( + "{} 遥测丢弃记录(累计 {} 行): {}", + self._backend, + self._dropped_rows, + reason, + ) + self._dropped_since_report = 0 + self._last_report_at = self._now() + + def should_retry(self) -> bool: + """现在是否允许(重新)准备后端: 纯查询,不触库也不改状态。 + + 未降级 → True(本就该正常走准备路径);fatal → False;冷却未到 → False; + 非 fatal 但没给冷却 → False(调用方没安排自动重试,tracker 不替它决定)。 + """ + if self._fatal: + return False + if self._degraded_since is None: + return True + if self._retry_at is None: + return False + return self._now() >= self._retry_at + + def snapshot(self) -> TelemetryStatus: + """当前状态的只读快照(公共出口 `client.telemetry_status` 的取值点)。""" + now = self._now() + since = self._degraded_since + retry_after_s: float | None = None + if since is not None and self._retry_at is not None: + retry_after_s = max(0.0, self._retry_at - now) # 到期后钳到 0,不给负数 + return TelemetryStatus( + degraded=since is not None, + fatal=self._fatal, + reason=self._reason, + degraded_for_s=None if since is None else now - since, + dropped_rows=self._dropped_rows, + retry_after_s=retry_after_s, + ) + + def _should_report(self) -> bool: + """本次丢弃是否该出声: 本段降级的第一行、满行数阈值、或超时间阈值。""" + if self._last_report_at is None: + return True + if self._dropped_since_report >= _DROP_REPEAT_EVERY_ROWS: + return True + return self._now() - self._last_report_at >= _DROP_REPEAT_EVERY_S + + @staticmethod + def _recovery_hint(cooldown_s: float | None, *, fatal: bool) -> str: + """把恢复条件写进日志: 运维看到降级后第一个问题就是"它自己会好吗"。""" + if fatal: + return "需修正配置后重启进程(本进程内不会自愈)" + if cooldown_s is None: + return "下次调用时重试" + return f"约 {cooldown_s:.0f}s 后自动重试" diff --git a/src/polygateway/types.py b/src/polygateway/types.py index bcb5180..7226f9b 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -258,6 +258,31 @@ class SourceStats: tpm_used: int +@dataclass(frozen=True) +class TelemetryStatus: + """遥测后端的可写状态快照;degraded 期间下游可据此对账(issue #15)。 + + 不叫 `health`: 库内 `health` 一律指**源的健康度**(`OcrTransport.check_health` + 探活、`SourceSelector.health` 成功率 EWMA),而这里描述的是"这个 recorder + 现在能不能写、为什么不能、丢了多少",是状态不是评分(设计 §3.3)。 + + 时长一律给**相对秒数**而非绝对时间戳: 库内的时钟是 monotonic,把它的读数 + 交给下游会与 wall clock 混淆成两个不可比的时间轴。 + """ + + degraded: bool + fatal: bool + """True = 本进程内不可恢复(仅 DSN 不可解析一类配置级失败),需改配置并重启。""" + reason: str | None + """降级原因;未降级为 None。""" + degraded_for_s: float | None + """已降级时长;未降级为 None。""" + dropped_rows: int + """累计丢弃行数;**进程生命周期内单调不减**——恢复不等于没丢过。""" + retry_after_s: float | None + """距下次重新准备的秒数;fatal 或未降级为 None,冷却已到期为 0.0。""" + + @dataclass(frozen=True) class TransportResult: """transport 单次原始调用的产物;治理字段由 RetryMW 补齐为 LLMResponse。""" diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index e301c2d..b645e51 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -14,6 +14,7 @@ import asyncio import json import os import re +import time from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -130,6 +131,27 @@ async def _record_minimal( return fields +# 集成用例统一的池上限与写入预算(issue #15;两者是 recorder 的必填 keyword-only)。 +# 池上限取 config 的生产缺省(4),让本文件跑的就是下游真实会跑的那个形状。 +# +# 预算却**远比生产的 5s 宽**,这不是抄错: `test_concurrent_writes_all_land` 一次 +# 发 50 行,50 行共享 4 条连接,实测跨内网 RTT 123ms 下整批约 3.2s——而那 50 个 +# `record_llm_call` 的预算是**同时**起算的,批越慢离预算越近。这个实例被多项目 +# 共用,别人的一次负载尖峰就能让批耗时翻几倍,于是"丢行"变成掷硬币(pool_max=2 +# 时实测批耗时 5.3s/15s 预算,已经在全套件里红过一次)。给它 60s 是把余量拉到 +# 近 20 倍,让这个用例只在真出 bug 时红(CLAUDE.md §4.6: 重跑一次就绿的测试是 +# 信号污染源)。突发排队本身超预算即丢行是设计上的既定取舍(设计 §6),不在此改。 +_POOL_MAX = 4 +_WRITE_TIMEOUT_S = 60.0 + + +def _recorder(dsn: str, *, auto_migrate: bool) -> PostgresRecorder: + """本文件唯一的 recorder 构造点: 池参数只写一遍,免得 16 处各抄一份。""" + return PostgresRecorder( + dsn, auto_migrate=auto_migrate, pool_max=_POOL_MAX, write_timeout_s=_WRITE_TIMEOUT_S + ) + + async def _fetch(dsn: str, sql: str, *args): import asyncpg @@ -205,7 +227,7 @@ class TestObservabilityColumns: """issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。""" async def test_values_round_trip(self, dsn): - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0) @@ -233,7 +255,7 @@ class TestObservabilityColumns: async def test_legacy_table_is_upgraded_in_place(self, legacy_schema): """18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。""" schema_dsn, schema = legacy_schema - recorder = PostgresRecorder(schema_dsn, auto_migrate=True) + recorder = _recorder(schema_dsn, auto_migrate=True) try: await _record_minimal( recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real" @@ -258,7 +280,7 @@ class TestObservabilityColumns: class TestSchema: async def test_schema_has_frozen_columns_in_order(self, dsn): - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) try: await _record_minimal(recorder) rows = await _fetch( @@ -271,7 +293,7 @@ class TestSchema: await recorder.aclose() async def test_call_id_idempotent(self, dsn): - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("dup")) await _record_minimal(recorder, call_id=_cid("dup"), response="second") @@ -283,7 +305,7 @@ class TestSchema: await recorder.aclose() async def test_concurrent_writes_all_land(self, dsn): - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) try: await asyncio.gather( *(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50)) @@ -298,17 +320,77 @@ class TestSchema: await recorder.aclose() +class _FakeClock: + """可手动推进的单调时钟: 冷却窗口靠它测,用例里绝不真睡 60 秒。""" + + def __init__(self, start: float = 1_000.0) -> None: + self.t = start + + def __call__(self) -> float: + return self.t + + def advance(self, seconds: float) -> None: + self.t += seconds + + class TestDegradation: async def test_unreachable_server_degrades_silently(self): - """结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。""" - recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True) + """服务端连不上 → warning 一次后降级,业务零感知(不抛、不拖)。""" + recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True) await _record_minimal(recorder) # 不抛 await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛 await recorder.aclose() + async def test_refused_connection_cools_down_and_retries_after_cooldown(self): + """连接被拒 → 冷却降级(**非 fatal**)→ 冷却期内零成本短路 → 到期真的重试。 + + 走**不可达 DSN** 而不是把共享实例的连接打满: 那台 PG 上还有 app/chs_prod + 等在用库,制造连接耗尽会伤到别人;而"连接被拒"与"连接耗尽"落的是同一档 + (环境级,`_classify_failure`),这条路验的是同一段状态机。 + + **时序前提**(避免间歇红): 假时钟只驱动 tracker 的冷却窗口,与真实网络耗时 + 完全无关,故三段断言都不依赖墙钟。`retry_after_s` 是"有没有真的重试过"的 + 唯一外部信号——重试失败会给冷却窗口续期,而短路不会碰它。 + """ + clock = _FakeClock() + recorder = PostgresRecorder( + "postgresql://u:p@127.0.0.1:1/x", + auto_migrate=True, + pool_max=_POOL_MAX, + write_timeout_s=_WRITE_TIMEOUT_S, + now=clock, + ) + try: + await _record_minimal(recorder, call_id=_cid("deg1")) + first = recorder.telemetry_status + # 非 fatal 正是 issue #15 的核心: 连接被拒过去在建池那一步被一刀判死, + # 整进程从此一行遥测都不落、只有重启能恢复 + assert (first.degraded, first.fatal) == (True, False) + assert first.retry_after_s == pytest.approx(60.0) + assert first.dropped_rows == 1 + # min_size=0 之后建池不再触库,连接被拒因此暴露在准备期而不是建池期 + assert "建表探测失败" in (first.reason or "") + + clock.advance(30.0) + await _record_minimal(recorder, call_id=_cid("deg2")) + mid = recorder.telemetry_status + # 冷却窗口没被刷新 = 这次调用压根没去连库(降级期间零成本短路) + assert mid.retry_after_s == pytest.approx(30.0) + assert mid.dropped_rows == 2 + + clock.advance(30.1) + await _record_minimal(recorder, call_id=_cid("deg3")) + after = recorder.telemetry_status + # 冷却窗口被重新拉满 = 真的重连了一次(照旧被拒,故仍降级但仍可自愈) + assert after.retry_after_s == pytest.approx(60.0) + assert (after.degraded, after.fatal) == (True, False) + assert after.dropped_rows == 3 + finally: + await recorder.aclose() + async def test_row_failure_does_not_poison_later_rows(self, dsn): """运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。""" - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte") await _record_minimal(recorder, call_id=_cid("good")) @@ -322,12 +404,83 @@ class TestDegradation: await recorder.aclose() async def test_aclose_idempotent(self, dsn): - recorder = PostgresRecorder(dsn, auto_migrate=True) + recorder = _recorder(dsn, auto_migrate=True) await _record_minimal(recorder) await recorder.aclose() await recorder.aclose() +def _tagged(dsn: str, app_name: str) -> str: + """给 DSN 挂上 `application_name` 查询参数,让本池的连接在服务端可被点名。 + + 走 DSN 参数而不是给 recorder 加 `server_settings` 入口: 纯测试便利不值得 + 扩公共 API(P1)。也**不能**改成"测试自建池后以 `pool=` 注入"——那会走 + `_external_pool` 分支、完全绕过被测的建池路径,而本节要验的恰恰是它。 + """ + sep = "&" if "?" in dsn else "?" + return f"{dsn}{sep}application_name={app_name}" + + +async def _pool_backend_count(dsn: str, app_name: str) -> int: + """数**本池**在服务端的连接数(只读查询,不改实例任何状态)。 + + 只按 run 级唯一的 `application_name` 过滤: 这台实例被多项目共用,按库名或 + 用户名计数会把别人的连接算进来,做出的是设计上就会间歇红的用例 + (CLAUDE.md §4.6)。本查询自己那条连接走未打 tag 的 DSN,故不会数到自己。 + """ + rows = await _fetch( + dsn, "SELECT count(*) AS n FROM pg_stat_activity WHERE application_name = $1", app_name + ) + return rows[0]["n"] + + +async def _settled_backend_count(dsn: str, app_name: str, *, timeout_s: float = 5.0) -> int: + """等本 tag 的连接数归零并返回最终值;超时则返回当下值,交给断言去红。 + + 轮询而不是一次采样: 客户端 `close()` 返回与服务端后台进程从 + `pg_stat_activity` 消失之间没有同步保证(实测立即归零,5s 余量只是不赌它)。 + """ + deadline = time.monotonic() + timeout_s + while True: + count = await _pool_backend_count(dsn, app_name) + if count == 0 or time.monotonic() >= deadline: + return count + await asyncio.sleep(0.1) + + +class TestPoolFootprint: + """issue #15 的直接回归钉子: 池不预连接,占用不超过库自己声明的上限。 + + 单元层断的是"`min_size`/`max_size` 传对了",这里断的是"服务端真的只开了 + 那么多连接"——两件事,只有真实 PG 能证后者。 + """ + + async def test_pool_does_not_preconnect_and_stays_within_pool_max(self, dsn): + app_name = f"{_RUN_PREFIX}-pool" # run 级唯一,与并跑的其他运行互不可见 + recorder = _recorder(_tagged(dsn, app_name), auto_migrate=True) + try: + # 构造只记参数、不触库: 这一条与下一条合起来才是钉子——修复前 + # `create_pool` 继承 asyncpg 的 min_size=10,首次写入后下面会是 10 + assert await _pool_backend_count(dsn, app_name) == 0 + + await _record_minimal(recorder, call_id=_cid("fp1")) + # **时序前提**: 写入已 await 到返回,连接必然已建立(没建立就写不成功), + # 归还只是还进池而不断开,asyncpg 空闲回收是 300s 不会在用例内触发。 + # 故这是个确定值,不是"某一刻恰好的采样" + assert await _pool_backend_count(dsn, app_name) == 1 + + await asyncio.gather( + *(_record_minimal(recorder, call_id=_cid(f"fp{i}")) for i in range(2, 22)) + ) + steady = await _pool_backend_count(dsn, app_name) + # 上界由 max_size 保证;下界 ≥1 不是凑数——它确保过滤条件真的命中了本池, + # 否则 tag 一旦拼错,上面那条 ==0 会以"永远绿"的形态通过 + assert 1 <= steady <= _POOL_MAX + finally: + await recorder.aclose() + assert await _settled_backend_count(dsn, app_name) == 0 # 关闭即归还全部连接 + + _PROBE_PASSWORD = "pgw_issue9_probe" # 临时角色,teardown 删除;非任何真实凭据 @@ -392,13 +545,13 @@ class TestLeastPrivilegeDeployment: await conn.close() async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn): - """修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。""" + """修复前: 建表被拒 → 整体判死 → 整个进程一条不落(下游 150 次调用全丢)。""" low_dsn, schema = least_privilege_dsn - recorder = PostgresRecorder(low_dsn, auto_migrate=True) + recorder = _recorder(low_dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("lp1")) await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5) - assert recorder._failed is False # 判死开关不得被建表权限触发 + assert recorder.telemetry_status.degraded is False # 建表权限不得触发降级 rows = await _fetch( low_dsn, "SELECT call_id, cost FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id", @@ -563,7 +716,7 @@ class TestCallerDimensionsAcceptance: async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema): """新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。""" fresh_dsn, schema = fresh_schema - recorder = PostgresRecorder(fresh_dsn, auto_migrate=True) + recorder = _recorder(fresh_dsn, auto_migrate=True) try: await _record_minimal( recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}' @@ -597,7 +750,7 @@ class TestCallerDimensionsAcceptance: 审计出来,历史欠账是可见、可量化、可补录的。 """ schema_dsn, schema = pre_tenant_schema - recorder = PostgresRecorder(schema_dsn, auto_migrate=True) + recorder = _recorder(schema_dsn, auto_migrate=True) try: await _record_minimal( recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}' @@ -644,15 +797,15 @@ class TestCallerDimensionsAcceptance: async def test_backfill_failure_degrades_per_row_not_wholesale( self, least_privilege_pre_tenant_dsn, captured_warnings ): - """补列失败的降级方向: 记 warning、不置 `_failed`、后续 INSERT 仍照发。 + """补列失败的降级方向: 记 warning、不整体降级、后续 INSERT 仍照发。 - 置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多), - 且一旦 DBA 补上列也不会自愈——必须等重启。 + 整体降级会让整个进程停写(比逐行丢弃严重得多),而缺列(SQLSTATE 42703) + 是判据的唯一具名例外: 必须逐行暴露,好让下游看见 schema 漂移(issue #13)。 """ - recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=True) + recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛 - assert recorder._failed is False + assert recorder.telemetry_status.degraded is False assert any("补列失败" in m for m in captured_warnings) # 缺列的表上 INSERT 必然失败;逐行 warning 正是"INSERT 照发了"的证据 assert any("写入失败" in m for m in captured_warnings) @@ -745,7 +898,7 @@ class TestConflictTargetFreeInsert: 断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。 """ fresh_dsn, _ = fresh_schema - recorder = PostgresRecorder(fresh_dsn, auto_migrate=True) + recorder = _recorder(fresh_dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("nodup")) await _record_minimal(recorder, call_id=_cid("nodup"), response="second") @@ -766,7 +919,7 @@ class TestConflictTargetFreeInsert: 遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。 """ part_dsn, _ = partitioned_schema - recorder = PostgresRecorder(part_dsn, auto_migrate=True) + recorder = _recorder(part_dsn, auto_migrate=True) try: await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p") assert [m for m in captured_warnings if "写入失败" in m] == [] @@ -797,7 +950,7 @@ class TestManualSchemaModeAcceptance: 同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。 """ schema_dsn, schema = pre_tenant_schema - recorder = PostgresRecorder(schema_dsn, auto_migrate=False) + recorder = _recorder(schema_dsn, auto_migrate=False) try: recorded = await _record_minimal( recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}' @@ -837,7 +990,7 @@ class TestManualSchemaModeAcceptance: 消灭的噪声。manual 档下 ALTER 压根不发,取而代之的是一条点名缺列并附可直接 执行的 ALTER 的提示,而遥测照常落库。 """ - recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=False) + recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False) try: recorded = await _record_minimal( recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}' @@ -846,7 +999,7 @@ class TestManualSchemaModeAcceptance: assert [m for m in captured_warnings if "补列失败" in m] == [] assert [m for m in captured_warnings if "写入失败" in m] == [] - assert recorder._failed is False + assert recorder.telemetry_status.degraded is False notices = [m for m in captured_warnings if "auto_migrate=False" in m] assert len(notices) == 1 # 准备期一次,第二行不再重复 assert "以下维度不会被记录: tenant_id, meta" in notices[0] diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 302e644..aa55ae2 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -45,6 +45,20 @@ _ENV = { "PGW_TELEMETRY_BACKEND": "none", } +_OCR_ENV = { + "OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866", + "OCR__MONKEY__1__API_KEY": "none", + "OCR__MONKEY__1__MODEL": "monkey-ocr", + "OCR__MONKEY__1__TIMEOUT_S": "120", + "LLM_MAX_RETRIES": "3", + "LLM_RETRY_BASE_DELAY": "2.0", + "LLM_RETRY_MAX_DELAY": "30.0", + "LLM_CIRCUIT_BREAKER_THRESHOLD": "5", + "LLM_CIRCUIT_BREAKER_COOLDOWN": "60", + "PGW_CACHE_BACKEND": "none", + "PGW_TELEMETRY_BACKEND": "none", +} + def _sse(content='{"answer": 1}'): chunk = json.dumps({"choices": [{"delta": {"content": content}}]}) @@ -359,20 +373,7 @@ class TestTelemetryTextCapWiring: """ _CAP_ENV = dict(_ENV, PGW_TELEMETRY_TEXT_CAP="8") - _OCR_CAP_ENV = { - "OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866", - "OCR__MONKEY__1__API_KEY": "none", - "OCR__MONKEY__1__MODEL": "monkey-ocr", - "OCR__MONKEY__1__TIMEOUT_S": "120", - "LLM_MAX_RETRIES": "3", - "LLM_RETRY_BASE_DELAY": "2.0", - "LLM_RETRY_MAX_DELAY": "30.0", - "LLM_CIRCUIT_BREAKER_THRESHOLD": "5", - "LLM_CIRCUIT_BREAKER_COOLDOWN": "60", - "PGW_CACHE_BACKEND": "none", - "PGW_TELEMETRY_BACKEND": "none", - "PGW_TELEMETRY_TEXT_CAP": "8", - } + _OCR_CAP_ENV = dict(_OCR_ENV, PGW_TELEMETRY_TEXT_CAP="8") def test_gateway_from_settings_wires_the_cap(self): settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV) @@ -555,3 +556,430 @@ class TestReferenceProtocolCompat: ): ... assert isinstance(_client(), proto) + + +# —— 资源所有权纪律(issue #15 D 组): 谁建的谁关,注入的一律不碰 —— + +_CACHE_ENV = dict( + _ENV, PGW_CACHE_BACKEND="memory", PGW_CACHE_NAMESPACE="proj", PGW_CACHE_TTL_S="3600" +) + + +class _Closable: + """记 close 次数的假组件;所有权纪律的唯一观测点。""" + + def __init__(self): + self.closed = 0 + + async def aclose(self): + self.closed += 1 + + +class _SyncClosable: + """只有同步 close 的假 recorder(SQLiteRecorder 形态,收敛后的 helper 须探测到)。""" + + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + +class _FalsyClosable(_Closable): + """`bool()` 为假的组件(空容器形态的后端就长这样)。 + + 所有权判定必须写 `is None` / `is not None`,不得写 `or`(设计 §3.4,ARCH §4.5 + 细则 2): 写 `or` 时注入这样一个后端会**悄悄走自建分支**,而所有权标志按 + `is None` 判成 False——于是既没用上注入的那个,自建的那个又没人关,正是本 + issue 要修的泄漏原地复活。判定与标志一漂移,两个 bug 一起回来。 + """ + + def __bool__(self): + return False + + +def _parts(*names): + return {name: _Closable() for name in names} + + +def _falsy_parts(*names): + return {name: _FalsyClosable() for name in names} + + +def _patch_builders(monkeypatch, built, *, transport_path): + """把工厂的自建点换成可计数假件;transport 无注入入口,故恒自建。""" + monkeypatch.setattr(transport_path, lambda **kwargs: built["transport"]) + monkeypatch.setattr("polygateway.client._build_limiter", lambda s, src: built["limiter"]) + monkeypatch.setattr("polygateway.client._build_breaker", lambda s: built["breaker"]) + monkeypatch.setattr("polygateway.client._build_telemetry", lambda s: built["telemetry"]) + if "cache" in built: + monkeypatch.setattr("polygateway.client._build_cache", lambda s: built["cache"]) + + +class TestGatewayClientOwnership: + """`__init__` 是全量注入路径,经它传入的一切都归调用方(设计 §3.4)。""" + + _GATEWAY_TRANSPORT = "polygateway.client.OpenAICompatTransport" + + async def test_injected_components_are_never_closed(self): + """共享 recorder/transport 被第一个关闭的 client 弄死,正是 R5 显式共享走不通的原因。""" + injected = _parts(*("transport", "telemetry", "cache", "limiter", "breaker")) + client = _client( + transport=injected["transport"], + telemetry=injected["telemetry"], + cache=injected["cache"], + cache_namespace="proj", + cache_ttl_s=3600, + limiter=injected["limiter"], + breaker=injected["breaker"], + ) + await client.aclose() + assert {name: part.closed for name, part in injected.items()} == { + "transport": 0, + "telemetry": 0, + "cache": 0, + "limiter": 0, + "breaker": 0, + } + + async def test_factory_closes_every_component_it_built(self, monkeypatch): + """泄漏钉子: 自建的 redis limiter/breaker 今天没人关,连引用都没留。""" + built = _parts("transport", "telemetry", "cache", "limiter", "breaker") + _patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT) + client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV)) + await client.aclose() + assert {name: part.closed for name, part in built.items()} == { + "transport": 1, + "telemetry": 1, + "cache": 1, + "limiter": 1, + "breaker": 1, + } + + async def test_factory_keeps_hands_off_injected_components(self, monkeypatch): + built = _parts("transport", "telemetry", "cache", "limiter", "breaker") + _patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT) + injected = _parts("telemetry", "cache", "limiter", "breaker") + client = GatewayClient.from_settings( + GatewaySettings.from_env("LLM", env=_CACHE_ENV), + limiter=injected["limiter"], + breaker=injected["breaker"], + cache=injected["cache"], + telemetry=injected["telemetry"], + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + assert built["transport"].closed == 1 # 工厂恒自建 transport,归 client + + async def test_falsy_injected_components_are_still_injected(self, monkeypatch): + """`is not None` 是所有权判定成立的**必要条件**,不是风格偏好(设计 §3.4)。 + + 改回 `or` 时: 工厂拿自建件顶掉注入件(下游以为在共享,其实各跑各的), + 且自建件的 `_owns_*` 仍是 False —— redis 客户端就地泄漏。 + """ + built = _parts("transport", "telemetry", "cache", "limiter", "breaker") + _patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT) + injected = _falsy_parts("telemetry", "cache", "limiter", "breaker") + client = GatewayClient.from_settings( + GatewaySettings.from_env("LLM", env=_CACHE_ENV), + limiter=injected["limiter"], + breaker=injected["breaker"], + cache=injected["cache"], + telemetry=injected["telemetry"], + ) + assert client._limiter_backend is injected["limiter"] + assert client._breaker_backend is injected["breaker"] + assert client._cache is injected["cache"] + assert client._telemetry is injected["telemetry"] + owns = (client._owns_limiter, client._owns_breaker, client._owns_cache) + assert owns == (False, False, False) and client._owns_telemetry is False + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + # 自建件根本不该被造出来更不该被关;只有恒自建的 transport 归 client + assert [built[name].closed for name in ("telemetry", "cache", "limiter", "breaker")] == [ + 0, + 0, + 0, + 0, + ] + + async def test_aclose_is_idempotent(self, monkeypatch): + built = _parts("transport", "telemetry", "cache", "limiter", "breaker") + _patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT) + client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV)) + await client.aclose() + await client.aclose() + assert all(part.closed == 1 for part in built.values()) + + async def test_sync_only_recorder_is_closed(self, monkeypatch): + """SQLiteRecorder 只有同步 `close()`;收敛成 helper 之后这条分支不得丢。""" + built = _parts("transport", "cache", "limiter", "breaker") + recorder = _SyncClosable() + built["telemetry"] = recorder + _patch_builders(monkeypatch, built, transport_path=self._GATEWAY_TRANSPORT) + client = GatewayClient.from_settings(GatewaySettings.from_env("LLM", env=_CACHE_ENV)) + await client.aclose() + assert recorder.closed == 1 + + +def _embedding_client(**overrides): + from polygateway.embedding import EmbeddingClient + + defaults = { + "scope": "embed", + "sources": [_source()], + "selector": RoundRobinSelector(), + "limiter": _Closable(), + "breaker": _Closable(), + "transport": _Closable(), + "retry": RetryPolicy(3, 2.0, 30.0), + "backpressure": BackpressurePolicy(300.0, 0.01), + "batch_size": 2, + } + defaults.update(overrides) + return EmbeddingClient(**defaults) + + +class TestEmbeddingClientOwnership: + """三处必须各钉一次: 收敛成 helper 之后,有人把逻辑复制回去也得当场被发现。""" + + async def test_injected_components_are_never_closed(self): + injected = _parts("transport", "telemetry", "limiter", "breaker") + client = _embedding_client( + transport=injected["transport"], + telemetry=injected["telemetry"], + limiter=injected["limiter"], + breaker=injected["breaker"], + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + + async def test_factory_closes_every_component_it_built(self, monkeypatch): + from polygateway.config import EmbeddingSettings + from polygateway.embedding import EmbeddingClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.openai_compat.OpenAICompatTransport", + ) + settings = EmbeddingSettings( + gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2 + ) + client = EmbeddingClient.from_settings(settings) + await client.aclose() + assert all(part.closed == 1 for part in built.values()) + + async def test_factory_keeps_hands_off_injected_components(self, monkeypatch): + from polygateway.config import EmbeddingSettings + from polygateway.embedding import EmbeddingClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.openai_compat.OpenAICompatTransport", + ) + injected = _parts("telemetry", "limiter", "breaker") + settings = EmbeddingSettings( + gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2 + ) + client = EmbeddingClient.from_settings( + settings, + limiter=injected["limiter"], + breaker=injected["breaker"], + telemetry=injected["telemetry"], + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + assert built["transport"].closed == 1 + + async def test_falsy_injected_components_are_still_injected(self, monkeypatch): + """三处工厂各写一遍 `is not None`,就是三处各有一次漂移回 `or` 的机会。""" + from polygateway.config import EmbeddingSettings + from polygateway.embedding import EmbeddingClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.openai_compat.OpenAICompatTransport", + ) + injected = _falsy_parts("telemetry", "limiter", "breaker") + settings = EmbeddingSettings( + gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2 + ) + client = EmbeddingClient.from_settings( + settings, + limiter=injected["limiter"], + breaker=injected["breaker"], + telemetry=injected["telemetry"], + ) + assert client._limiter_backend is injected["limiter"] + assert client._breaker_backend is injected["breaker"] + assert client._telemetry is injected["telemetry"] + assert (client._owns_limiter, client._owns_breaker, client._owns_telemetry) == ( + False, + False, + False, + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + assert [built[name].closed for name in ("telemetry", "limiter", "breaker")] == [0, 0, 0] + + +def _ocr_client(**overrides): + from polygateway.ocr import OcrClient + + defaults = { + "scope": "ocr", + "sources": [_source(name="m1", provider="monkey", model="monkey-ocr")], + "selector": RoundRobinSelector(), + "limiter": _Closable(), + "breaker": _Closable(), + "transport": _Closable(), + "retry": RetryPolicy(3, 2.0, 30.0), + "backpressure": BackpressurePolicy(300.0, 0.01), + } + defaults.update(overrides) + return OcrClient(**defaults) + + +class TestOcrClientOwnership: + async def test_injected_components_are_never_closed(self): + injected = _parts("transport", "telemetry", "limiter", "breaker") + client = _ocr_client( + transport=injected["transport"], + telemetry=injected["telemetry"], + limiter=injected["limiter"], + breaker=injected["breaker"], + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + + async def test_factory_closes_every_component_it_built(self, monkeypatch): + from polygateway.config import OcrSettings + from polygateway.ocr import OcrClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport", + ) + client = OcrClient.from_settings(OcrSettings.from_env("OCR", env=dict(_OCR_ENV))) + await client.aclose() + assert all(part.closed == 1 for part in built.values()) + + async def test_factory_keeps_hands_off_injected_components(self, monkeypatch): + from polygateway.config import OcrSettings + from polygateway.ocr import OcrClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport", + ) + injected = _parts("telemetry", "limiter", "breaker") + client = OcrClient.from_settings( + OcrSettings.from_env("OCR", env=dict(_OCR_ENV)), + limiter=injected["limiter"], + breaker=injected["breaker"], + telemetry=injected["telemetry"], + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + assert built["transport"].closed == 1 + + async def test_falsy_injected_components_are_still_injected(self, monkeypatch): + from polygateway.config import OcrSettings + from polygateway.ocr import OcrClient + + built = _parts("transport", "telemetry", "limiter", "breaker") + _patch_builders( + monkeypatch, + built, + transport_path="polygateway.transports.monkey_ocr.MonkeyOcrTransport", + ) + injected = _falsy_parts("telemetry", "limiter", "breaker") + client = OcrClient.from_settings( + OcrSettings.from_env("OCR", env=dict(_OCR_ENV)), + limiter=injected["limiter"], + breaker=injected["breaker"], + telemetry=injected["telemetry"], + ) + assert client._limiter_backend is injected["limiter"] + assert client._breaker_backend is injected["breaker"] + assert client._telemetry is injected["telemetry"] + assert (client._owns_limiter, client._owns_breaker, client._owns_telemetry) == ( + False, + False, + False, + ) + await client.aclose() + assert all(part.closed == 0 for part in injected.values()) + assert [built[name].closed for name in ("telemetry", "limiter", "breaker")] == [0, 0, 0] + + +class TestRedisCacheOwnership: + """组件内部自建的连接归组件自己;照抄 RedisLimiter._owns_client 的正确先例。""" + + async def test_injected_client_is_not_closed(self): + from polygateway.backends.redis_cache import RedisCache + + client = _Closable() + await RedisCache(client).aclose() + assert client.closed == 0 + + async def test_self_built_client_is_closed_once(self, monkeypatch): + from types import SimpleNamespace + + from polygateway.backends import redis_cache + + built = _Closable() + monkeypatch.setattr( + redis_cache, "aioredis", SimpleNamespace(from_url=lambda url, **kwargs: built) + ) + cache = redis_cache.RedisCache.from_url("redis://localhost:6379/0") + await cache.aclose() + await cache.aclose() # 幂等: 不重复关 + assert built.closed == 1 + + +class TestTelemetryStatusExposure: + """降级状态的只读出口: 一处 isinstance 判定,三个 client 各钉一次(设计 §3.3)。""" + + def _recorder(self, tmp_path): + from polygateway.telemetry.sqlite import SQLiteRecorder + + return SQLiteRecorder(tmp_path / "telemetry.db", auto_migrate=True) + + def _assert_snapshot(self, status): + from polygateway.types import TelemetryStatus + + assert isinstance(status, TelemetryStatus) + assert status.degraded is False + + def test_gateway_client_without_telemetry_reports_none(self): + assert _client().telemetry_status is None + + def test_gateway_client_with_foreign_recorder_reports_none(self): + """注入的第三方 recorder 不提供状态 → None,绝不得抛 AttributeError。""" + assert _client(telemetry=_Closable()).telemetry_status is None + + def test_gateway_client_with_builtin_recorder_reports_snapshot(self, tmp_path): + self._assert_snapshot(_client(telemetry=self._recorder(tmp_path)).telemetry_status) + + def test_embedding_client_exposes_the_same_outlet(self, tmp_path): + assert _embedding_client().telemetry_status is None + assert _embedding_client(telemetry=_Closable()).telemetry_status is None + self._assert_snapshot( + _embedding_client(telemetry=self._recorder(tmp_path)).telemetry_status + ) + + def test_ocr_client_exposes_the_same_outlet(self, tmp_path): + assert _ocr_client().telemetry_status is None + assert _ocr_client(telemetry=_Closable()).telemetry_status is None + self._assert_snapshot(_ocr_client(telemetry=self._recorder(tmp_path)).telemetry_status) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 82aeb8d..e29bc4d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -425,6 +425,73 @@ class TestTelemetryTextCap: GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k")) +class TestTelemetryPoolKeys: + """`PGW_TELEMETRY_PG_POOL_MAX` / `PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`(issue #15)。 + + 两键都带 `PG` 前缀,与 `PGW_TELEMETRY_PG_DSN` 一致: SQLite 侧没有池、也没有 + 等价的写入预算旋钮,这个不对称是已知且有理由的。缺省值(4 / 5.0)只写在 + config 一处——recorder 的两个同名参数是必填 keyword-only,不许各带一份缺省。 + """ + + def _pg_env(self, **overrides): + return _env( + PGW_TELEMETRY_BACKEND="postgres", + PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway", + **overrides, + ) + + def test_unset_keys_fall_back_to_the_documented_defaults(self): + s = GatewaySettings.from_env("LLM", env=self._pg_env()) + assert s.telemetry_pg_pool_max == 4 + assert s.telemetry_pg_write_timeout_s == 5.0 + + def test_values_parsed_from_env(self): + s = GatewaySettings.from_env( + "LLM", + env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="8", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="1.5"), + ) + assert s.telemetry_pg_pool_max == 8 + assert s.telemetry_pg_write_timeout_s == 1.5 + + @pytest.mark.parametrize("raw", ["0", "-1"]) + def test_non_positive_pool_max_rejected_naming_the_env_key(self, raw): + """池上限 0 = 永远拿不到连接(遥测全灭),负数无意义。""" + with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"): + GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX=raw)) + + @pytest.mark.parametrize("raw", ["0", "-1"]) + def test_non_positive_write_timeout_rejected_naming_the_env_key(self, raw): + """预算 0 = 每一行都当场超预算;不设预算不是这个键的写法。""" + with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_WRITE_TIMEOUT_S"): + GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S=raw)) + + def test_non_numeric_rejected_naming_the_env_key(self): + with pytest.raises(ValueError, match="PGW_TELEMETRY_PG_POOL_MAX"): + GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="many")) + + @pytest.mark.parametrize( + ("field", "value"), + [("telemetry_pg_pool_max", 0), ("telemetry_pg_write_timeout_s", 0.0)], + ) + def test_direct_construction_and_replace_are_validated_too(self, field, value): + """env 路只覆盖 from_env;直接构造与 replace 是同等官方的装配路(与 text_cap 同款)。""" + base = GatewaySettings.from_env("LLM", env=_env()) + with pytest.raises(ValueError, match=field): + dataclasses.replace(base, **{field: value}) + + def test_values_reach_the_recorder(self): + """配置到 recorder 之间不得断链——两个键唯一的作用就是抵达那里。""" + from polygateway.client import _build_telemetry + + settings = GatewaySettings.from_env( + "LLM", + env=self._pg_env(PGW_TELEMETRY_PG_POOL_MAX="7", PGW_TELEMETRY_PG_WRITE_TIMEOUT_S="2.5"), + ) + recorder = _build_telemetry(settings) + assert recorder._pool_max == 7 + assert recorder._write_timeout_s == 2.5 + + class TestOcrSettings: """M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。""" diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index fd33bcf..9df149f 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -37,3 +37,15 @@ def test_telemetry_schema_sql_exported(): assert callable(polygateway.telemetry_schema_sql) assert "missing_columns_warning" not in polygateway.__all__ assert not hasattr(polygateway, "missing_columns_warning") + + +def test_telemetry_status_exported(): + """issue #15: `client.telemetry_status` 的返回类型必须能从顶层 import。 + + 下游对账/告警要给这个快照做类型标注,若只在 `polygateway.types` 里,标注就得 + 深入子模块,而本库的约定是「顶层导出即公共 API 面」。`TelemetryStatusProvider` + 则**不**导出: 它是端口,库外无实现者,导出即多一份永久承诺。 + """ + assert "TelemetryStatus" in polygateway.__all__ + assert polygateway.TelemetryStatus is not None + assert "TelemetryStatusProvider" not in polygateway.__all__ diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index c5009ee..abcdd75 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -16,9 +16,10 @@ from polygateway.ports import ( SourceSelector, StructuredOutputStrategy, TelemetryRecorder, + TelemetryStatusProvider, Transport, ) -from polygateway.types import LLMResponse, SourceStats +from polygateway.types import LLMResponse, SourceStats, TelemetryStatus def _resp() -> LLMResponse: @@ -141,6 +142,28 @@ def test_protocols_are_runtime_checkable(impl, protocol): assert isinstance(impl, protocol) +class _DummyStatusProvider(_DummyRecorder): + @property + def telemetry_status(self) -> TelemetryStatus: + return TelemetryStatus( + degraded=False, + fatal=False, + reason=None, + degraded_for_s=None, + dropped_rows=0, + retry_after_s=None, + ) + + +def test_status_provider_is_a_separate_optional_port(): + """状态**不得**并进 TelemetryRecorder: 那会让只实现 record_llm_call 的对象 + 当场不再满足 @runtime_checkable 的结构检查(设计 §3.3,Codex 审查)。""" + assert isinstance(_DummyStatusProvider(), TelemetryStatusProvider) + assert isinstance(_DummyStatusProvider(), TelemetryRecorder) + assert not isinstance(_DummyRecorder(), TelemetryStatusProvider) + assert isinstance(_DummyRecorder(), TelemetryRecorder) # 这条断言是那条决策的执法点 + + def _decision(**overrides) -> GateDecision: base = { "source_name": "qwen_1", diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index 30d938b..408f7ab 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -147,6 +147,25 @@ def captured_warnings(): logger.remove(sink_id) +@pytest.fixture +def captured_logs(): + """捕获 WARNING 及以上的日志**连同级别**,产出 `(级别名, 文案)` 列表。 + + 与 `captured_warnings` 分开存在是有理由的: 后者只留文案,而 ERROR 与 WARNING + 在 `level="WARNING"` 的 sink 里同池——于是"配置级致命发 error 而非 warning" + 这条设计决策(§3.2)长期**没有执法点**,把发 error 的那几行整块删掉,原有用例 + 照样全绿。级别是决策的一部分,就必须能被断言。 + """ + from loguru import logger + + records: list[tuple[str, str]] = [] + sink_id = logger.add( + lambda m: records.append((m.record["level"].name, m.record["message"])), level="WARNING" + ) + yield records + logger.remove(sink_id) + + # 搬迁前(1.2.1)两个 recorder 各自持有的 INSERT 常量原文,逐字冻结在此。 # 这两条字符串是"纯搬迁不改行为"的机械证据: 构造逻辑换了地方,产物必须一字不差。 _FROZEN_SQLITE_INSERT = ( @@ -706,6 +725,13 @@ class TestSQLiteSchemaMode: SQLiteRecorder(tmp_path / "t.db") # type: ignore[call-arg] +# 假池用例的池上限与写入预算: 两者都是必填 keyword-only(缺省只写在 config 一处), +# 本文件统一取这一份,免得每个 helper 各写一个数字 +_TEST_POOL_MAX = 2 +_TEST_WRITE_TIMEOUT_S = 5.0 +_PG_DSN = "postgresql://u:p@h:5432/polygateway" + + class _FakePgConn: """记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。 @@ -720,15 +746,34 @@ class _FakePgConn: fail_alter: bool = False, fail_create: bool = False, probe_errors: int = 0, + hang_insert: bool = False, + fail_terminate: bool = False, + insert_error: BaseException | None = None, ): self.existing = existing self.fail_alter = fail_alter self.fail_create = fail_create self.probe_errors = probe_errors + # 只挂 INSERT: 准备期照常完成,挂住的才是业务路径上那次内联 await + self.hang_insert = hang_insert + self.fail_terminate = fail_terminate + # INSERT 阶段抛出的真实 PG 异常(带 SQLSTATE),用来钉失败三分的边界 + self.insert_error = insert_error + self.terminated = False self.statements: list[str] = [] + def terminate(self): + self.terminated = True + if self.fail_terminate: + raise RuntimeError("connection is already closed") + async def execute(self, sql, *args): self.statements.append(sql) + if sql.startswith("INSERT INTO"): + if self.hang_insert: + await asyncio.sleep(3600) + if self.insert_error is not None: + raise self.insert_error if sql.startswith("ALTER TABLE") and self.fail_alter: raise RuntimeError("must be owner of table llm_calls") if sql.lstrip().startswith("CREATE TABLE"): @@ -749,20 +794,62 @@ class _FakePgConn: class _FakePgPool: - def __init__(self, conn): + """假池: 记 acquire/release 的配对次数与实参 timeout(issue #15 T3)。 + + 形状跟着被测代码走: recorder 改用**显式** `acquire(timeout=)` / + `release(conn, timeout=)`,不再用 `async with pool.acquire()`(那条路 + 的 shielded release 会把写入的真实上界撑成 ≈2× 预算,设计 §3.1), + 故这里也不再提供上下文管理器。 + """ + + def __init__( + self, + conn, + *, + hang_acquire: bool = False, + fail_release: bool = False, + hang_close: bool = False, + acquire_error: BaseException | None = None, + ): self._conn = conn + # 取连接阶段抛出的真实异常: 连接耗尽/DSN 非法都在这一步现形 + # (`min_size=0` 之后建池不触库,实测 create_pool 连 DSN 都不解析) + self.acquire_error = acquire_error + self.hang_acquire = hang_acquire + self.fail_release = fail_release + # 模拟 asyncpg 的 `Pool.close()` 在 in-flight 连接未归还时**无限等** + # (pool.py:939-948, 961-972 只在 60s 发一条 warning) + self.hang_close = hang_close + self.acquired = 0 + self.released = 0 + self.close_calls = 0 + self.terminated = False + self.acquire_timeouts: list[object] = [] + self.release_timeouts: list[object] = [] - def acquire(self): - conn = self._conn + async def acquire(self, *, timeout=None): + self.acquire_timeouts.append(timeout) + if self.hang_acquire: + await asyncio.sleep(3600) + if self.acquire_error is not None: + raise self.acquire_error + self.acquired += 1 + return self._conn - class _Ctx: - async def __aenter__(self): - return conn + async def release(self, conn, *, timeout=None): + assert conn is self._conn + self.release_timeouts.append(timeout) + if self.fail_release: + raise RuntimeError("connection reset failed") + self.released += 1 - async def __aexit__(self, *exc): - return False + async def close(self): + self.close_calls += 1 + if self.hang_close: + await asyncio.sleep(3600) - return _Ctx() + def terminate(self): + self.terminated = True class TestPostgresBackfillDiscipline: @@ -785,15 +872,19 @@ class TestPostgresBackfillDiscipline: from polygateway.telemetry.postgres import PostgresRecorder return PostgresRecorder( - "postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True + "postgresql://u:p@h:5432/polygateway", + pool=_FakePgPool(conn), + auto_migrate=True, + pool_max=_TEST_POOL_MAX, + write_timeout_s=_TEST_WRITE_TIMEOUT_S, ) async def test_alter_failure_does_not_disable_the_recorder(self): - """ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。""" + """ALTER 失败(如账号只有 INSERT 权限)不得让 recorder 降级 —— 那会让遥测全灭。""" conn = _FakePgConn(self._LEGACY, fail_alter=True) recorder = self._recorder(conn) await _record_minimal(recorder) # 不得抛 - assert recorder._failed is False + assert recorder.telemetry_status.degraded is False assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements) async def test_no_alter_when_columns_already_exist(self): @@ -841,7 +932,11 @@ class TestPostgresTableProbe: from polygateway.telemetry.postgres import PostgresRecorder return PostgresRecorder( - "postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True + "postgresql://u:p@h:5432/polygateway", + pool=_FakePgPool(conn), + auto_migrate=True, + pool_max=_TEST_POOL_MAX, + write_timeout_s=_TEST_WRITE_TIMEOUT_S, ) def _created(self, conn): @@ -858,7 +953,7 @@ class TestPostgresTableProbe: conn = _FakePgConn(self._CURRENT, fail_create=True) recorder = self._recorder(conn) await _record_minimal(recorder) # 不得抛 - assert recorder._failed is False + assert recorder.telemetry_status.degraded is False assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements) async def test_missing_table_is_created_and_not_backfilled(self): @@ -868,15 +963,16 @@ class TestPostgresTableProbe: await _record_minimal(recorder) assert len(self._created(conn)) == 1 assert not [s for s in conn.statements if s.startswith("ALTER TABLE")] - assert recorder._failed is False + assert recorder.telemetry_status.degraded is False assert any(s.startswith("INSERT INTO llm_calls") for s in conn.statements) - async def test_create_failure_on_missing_table_degrades_to_noop(self): - """表确定不存在且建不出来 = 确定写不进去: 此时才允许永久 no-op。""" + async def test_create_failure_on_missing_table_enters_cooldown(self): + """表确定不存在且建不出来 = 环境级(DBA 建了表就该好): 冷却降级,不判死。""" conn = _FakePgConn([], fail_create=True) recorder = self._recorder(conn) await _record_minimal(recorder) # 不得抛 - assert recorder._failed is True + status = recorder.telemetry_status + assert status.degraded is True and status.fatal is False assert not [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] async def test_probe_failure_is_transient_not_terminal(self): @@ -884,7 +980,8 @@ class TestPostgresTableProbe: conn = _FakePgConn(self._CURRENT, probe_errors=1) recorder = self._recorder(conn) await _record_minimal(recorder, call_id="first") # 不得抛 - assert recorder._failed is False + # 不认识的失败不给升级: 探测抖动只丢本行,绝不进 60s 冷却(issue #9) + assert recorder.telemetry_status.degraded is False assert not [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] await _record_minimal(recorder, call_id="second") assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] @@ -903,7 +1000,11 @@ class TestPostgresSchemaMode: from polygateway.telemetry.postgres import PostgresRecorder return PostgresRecorder( - "postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=auto_migrate + "postgresql://u:p@h:5432/polygateway", + pool=_FakePgPool(conn), + auto_migrate=auto_migrate, + pool_max=_TEST_POOL_MAX, + write_timeout_s=_TEST_WRITE_TIMEOUT_S, ) async def test_manual_mode_trims_the_insert_instead_of_altering(self, captured_warnings): @@ -1686,3 +1787,675 @@ class TestTextCapCoversEmbedAndOcrChains: # 占位串 `` 共 24 字 assert json.loads(row["messages"])[0]["content"] == " None: + self.t = start + + def __call__(self) -> float: + return self.t + + def advance(self, seconds: float) -> None: + self.t += seconds + + +@pytest.fixture +def captured_infos(): + """捕获 INFO 及以上;恢复那条 info 是"降级已结束"的唯一外部信号。""" + from loguru import logger + + messages: list[str] = [] + sink_id = logger.add(messages.append, level="INFO") + yield messages + logger.remove(sink_id) + + +class TestTelemetryStatusTracker: + """状态机六字段逐个钉;它是两个 recorder 共用的降级事实源(设计 §3.3)。""" + + def _tracker(self, clock): + from polygateway.telemetry.status import TelemetryStatusTracker + + return TelemetryStatusTracker(backend="postgres", now=clock) + + def test_fresh_tracker_reports_no_degradation(self): + tracker = self._tracker(_FakeClock()) + status = tracker.snapshot() + assert (status.degraded, status.fatal, status.reason) == (False, False, None) + assert status.degraded_for_s is None and status.retry_after_s is None + assert status.dropped_rows == 0 + assert tracker.should_retry() is True # 未降级本就该正常走准备路径 + + def test_entering_degraded_reports_reason_and_cooldown(self, captured_warnings): + tracker = self._tracker(_FakeClock()) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + status = tracker.snapshot() + assert status.degraded is True and status.fatal is False + assert status.reason == "连接被拒" + assert status.degraded_for_s == pytest.approx(0.0) + assert status.retry_after_s == pytest.approx(60.0) + assert len(captured_warnings) == 1 + assert "连接被拒" in captured_warnings[0] and "60" in captured_warnings[0] + + def test_fake_clock_drains_the_cooldown(self): + clock = _FakeClock() + tracker = self._tracker(clock) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + clock.advance(25.0) + status = tracker.snapshot() + assert status.degraded_for_s == pytest.approx(25.0) + assert status.retry_after_s == pytest.approx(35.0) + assert tracker.should_retry() is False + clock.advance(40.0) # 越过冷却窗口 + assert tracker.snapshot().retry_after_s == pytest.approx(0.0) # 不得为负 + assert tracker.should_retry() is True + + def test_recover_clears_degradation_but_keeps_dropped_rows(self, captured_infos): + tracker = self._tracker(_FakeClock()) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + for _ in range(3): + tracker.record_drop("遥测已降级") + tracker.recover() + status = tracker.snapshot() + assert (status.degraded, status.fatal, status.reason) == (False, False, None) + assert status.degraded_for_s is None and status.retry_after_s is None + # 进程生命周期内单调不减: 恢复不是"没丢过",下游要靠它对账 + assert status.dropped_rows == 3 + assert any("3" in message and "恢复" in message for message in captured_infos) + + def test_drop_warnings_are_throttled_by_the_row_constant(self, captured_warnings): + from polygateway.telemetry.status import _DROP_REPEAT_EVERY_ROWS + + tracker = self._tracker(_FakeClock()) # 时钟不动: 只有行数阈值能触发复述 + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + captured_warnings.clear() # 只数丢弃复述,不数进入降级那条 + total = _DROP_REPEAT_EVERY_ROWS * 2 + 1 + for _ in range(total): + tracker.record_drop("遥测已降级") + # 首条必报,其后每满一个阈值报一次 —— 关系由常量决定,不写死数字 + assert len(captured_warnings) == 1 + (total - 1) // _DROP_REPEAT_EVERY_ROWS + assert tracker.snapshot().dropped_rows == total + + def test_drop_warnings_are_also_throttled_by_time(self, captured_warnings): + from polygateway.telemetry.status import _DROP_REPEAT_EVERY_S + + clock = _FakeClock() + tracker = self._tracker(clock) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + captured_warnings.clear() + tracker.record_drop("遥测已降级") + assert len(captured_warnings) == 1 + tracker.record_drop("遥测已降级") + assert len(captured_warnings) == 1 # 同一窗口内不刷屏 + clock.advance(_DROP_REPEAT_EVERY_S) + tracker.record_drop("遥测已降级") + assert len(captured_warnings) == 2 # 长跑进程里也不会静默 + + def test_fatal_degradation_never_retries(self, captured_warnings): + clock = _FakeClock() + tracker = self._tracker(clock) + tracker.enter_degraded("DSN 不可解析", fatal=True, cooldown_s=None) + clock.advance(1_000_000.0) + status = tracker.snapshot() + assert status.fatal is True and status.retry_after_s is None + assert tracker.should_retry() is False + assert "重启" in captured_warnings[0] # 恢复条件必须写在日志里 + + def test_log_level_is_decided_by_fatal_and_only_here(self, captured_logs): + """级别决策**收敛在 tracker 一处**: fatal → ERROR,其余 → WARNING(设计 §3.2)。 + + 这是那条决策在全库唯一的执法点。它同时钉两个方向: 把 fatal 那档改回 + warning 会红,把两档都提成 error 也会红——"人配错了"与"外部挂了"必须 + 在日志级别上分得开,运维的告警规则就架在这个区分上。 + """ + self._tracker(_FakeClock()).enter_degraded("DSN 不可解析", fatal=True, cooldown_s=None) + self._tracker(_FakeClock()).enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + assert [level for level, _ in captured_logs] == ["ERROR", "WARNING"] + + def test_repeating_the_same_reason_does_not_spam(self, captured_warnings): + """冷却到期重试再失败会反复进入降级: 同一原因只讲一次,只刷新窗口。""" + clock = _FakeClock() + tracker = self._tracker(clock) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + clock.advance(60.0) + tracker.enter_degraded("连接被拒", fatal=False, cooldown_s=60.0) + assert len(captured_warnings) == 1 + assert tracker.snapshot().retry_after_s == pytest.approx(60.0) # 窗口已刷新 + assert tracker.snapshot().degraded_for_s == pytest.approx(60.0) # 但仍是同一段降级 + + +class TestSQLiteStatusVisibility: + """SQLite 侧今天初始化失败后写入静默 return,连一条日志都没有(设计 §1.4)。""" + + def _broken(self, tmp_path): + blocker = tmp_path / "blocker" + blocker.write_text("父目录是个文件,mkdir 必然失败") + return SQLiteRecorder(blocker / "telemetry.db", auto_migrate=True) + + def test_init_failure_is_degraded_and_fatal(self, tmp_path, captured_logs): + recorder = self._broken(tmp_path) + status = recorder.telemetry_status + assert status.degraded is True and status.fatal is True + # 静默降级 ≠ 静默;且致命档两侧同级别——tracker 是级别的唯一决策点, + # SQLite 侧不该因为没人复制那条 error 就降一级 + assert [(level, "重启" in m) for level, m in captured_logs] == [("ERROR", True)] + + async def test_dropped_rows_are_counted_and_visible(self, tmp_path, captured_warnings): + recorder = self._broken(tmp_path) + captured_warnings.clear() + await _record_minimal(recorder) # 不得抛: 遥测绝不冒泡 + assert recorder.telemetry_status.dropped_rows == 1 + assert captured_warnings # 丢的第一行必须出声 + + def test_healthy_recorder_is_not_degraded(self, tmp_path): + recorder = SQLiteRecorder(tmp_path / "ok.db", auto_migrate=True) + assert recorder.telemetry_status.degraded is False + recorder.close() + + async def test_write_failure_counts_as_a_dropped_row(self, tmp_path, captured_warnings): + """逐行写入失败也必须计数,否则可见性在这条路径上是假的。 + + 只发一条 warning 而不计数,`dropped_rows` 会恒 0、`degraded` 恒 False—— + 磁盘满 / database is locked / 文件被外部改坏时行真的丢了,而下游按 + README 的口径("不必再靠人工对账")读快照完全看不见,与 issue #15 + 要消灭的静默失败同型。PG 侧两件都做(`postgres.py` 写入失败分支)。 + """ + db = tmp_path / "ok.db" + recorder = SQLiteRecorder(db, auto_migrate=True) + # 真实失败而非 mock: 另一连接把表删掉(等价于库文件被外部改坏), + # 此后 recorder 的每次 INSERT 都报 `no such table: llm_calls` + side = sqlite3.connect(db) + side.execute("DROP TABLE llm_calls") + side.commit() + side.close() + captured_warnings.clear() + await _record_minimal(recorder) # 不得抛: 遥测绝不冒泡 + assert recorder.telemetry_status.dropped_rows == 1 + assert captured_warnings # 丢的第一行必须出声 + recorder.close() + + async def test_drop_reason_after_close_matches_the_snapshot(self, tmp_path, captured_warnings): + """关闭后的丢行原因不得写死为"已降级": 此时快照里的 `degraded` 是 False。 + + 固定文案与下游读到的快照互相矛盾,对账的人分不清该等后端自愈、还是 + 修自己的关闭时序。PG 侧用 `_drop_reason()` 分档,SQLite 侧必须同口径。 + """ + recorder = SQLiteRecorder(tmp_path / "ok.db", auto_migrate=True) + recorder.close() + captured_warnings.clear() + await _record_minimal(recorder) # 不得抛 + status = recorder.telemetry_status + assert status.degraded is False and status.dropped_rows == 1 + assert "关闭" in captured_warnings[0] and "降级" not in captured_warnings[0] + + +class TestPostgresStatusVisibility: + """PG 侧的降级必须能被下游查到(计划 T2 建立可见性,T5 改判据)。""" + + def _recorder(self, conn, now=None): + """假时钟是缺省: 快照里的 `retry_after_s`/`degraded_for_s` 是**时间差**, + 用真实时钟断言就等于断言"这几行代码零耗时",是设计上就会间歇红的用例。""" + from polygateway.telemetry.postgres import PostgresRecorder + + return PostgresRecorder( + "postgresql://u:p@h:5432/polygateway", + pool=_FakePgPool(conn), + auto_migrate=True, + pool_max=_TEST_POOL_MAX, + write_timeout_s=_TEST_WRITE_TIMEOUT_S, + now=now or _FakeClock(), + ) + + async def test_unusable_table_shows_up_in_the_status(self, captured_warnings): + """表确定不存在且建不出来: 降级可见,且是**可自愈**的环境级而非永久判死。""" + from polygateway.telemetry.postgres import _DEGRADE_COOLDOWN_S + + recorder = self._recorder(_FakePgConn([], fail_create=True)) + await _record_minimal(recorder) + status = recorder.telemetry_status + assert status.degraded is True and status.fatal is False # 环境级: 建了表就该自愈 + assert status.dropped_rows == 1 # 降级那一次调用本身也丢了一行 + assert status.retry_after_s == _DEGRADE_COOLDOWN_S # 假时钟不动,余额恰是整个冷却期 + + async def test_healthy_recorder_is_not_degraded(self): + recorder = self._recorder(_FakePgConn(list(_EXPECTED_COLUMNS))) + await _record_minimal(recorder) + status = recorder.telemetry_status + assert status.degraded is False and status.dropped_rows == 0 + + +class TestPostgresPoolResourceSemantics: + """issue #15 A 组: 库必须自己声明池的资源占用,并给写入一个硬预算。 + + 建池这条路在本 issue 之前**零测试覆盖**(全部 PG 用例都经 `pool=` 注入, + 走的是外部池分支),`min_size=10` 因此潜伏至今: 4 个 client × 10 = 40 条 + 常驻连接专用于写遥测,共享实例余量不足时先倒下的必然是它。 + """ + + def _recorder(self, pool, **overrides): + from polygateway.telemetry.postgres import PostgresRecorder + + kwargs: dict[str, object] = { + "auto_migrate": True, + "pool_max": _TEST_POOL_MAX, + "write_timeout_s": _TEST_WRITE_TIMEOUT_S, + } + kwargs.update(overrides) + return PostgresRecorder(_PG_DSN, pool=pool, **kwargs) + + async def test_pool_is_created_without_preconnecting(self, monkeypatch): + """**主回归钉子**: `min_size=0` 且 `max_size` 取配置值。 + + `min_size` 的语义是"预连接"而非"下限"(asyncpg `pool.py:457` 为 0 时 + 一条连接都不连),故它是"建池要么全有要么全无"这个脆点的唯一来源。 + 继承第三方默认值等于库对自己的资源占用不表态(P4),本条防的就是回归。 + """ + import asyncpg + + from polygateway.telemetry.postgres import PostgresRecorder + + captured: dict[str, object] = {} + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + + async def fake_create_pool(dsn, **kwargs): + captured["dsn"] = dsn + captured.update(kwargs) + return pool + + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + recorder = PostgresRecorder(_PG_DSN, auto_migrate=True, pool_max=3, write_timeout_s=2.5) + await _record_minimal(recorder) + + assert captured["min_size"] == 0 + assert captured["max_size"] == 3 + # connect 与单条语句都在同一份写入预算内,不留继承来的 10s 默认值 + assert captured["timeout"] == 2.5 + assert captured["command_timeout"] == 2.5 + + async def test_acquire_gets_an_explicit_timeout(self): + """`pool.acquire()` 无参 = 无限等(asyncpg 缺省 `timeout=None`)。 + + 池满时那是挂在业务路径上的无限期 await,`max_size` 收到个位数后必现。 + """ + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + await _record_minimal(self._recorder(pool)) + assert pool.acquire_timeouts # 准备期与写入期各一次 + assert all(t == _TEST_WRITE_TIMEOUT_S for t in pool.acquire_timeouts) + + async def test_write_budget_drops_the_row_instead_of_blocking_the_caller( + self, captured_warnings + ): + """整次写入有硬预算: 后端挂住时丢一行,绝不把业务调用拖在那里。""" + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS), hang_insert=True)) + recorder = self._recorder(pool, write_timeout_s=0.05) + loop = asyncio.get_running_loop() + started = loop.time() + # 挂死就当场红,而不是把整个套件拖到 CI 超时 + await asyncio.wait_for(_record_minimal(recorder), timeout=5) + assert loop.time() - started < 1.0 + assert any("预算" in m for m in captured_warnings) + assert recorder.telemetry_status.dropped_rows == 1 + + async def test_release_is_paired_even_when_the_budget_fires(self): + """预算取消发生在 execute 上,连接照样要还回去——否则池被慢查询吃干。""" + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS), hang_insert=True)) + recorder = self._recorder(pool, write_timeout_s=0.05) + await asyncio.wait_for(_record_minimal(recorder), timeout=5) + assert pool.acquired == 2 # 准备期一次 + 写入一次 + assert pool.released == pool.acquired + # 归还有独立的小上限: 复用写入预算就等于允许再等一个预算(设计 §3.1) + assert all(t is not None and t < _TEST_WRITE_TIMEOUT_S for t in pool.release_timeouts) + + async def test_acquire_timeout_drops_the_row_without_leaking(self, captured_warnings): + """取连接本身挂住时同样丢行;没拿到的连接不许伪造一次 release。""" + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS)), hang_acquire=True) + recorder = self._recorder(pool, write_timeout_s=0.05) + await asyncio.wait_for(_record_minimal(recorder), timeout=5) + assert pool.acquired == 0 and pool.released == 0 + assert captured_warnings + + async def test_external_cancellation_is_not_swallowed_as_a_timeout(self): + """铁律"取消可穿透": `asyncio.timeout` 只把**自己**触发的 cancel 转成 + TimeoutError,外部取消必须照常以 CancelledError 冒出去。 + """ + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS), hang_insert=True)) + recorder = self._recorder(pool, write_timeout_s=30.0) + task = asyncio.create_task(_record_minimal(recorder)) + await asyncio.sleep(0.05) # 让它跑到挂住的那次 INSERT + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert pool.released == pool.acquired # 取消路径上也不许泄漏连接 + + +def _pg_recorder(*, pool=None, **overrides): + """本文件统一的 PG recorder 构造口: 池上限与写入预算取同一份测试常量。""" + from polygateway.telemetry.postgres import PostgresRecorder + + kwargs: dict[str, object] = { + "auto_migrate": True, + "pool_max": _TEST_POOL_MAX, + "write_timeout_s": _TEST_WRITE_TIMEOUT_S, + } + kwargs.update(overrides) + return PostgresRecorder(_PG_DSN, pool=pool, **kwargs) + + +class TestPostgresCloseIsBounded: + """issue #15 B 组: 关闭动作本身必须有界,且"关了就是关了"(设计 §3.2 第 4 点)。 + + 两个缺口各钉一次: ① `Pool.close()` 会 await 每个 holder 的 + `wait_until_released()`,in-flight 未归还时无限等 —— 收尾路径上照样是 + "遥测拖垮业务";② 关完还能自己重建池的灰色状态 —— 关闭是所有权终结, + 恢复归运行时的冷却机制管,不该是关闭动作的副作用。 + """ + + def _self_built(self, monkeypatch, pool): + """让 recorder 走**自建池**那条路,并交回建池次数(复活的唯一证据)。""" + import asyncpg + + created: list[str] = [] + + async def fake_create_pool(dsn, **kwargs): + created.append(dsn) + return pool + + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + return _pg_recorder(), created + + async def test_stuck_pool_close_falls_back_to_terminate(self, monkeypatch, captured_warnings): + """**主回归钉子**: 池关不掉时超时即 terminate,绝不无限期挂在收尾路径上。""" + from polygateway.telemetry import postgres + + monkeypatch.setattr(postgres, "_CLOSE_TIMEOUT_S", 0.05) + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS)), hang_close=True) + recorder, _ = self._self_built(monkeypatch, pool) + await _record_minimal(recorder) + + loop = asyncio.get_running_loop() + started = loop.time() + # 用例自带超时: 实现无界时这里要当场红,而不是挂死整个套件 + await asyncio.wait_for(recorder.aclose(), timeout=5) + + assert loop.time() - started < 1.0 + assert pool.close_calls == 1 and pool.terminated is True + assert captured_warnings # 强制拆池是异常路径,不许静默 + + async def test_writes_after_close_do_not_rebuild_the_pool(self, monkeypatch): + """关了就是关了: 后续写入短路丢行,**不**再建一个没人负责关的池。""" + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + recorder, created = self._self_built(monkeypatch, pool) + await _record_minimal(recorder) + assert len(created) == 1 + + await recorder.aclose() + dropped_before = recorder.telemetry_status.dropped_rows + await _record_minimal(recorder, call_id="c2") # 遥测绝不冒泡 + + assert len(created) == 1 # 复活的唯一证据: 第二次 create_pool + assert pool.acquired == 2 # 准备期 + 首次写入;关闭后一次都没有 + assert recorder.telemetry_status.dropped_rows == dropped_before + 1 + + async def test_close_is_not_degradation(self, monkeypatch, captured_warnings): + """**关闭 ≠ 降级**(T4 留下的语义问题,T5 收口)。 + + `degraded` 的含义是"后端本该可写却写不进去,库正在设法恢复"。关闭是调用方 + 自己的决定,没有异常、也按设计不会自愈——把它记成降级,等于让每一次正常 + 收尾都发一次降级信号,下游"degraded 就告警"的规则会被每次退出打穿。 + 关闭后真正要对账的是"还有多少行没落地",那由 `dropped_rows` 与逐条原因 + 承担,不必污染 `degraded`。 + """ + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + recorder, _ = self._self_built(monkeypatch, pool) + await _record_minimal(recorder) + await recorder.aclose() + captured_warnings.clear() + + await _record_minimal(recorder, call_id="c2") + + status = recorder.telemetry_status + assert status.degraded is False and status.fatal is False + assert status.reason is None and status.retry_after_s is None + assert status.dropped_rows == 1 # 丢了多少行照样可对账 + assert any("遥测已关闭" in m for m in captured_warnings) # 且分得清是哪一种丢 + + async def test_aclose_is_idempotent(self, monkeypatch): + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + recorder, _ = self._self_built(monkeypatch, pool) + await _record_minimal(recorder) + await recorder.aclose() + await recorder.aclose() + assert pool.close_calls == 1 + + async def test_injected_pool_is_left_to_its_owner(self, monkeypatch): + """注入的池既不关也不拆(既有纪律),但 recorder 自己照样"关了就是关了"。 + + 注入档的复活更隐蔽: 关闭把 `_pool` 置 None 后,下一次写入会拿 DSN + **自建**一个池——注入方以为自己管着全部连接,实际早已不是。 + """ + import asyncpg + + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + created: list[str] = [] + + async def fake_create_pool(dsn, **kwargs): + # 不能直接 raise: recorder 会把它当建池失败吞掉,用例就白测了 + created.append(dsn) + return _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS))) + + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + recorder = _pg_recorder(pool=pool) + await _record_minimal(recorder) + await recorder.aclose() + + assert pool.close_calls == 0 and pool.terminated is False + await _record_minimal(recorder, call_id="c2") + assert created == [] # 注入档的复活: 拿 DSN 另起一个池 + assert pool.acquired == 2 # 关闭后也不再往注入的池上写 + + async def test_external_cancellation_during_close_is_not_swallowed(self, monkeypatch): + """铁律"取消可穿透": 有界关闭不得把外部取消吃成一次超时降级。""" + pool = _FakePgPool(_FakePgConn(list(_EXPECTED_COLUMNS)), hang_close=True) + recorder, _ = self._self_built(monkeypatch, pool) + await _record_minimal(recorder) + + task = asyncio.create_task(recorder.aclose()) + await asyncio.sleep(0.05) # 让它跑进那次挂住的 close() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +class TestPostgresReleaseDegradation: + """归还连接失败时的防御路径(T3 未覆盖的缺口,借 T4 的假池顺带钉住)。 + + 留一条状态不明的连接在池里比断开更坏: 它会被下次 acquire 取到, + 把一次失败放大成持续失败。 + """ + + async def test_failed_release_terminates_the_connection(self, captured_warnings): + conn = _FakePgConn(list(_EXPECTED_COLUMNS)) + await _record_minimal(_pg_recorder(pool=_FakePgPool(conn, fail_release=True))) + assert conn.terminated is True + assert any("归还失败" in m for m in captured_warnings) + + async def test_terminate_failure_does_not_escape(self, captured_warnings): + """断开本身再失败也只记 warning: 遥测绝不冒泡,剩下的交给池自行回收。""" + conn = _FakePgConn(list(_EXPECTED_COLUMNS), fail_terminate=True) + await _record_minimal(_pg_recorder(pool=_FakePgPool(conn, fail_release=True))) + assert any("断开失败" in m for m in captured_warnings) + + +class TestPostgresFailureClassification: + """issue #15 B 组: 判死判据从"哪一步失败"改为"失败是什么性质"(设计 §3.2)。 + + 判据两句: ①**致命 = 失败原因完全在进程内部且不可变**;②**行级 vs 环境级看 + 失败与这一行的数据有没有关系**。三档边界两侧各钉一次——按 SQLSTATE 前两位 + 一刀切正是本 issue 之前的错法,回归会当场红。 + """ + + def _self_built(self, monkeypatch, *, outcomes, clock): + """走**自建池**那条路;`outcomes` 逐次消费,元素是异常就抛出。 + + 必须自建而非注入: 注入档走 `_external_pool=True` 分支,完全绕过建池, + 而 issue 现场的失败恰恰发生在建池那一步。 + """ + import asyncpg + + created: list[str] = [] + + async def fake_create_pool(dsn, **kwargs): + created.append(dsn) + outcome = outcomes[min(len(created) - 1, len(outcomes) - 1)] + if isinstance(outcome, BaseException): + raise outcome + return outcome + + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + return _pg_recorder(now=clock), created + + async def test_pool_exhaustion_degrades_with_cooldown_and_self_heals(self, monkeypatch): + """**issue 场景直接回归**: 53300 落在准备期,过去 = 整进程永久失遥测。 + + `too many clients` 是外部状态,别人还连接就该好——它永远不满足"原因完全 + 在进程内部且不可变",故绝不许判死,只许冷却重试。 + """ + import asyncpg + + from polygateway.telemetry.postgres import _DEGRADE_COOLDOWN_S + + clock = _FakeClock() + conn = _FakePgConn(list(_EXPECTED_COLUMNS)) + recorder, created = self._self_built( + monkeypatch, + outcomes=[ + asyncpg.exceptions.TooManyConnectionsError("sorry, too many clients already"), + _FakePgPool(conn), + ], + clock=clock, + ) + + await _record_minimal(recorder, call_id="c1") # 不得抛 + status = recorder.telemetry_status + assert status.degraded is True + assert status.fatal is False # ← 现状在这里判死,整进程从此一条不落 + assert status.retry_after_s == pytest.approx(_DEGRADE_COOLDOWN_S) + assert not conn.statements + + await _record_minimal(recorder, call_id="c2") # 冷却期内零成本短路 + assert len(created) == 1 # 不再内联吞一次 connect 超时 + + clock.advance(_DEGRADE_COOLDOWN_S) + await _record_minimal(recorder, call_id="c3") + + assert len(created) == 2 # 到期放行一次重新准备 + assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] + assert recorder.telemetry_status.degraded is False # 自愈,无需重启进程 + assert recorder.telemetry_status.dropped_rows == 2 # 降级期间那两行确实丢了 + + async def test_unparseable_dsn_is_fatal_and_costs_nothing_afterwards(self, captured_logs): + """DSN 是构造期定死的字符串: 唯一"进程内不可能变好"的东西,故唯一的致命档。 + + 级别与条数一起断言: 同一个事实只该出一条 **ERROR**。recorder 侧曾在 tracker + 之外另发一条,于是同一次配置错误刷出 error + warning 两条语义重复的日志, + 而"级别"这个决策也就有了两个源头(设计 §3.2)。 + """ + import asyncpg + + clock = _FakeClock() + pool = _FakePgPool( + _FakePgConn(list(_EXPECTED_COLUMNS)), + acquire_error=asyncpg.exceptions.ClientConfigurationError("invalid DSN: bad scheme"), + ) + recorder = _pg_recorder(pool=pool, now=clock) + + await _record_minimal(recorder, call_id="c1") # 不得抛 + status = recorder.telemetry_status + assert status.degraded is True and status.fatal is True + assert status.retry_after_s is None # 本进程内不会自愈 + # 按"恢复条件"这个标记捞降级播报本身(丢行复述是另一个事实,不在此列): + # 同一次配置错误只该播报**一条**,且级别是 error + announced = [(level, m) for level, m in captured_logs if "重启" in m] + assert [level for level, _ in announced] == ["ERROR"] + + clock.advance(1_000_000.0) + attempts = len(pool.acquire_timeouts) + await _record_minimal(recorder, call_id="c2") + assert len(pool.acquire_timeouts) == attempts # 此后零成本短路,不再触库 + + @pytest.mark.parametrize("error_name", ["InsufficientPrivilegeError", "UndefinedTableError"]) + async def test_environment_level_sqlstates_enter_cooldown(self, error_name): + """`42501`/`42P01` 与这一行的数据无关(每一行都会同样失败)→ 环境级。 + + 它们与 `42703` 同属 SQLSTATE `42` 类却分属两档: 判据看的是"失败与这一行的 + 数据有没有关系",不是前两位。 + """ + import asyncpg + + from polygateway.telemetry.postgres import _DEGRADE_COOLDOWN_S + + clock = _FakeClock() + conn = _FakePgConn( + list(_EXPECTED_COLUMNS), insert_error=getattr(asyncpg.exceptions, error_name)("boom") + ) + recorder = _pg_recorder(pool=_FakePgPool(conn), now=clock) + + await _record_minimal(recorder, call_id="c1") + status = recorder.telemetry_status + assert status.degraded is True and status.fatal is False + assert status.retry_after_s == pytest.approx(_DEGRADE_COOLDOWN_S) + + inserts = len([s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]) + await _record_minimal(recorder, call_id="c2") + # 冷却期内不再每行内联付一次往返;权限/建表修好后由冷却到期自动恢复 + assert len([s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]) == inserts + + async def test_missing_column_stays_row_level(self, captured_warnings): + """`42703` 是判据的**唯一具名例外**,由 issue #13 定死: 缺列要逐行暴露。 + + 按判据第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 manual 档 + 会裁剪 INSERT 继续写,"部分列写进去了 + 逐行 warning"本身有价值, + 不该被冷却掉——下游正是靠这条 warning 发现 schema 漂移的。 + """ + import asyncpg + + conn = _FakePgConn( + list(_EXPECTED_COLUMNS), + insert_error=asyncpg.exceptions.UndefinedColumnError('column "meta" does not exist'), + ) + recorder = _pg_recorder(pool=_FakePgPool(conn)) + + await _record_minimal(recorder, call_id="c1") + await _record_minimal(recorder, call_id="c2") + + status = recorder.telemetry_status + assert status.degraded is False # 不进冷却 + assert status.dropped_rows == 2 + # 每一行都照发 INSERT,每一行都出声: schema 漂移必须持续可见 + assert len([s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]) == 2 + # 逐行那条不节流(与累计计数那条区分开): 每丢一行都要出声 + assert len([m for m in captured_warnings if "写入失败(丢弃该行" in m]) == 2 + + async def test_uncreatable_table_recovers_once_the_dba_creates_it(self): + """表建不出来是环境级: DBA 建完表,冷却到期就该自己好,不必重启进程。""" + from polygateway.telemetry.postgres import _DEGRADE_COOLDOWN_S + + clock = _FakeClock() + conn = _FakePgConn([], fail_create=True) + recorder = _pg_recorder(pool=_FakePgPool(conn), now=clock) + + await _record_minimal(recorder, call_id="c1") + assert recorder.telemetry_status.degraded is True + + conn.existing = list(_EXPECTED_COLUMNS) # DBA 手工建了表 + clock.advance(_DEGRADE_COOLDOWN_S) + await _record_minimal(recorder, call_id="c2") + + assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")] + assert recorder.telemetry_status.degraded is False