22 Commits

Author SHA1 Message Date
iomgaa f5cf69a1ac Merge branch 'feat/issue-15-telemetry-pool-lifecycle' 2026-08-24 13:18:16 -04:00
iomgaa ef13ca7ea9 chore: cut 1.3.0 and date its changelog entry 2026-08-24 13:18:07 -04:00
iomgaa 8e66a362f7 docs: fix the callout counts the fourth entry invalidated
补进「请先读这一条(四)」之后,CHANGELOG:20 与设计 §7 仍写「三条/三处」,
同一份文档里出现自相矛盾的计数——正是本轮在消灭的那类失真。一并给设计
§7 补上第 4 条的正文,免得清单与 CHANGELOG 再次漂移。
2026-08-24 12:55:45 -04:00
iomgaa 15f0c16782 docs: correct three claims the code never made good on
1. 两个新遥测配置字段被 CHANGELOG 与 ARCHITECTURE 说成「带缺省」,实际是无
   默认值的必填字段(缺省只在 env 装配路 _load_*),且就地加默认值在 dataclass
   上根本不可能(后面跟着四个无默认值字段)。直接构造 GatewaySettings 的下游
   升级即 TypeError,这是真正的破坏性变更,补进 CHANGELOG 的「请先读这一条」。
2. ARCHITECTURE Q2 仍写最低 Python 3.11,按 2026-08-24 人类确认改 3.12,并
   记明原依据「覆盖三项目 3.11×2」已过时,三个迁移目标均已 ≥3.12。
3. 三分表里的 TimeoutError 只在准备期路径可达: 写入期的超时先被
   record_llm_call 的 except 顺序按行级丢弃,故「最坏成本每 60s 一次、上界一
   个预算」的承诺只在准备期成立。本次只改文档不改行为,连续超预算丢行是否
   升档留作后续议题。
2026-08-24 12:44:41 -04:00
iomgaa 28e0ea2442 fix: count every dropped SQLite telemetry row
SQLite 的逐行写入失败只发 warning、不计数,磁盘满 / database is locked /
文件被外部改坏时行真的丢了,而 dropped_rows 恒 0、degraded 恒 False——下游
按 README 的口径读快照对账完全看不见,与 issue #15 要消灭的静默失败同型。
同批修掉关闭后的丢行文案: 写死的遥测已降级与此时 degraded=False 的快照
互相矛盾,改为按状态分档(降级中 / 已关闭),与 PG 侧 _drop_reason 同口径。
2026-08-24 12:39:02 -04:00
iomgaa 1fb02a24e9 docs: fill in the T8 commit hashes in the plan 2026-08-24 11:58:39 -04:00
iomgaa 6d6b3cf59c docs: correct the stale throughput numbers and wiki state
独立验证发现的 3 处文档欠账:

③ 两处代码内注释还挂着已作废的吞吐估算,`.env.example`/README/
   CHANGELOG/ARCHITECTURE 四处早已改成实测口径:
   - `config.py` 的 `# 4 条 ≈ 32 行/秒(实测…)` —— "32 行/秒"正是设计
     §10 修订 #1 判定"偏乐观一倍"并作废的估算值,却挂着"实测"二字;
   - `postgres.py` 的 `pool_max` docstring 写着 `稳态吞吐 ≈ pool_max /
     RTT`,正是设计要求下游**不要**用的那个公式。
   两处统一为实测值: RTT ≈ 123ms 上 `pool_max=4` 约 15.6 行/秒
   (50 行并发批 3.2s)。设计 §8 与计划 T7 里残留的同一公式一并标注作废。

④ 文档写 `acquire(timeout=剩余预算)`,实现传的是完整预算(行为无害,
   外层 `asyncio.timeout` 才是真正上界)。**改文档不改代码**: 设计
   §3.1、计划 T3、ARCH §7.8 三处对齐,并写明为什么内层不再算剩余量。

⑤ wiki 登记页与正文状态漂移: design 登记页仍写"待人类审"(正文已是
   "已实施")、plan 登记页写"正文 326 行"(实际 380)、log.md 末条停在
   T0 之前。三处校正,T1-T8 补登记,rebuild_index。

另补一条独立验证在真实 PG 上发现的语义细节: 本地池饱和造成的丢行走
**行级丢弃**,`degraded` 保持 False,只有 `dropped_rows` 增长——只按
`degraded` 配告警的下游会完全看不见这类丢行,而它恰是 `pool_max` 配小
了的唯一信号。README / .env.example / ARCHITECTURE / CHANGELOG 各补一句。
2026-08-24 11:55:22 -04:00
iomgaa f90f7b036c test: give the log level and ownership rules real enforcement
两条"确证的假绿"(独立验证发现):

