Files
PolyGateway/research-wiki/designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md
T
iomgaa 4e1f09d231 docs: record the telemetry pool semantics and ownership rule
ARCHITECTURE 7.8 gains the pool resource semantics, the two-sentence
failure verdict and the two config keys; the ownership rule lands in a
new 4.5 because it is a cross-subsystem discipline, not a telemetry
convention. CHANGELOG leads with the three items downstream must read
first: the 3.12 floor, the connection count going from 10 per client to
on demand, and aclose no longer closing injected components.
2026-08-24 11:09:22 -04:00

36 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,分支 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: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 不只是"调小",它把建池从一次全有全无的重资源动作变成零成本、不触库的动作。这一步走出去,后面三层的性质全变。

实施后在同一台真实实验室 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-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 正确先例

实施期挖出的第四个现象(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: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;不新起文件避免碎片化)。认不出的失败归最轻档(行级)是它的保守缺省,而这个缺省在建池路径上安全的理由比"最轻档代价最小"更强(实施期核实,T5): min_size=0 让建池不触库(实测 0.000s),所以"归行级 = 下次调用再重试一次建池"本身零成本——postgres.py:104-105 那条注释担心的"每次重试内联吞一次 connect 超时"是 min_size=10 语义下的顾虑,在新语义下不成立。这是 §1.1 那个关键推论的又一处红利: 地基一换,原本需要小心处理的保守缺省变成了白拿。

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 探测后跳过。
  • 判定一律用 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 >= 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 时遥测写入排队。按下一格更正后的实测口径(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)
端口签名 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),两处独立验证互为佐证。

10. 实施期修订(2026-08-24,T0T7 执行中发现)

设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。

# 修订 落点
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