补进「请先读这一条(四)」之后,CHANGELOG:20 与设计 §7 仍写「三条/三处」, 同一份文档里出现自相矛盾的计数——正是本轮在消灭的那类失真。一并给设计 §7 补上第 4 条的正文,免得清单与 CHANGELOG 再次漂移。
39 KiB
遥测连接池的资源语义与生命周期: 从"预占 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(asyncpgpool.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):
- 致命 = 失败原因完全在进程内部且不可变;其余一切失败都可能被外部修好,故一律带冷却重试。
- 行级 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),但接入节流复述 |
四点必须说清:
- 致命档收到极窄是有意的。认证失败、库不存在、表建不出来一律归环境级——它们都是外部状态,DBA 改完密码/建完表就该自动恢复。永久失能是最坏结局,只留给"重试在任何时刻都不可能成功"的情形,而 DSN 是构造期固定的字符串,是唯一满足这条的东西。
42703是判据的唯一具名例外,且必须写明理由。按第 2 句它本该是环境级(缺列时每行都失败),归行级是因为 issue #13 定下了一条更高优先级的承诺: manual 档缺列时按现有列裁剪 INSERT 继续写,缺列以逐行 warning 暴露,好让下游发现 schema 漂移——即"部分列写进去了"这件事本身有价值,不该被冷却掉。代价(无限逐行 warning)由接入节流复述抵消。例外只此一条,新增例外必须同款论证。- 带冷却正面回答了
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 的"连续超预算丢行是否该升档"一格。 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 useasyncio.wait_forto 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-linterports : 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 条是合并前审查补的):
- 最低 Python 提到 3.12(人类决策,2026-08-24;
requires-python、rufftarget-version、README、CLAUDE.md 四处已同步)。这是四处里唯一会让下游装不上的变更: 仍在 3.11 的部署pip install直接被 pip 拒绝。这一条本身就足以把版号推到 1.3.0——它不是"新增能力",是缩小了支持面。 - 遥测常驻连接从
10 × client 数变为按需(纯改善,但监控上会看到连接数曲线突变)。 aclose不再关闭注入的组件。这是修正越权,但若有下游依赖了"注入后由 client 代关",升级后会漏关——必须显式声明。- 直接构造
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 |