① 设计 §3.2 的"配置级致命发 error 而非 warning"没有执法点:
   `captured_warnings` fixture 挂在 level="WARNING",ERROR 与 WARNING
   同池,且 tracker 自己那条 WARNING 文案就含"重启"——把 recorder 的
   `logger.error` 整块删掉,原用例照样绿。新增 `captured_logs` fixture
   连级别一起捕获,三处补上级别断言。

   顺带消掉实现与设计的偏离: 原实现同时发 1 条 ERROR(recorder)+ 1 条
   语义重复的 WARNING(tracker)。级别决策收敛到 tracker 一处(fatal →
   error,其余 → warning),recorder 侧不再另发,SQLite 侧同时受益。

② 所有权判定的 `is None` / `is not None` 纪律(设计 §3.4)零覆盖:
   所有假件都是 truthy,把工厂改回 `limiter or _build_limiter(...)`
   全套件照样绿。补 `_FalsyClosable`(`__bool__` 返 False)与三个工厂
   各一条用例: 注入 falsy 后端时工厂不得自建、`_owns_*` 为 False、
   `aclose` 不得关它。
2026-08-24 11:45:32 -04:00
iomgaa 9026acd7dc docs: fill in the T7 commit hashes in the plan 2026-08-24 11:12:36 -04:00
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
iomgaa 7834d751d0 feat: export TelemetryStatus from the package root
client.telemetry_status exists so downstream can reconcile telemetry
programmatically, but annotating its return type meant reaching into
polygateway.types while the convention here is that the top-level
exports are the public API surface. The port itself stays unexported:
nobody outside the library implements it.
2026-08-24 11:06:22 -04:00
iomgaa 69a5b5fadb test: pin the cooldown assertion to a fake clock
The status snapshot reports elapsed time, so asserting retry_after_s
against the real monotonic clock was really asserting that a few lines
of code take zero time; it failed at 59.99993 vs 60.0. The recorder
already accepts an injected clock for exactly this reason.
2026-08-24 11:03:33 -04:00
iomgaa bfeda5b5e9 test: prove on real PG that the pool never preconnects
The min_size=10 default survived to 1.2.4 because every PG test injected a
pool and thus skipped the pool-building path entirely. Unit tests now assert
the create_pool arguments, but "we passed min_size=0" and "the server really
opened that many backends" are two different claims, and only a real instance
can settle the second one. Count via a run-unique application_name carried on
the DSN: the instance is shared with other projects, so counting by database
or role would fold their connections into ours and make the case flaky by
construction.

Degradation is exercised through an unreachable DSN rather than by exhausting
the shared instance's connections. A refused connection lands in the same
class as exhaustion, and the fake clock lets the 60s cooldown be observed
without sleeping. retry_after_s is the signal that separates a real retry
(which renews the window) from the cheap short circuit (which does not).

Evidence: with create_pool reverted to its pre-fix form both cases go red
(observed 10 backends after a single write, and refusal surfacing at pool
creation instead of at prepare time).
2026-08-24 10:38:36 -04:00
iomgaa eef2fdc5df fix: judge telemetry failures by nature, not by step
The pool exhaustion in issue #15 was fatal only because min_size=10 forced
a transient error to surface at pool creation, and that step was hardcoded
to permanent death. Step is the wrong axis: it conflates "the DSN cannot
be parsed" with "someone else holds all the connections right now".

Failures are now classified by two rules. Fatal means the cause lies
entirely inside this process and cannot change, which only the
construction-time DSN satisfies. Everything else splits on whether the
failure has anything to do with this row's data: row-level failures drop
one row and keep trying, environment-level failures cool down for 60s and
then get exactly one retry, so a restarted database or a DBA creating the
table heals on its own.

42703 (missing column) is the single named exception and stays row-level
even though every row fails alike: issue #13 promised that the manual mode
trims the INSERT and exposes drift per row, and that promise outranks the
rule. Any future exception owes the same argument.

The _failed boolean is gone; the tracker is the only degradation state,
because two copies of the same fact drift apart. Closing stays outside
that state: it is the caller's own decision, not an anomaly to recover
from, so the snapshot reports it through dropped_rows and the drop reason
instead of raising the degraded flag on every clean shutdown.
2026-08-24 10:18:53 -04:00
iomgaa bc071c6f41 fix: make closing the telemetry pool bounded and final
Closing was the last unbounded wait on the shutdown path: asyncpg's
Pool.close() awaits wait_until_released() on every holder, so a single
in-flight connection parks the caller forever (60s only buys a warning).
It now runs under asyncio.wait_for and terminates the pool on timeout;
external cancellation still propagates untouched.

