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.
This commit is contained in:
2026-08-24 11:09:22 -04:00
parent 7834d751d0
commit 4e1f09d231
6 changed files with 200 additions and 17 deletions
@@ -3,6 +3,7 @@
- **设计**: `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、整分支审查)见「完成判据」)
## 目标
@@ -131,9 +132,9 @@ def _classify_failure(exc: BaseException) -> str: ...
### T0 — 分支与基线(把已完成的 3.12 迁移落盘)
- [ ]`main` 建分支 `feat/issue-15-telemetry-pool-lifecycle`
- [ ] 把工作区现有改动分两次提交: ① `chore: 最低 Python 提到 3.12 并改用 PEP 695 泛型语法`(`pyproject.toml`/`README.md`/`CLAUDE.md`/`client.py`/`streaming.py`);② `docs: issue #15 设计文档与 wiki 登记`(`research-wiki/`)
- [ ] 记录基线用例计数(执行时实测;2026-08-24 本机为 **973 passed / 23 skipped / 45 deselected**,覆盖率 94%)。该数只作**同环境**参照,不作硬验收——`addopts = "-m 'not slow'"` 与 Redis/PG 可达性都会改变它
- [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` → 分支名正确。
@@ -151,6 +152,8 @@ def _classify_failure(exc: BaseException) -> str: ...
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`,探测后跳过。
@@ -164,7 +167,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py -q` → PASS;`make check` 绿;全套件绿。
- [ ] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏`
- [x] 提交: `fix: 统一资源所有权纪律(谁建的谁关),修 aclose 越权与 redis 客户端泄漏`
---
@@ -195,7 +198,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `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);全套件绿。
- [ ] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)`
- [x] 提交: `feat: 遥测降级升格为一等状态(共用 tracker + 只读快照 + 节流日志)`
---
@@ -228,7 +231,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `pytest tests/unit/test_config.py tests/unit/test_telemetry.py -q` → PASS;`make check` 绿;全套件绿。
- [ ] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算`
- [x] 提交: `feat: 遥测池显式声明资源占用(min_size=0/max_size 可配)并给写入硬预算`
---
@@ -249,7 +252,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;全套件绿。
- [ ] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活`
- [x] 提交: `fix: 遥测池关闭有界化(wait_for + terminate),关闭后不再复活`
---
@@ -280,7 +283,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `pytest tests/unit/test_telemetry.py -q` → PASS;`radon cc src/polygateway/telemetry/postgres.py -n C -s` → 无输出;全套件绿。
- [ ] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈`
- [x] 提交: `fix: 遥测失败按性质三分,永久判死收窄到 DSN 不可解析,其余带冷却自愈`
---
@@ -305,7 +308,7 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `pytest tests/integration/test_postgres_telemetry.py -q` → PASS(或无 DSN 时全 skip);全套件绿。
- [ ] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈`
- [x] 提交: `test: 真实 PG 验证遥测池不预连接与降级自愈`
---
@@ -321,13 +324,13 @@ def _classify_failure(exc: BaseException) -> str: ...
**验证**: `make check` 绿;人工通读 `.env.example` 两键注释,确认调参口径可执行。
- [ ] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文`
- [x] 提交: `docs: 遥测池资源语义、失败判据与所有权纪律成文`
---
## 完成判据(合并前)
- [ ] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件)
- [x] T0-T7 全部提交完成,每次提交都过了提交门(ruff + radon + 全套件)
- [ ] `pytest -m slow` 单独跑过一次(发布清单第 4 步;本次改动触及遥测写入路径,e2e 与 Redis 时间语义变体必须实测)
- [ ] 派**全新上下文**的 verifier subagent 独立验证(`verification-before-completion`,里程碑级/合并前 MANDATORY)
- [ ] 整分支审查(`requesting-code-review`,合并前 MANDATORY)
@@ -346,3 +349,28 @@ def _classify_failure(exc: BaseException) -> str: ...
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 ② | 本文件所在的这次提交 | `docs: record the telemetry pool semantics and ownership rule` |
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 |