Files
PolyGateway/research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md
T
iomgaa e7caa500e2 docs: plan the telemetry pool lifecycle rework for issue 15
The design traces the incident to four stacked defects rather than one bad
default: the pool is the only resource in the library that pre-allocates,
the kill switch keys off which step failed instead of what failed, the
degraded state can neither recover nor be observed, and the ownership rules
make the sanctioned sharing path unusable.

The plan sequences the tracker ahead of the pool and failure work so every
commit stays green, and records two facts the implementer needs up front:
the pool-construction path has zero test coverage today, and the commit
gate runs the full suite plus a complexity ceiling.
2026-08-24 08:17:37 -04:00

32 KiB
Raw Blame History

遥测连接池的资源语义与生命周期: 从"预占 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);Codex 已审,7 条全部处置完毕(§9);待人类审 → 实施
  • 实测环境: asyncpg 0.31.0;真实实验室 PG(polygateway 专用库,跨内网 RTT ≈ 123ms)

1. 问题的真实形状

issue 把问题命名为"asyncpg 默认 min_size=10 太大"。这个命名会把方案引向"改个默认值"。实际是四层缺陷叠加,只改默认值会留下三层,且下一次换个瞬时错误(PG 重启、DNS 抖动)照样全量失遥测。必须分开命名。

1.1 前提实测: min_size 的语义是"预连接",不是"下限"

asyncpg pool.py:457if self._minsize: ——为 0 时 _initialize 只创建 holder 对象,一条连接都不连。由此实测得到本设计的全部地基:

实测项 min_size=0, max_size=2 默认 10/10(现状)
建池指向不可达端口 立即成功,0.000s,size=0 立即抛 ConnectionRefusedErrorissue 的失败点
建池连真实库 0.000s,size=0 0.72s,10 条常驻
首次写入 / 稳态写入 513ms(含建连 ≈390ms)/ 123ms(一次 RTT) 同(稳态无差异)
acquire 失败后再 acquire 照常重试,池不进坏状态
空闲超 max_inactive_connection_lifetime 连接归 0,下次写入重连

关键推论: min_size=0 不只是"调小",它把建池从一次全有全无的重资源动作变成零成本、不触库的动作。这一步走出去,后面三层的性质全变。

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-461ocr.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 正确先例

三个现象一个根因: 库对"谁建的、谁负责关"没有统一纪律。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:238pool.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=剩余预算) + finally: await pool.release(con, timeout=<小的独立上限>),释放超时则 con.terminate()。承诺相应精确化为: 主写入尝试 ≤ 预算,释放路径独立有界
  • 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/TimeoutError/其余 InterfaceError;表确定不存在且建不出来 冷却降级(内部常量 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 一次、上界一个预算",有界且可解释。进程不再需要重启
  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;不新起文件避免碎片化)。

3.3 C 组 · 降级可见 + 可编程

新增 telemetry/status.pyTelemetryStatusTracker(两个 recorder 共用,消除两侧不对称):

能力 行为
进入降级 warning 一条,含原因分档与恢复条件(冷却剩余 / "需重启")
降级期间 按丢弃行数与时间节流复述(不刷屏,也不静默)——这一条是 §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.pyports.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-498ocr.py:510-511 自建、embedding.py:452-461ocr.py:462-467aclose 触达不到)。内存后端无 aclose,helper 探测后跳过。
  • 零公共 API 面变化: _owns_* 是私有属性,由工厂置位。
  • 有了 D 组,issue 的"共享池"方向以显式注入形态自然成立(PostgresRecorder(dsn, pool=...) 已支持且不关外部池),无需任何隐式全局。

3.5 新配置键与缺省值(人类已定)

字段 缺省 依据
PGW_TELEMETRY_PG_POOL_MAX telemetry_pg_pool_max: int 4 稳态吞吐 ≈ max_size / RTT = 4/0.123 ≈ 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 >= 1write_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 == 0max_size == 配置值(钉住"库对资源占用的表态",防回归到继承第三方默认值,这是本 issue 的主回归钉子);too many clients(53300)落在准备期 → 进冷却降级、 fatal → 假时钟推进 60s → 自动恢复;ClientConfigurationError → fatal + 一条 error + 此后零成本短路(断言不再调 acquire);42501/42P01 → 进冷却降级42703 → 行级丢弃且进降级(§3.2 的分档边界,两侧各钉一次);假 pool 的 acquire 挂住 → 硬预算生效、丢一行、耗时 ≤ 预算;外部 CancelledErrorasyncio.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 时遥测写入排队。64 行同时到达、32 行/秒 → 最坏约 2s,在 5s 预算内;超出即丢行(铁律"丢一条 < 拖垮调用")
突发排队(冷启动/空闲后) 上一格的算术只在"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 的调参口径 须进文档: 期望吞吐 ≈ pool_max / RTT。共享一个 recorder 给多 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)
端口签名 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 有三处需"请先读这一条"待遇:

  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 代关",升级后会漏关——必须显式声明。

版本提升的两项前置——已于 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.pyTelemetryStatus;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 的调参口径,否则这两个旋钮等于不存在
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,141isinstance 当场变 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),两处独立验证互为佐证。