Closing is also final now. Clearing _pool used to leave the recorder free
to build a fresh pool on the next write - worse in the injected case,
where the owner believes it still holds every connection while the
recorder quietly opened its own. Recovery is a runtime concern (cooldown
retry), not a side effect of shutdown, so writes after aclose short out
and count the dropped row with a reason of their own.

Also covers the release/terminate fallback left untested by the pool
work: the fake pool needed for the close cases makes it nearly free.
2026-08-24 09:53:23 -04:00
iomgaa 84c2cc11a4 feat: make the telemetry pool declare what it costs
The pool was the only external resource in the library that pre-allocated:
asyncpg's default min_size=10 turned pool creation into an all-or-nothing
action, so on a shared instance running low on connection budget the first
thing to fall over was the one component that must not fail silently
(4 clients x 10 = 40 idle connections just to write telemetry).

min_size=0 means "do not pre-connect" - asyncpg only builds holders - so
pool creation becomes free and never touches the database; connection
failures then land on acquire, the path that already drops one row and lets
the pool recover. max_size and the write budget become the library's
explicit statement about its own footprint, configurable through two new
keys whose defaults live in config alone (the recorder parameters are
required keyword-only, same discipline as auto_migrate).

The whole write - prepare, acquire, execute - now runs inside one
asyncio.timeout: acquire used to have no timeout at all, so a full pool
would hang forever on the caller's path. Release is explicit rather than
`async with`, because asyncpg shields release and reuses the acquire
timeout, which would let a single telemetry write consume twice the budget.
2026-08-24 09:32:35 -04:00
iomgaa f958138e83 feat: make telemetry degradation a first-class state
Telemetry degradation used to be a single warning and a private boolean.
In a long-running process that is indistinguishable from telemetry working:
issue #15 was only found by hand-reconciling milestone log lines against
llm_calls rows, after 19 calls had silently gone unrecorded. The SQLite
side was worse — once init failed, every write returned without even a
log line.

Degradation now has one shared owner. TelemetryStatusTracker holds the
state machine (enter/recover/drop/should-retry), announces entry and
recovery once each, and repeats the drop count under a row-and-time
double threshold so a degraded backend neither floods the log nor goes
quiet. Both recorders hold one; both count the rows they drop.

For programmatic consumers, TelemetryStatus is a frozen snapshot exposed
as telemetry_status on all three clients, resolved through a single
isinstance check. It is a separate optional port rather than a member of
TelemetryRecorder: that protocol is @runtime_checkable, so adding an
attribute would make every implementation that only defines
record_llm_call stop satisfying it — downstream isinstance assertions
would break on upgrade. The existing assertion in test_ports.py is what
keeps that decision honest.

Failure criteria are deliberately untouched here: Postgres still treats a
pool failure as permanent, only now visibly. `_failed` and the tracker
therefore both carry the verdict for the span of this one change; the
cooldown rework collapses them into the tracker alone.
2026-08-24 08:57:23 -04:00
iomgaa e69ca4c82c fix: make every client close what it built and nothing else
A client used to close whatever transport, recorder or cache it happened
to hold, injected or not, so the first client to shut down killed the
backend its siblings were still using. That is why the explicit-sharing
path the architecture prescribes was unusable in practice and downstream
projects fell back to one private instance per client. The mirror image
of the same gap: the redis clients the factories build for the limiter
and the breaker were never closed at all, because nobody kept a
reference to them once they were handed to the retry middleware.

Ownership is now stated once, the way RedisLimiter already stated it:
whoever builds a resource closes it, injected ones are left alone. The
constructor is the full-injection path, so it owns nothing by default
and only the factories mark what they built. RedisCache gains the same
rule for its own client, and the three copies of the "probe for aclose,
fall back to close" dance collapse into a single helper so the next
correction cannot land in only one of them.
2026-08-24 08:34:06 -04:00
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
iomgaa 157a27f3bb chore: require python 3.12 and adopt PEP 695 type parameters
The telemetry write budget needs asyncio.timeout, whose uncancel accounting
was only fixed after 3.11.1 — pinning the floor at 3.12 removes that hazard
instead of working around it.

Raising ruff's target-version turns on UP047, so gather_bounded,
_anext_within and stream_with_liveness_timeouts move to def f[T](...) and
the two module-level TypeVars go away. That syntax is a SyntaxError on
3.11, so it can only land together with the version bump.
2026-08-24 08:14:45 -04:00
iomgaa 59a4bc3d14 Merge branch 'chore/e2e-slow-marks' 2026-08-24 00:24:51 -04:00
iomgaa 620b426ede test: keep gateway-dependent e2e out of the commit gate
The pre-commit hook runs the whole suite, and tests/e2e/ talks to a real
LLM gateway, so whether a commit is allowed depended on how fast that
gateway happened to be. During the issue 14 work it blocked two commits
on two different cases; both passed when rerun alone, and the suite went
from 165s to 336s that hour.

The wasted minutes are not the real cost. Retrying on red teaches you to
read "test failed" as "gateway was slow", and a genuinely flaky bug then
gets retried away too. An alarm that cries wolf stops being an alarm.

test_thinking_live.py already carried the slow marker; the other three
files now match it, and the release checklist gains an explicit
`pytest -m slow` step so they still run where a human is watching --
without that step this change would just delete the coverage.

Also raises test_flat_legacy_keys_assemble's LLM_TIMEOUT from 120 to
300, matching .env. At 120 the case allowed half of what production
allows, on a gateway that needs the full 300 -- it measured 116s in a
solo run. The assertion is that the flat key name parses into
SourceConfig.timeout_s; the value itself was never under test.
2026-08-20 05:49:28 -04:00
34 changed files with 3296 additions and 185 deletions
+20
View File
@@ -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 摘要不受影响。
+69
View File
@@ -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` 逐项对齐。
+3 -2
View File
@@ -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. 常用命令
@@ -91,7 +91,7 @@ make ci # 只读验证(check + test)
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
@@ -113,6 +113,7 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
## 5. 项目结构
+7 -4
View File
@@ -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
+3 -3
View File
@@ -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]
+61 -1
View File
@@ -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=<PGW_TELEMETRY_PG_POOL_MAX>, 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` |
@@ -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,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 |
| 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 |
@@ -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。
+31
View File
@@ -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"
}
]
}
+7 -3
View File
@@ -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`
+17
View File
@@ -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 篇页面
@@ -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)。T0T7 全部提交完成,提交表见文末;合并前的三道门(`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=<tag>` 走 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 |
@@ -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")。
+3 -1
View File
@@ -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",
+10 -1
View File
@@ -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:
"""幂等释放自建客户端;注入的客户端归注入方管理。"""
if self._owns_client:
self._owns_client = False
await self._client.aclose()
+99 -23
View File
@@ -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
+74
View File
@@ -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 秒。
这个值同时是 connectacquire 与整次写入的上界: 遥测是业务路径上的内联
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("://")
+35 -14
View File
@@ -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(
+35 -14
View File
@@ -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(
+15
View File
@@ -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。
+7 -9
View File
@@ -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否则总时长
+305 -38
View File
@@ -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:
,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
awaitkeyword-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:
conn = await pool.acquire(timeout=self._write_timeout_s)
try:
async with pool.acquire() as conn:
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)
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="")
+30 -1
View File
@@ -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 # 内部不变量: 调用方已判空
+185
View File
@@ -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 后自动重试"
+25
View File
@@ -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。"""
+13 -3
View File
@@ -18,9 +18,16 @@ _REPO = Path(__file__).resolve().parents[2]
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
pytestmark = pytest.mark.skipif(
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而
# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了
# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
)
),
]
@pytest.fixture
@@ -69,7 +76,10 @@ class TestVideoTreeOnboarding:
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
flat_env = {
**source_keys,
"LLM_TIMEOUT": "120",
# 与 .env 的 LLM__MINIMAX__1__TIMEOUT_S 同值。取 120(VT 旧值)会让本用例的
# 超时比生产配置还紧一半,在慢网关上必然间歇红——而本用例断言的是平铺
# 键名能否解析成 SourceConfig.timeout_s,超时取值本身不是被测对象
"LLM_TIMEOUT": "300",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
+9 -3
View File
@@ -21,9 +21,15 @@ from polygateway.types import SourceConfig
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
pytestmark = pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺真实网关配置(.env)"
)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV,
reason="缺真实网关配置(.env)",
),
]
_OUT = Path("tests/outputs/embedding")
+9 -3
View File
@@ -19,9 +19,15 @@ from polygateway import GatewayClient
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
pytestmark = pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)"
)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE,
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
),
]
_OUT_DIR = Path("tests/outputs/e2e")
+177 -24
View File
@@ -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]
+442 -14
View File
@@ -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)
+67
View File
@@ -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 专用键。"""
+12
View File
@@ -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__
+24 -1
View File
@@ -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",
+793 -20
View File
@@ -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:
# 占位串 `<ocr:text image_bytes=3>` 共 24 字
assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)"
assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限
# —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 ——
class _FakeClock:
"""可手动推进的单调时钟: 冷却与节流都靠它测,用例里绝不真睡。"""
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
@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