Compare commits
60 Commits
v1.2.1
..
f5cf69a1ac
| Author | SHA1 | Date | |
|---|---|---|---|
| f5cf69a1ac | |||
| ef13ca7ea9 | |||
| 8e66a362f7 | |||
| 15f0c16782 | |||
| 28e0ea2442 | |||
| 1fb02a24e9 | |||
| 6d6b3cf59c | |||
| f90f7b036c | |||
| 9026acd7dc | |||
| 4e1f09d231 | |||
| 7834d751d0 | |||
| 69a5b5fadb | |||
| bfeda5b5e9 | |||
| eef2fdc5df | |||
| bc071c6f41 | |||
| 84c2cc11a4 | |||
| f958138e83 | |||
| e69ca4c82c | |||
| e7caa500e2 | |||
| 157a27f3bb | |||
| 59a4bc3d14 | |||
| 620b426ede | |||
| f31f7caf99 | |||
| 41bca375d2 | |||
| 84ee6dee84 | |||
| c5b2b3fade | |||
| 5a025b6e5d | |||
| d9ceaecf20 | |||
| 2a9bc44abf | |||
| 6edf4ac9de | |||
| eb956b2cdf | |||
| 8edd3fb2cd | |||
| 942af99856 | |||
| 0b3e84b3be | |||
| 296c765337 | |||
| 429d767737 | |||
| 91354e4e10 | |||
| 4b06093d6c | |||
| ea9b6fbcd9 | |||
| daf7ab3268 | |||
| 511aa4899c | |||
| c26b34e854 | |||
| 33ed7ecdfc | |||
| e0a33ecf93 | |||
| 0721cf60aa | |||
| ba4a138692 | |||
| 483683b834 | |||
| 7b49e580c0 | |||
| e17e1067a1 | |||
| 21a19ab374 | |||
| e949edb62a | |||
| ecc22b34fc | |||
| d4b40b0e64 | |||
| 1471e0a2c6 | |||
| e9adb36577 | |||
| 172f3180e5 | |||
| 8f792bc697 | |||
| 5b2e3ba82d | |||
| 39fcf2631d | |||
| 72b6b54719 |
+42
-1
@@ -48,7 +48,12 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
|
||||
# LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown))
|
||||
# ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit,
|
||||
# ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ──
|
||||
# LLM__QUOTA_FULL=wait # wait(默认) | fail_fast
|
||||
# LLM__QUOTA_FULL=wait # 配额满: wait(默认) | fail_fast
|
||||
# ── 熔断全拒时的处置(issue #14)。单源 scope 建议 wait: 只有一个源时
|
||||
# ── "停用这个源"等于"整个 scope 停服",fail_fast 会让开路期间的每次调用
|
||||
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
|
||||
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
|
||||
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
|
||||
|
||||
# ══ 装配选择(PGW_*)══
|
||||
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
|
||||
@@ -56,7 +61,43 @@ PGW_BREAKER_BACKEND=memory # memory | redis
|
||||
PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式)
|
||||
PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
||||
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||
# PGW_TELEMETRY_SCHEMA_MODE=manual # auto | manual;三态: 不设 = 按后端派生(sqlite→auto、postgres→manual),
|
||||
# # 显式设置则两侧都可覆盖。auto = 库给已存在的旧表自动 ALTER 补列;
|
||||
# # manual = 库不发 ALTER,只 warning 点名缺列并打印可执行 SQL,
|
||||
# # 按现有列裁剪 INSERT 继续写(遥测不会因缺列而全线丢失)。
|
||||
# # 缺省为何不对称: postgres 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁,
|
||||
# # 会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 await;
|
||||
# # 且这类部署有 DBA、有迁移工具、讲最小权限,DDL 该由他们择时执行。
|
||||
# # 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 摘要不受影响。
|
||||
# # 缺省为何是"不截断": 遥测被下游当**审计证据**用——出了问题要回答"当时到底发了什么",
|
||||
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
|
||||
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
|
||||
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
|
||||
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
|
||||
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
|
||||
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
|
||||
|
||||
+173
@@ -1,5 +1,178 @@
|
||||
# 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` 逐项对齐。
|
||||
|
||||
**缺省是 `fail_fast`,即今天的行为**,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,`MAX_ATTEMPTS=8` 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 `wait` 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长——上限是 `STALL_WINDOW_S`(缺省 300 秒)。**但 `wait` 并不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`,所以密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而不是等满窗口后的 `stalled`;两者哪个先到取决于 `MAX_ATTEMPTS` 与冷却时长、`STALL_WINDOW_S` 的相对大小。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不当场死"。
|
||||
|
||||
### 请先读这一条: `retry_after_s` 在半开状态下的取值变了(缺省档同样生效)
|
||||
|
||||
`retry_after_s` 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:**距离确定可再试的时刻还有多久**。健康与准入允许 → `0.0`;开路 → 剩余冷却;**半开(探针在途)→ `0.0`**,因为探针随时可能出结果,不存在确定的时刻——而 `0 = 可立即重试` 本就是这个字段的既有约定。
|
||||
|
||||
变更点在半开:此前返回的是**探针租约剩余**。那是个死锁保护参数,派生自 `max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5)`,与"这个源多久能恢复"没有任何因果关系。`TIMEOUT_S=300` 的部署里它是 600 秒,而冷却期只有 60 秒。**照它延期重投的下游,等的是一个物理上无意义的数。**
|
||||
|
||||
更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 `set_until` 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——**本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。
|
||||
|
||||
同批统一了两个后端在**六个出口**上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
|
||||
|
||||
### 其他
|
||||
|
||||
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
|
||||
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
|
||||
|
||||
|
||||
## 1.2.3(2026-08-19)
|
||||
|
||||
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
|
||||
|
||||
同一版里,issue #12 补上这条边界的另一半——**删数据**,并把它落成三样**手段**: 遥测正文的可配置上限、`tools/` 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样**没有一样改变缺省行为**——不设 `PGW_TELEMETRY_TEXT_CAP` 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 `DELETE`/`DROP` 权限: 保留期是 `tools/` 下的独立脚本,库不 import 它。
|
||||
|
||||
### 请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的
|
||||
|
||||
1.2.1 的 README 给的 RLS 模板把**写侧**也绑在了 `app.tenant_id` 这个 GUC 上:
|
||||
|
||||
```sql
|
||||
-- 1.2.1 的模板,有缺陷,勿用
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
但 `PostgresRecorder` 用**一个连接池给所有租户**写遥测,源码里从不发 `set_config('app.tenant_id', ...)`——库既拿不到也不该猜租户上下文该怎么设。于是 `WITH CHECK` 里的 `current_setting` 恒为 NULL、等值比较恒不为真,**库的每一条 `INSERT` 都被 policy 拒绝**。而遥测的失败方向是静默降级,所以表现不是报错,是**整张表零行**——业务调用一切正常,不看日志根本发现不了。
|
||||
|
||||
照抄过就请现在查这两条:
|
||||
|
||||
| 查什么 | 中招的样子 |
|
||||
|---|---|
|
||||
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
|
||||
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
|
||||
|
||||
本版的新模板把写侧改为 `WITH CHECK (true)`,隔离交由**读侧**的 `USING` 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。
|
||||
|
||||
### 破坏性变更(五项)
|
||||
|
||||
| # | 变更 | 影响与应对 |
|
||||
|---|---|---|
|
||||
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
|
||||
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
|
||||
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
|
||||
| ④ | `GatewaySettings` 再新增**必填**字段 `telemetry_text_cap: int \| None` | 同 ③,只影响直接构造这条路。`None`(不截断)是**取值**而不是默认值——字段本身没有默认值;`<= 0` 在 `__post_init__` 直接 `ValueError`,不会被当成"不截断" |
|
||||
| ⑤ | `TelemetryEmitter` 新增 **keyword-only 必填**参数 `text_cap` | 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 `TypeError`。同样**故意不给默认值**: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 `text_cap` 全汇流到这里,而 `GatewaySettings` 那道只管 env 一条路 |
|
||||
|
||||
### 新增
|
||||
|
||||
- **`PGW_TELEMETRY_SCHEMA_MODE`(可选键,值域 `auto` / `manual`)**,**三态**:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则**有意不对称**——`postgres` → `manual`,`sqlite` → `auto`。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 `runs/*.db`),没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
|
||||
- **公共函数 `telemetry_schema_sql(backend) -> str`**(已进顶层 `__all__`):返回可直接粘进迁移文件的完整脚本——注释头 + `CREATE TABLE IF NOT EXISTS`(全量列)+ 各补列语句。PG 变体带 `ADD COLUMN IF NOT EXISTS`,整段**可重复执行**;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 `backend` 抛 `ValueError`。
|
||||
- **manual 档的缺列告警**逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且**只在准备期发一次**,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。
|
||||
|
||||
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
|
||||
|
||||
| 手段 | 内容 |
|
||||
|---|---|
|
||||
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments`、`name` 等 `content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
|
||||
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
|
||||
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
|
||||
|
||||
### 变更
|
||||
|
||||
- **Postgres 的写入去掉了冲突目标**:`ON CONFLICT (call_id) DO NOTHING` → `ON CONFLICT DO NOTHING`。普通表上语义**逐字等价**(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 `(call_id)` 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区后主键变成 `(call_id, created_at)`,该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 `INSERT OR IGNORE` 本就无目标,未动。
|
||||
- **manual 档按现有列裁剪 `INSERT`**。这不是可选增强而是关掉 `ALTER` 的前提:旧表缺列时若仍发全量 `INSERT`,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
|
||||
- **schema 常量收敛为单一事实源** `telemetry/schema.py`(内部模块):列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是**正确性**而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
### 不变
|
||||
|
||||
- **manual 档仍然建表**。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 `to_regclass` 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
|
||||
- **auto 档行为与从前逐字相同**,包括补列失败时**不裁剪**:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||
- 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
|
||||
- **遥测缺省不截断**: 不设 `PGW_TELEMETRY_TEXT_CAP` 时落库正文与今天逐字节相同。`digest_messages`(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断**只产出新对象、绝不就地修改**——`digest_messages` 对非 list 的 `content` 是原样透传**同一个 dict 对象**,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 `build_cache_key` 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
|
||||
- embedding 与 OCR 两条链路各自既有的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系;多模态 `image_url` 早已是 sha256 摘要,不受 cap 影响。
|
||||
|
||||
### 库对下游数据库的承诺(Expand/Contract,本版成文)
|
||||
|
||||
以下五条此前已被实现满足,但从未写成承诺。本版起它们是**承诺**:新列**只增不删不改名**且一律追加在既有列之后;新列必**可空**或带**非易失常量默认值**(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);`INSERT` **永远显式写出列名**;库**从不 `SELECT *`**、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的**冲突处理不绑定具体约束**。
|
||||
|
||||
合起来它们保证:你可以自行给 `llm_calls` 加列、加索引、挂 RLS,乃至把它建成 `PARTITION BY RANGE (created_at)` 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,`research-wiki/` 不在 sdist 内。
|
||||
|
||||
同一条边界的另一半是**删数据**: 库不持有 `DELETE`/`DROP` 权限,保留期与访问控制以 README 模板加 `tools/` 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`(那是 DDL,同样不触发不可变性触发器)。分区在这里**不可替代**,不是性能偏好。
|
||||
|
||||
### 升级提示
|
||||
|
||||
- 用 `from_env()` / `from_settings()` 装配的下游**无需改代码**;Postgres 下游升级后建议执行一次 `python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))"` 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
|
||||
- 直接构造 `SQLiteRecorder` / `PostgresRecorder` 或直接构造 `GatewaySettings` 的调用点必须补上新参数/新字段,否则 `TypeError`。
|
||||
- **截断不需要任何升级动作**: 不设 `PGW_TELEMETRY_TEXT_CAP` 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
|
||||
- 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
|
||||
- README 的安装 pin 由 `>=1.2.1,<2` 收紧为 `>=1.2.3,<2`。按旧 pin 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关**,而包内那份 README 描述的正是它们。
|
||||
|
||||
## 1.2.1(2026-08-18)
|
||||
|
||||
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
|
||||
|
||||
@@ -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. 项目结构
|
||||
|
||||
|
||||
@@ -13,18 +13,20 @@
|
||||
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
|
||||
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
|
||||
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
|
||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 |
|
||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||
| 响应缓存 | 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 一律不碰(由注入方自己关)。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
```bash
|
||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||
"polygateway[redis,postgres,structured]>=1.2.1,<2"
|
||||
"polygateway[redis,postgres,structured]>=1.3.0,<2"
|
||||
```
|
||||
|
||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||
@@ -44,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。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -125,32 +127,241 @@ resp = await client.chat(
|
||||
|
||||
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
||||
|
||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||
|
||||
**库只提供列,不启用 RLS、不建索引。** 要数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。
|
||||
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
|
||||
|
||||
## 遥测表 schema 与升级纪律
|
||||
|
||||
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
|
||||
|
||||
| 库会发 | 库不发 |
|
||||
|---|---|
|
||||
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
|
||||
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
|
||||
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
|
||||
|
||||
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres` → **manual** |
|
||||
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
|
||||
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
|
||||
|
||||
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
|
||||
|
||||
| 表状态 | `auto` | `manual` |
|
||||
|---|---|---|
|
||||
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
|
||||
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
|
||||
|
||||
无论哪档,遥测的失败方向都是**静默降级**:缺列、补列失败、写入失败都只 warning,绝不冒泡打断业务调用。
|
||||
|
||||
### 自取建表脚本
|
||||
|
||||
`telemetry_schema_sql` 输出与库运行时执行的 DDL **同源**(同一份常量),照它建完表,库探测到的列就是齐的:
|
||||
|
||||
```python
|
||||
import polygateway
|
||||
|
||||
print(polygateway.telemetry_schema_sql("postgres")) # 或 "sqlite";非法值抛 ValueError
|
||||
```
|
||||
|
||||
```bash
|
||||
# 直接落成迁移文件:注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
> migrations/001_llm_calls.sql
|
||||
```
|
||||
|
||||
PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**(它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故请挑低峰);SQLite 没有该语法,脚本以注释标明"仅当该列不存在时执行"。注意这与库**内部**执行的 ALTER 是两份文本:库侧一律先探测后 ALTER,不用 `IF NOT EXISTS`,正是为了在稳态下一条排他锁都不取。
|
||||
|
||||
### Expand/Contract 承诺
|
||||
|
||||
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
|
||||
|
||||
| 承诺 | 你可以据此做什么 |
|
||||
|---|---|
|
||||
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
|
||||
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
|
||||
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
|
||||
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
|
||||
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
|
||||
|
||||
## 生产部署 DDL 模板(PostgreSQL)
|
||||
|
||||
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
|
||||
|
||||
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
|
||||
|
||||
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
|
||||
|
||||
| # | 锚点 | 做什么 |
|
||||
|---|---|---|
|
||||
| 1 | `roles` | 建三角色并授 schema 级权限 |
|
||||
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
|
||||
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
|
||||
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
|
||||
| 5 | `immutable` | 触发器兜底(只防误操作) |
|
||||
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
|
||||
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
|
||||
|
||||
### 1. 三角色
|
||||
|
||||
| 角色 | 拿到什么 | 谁在用 |
|
||||
|---|---|---|
|
||||
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
|
||||
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
|
||||
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
|
||||
|
||||
<!-- pg-template:roles -->
|
||||
|
||||
```sql
|
||||
CREATE ROLE polygateway_owner NOLOGIN;
|
||||
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
|
||||
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
|
||||
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
|
||||
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
|
||||
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
|
||||
```
|
||||
|
||||
### 2. 分区表
|
||||
|
||||
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
|
||||
|
||||
```bash
|
||||
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
|
||||
| psql "$PGW_TELEMETRY_PG_DSN"
|
||||
```
|
||||
|
||||
<!-- pg-template:table -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
|
||||
CREATE TABLE llm_calls (
|
||||
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
|
||||
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
|
||||
) PARTITION BY RANGE (created_at);
|
||||
DROP TABLE llm_calls_seed;
|
||||
ALTER TABLE llm_calls OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
<!-- pg-template:partition -->
|
||||
|
||||
```sql
|
||||
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
|
||||
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
|
||||
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
|
||||
```
|
||||
|
||||
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'`、`retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
|
||||
|
||||
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at` 由 `DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
|
||||
|
||||
### 3. 权限与不可变性
|
||||
|
||||
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
|
||||
|
||||
<!-- pg-template:grants -->
|
||||
|
||||
```sql
|
||||
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
|
||||
GRANT SELECT ON llm_calls TO polygateway_report;
|
||||
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
|
||||
```
|
||||
|
||||
<!-- pg-template:immutable -->
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
|
||||
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
|
||||
```
|
||||
|
||||
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
|
||||
|
||||
`DROP PARTITION` 与 `DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
|
||||
|
||||
### 4. 行级安全与多租户隔离
|
||||
|
||||
> **照抄过 1.2.1 那份 RLS 模板的部署请先查一遍**:那份模板把**写侧**也绑在 `app.tenant_id` 这个 GUC 上,而库从不设这个 GUC,于是它的每一条 `INSERT` 都被 policy 拒绝——遥测的失败方向是静默降级,表现不是报错而是**整张表零行**。用能绕过 RLS 的角色(superuser 或带 `BYPASSRLS`)执行 `SELECT count(*) FROM llm_calls;`,并在应用日志里搜 `Postgres 遥测写入失败(丢弃该行):`。下面这份是修正后的模板。
|
||||
|
||||
<!-- pg-template:rls -->
|
||||
|
||||
```sql
|
||||
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
|
||||
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
|
||||
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
|
||||
WITH CHECK (true);
|
||||
CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
|
||||
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
|
||||
```
|
||||
|
||||
<!-- pg-template:index -->
|
||||
|
||||
```sql
|
||||
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
ON llm_calls (tenant_id, created_at);
|
||||
CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
|
||||
```
|
||||
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。
|
||||
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`。
|
||||
|
||||
三个陷阱,每一个的失败形态都是**静默的**:
|
||||
**写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
|
||||
|
||||
四个陷阱,每一个的失败形态都是**静默的**:
|
||||
|
||||
| 陷阱 | 后果 |
|
||||
|---|---|
|
||||
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
|
||||
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
|
||||
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
|
||||
| policy 必须同时写 `USING` 与 `WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 |
|
||||
| 读侧 policy 漏写 `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
|
||||
|
||||
### 5. 库本身需要的最小权限
|
||||
|
||||
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
|
||||
|
||||
| 库会发的语句 | 需要什么 |
|
||||
|---|---|
|
||||
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
|
||||
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
|
||||
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
|
||||
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
|
||||
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
|
||||
|
||||
### 6. 合规下游的推荐配置
|
||||
|
||||
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
|
||||
|
||||
```dotenv
|
||||
PGW_TELEMETRY_BACKEND=postgres
|
||||
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
|
||||
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
|
||||
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
|
||||
```
|
||||
|
||||
| 层 | 配置 |
|
||||
|---|---|
|
||||
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
|
||||
| 保留期 | 上面的分区模板 + `pg_partman` 的 `retention`,过期分区整块 `DROP` |
|
||||
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
|
||||
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
|
||||
|
||||
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response` 与 `thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments`、`name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
|
||||
|
||||
### 7. SQLite 侧的保留期
|
||||
|
||||
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db`、`runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
|
||||
|
||||
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
|
||||
|
||||
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
|
||||
|
||||
## 错误模型(四分类)
|
||||
|
||||
@@ -190,13 +401,25 @@ CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|
||||
|---|---|
|
||||
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
|
||||
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
|
||||
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
|
||||
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
|
||||
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
|
||||
| `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`**
|
||||
|
||||
熔断的设计前提是"这个源坏了,把流量导到别的源"。**只配了一个源时这个前提不成立**,同一段代码做的事变成"这个源坏了,所以整个 scope 停止服务":开路期间每一次调用都在几毫秒内失败,`MAX_ATTEMPTS` 一格用不上,一个网络包都没发出去。中转抖动几十秒就足以打断一条跑了几小时的长任务。
|
||||
|
||||
`wait` 档改变的**只是**"调用方当场失败还是排队等":等待期间照样一个请求都不发,熔断对配额和钱包的保护完整保留。代价是单次调用的最坏墙钟被拉长,上限为 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300 秒)。**`wait` 不豁免重试预算**——冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`;因此密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而非等满窗口的 `stalled`。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不要当场死"。多源部署保持 `fail_fast`:有源可换时,换源比等待快。
|
||||
|
||||
该键与 `{SCOPE}__QUOTA_FULL` 同形但**不可互相替代**:配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),所以两者分开配置。
|
||||
|
||||
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
|
||||
|
||||
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
|
||||
@@ -224,7 +447,7 @@ graph LR
|
||||
| `telemetry/` | SQLite / Postgres 遥测后端 |
|
||||
| `structured/` | 结构化输出策略 |
|
||||
|
||||
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D14 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D15 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
|
||||
|
||||
## 可靠性证据
|
||||
|
||||
@@ -241,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
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "polygateway"
|
||||
version = "1.2.1"
|
||||
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]
|
||||
|
||||
@@ -119,7 +119,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构决策记录(D1–D14,含讨论过程与备选方案)
|
||||
## 3. 架构决策记录(D1–D15,含讨论过程与备选方案)
|
||||
|
||||
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
|
||||
|
||||
@@ -248,6 +248,22 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
||||
|
||||
**影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。
|
||||
|
||||
### D15 库对下游数据库只做 SELECT/INSERT + 可选 CREATE;改结构与删数据归下游(2026-08-19,issue #13 立,issue #12 补删数据一面)
|
||||
|
||||
**决策**: 遥测表 `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类——catalog 探测(PG `to_regclass` + `pg_attribute`,SQLite `PRAGMA table_info`)、显式列名的 `INSERT`、以及表不存在时的 `CREATE TABLE IF NOT EXISTS`;**改结构(`ALTER`)与删数据(`UPDATE`/`DELETE`/`TRUNCATE`/`DROP`)一律归下游**。`ALTER` 保留唯一一个受控出口:`PGW_TELEMETRY_SCHEMA_MODE=auto` 时给已存在的旧表补列,而该档在 PG 侧**不是缺省**(缺省按后端派生: sqlite→auto、postgres→manual)。配套五条 Expand/Contract 承诺:新列只增不删不改名且追加在既有列之后、新列必可空或带非易失常量默认值、`INSERT` 永远显式列名、库从不 `SELECT *` 也从不读回该表数据、写入的冲突处理不绑定具体约束。
|
||||
|
||||
**背景与讨论**: 补列此前没有任何开关,库一升级、下次调用即在下游生产库上发 DDL。issue #13 的三条指控成立: ① 与最小权限原则冲突;② 多进程/多版本共存时谁先补列是竞态;③ DDL 不进任何迁移记录,DBA 事后无从审计。量级判据是 `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 `await`。调研的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。
|
||||
|
||||
两条边界是讨论出来的、不是照抄先例: **① 缺省按后端不对称**(D-a,人类拍板)——issue 引用的全部先例语境都是共享的生产 PG,而本库的 SQLite 侧是下游自己的本地文件(没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作),两侧统一 manual 会给零运维场景强加运维步骤;两侧有意不对称在本库已有先例(§7.8 的建表探测,issue #9)。**② manual 档不连 `CREATE TABLE` 一起停**——新建表没有既有数据与并发访问者,不存在锁队列与数据风险,停掉它会让"零配置起步"断掉(Celery 的先例同样是"自动建表 + 永不 ALTER")。**③ 关掉 `ALTER` 必须配套按现有列裁剪 `INSERT`**,否则旧表缺列时每行写入都被拒,是把自动补列换成静默全失能,比原问题更严重地违反「遥测必录」。
|
||||
|
||||
五条承诺本身是既有实现的**成文化**(零代码变更),但成文后才可被下游依赖——它同时是遥测保留期方案(issue #12)能成立的前提: 下游拿这份 schema 自己加 `PARTITION BY RANGE (created_at)` 建成分区表后,库的 `to_regclass` 探测、列探测与 `INSERT` 路由都照常工作。第五条(冲突处理不绑定约束)是审查带出的**新增**承诺,并伴随一处真实修复,见 §7.8。
|
||||
|
||||
**删数据这一半(2026-08-19,issue #12)**: D15 里 `DELETE`/`TRUNCATE`/`DROP` 归下游,不只是"库不去做",是库连**手段**都不该持有——保留期与访问控制因此以 README 的 DDL 模板加 `tools/telemetry_retention.py` 独立脚本交付,库本体不 import 该脚本,连接串上也不需要任何删权限。这是 (b) 保留期与 (c) 不可变性两条诉求的**权限张力**逼出来的唯一解: 模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`——分区在这里**不可替代**,不是性能偏好(`DROP PARTITION` 是 DDL,同样不触发行级的不可变性触发器,且 O(1)、不留膨胀)。脚本只是存量普通表的兜底: 默认 dry-run,探测到分区表即以退出码 3 让路。库本体在 #12 里唯一的代码面是**预防性**的正文截断(§7.8)——没写进去的数据不需要删,这也是三个子问题里唯一能靠库解决的那个。
|
||||
|
||||
**被否决的备选**: 两侧统一缺省 manual(语义最一致,但现有 SQLite 下游升级即需人工干预,而这些场景根本没有承接手工 SQL 的角色);保持 auto 缺省只加关闭档(默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足);Celery 式"自动建表但永不 ALTER、无开关"(SQLite 场景纯净损失,且真想要自动补列的下游没有出路);APScheduler 4.x 式"schema 不认识就拒绝启动"(与「遥测初始化失败必须静默降级」的库铁律正面冲突,不可选)。
|
||||
|
||||
**影响**: §7.8 补列一节按档位重写;新增配置键 `PGW_TELEMETRY_SCHEMA_MODE`(§9)与公共函数 `telemetry_schema_sql`;两个 recorder 新增 keyword-only 必填参数 `auto_migrate`、`GatewaySettings` 新增必填字段 `telemetry_auto_migrate`(缺省规则只写在 config 一处,不与类签名漂移);五条承诺进 README(随包分发)。issue #12 实现同一条边界的"删数据"一面: 新增可选键 `PGW_TELEMETRY_TEXT_CAP` 与遥测正文截断(§7.8、§9),保留期与访问控制走文档模板 + `tools/` 脚本,库的权限面不扩大。
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体架构
|
||||
@@ -309,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. 核心类型
|
||||
@@ -456,6 +498,12 @@ flowchart TB
|
||||
|
||||
**M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。
|
||||
|
||||
**熔断拒绝补齐等待档(2026-08-19,issue #14,设计 `designs/2026-08-19-issue14-admission-wait-policy-design.md`;人类确认缺省与实施边界)**: 准入侧此前有一格是空的——限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 wait),熔断门拒时**只有 fail-fast 一档且不可配**。两者在准入语义上同构(都不发请求、都带 `retry_after` 提示),处置却分叉。补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 **fail_fast**,不跟随 quota_full——把最坏墙钟从毫秒抬到 stall 窗口是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游)。`wait` 档下熔断的保护作用完整保留(等待期一个请求都不发),改变的只是调用方当场死还是排队等。**这一格的缺失与源数量无关**: 多源全部同时开路(共同上游挂掉、全网抖动)行为一模一样,单源只是把"全部开路"的概率从罕见变成必然;故实现上**严禁按池大小分叉**(`if len(sources) == 1` 会让行为随配置突变且无法组合测试)。等待时长按 `retry_after_s` 睡到冷却截止(而非 `poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次往返 × 每个在途调用),抖动**上**加不缩放(对确定的截止时刻提前醒必然白醒),并夹到剩余 stall 预算,故单次调用最坏墙钟 = `stall_window_s` + 一个 poll 间隔,不随 `max_cooldown_s` 漂移。控制流必须**按拒绝原因分派**而非串行: 串行写法下 `circuit_open=wait` 不抛之后会掉进配额分支,`quota_full=fail_fast` 的调用方会收到 `reason=quota_exhausted` 而配额其实是满的。
|
||||
|
||||
**`retry_after_s` 的契约定死(同批,issue #14)**: 语义 = "距离**确定**可再试的时刻还有多久"。CLOSED/准入允许 → `0.0`(现在就能试);OPEN → 剩余冷却(确定时刻);**HALF_OPEN → `0.0`**——探针随时可能出结果,不存在确定时刻,而 `0 = 可立即重试` 本就是库既有约定。此前 HALF_OPEN 返回**探针租约剩余**,那是死锁保护参数(派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`),与"源多久能恢复"无因果关系: 现场 `TIMEOUT_S=300` 时它是 600s 而冷却只有 60s。**更重的后果不在对外报数而在库内**: 该值被喂进源冷却备忘(`SourceCooldownMemo.set_until` 取更晚者、不可回退),于是探针成功、门已恢复 CLOSED 之后,本进程仍跳过该源整整一个租约——单源下每次调用照旧判死,多源下则是"池子里少一个源"且被其他源接住流量所掩盖(issue 提交方未发现这一条)。修正后备忘写入的是已过期时刻,自动回归"只记 OPEN 的确定冷却期"。契约在**六个出口**上统一(memory 三处 + redis 六个 Lua 返回格),其中后四处是**既有的双后端分叉**(redis 在授予探针时返回 probe TTL、在 fencing 未命中时返回租约剩余,而 memory 一直是 0),由契约测试盲区掩护至今——旧用例只钉"第二个进入者被拒",从没钉它拿到什么数。
|
||||
|
||||
**准入逻辑三处收敛(同批)**: `_pick_runnable`/`_on_no_runnable` 此前在 `middleware/retry.py`、`embedding.py`、`ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义一直在演进(issue #8 的 stall 口径、M2.5 的 pacer、本次的等待档),每次都要三处同步。收敛为 `middleware/admission.py::SourceAdmission`,差异用注入表达而非分支: 调用内降权传空 `attempt_fails` 时恒等、AIMD pacer 为 `None` 时跳过。`QuotaGate`/`BreakerGate`/`AdaptivePacer` 由三条循环持有并与 admission **共享同一实例**(三处 `_attempt` 仍要用它们做记账写回与 `pacer.leave()`;pacer 有在途计数,分裂成两个计数器会让 admit/enter 与 leave 记到不同账上),`SourceCooldownMemo` 归 admission 独占。
|
||||
|
||||
### 7.5 响应缓存
|
||||
|
||||
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`。
|
||||
@@ -495,10 +543,47 @@ flowchart TB
|
||||
|
||||
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
|
||||
|
||||
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
|
||||
|
||||
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"` 的 `text`、`response`、`thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
|
||||
|
||||
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。
|
||||
- **单一 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)
|
||||
|
||||
| 级 | 内容 | 成本 |
|
||||
@@ -558,6 +643,10 @@ src/polygateway/
|
||||
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
|
||||
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
|
||||
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis`、`PGW_TELEMETRY_BACKEND=sqlite|postgres`、`PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
|
||||
- **`{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` 重一倍。
|
||||
|
||||
---
|
||||
|
||||
@@ -630,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,145 @@
|
||||
# issue #12 设计: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例)
|
||||
> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
|
||||
|
||||
1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
|
||||
2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
|
||||
|
||||
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) |
|
||||
| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 |
|
||||
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
|
||||
|
||||
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
|
||||
## 3. 三个子问题的边界
|
||||
|
||||
| 子问题 | 库能做什么 | 性质 |
|
||||
|---|---|---|
|
||||
| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 |
|
||||
| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP |
|
||||
| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 |
|
||||
|
||||
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
|
||||
|
||||
## 4. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
|
||||
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
|
||||
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
|
||||
| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
|
||||
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
|
||||
|
||||
## 5. 设计: (a) 正文截断
|
||||
|
||||
### 5.1 配置与装配
|
||||
|
||||
| 层 | 形态 |
|
||||
|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0` 报 `ValueError` |
|
||||
| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) |
|
||||
|
||||
### 5.2 作用面与切法
|
||||
|
||||
截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"` 的 `text` 字段)、`response`、`thinking`。
|
||||
|
||||
**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
|
||||
|
||||
**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
|
||||
|
||||
非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
|
||||
|
||||
**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。
|
||||
|
||||
**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73` 与 `ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `<ocr:kind image_bytes=N>` 占位、`response` 走 `_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
|
||||
|
||||
### 5.3 红线
|
||||
|
||||
**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
|
||||
|
||||
## 6. 设计: (b) 保留期
|
||||
|
||||
**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
|
||||
|
||||
### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
|
||||
|
||||
PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
|
||||
|
||||
修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。
|
||||
|
||||
**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。
|
||||
|
||||
### 6.2 模板与工具
|
||||
|
||||
分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13 的 `telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
|
||||
|
||||
**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则):
|
||||
|
||||
| 项 | 设计 |
|
||||
|---|---|
|
||||
| 参数 | `--backend sqlite\|postgres`、`--path/--dsn`、`--older-than-days N`、`--apply`(**默认 dry-run**)、`--batch-size`、`--vacuum`(仅 SQLite,显式) |
|
||||
| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 |
|
||||
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE |
|
||||
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) |
|
||||
| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
|
||||
|
||||
## 7. 设计: (c) 访问控制与不可变性(纯文档)
|
||||
|
||||
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
|
||||
|
||||
| 内容 | 要点 |
|
||||
|---|---|
|
||||
| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) |
|
||||
| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 |
|
||||
| 分区 | 与 §6 的 retention 模板同一段落 |
|
||||
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
|
||||
|
||||
**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
|
||||
## 8. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 |
|
||||
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 |
|
||||
| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 |
|
||||
| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
|
||||
|
||||
## 9. 错误处理与测试策略
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 |
|
||||
| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" |
|
||||
| unit | config: 未设 → `None`;`<= 0` → `ValueError`;合法值透传到 emitter |
|
||||
| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 |
|
||||
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
|
||||
| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 |
|
||||
| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
|
||||
|
||||
遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。
|
||||
|
||||
## 10. 兼容性、文档与发布
|
||||
|
||||
**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。
|
||||
|
||||
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。
|
||||
|
||||
## 11. 开放问题
|
||||
|
||||
1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。
|
||||
2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
|
||||
3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。
|
||||
@@ -0,0 +1,146 @@
|
||||
# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来)
|
||||
> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md)
|
||||
|
||||
## 1. 问题
|
||||
|
||||
两个遥测后端在构造期(SQLite)/首次写入前(PG)会对下游数据库发 DDL: 表不存在则 `CREATE TABLE`,表存在但缺列则逐列 `ALTER TABLE ... ADD COLUMN`。**补列没有任何开关**,库升级后首次调用即自动执行,而 issue #11 刚给这张表加了两列,这条路径的使用频率正在上升。
|
||||
|
||||
issue #13 的三条指控成立: ① 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突; ② 多进程/多版本共存时谁先补列是竞态; ③ DDL 不进任何迁移记录,下游 DBA 事后无从审计表何时被谁改过。调研的 11 个同类先例(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中,**没有一个支持"库在下游库里自动 ALTER 出列"作为默认行为**。
|
||||
|
||||
### 1.1 issue 未区分、但决定方案形状的两点
|
||||
|
||||
**① SQLite 与 Postgres 的风险完全不对称。** issue 引用的全部先例(Hangfire 的锁队列雪崩、Prefect 的多实例竞态、Alembic 的 DBA 审计链)语境都是**共享的生产 PG**: `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。本库的 SQLite 侧则是下游自己的本地文件(VT / CHSAnalyzer / dissect 的 `runs/*.db` 全是这个形态): 没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作。让 SQLite 也要求"升级后手工跑一条 SQL",是给零运维场景强加运维步骤。两侧有意不对称在本库已有先例——`sqlite.py` 文件头写着"别为了代码对称把建表探测加回来"(issue #9)。
|
||||
|
||||
**② 关掉 ALTER 必须配套"按现有列裁剪 INSERT",否则是把自动补列换成静默全失能。** 今天 `_INSERT` 是 24 列的固定语句。旧表缺 `tenant_id` 时若不 ALTER,INSERT 会因未知列**全部失败** → 逐行 warning → 遥测彻底丢失。这比自动 ALTER 更严重地违反"遥测必录"。故降级写入不是可选增强,是本变更成立的前提。
|
||||
|
||||
## 2. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| D-a | 默认档 | **不对称**: PG 默认 manual(不 ALTER),SQLite 默认 auto(保持自动);同一配置项两侧均可覆盖 |
|
||||
| D-b | SQL 投放渠道 | warning 打印完整语句 **+** 新增公共函数供下游主动索取 |
|
||||
| D-c | 缺省规则落点 | **config 层派生**,recorder 的开关参数为 keyword-only **必填** |
|
||||
| D-d | 交付节奏 | 独立分支实现,与 issue #12 合并发 **1.2.3** |
|
||||
|
||||
## 3. 备选方案对比
|
||||
|
||||
| 方案 | 内容 | 权衡 | 结论 |
|
||||
|---|---|---|---|
|
||||
| **A(采纳)** | 按后端不对称默认 + 三态配置 + 裁剪写入 + schema SQL 公共函数 | PG 侧满足 issue 全部诉求;SQLite 侧零运维负担不变;代价是同一配置键在两后端缺省值不同,须文档讲清 | ✅ |
|
||||
| B | 两侧统一默认 manual | 语义最一致、最贴 issue 原文 | ❌ 现有 SQLite 下游(VT/CHS/dissect)升级即需人工干预,否则新维度静默缺失,而这些场景根本没有承接手工 SQL 的角色 |
|
||||
| C | 保持 auto 默认,只加关闭档 | 非破坏性 | ❌ 默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足,只是提供了绕法 |
|
||||
| D | Celery 式: 自动建表但**永不** ALTER,无开关 | 最简、无配置面 | ❌ SQLite 场景纯净损失;且下游若确实想要自动补列,库不给任何出路 |
|
||||
| E | APScheduler 4.x 式: schema 不认识就 `RuntimeError` 拒绝启动 | 最安全的一致性保证 | ❌ 与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选 |
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 配置与装配
|
||||
|
||||
新增环境键 `PGW_TELEMETRY_SCHEMA_MODE`,值域 `auto | manual`,**三态**: 未设 = 按后端派生,显式设置 = 两侧都可覆盖。
|
||||
|
||||
| 层 | 形态 | 理由 |
|
||||
|---|---|---|
|
||||
| 环境 | `PGW_TELEMETRY_SCHEMA_MODE`(可选键),经既有 `_load_choice` 校验值域 | 与 `PGW_LIMITER_BACKEND` 等同族 |
|
||||
| `GatewaySettings` | 新增字段 `telemetry_auto_migrate: bool`,**无默认值**(与既有全部字段一致) | settings 承载的是装配事实而非环境文本;派生只发生一次 |
|
||||
| recorder | `SQLiteRecorder(db_path, *, auto_migrate: bool)`、`PostgresRecorder(dsn, *, pool=None, auto_migrate: bool)`,keyword-only **必填** | D-c: 关键行为参数不给默认值(P4);缺省规则只写在 config 一处,不会与类签名漂移 |
|
||||
|
||||
`telemetry_backend=none` 时无 recorder 消费该字段,派生为 `False`。
|
||||
|
||||
### 4.2 行为矩阵
|
||||
|
||||
| 场景 | auto(今天的行为) | manual(新增) |
|
||||
|---|---|---|
|
||||
| 表不存在 | 建表 | **仍然建表** |
|
||||
| 表存在、列齐 | 不发任何 DDL | 不发任何 DDL |
|
||||
| 表存在、缺列 | 逐列 ALTER;失败只 warning,不判死 | **不发 DDL**;warning 逐列点名 + 打印可执行 SQL(仅一次);按现有列裁剪 INSERT 继续写入 |
|
||||
| 列探测失败 | warning,沿用全量 24 列 | warning,沿用全量 24 列 |
|
||||
|
||||
**manual 档为什么不连 `CREATE TABLE` 一起停**: issue 把建表列为现状描述而非指控(它已在 #3/#9 收口为"先探测后建")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。Celery 的先例同样是"自动建表 + 永不 ALTER"。
|
||||
|
||||
### 4.3 裁剪写入
|
||||
|
||||
`effective_columns = [c for c in COLUMNS if c in existing]`(保序),据此实例级构造 INSERT 语句,`record_llm_call` 按 `self._columns` 取值。SQLite 在 `__init__` 末尾定型,PG 在 `_prepare_schema` 成功后与 `_schema_ready` **一起**赋值(两者必须同时生效,否则会出现"已就绪但语句还是旧的"的窗口)。
|
||||
|
||||
缺列 warning 必须**逐列点名**并写明后果("以下维度不会被记录: tenant_id, meta"),不能只说"缺列"——静默丢维度的后果是多租户账目全归空串且无任何报错。warning 只在准备期发一次,不逐行。
|
||||
|
||||
`call_id` 若不在现有列内,说明该表不是本库的 `llm_calls`(下游魔改或撞名),warning 升级措辞并照常尝试写入(由数据库自己拒绝),库不做二次判定。
|
||||
|
||||
### 4.4 新公共函数(D-b)
|
||||
|
||||
```python
|
||||
polygateway.telemetry_schema_sql(backend: str) -> str
|
||||
```
|
||||
|
||||
返回可直接粘进迁移文件的完整脚本: 注释头 + `CREATE TABLE IF NOT EXISTS`(全量列) + 分隔注释 + 各补列语句(PG 用 `ADD COLUMN IF NOT EXISTS`;SQLite 无该语法,以注释标明"仅当列不存在时执行")。非法 `backend` 抛 `ValueError`(公共入口显式校验,先例同 issue #11 的维度校验)。
|
||||
|
||||
**这不是锦上添花而是正确性要求**: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 `_DDL` / `_BACKFILL` / `_COLUMNS` 在 `sqlite.py` 与 `postgres.py` 各存一份,公共函数若再写一份,三份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。故新增 `telemetry/schema.py` 收敛为单一事实源,两个 recorder 与公共函数共用;顶层 `__init__` re-export 进 `__all__`。依赖方向不变(schema.py 在 telemetry 层内部,不 import 任何其他层),import-linter 契约无需改动。
|
||||
|
||||
### 4.5 Expand/Contract 成文化(零代码)
|
||||
|
||||
库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)、写入的冲突处理不绑定具体约束**。最后一条是 Codex 审查带出的**新增承诺**,见 §4.6。
|
||||
|
||||
它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测、列探测与 INSERT 路由都照常工作。
|
||||
|
||||
### 4.6 冲突目标改为无绑定(Codex 审查发现,阻断级)
|
||||
|
||||
PG 侧今天的写入是 `ON CONFLICT (call_id) DO NOTHING`,它要求一个恰好匹配 `(call_id)` 的唯一约束。而 PostgreSQL 要求分区表的唯一约束**必须包含分区键**——issue #12 的按 `created_at` 分区方案会把主键逼成 `(call_id, created_at)`,届时该语句**直接报错**,遥测在分区部署下全线写不进去。
|
||||
|
||||
改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 两种表形态都合法,普通表上与今天逐字等价(表上只有主键这一个唯一约束),SQLite 侧的 `INSERT OR IGNORE` 本就无目标、无需改动。
|
||||
|
||||
改动归属本 issue 而非 #12: 本 issue 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。分区部署下幂等语义的差异(缓存命中行复用历史 `call_id`)由 #12 的文档承接。
|
||||
|
||||
## 5. 旧版行为审计
|
||||
|
||||
| 既有行为 | 处置 |
|
||||
|---|---|
|
||||
| SQLite 构造期 `PRAGMA table_info` 探测 | 保留 |
|
||||
| SQLite 逐列独立 try、`duplicate column` 视为成功(多进程共库竞态) | 保留(auto 档) |
|
||||
| SQLite 补列失败只 warning、绝不清空 `_conn` | 保留 |
|
||||
| SQLite 不做建表前探测(issue #9 的有意不对称) | 保留 |
|
||||
| PG `to_regclass` 建表前探测(权限检查早于 IF NOT EXISTS) | 保留 |
|
||||
| PG `pg_attribute` 列探测(避开 `ADD COLUMN IF NOT EXISTS` 的排他锁) | 保留 |
|
||||
| PG 补列失败不置 `_failed`、探测失败只跳过本次下次重试 | 保留 |
|
||||
| 24 列模块级固定 INSERT 常量 | **替换**为按探测结果裁剪的实例语句 |
|
||||
| `_DDL`/`_BACKFILL`/`_COLUMNS` 两文件各一份 | **替换**为 `telemetry/schema.py` 单一事实源 |
|
||||
| 补列无开关、库升级即自动执行 | **替换**为 `schema_mode` 三态配置 |
|
||||
| PG `ON CONFLICT (call_id) DO NOTHING` | **替换**为无冲突目标的 `ON CONFLICT DO NOTHING`(§4.6);普通表上语义逐字等价 |
|
||||
| SQLite `INSERT OR IGNORE` | 保留(本就无冲突目标) |
|
||||
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
|
||||
|
||||
无有意放弃项。
|
||||
|
||||
## 6. 非功能维度
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 并发与取消 | DDL 与探测仍只发生在构造期(SQLite)/首次准备期(PG,由既有 `_init_lock` 串行);manual 档不发 DDL,多进程竞态面积**缩小**;裁剪是纯计算,不新增 await 点;PG 既有 `except asyncio.CancelledError: raise` 全部保留 |
|
||||
| 降级方向 | 遥测属静默降级档: 缺列 → 降级写入 + warning,**绝不判死、绝不报错**;与"限流/熔断后端不可用须报错"的方向差异不变 |
|
||||
| 幂等与重复 | 探测与裁剪是纯读,重复执行安全;auto 档 ALTER 经探测 + duplicate 容错幂等;`ON CONFLICT (call_id) DO NOTHING` / `INSERT OR IGNORE` 不受影响 |
|
||||
| 持久化与原子性 | 无跨行事务;单条 INSERT 原子;裁剪不触及主键 `call_id`,幂等键语义不变;部分写入不可能发生 |
|
||||
|
||||
## 7. 错误处理与测试策略
|
||||
|
||||
遥测路径的一切失败仍不落四分类、不冒泡;`telemetry_schema_sql` 的非法参数是公共入口校验,抛裸 `ValueError`。
|
||||
|
||||
| 层 | 用例 |
|
||||
|---|---|
|
||||
| unit(真实临时 SQLite) | manual + 22 列旧表 → `PRAGMA` 列数不变(证明未 ALTER)、INSERT 成功且能读回、warning 同时含缺列名与 ALTER 语句;auto + 22 列旧表 → 补列(现状回归) |
|
||||
| unit | `telemetry_schema_sql` 与 `COLUMNS` 同源(输出含全部列名且顺序一致)、非法 backend 报 `ValueError` |
|
||||
| unit | config 派生: 未设键 → sqlite `True` / postgres `False`;显式设置覆盖两侧;非法值报错;`backend=none` → `False` |
|
||||
| integration(真实 PG) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 |
|
||||
| integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning |
|
||||
|
||||
每条行为变更须有先失败后通过的证据(测试结果门)。
|
||||
|
||||
## 8. 兼容性、文档与发布
|
||||
|
||||
**破坏性**(CHANGELOG 须给"请先读这一条"待遇): ① PG 下游升级后不再自动补列,新列需手工执行(库会打印语句); ② 两个 recorder 新增 keyword-only 必填参数,直接构造的调用点需改(全库 35 处,除 `client.py` 的两处装配点外均在测试内); ③ `GatewaySettings` 新增必填字段,影响"构造函数全量注入"这条装配路。
|
||||
|
||||
文档同步: README(配置键、Expand/Contract 承诺、schema SQL 用法)、`.env.example`、ARCHITECTURE §7.8、Wiki `参考-配置键` / `参考-公共API` / `指南-遥测与成本`。
|
||||
|
||||
## 9. 开放问题
|
||||
|
||||
1. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
|
||||
2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。
|
||||
@@ -0,0 +1,227 @@
|
||||
# 熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
|
||||
|
||||
- **issue**: #14(dissect,单源第三方中转部署)
|
||||
- **核查基准**: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后**全部仍然成立**(`backends/memory/breaker.py` md5 `630ed36ddeb87e08a9bac58260056046`,1.0.6→1.2.3 逐字节未变)
|
||||
- **状态**: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施
|
||||
|
||||
## 1. 问题的真实形状
|
||||
|
||||
issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——**单源不是病因,是让病灶 100% 复现的放大器**。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。
|
||||
|
||||
### 1.1 缺陷一: 准入策略矩阵缺了一格
|
||||
|
||||
`_pick_runnable` 有四种"拒绝",库对它们的处置并不对称:
|
||||
|
||||
| 拒绝原因 | 计入 `gate_rejections` | 全被拒时的处置 | 可配? |
|
||||
|---|---|---|---|
|
||||
| `rate_limited`(permit 拿不到) | 否 | 走 `quota_full` 分支 | **是**(`wait`/`fail_fast`) |
|
||||
| `adaptive_paced`(AIMD 超限) | 否 | 走 `quota_full` 分支 | **是**(同上) |
|
||||
| `circuit_open`(熔断门拒) | 是 | 当场抛 `CircuitOpenError` | **否** |
|
||||
| `cooldown`(源冷却备忘) | 是 | 同上 | **否** |
|
||||
|
||||
限流闸满时库不判死、允许排队(`quota_full=wait`,缺省);熔断门拒时库**只有 fail-fast 一档且不可配**。两者在准入语义上完全同构(都不发请求、都带 `retry_after` 提示),处置却分叉。
|
||||
|
||||
**这一格的缺失与源数量无关**:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此**任何形态的单源特判(`if len(sources) == 1`)都是错的**——它会让行为随池大小突变、无法组合测试,是比现状更重的债。
|
||||
|
||||
### 1.2 缺陷二: `retry_after_s` 在 HALF_OPEN 下返回了一个物理上无意义的数
|
||||
|
||||
`try_enter` 在 HALF_OPEN 拒绝时返回 `probe_expires - now`,即**探针租约的剩余时长**。而 `probe_ttl_s` 派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`(`config.py:400-407`),现场 `TIMEOUT_S=300` ⇒ **600 秒**,而冷却期只有 60 秒。
|
||||
|
||||
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(`backends/redis/breaker.py` 的 `TRY_ENTER`/`RETRY_AFTER` 两个 Lua 均返回 `probe_until - now`)。
|
||||
|
||||
### 1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
|
||||
|
||||
缺陷二的值被喂进了源冷却备忘:
|
||||
|
||||
```text
|
||||
retry.py:354 self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
sources.py:139 self._until[name] = max(已有, until) # 取更晚者,不可回退
|
||||
```
|
||||
|
||||
于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → **给 A 记 600 秒本地冷却** → 调用 1 的探针成功、门恢复 CLOSED → **本进程此后 600 秒仍然跳过 A**,且 `reasons[A]="cooldown"` 计入 `gate_rejections`,单源下每次调用照旧抛 `CircuitOpenError`。
|
||||
|
||||
实测复现(`InMemoryGate` + 注入时钟,`cooldown_s=60`、`probe_ttl_s=600`):
|
||||
|
||||
```text
|
||||
B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s
|
||||
B 给 s1 记的本地冷却剩余: 600.0 秒
|
||||
探针成功后门 state: closed
|
||||
门已 CLOSED,memo.active('s1') = True
|
||||
再过 120 秒(远超 60s 冷却)memo.active = True 剩余 480.0 秒
|
||||
```
|
||||
|
||||
**这条与源数量、与是否单源都无关**:多源部署里,一个源每开路一次就会被本进程从池中除名 `probe_ttl_s`(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。
|
||||
|
||||
## 2. 备选方案与否决理由
|
||||
|
||||
issue 给了 A/B/C/D 四条。逐条判:
|
||||
|
||||
| 方案 | 判定 | 理由 |
|
||||
|---|---|---|
|
||||
| A `PGW_BREAKER_BACKEND=noop` | **否决** | 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决 |
|
||||
| B `{SCOPE}__CIRCUIT_OPEN=wait\|fail_fast` | **采纳为主干** | 与 `quota_full` 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C |
|
||||
| C 修 HALF_OPEN 的 `retry_after_s` | **采纳,且不是"治标"** | issue 把它列为"可并行的小修"。实际上它是 B 的**前提**:wait 档要按 `retry_after` 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根 |
|
||||
| D 只写文档 | **否决** | 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 `force_open` |
|
||||
|
||||
**方案 = B + C,合并为一件事**:B 依赖 C 的正确性,C 修完 §1.3 自动消失。
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 3.1 `retry_after_s` 的契约定死为"确定的最早可尝试时刻"
|
||||
|
||||
| 门状态 | 返回值 | 依据 |
|
||||
|---|---|---|
|
||||
| CLOSED | `0.0` | 现状,不变 |
|
||||
| OPEN | `open_until - now` | 现状,不变。冷却截止是确定时刻 |
|
||||
| HALF_OPEN(被拒) | **`0.0`** | 探针随时可能出结果,**不存在**确定的等待时刻 |
|
||||
|
||||
`0.0` 不是新约定:`errors.py` 早已定义 `retry_after_s` 的 `0 = 可立即重试`,契约测试 `test_retry_after_semantics` 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。
|
||||
|
||||
信息不丢失:`GateDecision.state` 已经携带 `HALF_OPEN`,调用方要区分"门闭着"与"探针在途"照样能区分。
|
||||
|
||||
**惊群由既有机制承担,不由这个数承担**:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 `poll_interval_s` 抖动睡眠承担(§3.3)。
|
||||
|
||||
**§1.3 随之闭合**:`set_until(now + 0.0)` 写入一个已过期的截止时刻,`active()` 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 `retry.py` 加任何状态分支。备忘回归它唯一正当的用途:**记 OPEN 的确定冷却期**。
|
||||
|
||||
**准入被允许时恒 `0.0`**:`allowed=True` 意味着现在就能试,这个字段没有别的合理取值。
|
||||
|
||||
**改动面是五个出口,不是两个(Codex 审查修正)**。原稿只点了 `try_enter` 与 `retry_after_s()`,漏了 `GateUpdate` 那一侧;逐一核实后发现**两个后端在这两处本就已经分叉**——本 issue 的病根正是"`retry_after_s` 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:
|
||||
|
||||
| 出口 | memory 现状 | redis 现状 | 统一为 |
|
||||
|---|---|---|---|
|
||||
| `try_enter` 拒绝(OPEN) | `open_until - now` | 同 | 不变 |
|
||||
| `try_enter` 拒绝(HALF_OPEN) | `probe_expires - now` | `probe_until - now` | **`0.0`** |
|
||||
| `try_enter` **授予探针** | `0.0`(`memory:114`) | **`probe_ttl_ms`**(`redis:53`) | **`0.0`**(redis 侧改) |
|
||||
| `GateUpdate`(fencing 未命中,HALF_OPEN) | `0.0`(`memory:175-177` 非 OPEN 一律 0) | **`probe_until - now`**(`redis:127/158/258`) | **`0.0`**(redis 侧三处改) |
|
||||
| `retry_after_s()` 跨源取 min | HALF_OPEN 记 `probe_expires - now` | 同 | **HALF_OPEN 记 `0.0`** |
|
||||
|
||||
后两行是**既有缺陷**,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。
|
||||
|
||||
memory 侧抽 `_remaining(g)` 私有纯方法供三处共用;redis 侧四个 Lua(`TRY_ENTER`/`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE`)与 `RETRY_AFTER` 各改一处(Lua 无法共享函数,这是既有约束,`_WINDOW_HELPERS` 已是同款处理),由同一批双后端参数化契约用例锁死。
|
||||
|
||||
### 3.2 新配置键 `{SCOPE}__CIRCUIT_OPEN`
|
||||
|
||||
与 `quota_full` 逐项对齐,不发明新形状:
|
||||
|
||||
| 维度 | `quota_full`(既有) | `circuit_open`(新增) |
|
||||
|---|---|---|
|
||||
| 合法域 | `_QUOTA_FULL = {"wait","fail_fast"}` | `_CIRCUIT_OPEN = {"wait","fail_fast"}` |
|
||||
| 缺省 | `wait` | **`fail_fast`**(见 §3.5) |
|
||||
| env 键 | `{SCOPE}__QUOTA_FULL` | `{SCOPE}__CIRCUIT_OPEN` |
|
||||
| 装配 | settings → `GatewayClient` → 三条循环 | 同 |
|
||||
| 校验 | `_validate_backends` 表驱动 + 构造期 | 同(各加一行) |
|
||||
|
||||
改动面: `config.py`(常量 / 字段 / 校验元组 / `from_env` 各一行)、`client.py`(签名 + 透传各一处)、`SourceAdmission`(§3.4)一处。
|
||||
|
||||
### 3.3 `_on_no_runnable` 的控制流
|
||||
|
||||
现状两个分支是**串行**的。今天走不到那个坑(没有 wait 档,第一分支必抛),但**只要把第一分支改成"wait 时不抛"就会立刻踩中**:控制流会往下掉进 `quota_full` 分支,`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。必须改成按拒绝原因分派:
|
||||
|
||||
```text
|
||||
if gate_rejections == len(sources): # 全部因熔断类原因被拒
|
||||
if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
|
||||
hint = await gate.retry_after_s(names) # OPEN 有确定值;全 HALF_OPEN 得 0
|
||||
else: # 至少一源是被配额/AIMD 挡的
|
||||
if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
|
||||
hint = 0.0
|
||||
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
|
||||
await self._sleep(self._nap(hint, clock))
|
||||
```
|
||||
|
||||
睡眠时长 `_nap(hint, clock)`,三条约束同时满足:
|
||||
|
||||
| 约束 | 实现 | 理由 |
|
||||
|---|---|---|
|
||||
| 不空转 | `hint > 0` 时睡到冷却结束再加抖动,而非 50ms 轮询 | 60 秒冷却下,`poll_interval=0.05` 会产生 1200 次无谓复查;memory 后端只是字典查询,**redis 后端是 1200 次往返 × 每个在途调用** |
|
||||
| 不白醒 | 抖动**上**加(`hint + poll_interval × (0.5+0.5×rng)`),不缩放 | 对一个确定的截止时刻提前醒必然被再拒一次 |
|
||||
| 等待有可解释上界 | 夹到剩余 stall 预算:`min(睡眠, stall_window - clock.stalled_s())`,下界 `poll_interval` | 最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = `stall_window_s`(缺省 300s),不随 `max_cooldown_s` 漂移 |
|
||||
|
||||
`hint = 0` 时该式退化为现有的 `poll_interval × (0.5+0.5×rng)`,配额等待路径逐字不变。
|
||||
|
||||
**计时归属无需改动**:这段睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。
|
||||
|
||||
### 3.4 前置收敛: 准入逻辑三处复制归一
|
||||
|
||||
`_pick_runnable` / `_on_no_runnable` 目前在 `middleware/retry.py`、`embedding.py`、`ocr.py` **各有一份**,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——**那才是本次真正会留下的技术债**(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。
|
||||
|
||||
抽 `middleware/admission.py::SourceAdmission`,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 `pick()` 与 `on_no_runnable()`。三条循环的差异用注入表达,不留分支:
|
||||
|
||||
| 差异 | 处理 | 行为等价性 |
|
||||
|---|---|---|
|
||||
| 调用内降权(仅 chat) | `attempt_fails` 作 `pick()` 入参 | embedding/ocr 传空 dict 时 `_demote_call_failures` 恒等返回原序(`demoted` 为空即 `return ordered`) |
|
||||
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit`/`enter`,无副作用 |
|
||||
| `_settle_and_release` 三份复制 | 提为 `middleware/` 模块级 async 函数 | chat/embedding 签名为 `(permit, actual)`,**OCR 为 `(permit)` 且体内恒 `settle(0)`**(`ocr.py:438`,Codex 审查补)。OCR 侧改为传 `0`,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一 |
|
||||
|
||||
已逐字 diff 核实(`embedding` 与 `ocr` 两份**完全相同**;chat 多出的只有上表三类)。另有两处**不在抽取边界内**、须原样保留:chat 主循环顶部额外的一次 `_stalled` 预判(`retry.py:286`),以及 OCR 的健康喂数——它们属于各自的主循环与 `_attempt`,本次一行不动。
|
||||
|
||||
**这不是任务外重构**:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——`StallClock` 与 `backoff_delay` 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,**`_attempt` 一行不动**(三者差异大: 流式 / 批 / 图)。
|
||||
|
||||
执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。
|
||||
|
||||
### 3.5 缺省值取 `fail_fast`
|
||||
|
||||
`quota_full` 缺省 `wait`,但 `circuit_open` **不跟随**,理由是变更方向的危险性不对称:
|
||||
|
||||
| 取值 | 对存量下游的影响 |
|
||||
|---|---|
|
||||
| `fail_fast`(采纳) | **控制流**逐字不变(全源被熔断拒仍当场抛 `CircuitOpenError`) |
|
||||
| `wait` | 把所有人的最坏墙钟从毫秒抬到 `stall_window_s`,且是"快速失败 → 长时间挂起"这个最危险的方向 |
|
||||
|
||||
issue 的诉求本身也不是改默认值,而是**表达能力**——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且**不允许调用方表达自己属于哪一种**"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 `wait` 即可。README 与 wiki 需明写"单源 scope 建议配 `wait`"。
|
||||
|
||||
### 3.6 `errors.py` 的职责边界补写
|
||||
|
||||
issue 要求修订 `GatewayUnavailableError` 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待**全部在库内**,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同。
|
||||
|
||||
这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。
|
||||
|
||||
**"缺省档零感知"须诚实收窄(Codex 审查修正)**: 缺省档保证的是**控制流**不变,不是零可见变更。`retry_after_s` 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 `CircuitOpenError.retry_after_s` 由"探针租约剩余"变为 `0.0`,而它是公开字段(`errors.py:118`)。这正是本次记 **1.3.0** 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 `GatewaySettings` 全部字段均无默认值(既有风格),新增 `circuit_open` 沿用之,直接构造该类的调用方须补一个参数。
|
||||
|
||||
## 4. 行为矩阵
|
||||
|
||||
| 场景 | `fail_fast`(缺省,= 现状) | `wait` |
|
||||
|---|---|---|
|
||||
| 单源 OPEN,冷却 60s | 立即 `CircuitOpenError(retry_after=剩余冷却)` | 睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回 |
|
||||
| 单源 `force_open`(401/403) | 立即失败 | 等 60 → 探针又 401(**烧掉一格 `max_attempts`**)→ 等 120 → …… 以**先耗尽的那个预算**的 reason 失败: `max_attempts` 先尽则 `retry_exhausted`,冷却累计超过 stall 预算则 `stalled`。**代价须进文档** |
|
||||
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
|
||||
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(`retry_after_s` 取 min) |
|
||||
| 全部 HALF_OPEN(探针在途) | `CircuitOpenError(retry_after=0)`,语义准确(随时可能好) | `poll_interval` 抖动复查,秒级拿到探针结果 |
|
||||
| 配额满 / AIMD 超限 | 归 `quota_full` 管,逐字不变 | 逐字不变 |
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:
|
||||
|
||||
**契约层**(`tests/contracts/test_breaker_contract.py`,双后端参数化自动覆盖 memory + redis):
|
||||
按 §3.1 那张表**逐个出口**钉——HALF_OPEN 被拒、授予探针、`GateUpdate` fencing 未命中、`retry_after_s()` 探针在途,四处均须 `== 0.0`;OPEN 语义不变(现有 `test_retry_after_semantics` 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 `tests/integration/test_redis_governance_time.py` 的真实等待变体(既有约定,不缩放时长)。
|
||||
|
||||
**单元层**(`tests/unit/test_backpressure.py` 邻域,注入时钟/睡眠/rng):
|
||||
§1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);`circuit_open=wait` 下全源开路不抛 `CircuitOpenError` 而按 `retry_after` 睡;`wait` + `quota_full=fail_fast` 组合下熔断等待**不**被误报成 `quota_exhausted`(§3.3 那个坑的钉子);`wait` 档最坏墙钟 ≤ `stall_window_s` 且判死 reason 为 `stalled`、`per_source_reasons` 含 `circuit_open`;`fail_fast` 缺省下全部现有用例逐字绿。
|
||||
|
||||
**收敛层**: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。
|
||||
|
||||
## 6. 非功能与已知取舍
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 取消穿透 | `_nap` 的长睡眠是 `await self._sleep(...)`,`CancelledError` 逐字穿透;无新增 finally 资源 |
|
||||
| 后端往返 | wait 档每个冷却周期约 1 次 gate 查询(vs. `poll_interval` 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait |
|
||||
| 遥测 | **不加列**。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 `logger.info`(scope、per-source reasons、预计等待),使"等了多久"可从日志还原 |
|
||||
| 等待上界的精确值 | `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 `stalled_s()` 等于窗口而不大于,不判死。故 `_nap` 夹到 `剩余预算 + poll_interval_s`,一次到位;最坏墙钟精确表述为 `stall_window_s + 一个 poll 间隔`,不是"恰好 stall_window_s" |
|
||||
| 备忘的跨进程滞后 | 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(`_pick_runnable` 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),**既有性质、本次不改**;备忘是进程内存,无持久化,故不存在滚动升级残留 |
|
||||
| 无限等待 | `_stalled` 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 `asyncio.wait_for` |
|
||||
| 未解决 | `force_open` 在 wait 档下把坏密钥的失败从毫秒拖长(上限 stall 窗口)。**有意不特判**——库无法区分"密钥坏了"与"中转抖了",选 `wait` 即声明"宁可等也不当场死" |
|
||||
| 两个预算并行(整分支审查发现,2026-08-20) | `wait` **不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `max_attempts`(issue #8 的划分依据是"谁消耗重试预算",探针发出了真实请求,理应记在重试预算上)。故 force_open 的源常以 `retry_exhausted` 而非 `stalled` 结束。原稿 §4 只写了 stall 一种结局,已更正;由 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉住 |
|
||||
|
||||
## 7. 文档与发布
|
||||
|
||||
ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 `docs-convention.md` §2 同步;CHANGELOG 记为 **1.3.0**(新增配置键 + `retry_after_s` 语义变更,后者对下游可见,需"请先读这一条"待遇)。
|
||||
|
||||
`GateDecision` 的字段与 `ProviderGate` 端口签名**均不变**,故不触碰迁移兼容约束(ARCH §5.1)。
|
||||
|
||||
## 8. 已定决策(人类,2026-08-19)
|
||||
|
||||
| # | 决策 | 随之固定的实施边界 |
|
||||
|---|---|---|
|
||||
| 1 | 缺省取 **`fail_fast`**(§3.5) | 存量下游零感知;issue 提交方需自行加 `{SCOPE}__CIRCUIT_OPEN=wait`。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在 |
|
||||
| 2 | §3.4 的三处收敛**本次一并做** | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |
|
||||
@@ -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,T0–T7 执行中发现)
|
||||
|
||||
设计经人类审后实施,过程中三处需要回改设计本身——都不是措辞问题,而是"原稿的事实基础不够"。逐条落回正文而非只记在这里,以免后来人读正文时踩同一个坑。
|
||||
|
||||
| # | 修订 | 落点 |
|
||||
|---|---|---|
|
||||
| 1 | **吞吐算术偏乐观一倍**。原稿按 `pool_max / RTT` 估 32 行/秒,T3 实测 50 行并发批 3.2s(≈15.6 行/秒)——`INSERT` 的实际往返比测 RTT 用的 `SELECT 1` 重。方向不变,但下游调参必须拿实测数字 | §3.5 表、§6 两格 |
|
||||
| 2 | **原稿未预见的一处真 bug**: 注入外部池时 `aclose()` 之后的下一次写入会拿 DSN 偷偷自建一个池。与 §1.5 三条同根因,只是表现在关闭之后,T4 修掉 | §1.5 |
|
||||
| 3 | **两条论证被补强**: ①"认不出的失败归行级"这个保守缺省在建池路径上安全,理由是 `min_size=0` 让重试建池零成本(T5);②所有权判定必须用 `is not None` 而非 `or`,否则注入 falsy 后端时自建分支与所有权标志漂移(T1) | §3.2 末、§3.4 |
|
||||
| 4 | **日志级别的决策点收敛到 tracker**(独立验证发现)。原实现在 recorder 的 fatal 分支另发一条 `logger.error`,而 tracker 同时发一条语义重复的 warning——同一个事实两条日志,"级别"这个决策两个源头。改为 `enter_degraded` 按 `fatal` 选级别(error / warning),recorder 不再另发;SQLite 侧的致命档同步升为 error。**这条决策此前没有执法点**: 测试 fixture 挂 `level="WARNING"`,ERROR 与 WARNING 同池,删掉那条 error 用例照样绿。补 `captured_logs` fixture(连级别一起捕获)后三处补上级别断言 | §3.2 表、§3.3 表、§5 单元层 |
|
||||
| 5 | **`acquire` 传的是完整预算,不是剩余预算**(独立验证发现,改文档不改代码): 真正的上界是外层那一层 `asyncio.timeout`,内层再算一次剩余量只是把同一个上界写两遍。行为无害,实测总耗时正好等于预算 | §3.1 |
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue12-telemetry-retention
|
||||
title: "issue #12: 遥测表的正文体量、保留期与访问控制"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #12: 遥测表的正文体量、保留期与访问控制
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。
|
||||
|
||||
- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。
|
||||
- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。
|
||||
- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。
|
||||
- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。
|
||||
- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。
|
||||
- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。
|
||||
- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
|
||||
- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。
|
||||
- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder` 加 `purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。
|
||||
- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: design
|
||||
node_id: design:issue13-schema-mode
|
||||
title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。
|
||||
- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。
|
||||
- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。
|
||||
- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。
|
||||
- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。
|
||||
- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。
|
||||
- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。
|
||||
- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。
|
||||
- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。
|
||||
@@ -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。
|
||||
|
||||
@@ -165,6 +165,41 @@
|
||||
"id": "plan:issue11-caller-dimensions",
|
||||
"label": "调用方自定义维度实现计划(issue #11)",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "design:issue13-schema-mode",
|
||||
"label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "design:issue12-telemetry-retention",
|
||||
"label": "issue #12: 遥测表的正文体量、保留期与访问控制",
|
||||
"type": "design"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue13-schema-mode",
|
||||
"label": "实现计划: issue13-schema-mode",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"id": "plan:plan-issue12-telemetry-retention",
|
||||
"label": "实现计划: issue12-telemetry-retention",
|
||||
"type": "plan"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
@@ -300,6 +335,55 @@
|
||||
"relation": "implements",
|
||||
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
|
||||
"added": "2026-08-17T10:09:08.967997+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue13-schema-mode",
|
||||
"target": "design:issue13-schema-mode",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md",
|
||||
"added": "2026-08-19T13:10:55.616264+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue12-telemetry-retention",
|
||||
"target": "design:issue12-telemetry-retention",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md",
|
||||
"added": "2026-08-19T13:10:57.986963+00:00"
|
||||
},
|
||||
{
|
||||
"source": "plan:plan-issue14-admission-wait-policy",
|
||||
"target": "design:2026-08-19-issue14-admission-wait-policy-design",
|
||||
"relation": "implements",
|
||||
"evidence": "research-wiki/plans/plan-issue14-admission-wait-policy.md;T0-T8 逐节映射设计 §3.1-§3.6",
|
||||
"added": "2026-08-20T03:30:06.280582+00:00"
|
||||
},
|
||||
{
|
||||
"source": "review:issue14-branch-review",
|
||||
"target": "plan:plan-issue14-admission-wait-policy",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+20
-3
@@ -1,8 +1,8 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-08-17 10:09 UTC
|
||||
> 自动生成,更新时间:2026-08-24 15:51 UTC
|
||||
|
||||
## design (30)
|
||||
## 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`
|
||||
@@ -17,10 +17,17 @@
|
||||
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
|
||||
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
|
||||
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
|
||||
- [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`
|
||||
@@ -48,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 (25)
|
||||
## 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`
|
||||
@@ -61,6 +68,9 @@
|
||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||
- [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`
|
||||
@@ -68,13 +78,20 @@
|
||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||
- [实现计划: 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`
|
||||
|
||||
## review (1)
|
||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||
|
||||
## schema (1)
|
||||
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||
|
||||
|
||||
@@ -106,3 +106,37 @@
|
||||
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
|
||||
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
|
||||
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode)
|
||||
- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention)
|
||||
- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode
|
||||
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention)
|
||||
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention
|
||||
- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面
|
||||
- [2026-08-20 03:29 UTC] 新增 design: 熔断拒绝补齐等待档(issue #14) (design:issue14-admission-wait-policy)
|
||||
- [2026-08-20 03:30 UTC] 新增 plan: 实现计划: 熔断拒绝补齐等待档(issue #14) (plan:issue14-admission-wait-policy)
|
||||
- [2026-08-20 03:30 UTC] 新增边: plan:issue14-admission-wait-policy --implements--> design:issue14-admission-wait-policy
|
||||
- [2026-08-20 03:30 UTC] 重建索引: 82 篇页面
|
||||
- [2026-08-20 03:30 UTC] 重建索引: 80 篇页面
|
||||
- [2026-08-20 05:01 UTC] 新增边: review:issue14-branch-review --informs--> plan:plan-issue14-admission-wait-policy
|
||||
- [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,180 @@
|
||||
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
|
||||
|
||||
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。
|
||||
- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451` 的 `_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter` 增 `text_cap` 必填 |
|
||||
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings` 增 `telemetry_text_cap` |
|
||||
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
|
||||
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
|
||||
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
|
||||
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
|
||||
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
|
||||
| `tests/unit/test_config.py` | 修改 | 配置校验 |
|
||||
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
|
||||
| `README.md`、`CHANGELOG.md`、`.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
|
||||
|
||||
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
|
||||
|
||||
```python
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
"""
|
||||
```
|
||||
|
||||
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class TelemetryEmitter:
|
||||
def __init__(
|
||||
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None: ...
|
||||
```
|
||||
|
||||
三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性):
|
||||
|
||||
```python
|
||||
class GatewayClient: # client.py:130 起的构造签名
|
||||
def __init__(self, *, ..., text_cap: int | None = None) -> None: ...
|
||||
# EmbeddingClient / OcrClient 同款
|
||||
```
|
||||
|
||||
**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。
|
||||
|
||||
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
|
||||
|
||||
```python
|
||||
telemetry_text_cap: int | None
|
||||
```
|
||||
|
||||
`tools/telemetry_retention.py` 的 CLI 契约:
|
||||
|
||||
```text
|
||||
--backend sqlite|postgres 必填
|
||||
--path PATH | --dsn DSN 按 backend 二选一,必填
|
||||
--older-than-days N 必填,N >= 0
|
||||
--apply 缺省不带即 dry-run(只统计不删)
|
||||
--batch-size N 仅 postgres,缺省 1000
|
||||
--vacuum 仅 sqlite,须与 --apply 同时给
|
||||
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 正文截断与 emitter 参数
|
||||
|
||||
- [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。
|
||||
- **行为**:
|
||||
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。
|
||||
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。
|
||||
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
|
||||
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
|
||||
- `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
|
||||
- **验收**:
|
||||
- `cap=None` → 落库正文与今天逐字节相同。
|
||||
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
|
||||
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
|
||||
- 非字符串 content(如 `123`、`None`、嵌套 dict)不抛异常。
|
||||
- `response`/`thinking` 同样受 cap。
|
||||
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
|
||||
- **测试**:
|
||||
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
|
||||
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
|
||||
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
|
||||
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
|
||||
- **提交**: `feat: cap telemetry bodies at a configurable length`
|
||||
|
||||
## Task 2: 配置与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。
|
||||
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings` 增 `telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0` 报 `ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
|
||||
- **验收**: 未设 → `None`;`"0"` 与 `"-1"` 报 `ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
|
||||
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: wire the telemetry text cap through settings`
|
||||
|
||||
## Task 3: 保留期脚本
|
||||
|
||||
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`。
|
||||
- **行为**: 按上文 CLI 契约实现。
|
||||
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
|
||||
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
|
||||
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
|
||||
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
|
||||
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
|
||||
- dry-run 后行数不变,stdout 含将删行数与时间范围。
|
||||
- `--apply` 后仅超期行被删,未超期行完好。
|
||||
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
|
||||
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
|
||||
- `--vacuum` 不带 `--apply` 时退出码 1。
|
||||
- **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。
|
||||
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。
|
||||
- **提交**: `feat: add a retention script downstreams can schedule`
|
||||
|
||||
## Task 4: 生产部署模板与其机械化验收
|
||||
|
||||
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
|
||||
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
|
||||
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
|
||||
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
|
||||
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
|
||||
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
|
||||
- **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"` 的 `text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。
|
||||
- **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。
|
||||
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
|
||||
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
|
||||
- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
|
||||
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
|
||||
- `report` 角色能读、不能写。
|
||||
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
|
||||
- 分区表上写入成功且落进当月分区。
|
||||
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
|
||||
- **提交**: `docs: ship a production deployment template with its own test`
|
||||
|
||||
## Task 5: CHANGELOG 与 wiki
|
||||
|
||||
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`。
|
||||
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
|
||||
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
|
||||
- **测试**: 无自动化测试。
|
||||
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
|
||||
- **提交**: `docs: record the retention boundary and its knobs`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 五个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
|
||||
@@ -0,0 +1,177 @@
|
||||
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
|
||||
|
||||
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
|
||||
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
|
||||
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
|
||||
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
|
||||
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
|
||||
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
|
||||
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
|
||||
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings` 增 `telemetry_auto_migrate` |
|
||||
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
|
||||
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
|
||||
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
|
||||
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
|
||||
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
|
||||
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
|
||||
| `.env.example`、`README.md`、`CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
|
||||
|
||||
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
|
||||
|
||||
---
|
||||
|
||||
## 关键接口(跨任务消费,此处定稿)
|
||||
|
||||
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
|
||||
|
||||
```python
|
||||
COLUMNS: tuple[str, ...] # 24 个 INSERT 字段(call_id 起、meta 止)
|
||||
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
|
||||
PG_DDL: str
|
||||
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
|
||||
PG_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
|
||||
```
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS` 含 `created_at`,可作对照)。
|
||||
|
||||
**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。
|
||||
|
||||
两个语句构造函数:
|
||||
|
||||
```python
|
||||
def insert_sql(backend: str, columns: Sequence[str]) -> str:
|
||||
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
|
||||
|
||||
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
|
||||
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
|
||||
"""
|
||||
|
||||
def telemetry_schema_sql(backend: str) -> str:
|
||||
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
|
||||
```
|
||||
|
||||
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
|
||||
|
||||
```python
|
||||
class SQLiteRecorder:
|
||||
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
|
||||
|
||||
class PostgresRecorder:
|
||||
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
|
||||
```
|
||||
|
||||
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
|
||||
|
||||
```python
|
||||
telemetry_auto_migrate: bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 建 `telemetry/schema.py` 单一事实源
|
||||
|
||||
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
|
||||
- **行为**: 把 `sqlite.py` 的 `_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS` 与 `postgres.py` 的 `_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()` 与 `telemetry_schema_sql()`。
|
||||
- **验收**:
|
||||
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
|
||||
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
|
||||
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。
|
||||
- `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`。
|
||||
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
|
||||
- **提交**: `refactor: make the telemetry schema a single source of truth`
|
||||
|
||||
## Task 2: PG 写入去掉冲突目标
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
|
||||
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12 按 `created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
|
||||
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
|
||||
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
|
||||
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
|
||||
|
||||
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)
|
||||
|
||||
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`。
|
||||
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
|
||||
- **行为**:
|
||||
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`。
|
||||
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns` 与 `self._insert = insert_sql(backend, effective)`;`record_llm_call` 按 `self._columns` 取值。
|
||||
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
|
||||
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
|
||||
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
|
||||
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
|
||||
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
|
||||
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
|
||||
- **验收**: 见测试。
|
||||
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
|
||||
- manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。
|
||||
- auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
|
||||
- manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
|
||||
- **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436` 的 `captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
|
||||
- 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。
|
||||
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
|
||||
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
|
||||
|
||||
## Task 4: 配置派生与装配
|
||||
|
||||
- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
|
||||
- **行为**:
|
||||
- `config.py` 增 `_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
|
||||
- `GatewaySettings` 增 `telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`。
|
||||
- `.env.example` 在 `PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
|
||||
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
|
||||
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
|
||||
- **提交**: `feat: derive the schema mode from the telemetry backend`
|
||||
|
||||
## Task 5: 公共导出
|
||||
|
||||
- [ ] **文件**: `src/polygateway/__init__.py`、`tests/unit/test_package.py`。
|
||||
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
|
||||
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
|
||||
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
|
||||
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
|
||||
|
||||
## Task 6: 真实 Postgres 集成验收
|
||||
|
||||
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。
|
||||
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
|
||||
- **验收**:
|
||||
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
|
||||
- **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
|
||||
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
|
||||
- **提交**: `test: prove manual mode leaves a stale table untouched`
|
||||
|
||||
## Task 7: 文档与承诺
|
||||
|
||||
- [ ] **文件**: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
|
||||
- **行为**:
|
||||
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
|
||||
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
|
||||
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
|
||||
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
|
||||
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` ∪ `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
|
||||
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
|
||||
- **提交**: `docs: document the schema mode and the expand-contract promise`
|
||||
|
||||
---
|
||||
|
||||
## 完成判据
|
||||
|
||||
1. 七个任务的提交点全部落地,`make ci` 全绿。
|
||||
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
|
||||
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
|
||||
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
|
||||
@@ -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)。T0–T7 全部提交完成,提交表见文末;合并前的三道门(`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-issue12-telemetry-retention
|
||||
title: "实现计划: issue12-telemetry-retention"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue12-telemetry-retention
|
||||
|
||||
|
||||
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
|
||||
|
||||
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
|
||||
|
||||
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。
|
||||
|
||||
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter` 的 `text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient` 的 `__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py` 挂 `PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:plan-issue13-schema-mode
|
||||
title: "实现计划: issue13-schema-mode"
|
||||
date: 2026-08-19
|
||||
---
|
||||
|
||||
# 实现计划: issue13-schema-mode
|
||||
|
||||
|
||||
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
|
||||
|
||||
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
|
||||
|
||||
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。
|
||||
- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS` 是 **INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint` 带 `--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。
|
||||
@@ -0,0 +1,312 @@
|
||||
# 实现计划: 熔断拒绝补齐等待档(issue #14)
|
||||
|
||||
- **设计**: `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`(人类已确认 + Codex 已审)
|
||||
- **分支**: `feat/issue-14-circuit-open-policy`
|
||||
- **版本**: 1.3.0(新增配置键 + `retry_after_s` 语义变更)
|
||||
|
||||
## 目标
|
||||
|
||||
让"源不健康"不再等同于"这次调用当场判死"——补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 这一格准入策略,并把 `retry_after_s` 的语义在两个后端的五个出口上定死。
|
||||
|
||||
## 方案概述
|
||||
|
||||
三件事环环相扣: ①把 `retry_after_s` 定义为"距离**确定**可再试的时刻还有多久",HALF_OPEN 与准入允许一律 `0.0`(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 `circuit_open` 策略键,`wait` 档下不抛 `CircuitOpenError` 而按 `retry_after` 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。
|
||||
|
||||
涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。
|
||||
|
||||
## 保真校验适用性
|
||||
|
||||
**适用**。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 `reference/Video-Tree-TRM5/adapters/breaker.py` 与 `reference/CHSAnalyzer/app/coordination/provider_gate.py`),准入循环蓝本为 `reference/CHSAnalyzer/app/providers/governance.py:107-285`。T1 与 T2/T3 各带保真校验检查点。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/polygateway/middleware/admission.py` | **新建** | `SourceAdmission`(准入与无源可跑的处置,三条循环共用)+ 模块级 `settle_and_release` |
|
||||
| `src/polygateway/middleware/retry.py` | 修改 | 删除本地 `_pick_runnable`/`_on_no_runnable`/`_settle_and_release`,改用 `SourceAdmission`;主循环与 `_attempt` 不动 |
|
||||
| `src/polygateway/embedding.py` | 修改 | 同上 |
|
||||
| `src/polygateway/ocr.py` | 修改 | 同上(注意 `_settle_and_release` 原签名只有 `permit`) |
|
||||
| `src/polygateway/backends/memory/breaker.py` | 修改 | 抽 `_remaining(g)`,三处出口共用;HALF_OPEN 与授予探针恒 `0.0` |
|
||||
| `src/polygateway/backends/redis/breaker.py` | 修改 | 五个 Lua 出口同步(`TRY_ENTER` 两处、`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE` 各一处、`RETRY_AFTER` 一处) |
|
||||
| `src/polygateway/config.py` | 修改 | `_CIRCUIT_OPEN` 常量、`GatewaySettings.circuit_open` 字段、`_validate_backends` 元组、`from_env` 装载 |
|
||||
| `src/polygateway/client.py` | 修改 | 构造签名 + 透传 |
|
||||
| `src/polygateway/errors.py` | 修改 | `GatewayUnavailableError` docstring 职责边界 |
|
||||
| `tests/contracts/test_breaker_contract.py` | 修改 | 按五个出口逐个钉 `retry_after_s` |
|
||||
| `tests/integration/test_redis_governance_time.py` | 修改 | Redis 真实等待变体补 HALF_OPEN 出口 |
|
||||
| `tests/unit/test_backpressure.py` | 修改 | `circuit_open` 行为矩阵、备忘污染回归、`_nap` 上界 |
|
||||
| `tests/unit/test_config.py` | 修改 | 新键的合法域、缺省、两条装配路一致 |
|
||||
|
||||
## 关键接口(跨任务消费,此处定死)
|
||||
|
||||
`SourceAdmission` 构造与两个方法:
|
||||
|
||||
```python
|
||||
class SourceAdmission:
|
||||
def __init__(self, *, scope: str, sources: list[SourceConfig],
|
||||
selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
|
||||
memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
|
||||
quota_full: str, circuit_open: str,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
health_view: Callable[[str], float] | None = None,
|
||||
now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...
|
||||
|
||||
async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...
|
||||
|
||||
async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
|
||||
clock: StallClock) -> None: ...
|
||||
|
||||
async def stalled(self, clock: StallClock) -> bool: ...
|
||||
```
|
||||
|
||||
`quota`/`breaker`/`pacer`/`selector`/`sources` 均为**调用方传入的同一实例**(不在 admission 内新建),因为三处 `_attempt` 仍需引用它们;`memo` 则由 admission 独占。`health_view` 对应 chat 的 `self._health_view`(由 `isinstance(selector, OutcomeAwareSelector)` 在 RetryMW 构造期判定一次),embedding/ocr 传 `None`。
|
||||
|
||||
模块级结算函数(三处 `_attempt` 的 finally 与 admission 共用):
|
||||
|
||||
```python
|
||||
async def settle_and_release(permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
|
||||
```
|
||||
|
||||
睡眠时长(T5 实现,写死在 `SourceAdmission._nap`):
|
||||
|
||||
```python
|
||||
def _nap(self, hint: float, clock: StallClock) -> float:
|
||||
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
|
||||
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
|
||||
wait = hint + jitter if hint > 0 else jitter
|
||||
return max(jitter, min(wait, budget))
|
||||
```
|
||||
|
||||
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
|
||||
|
||||
**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05` → `jitter∈[0.025,0.05]`、`budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。
|
||||
|
||||
## 任务清单
|
||||
|
||||
### T0 — 分支与基线
|
||||
|
||||
- [ ] 建分支 `feat/issue-14-circuit-open-policy`(从 main)
|
||||
- [ ] 记录基线: `conda run -n PolyGateway python -m pytest tests/ -q` 与 `make check` + `lint-imports` 全绿,记下**本机本环境**的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——`addopts = "-m 'not slow'"` 与 Redis 可达性都会改变它,不作硬验收
|
||||
|
||||
**验证**: `conda run -n PolyGateway python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确
|
||||
|
||||
---
|
||||
|
||||
### T1 — 纯重构: 准入逻辑三处收敛(回滚点)
|
||||
|
||||
**动**: 新建 `src/polygateway/middleware/admission.py`;改 `middleware/retry.py`、`embedding.py`、`ocr.py`。
|
||||
|
||||
**要实现的行为**: 把 `_pick_runnable`/`_on_no_runnable`/`_stalled`/`_settle_and_release` 从三处搬进 `SourceAdmission` 与模块级 `settle_and_release`,三条循环改为持有 `SourceAdmission` 实例并调用其方法。**本任务不引入 `circuit_open` 参数**(构造签名先只收 `quota_full`,T4 再加),控制流一字不改。
|
||||
|
||||
三条循环的差异只用注入表达,不留 `if` 分支:
|
||||
|
||||
| 差异 | 处理 | 等价性依据 |
|
||||
|---|---|---|
|
||||
| 调用内降权(仅 chat) | `attempt_fails` 作 `pick()` 入参,内部无条件调 `_demote_call_failures` | 传空 dict 时 `demoted` 为空 → `return ordered` 原对象返回,恒等(`retry.py:148-150`) |
|
||||
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit()` 与 `enter()` 两个调用点,无副作用 |
|
||||
| `_settle_and_release` 签名 | OCR 原为 `(permit)`、体内恒 `settle(0)`;改为调 `settle_and_release(permit, 0)` | 逐字等价 |
|
||||
| warning 文案**三处都不同** | 归一为 "permit 结算/释放失败(不掩盖主异常)" | chat `retry.py:536` 已是该文案;embedding `embedding.py:411` 为 "embedding permit …"、OCR `ocr.py:448` 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务**唯一**的可见行为变化,须在提交信息里点名 |
|
||||
| `_stalled` 形态 | chat 已抽成方法,embedding/ocr 为内联表达式 | 两者语义逐字相同(已 diff 核实),统一用 `SourceAdmission.stalled()` |
|
||||
|
||||
**搬走 vs 共享(自审修正,这一条决定 T1 能否成立)**: 三处 `_attempt` 仍在引用 `self._breaker`(记账写回)、`self._quota`(mark_progress)、`self._pacer`(leave)、OCR 还有 `self._selector`(健康喂数,`ocr.py:426`)。因此这些字段**不搬走,而是共享同一实例**——循环保留自己的引用,构造 `SourceAdmission` 时把同一对象传进去(`AdaptivePacer` 有在途计数状态,必须是同一实例而非新建,否则 `admit`/`enter` 与 `leave` 分裂到两个计数器上)。真正搬走的只有 `_pick_runnable`/`_on_no_runnable`/`_stalled` 三个方法与 `self._memo`(仅被 `pick` 消费)。
|
||||
|
||||
**`_attempt` 的唯一改动**: `self._settle_and_release(permit, actual)` → 模块级 `settle_and_release(permit, actual)`,OCR 侧由 `(permit)` 变为 `(permit, 0)`。除此之外 `_attempt` 一行不动。原稿"三处 `_attempt` 本体不在边界内"的说法与"搬走 `_settle_and_release`"自相矛盾,此处更正。
|
||||
|
||||
**不在边界内、须原样保留**: chat 主循环顶部那次额外的 `_stalled` 预判(`retry.py:286`)、OCR 的 `_gate_on_terminal`(`ocr.py:412`)与健康喂数。
|
||||
|
||||
**保真校验检查点**: 对照 `reference/CHSAnalyzer/app/providers/governance.py:107-285`,确认搬运后 `_pick_runnable` 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、`gate_rejections` 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、`_on_no_runnable` 的三段判定顺序逐段未变。
|
||||
|
||||
**测试要求(本任务特殊)**: **不新增行为用例**。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次**机械差异审查**: 把搬迁前后的 `pick`/`on_no_runnable` 逐语句对照,确认候选跳过顺序、`gate_rejections` 计入规则、`reasons` 的 `[]=` 与 `setdefault` 用法(两者语义不同,不可互换)一字未变。
|
||||
|
||||
**已知会碰到的既有测试**: `tests/unit/test_health_selector.py:146` 断言 `client._terminal._pacer._ceiling`,`tests/unit/test_client.py:380` 断言 `._terminal._emitter._text_cap`——这两个字段必须留在 `RetryMW` 上(与上面"共享而非搬走"一致),否则这些用例会红。
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/ -q # 期望: 全 PASS,计数与 T0 同环境基线一致
|
||||
conda run -n PolyGateway make check # 只读: ruff format --check + ruff check
|
||||
conda run -n PolyGateway lint-imports # 依赖铁律
|
||||
```
|
||||
**不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check` + `lint-imports`。用例计数只作**同环境**参照,不作硬验收: `pytest` 默认 `-m 'not slow'`(`pyproject.toml:51`),且无 `REDIS_URL` 时 Redis 用例 skip,计数随环境浮动。
|
||||
|
||||
import-linter 层级(`pyproject.toml:76`)允许 `middleware/admission.py` 依赖 `ports`/`types`/`errors`/`sources`(更内层),但不得 import 任何 `backends/`、`transports/`、`telemetry/`。搬迁后须清理三个原文件中失去引用的 import(`CircuitOpenError`、`QuotaGate`、`BreakerGate`、`SourceCooldownMemo` 等),否则 ruff 报未使用导入。
|
||||
|
||||
- [ ] 提交: `refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission`
|
||||
|
||||
---
|
||||
|
||||
### T2 — `retry_after_s` 语义统一(两个后端一次到位)
|
||||
|
||||
**动**: `src/polygateway/backends/memory/breaker.py`、`src/polygateway/backends/redis/breaker.py`、`tests/contracts/test_breaker_contract.py`、`tests/integration/test_redis_governance_time.py`。
|
||||
|
||||
**为什么两个后端必须同一个提交(Codex 审查修正)**: 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 `.claude/settings.json` 注册的 `pre-commit-guard.sh` 在检测到 `git commit` 时会跑 `pytest tests/ --tb=line -q`(`pre-commit-guard.sh:61`),红态直接卡住提交。且两者本就是**同一个契约的两个实现**,分开提交没有独立意义。
|
||||
|
||||
**要实现的行为**: `retry_after_s` = "距离**确定**可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 `0.0`;准入被允许时同样恒 `0.0`。`0 = 可立即重试` 是库既有约定(`errors.py` 与现有契约用例"健康 → 0、冷却到期 → 0")。
|
||||
|
||||
memory 侧: 抽私有纯方法 `_remaining(g: _SourceGate) -> float`(OPEN 返回 `max(0.0, g.open_until - now)`,其余状态含 HALF_OPEN 返回 `0.0`),`try_enter` 的 HALF_OPEN 拒绝分支(`memory:148`)与 `retry_after_s()`(`memory:267`)改用它。`_snapshot`(`memory:169`)与授予探针(`memory:114`)已符合新契约,保持不变。
|
||||
|
||||
redis 侧共**六个返回格**,逐处点名(改前先确认行号仍对得上):
|
||||
|
||||
| 脚本 | 位置 | 现状 | 改为 |
|
||||
|---|---|---|---|
|
||||
| `TRY_ENTER` HALF_OPEN 拒绝 | `redis:44` | `probe_until - now` | `0` |
|
||||
| `TRY_ENTER` 授予探针 | `redis:53` | `tonumber(ARGV[2])`(= probe TTL) | `0` |
|
||||
| `RECORD_SUCCESS` fencing 未命中 | `redis:124` | half_open 取 `probe_until` | half_open 记 `0`(只 OPEN 取 `open_until - now`) |
|
||||
| `RECORD_FAILURE` fencing 未命中 | `redis:155` | 同上 | 同上 |
|
||||
| `RELEASE_PROBE` fencing 未命中 | `redis:255` | 同上 | 同上 |
|
||||
| `RETRY_AFTER` | `redis:275` | half_open 取 `probe_until` | half_open 记 `0` |
|
||||
|
||||
后四行修的是**既有的双后端语义分叉**(memory `_snapshot` 对非 OPEN 一律 `0.0`),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。
|
||||
|
||||
**保真校验检查点**: 状态机转换、双通道开路判据、`_cooldown_eff` 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 `redis.call('TIME')` 服务器时钟口径**一律不动**——本任务只改"对外报几"这一件事,即 return 元组里 `retry_after_ms` 那一格。改完逐脚本与 memory 实现对照走一遍状态机。
|
||||
|
||||
**测试要求**(先失败后通过,`tests/contracts/` 双后端参数化,一次覆盖 memory + redis):
|
||||
- HALF_OPEN 被拒: `decision.retry_after_s == 0.0` 且 `decision.state is GateState.HALF_OPEN`
|
||||
- 授予探针的决定: `retry_after_s == 0.0`
|
||||
- `record_*` 在 fencing 未命中且门处于 HALF_OPEN: `GateUpdate.retry_after_s == 0.0`(须同时断言 `applied is False`、`state is HALF_OPEN`,否则用例可能在别的分支上误绿)
|
||||
- `gate.retry_after_s(("s1",))` 探针在途时返回 `0.0`
|
||||
- 现有 `test_retry_after_semantics` / `test_retry_after_takes_min_across_sources` 保持绿(OPEN 语义未变)
|
||||
|
||||
**Redis 时间语义变体**: 契约层用 `clock.advance()` 的用例在 redis 参数下会 skip(`conftest.py:39` 的 `SkipClock` 哨兵),故须在 `tests/integration/test_redis_governance_time.py` 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 `test_meta_variants_cover_all_time_cases`(`:56`)会**机械拦截**漏配,漏了就红。
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q # 双后端全 PASS
|
||||
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q
|
||||
```
|
||||
第二条**必须带 `-m slow`**: `pyproject.toml:51` 的 `addopts = "-m 'not slow'"` 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。
|
||||
|
||||
- [ ] 提交: `fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)`
|
||||
|
||||
---
|
||||
|
||||
### T3 — (已并入 T2)
|
||||
|
||||
原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。
|
||||
|
||||
---
|
||||
|
||||
### T4 — 新配置键 `{SCOPE}__CIRCUIT_OPEN`
|
||||
|
||||
**动**: `src/polygateway/config.py`、`src/polygateway/client.py`、`src/polygateway/middleware/admission.py`、`embedding.py`、`ocr.py`、`tests/unit/test_config.py`。
|
||||
|
||||
**要实现的行为**: 与 `quota_full` 逐项同构,不发明新形状。
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `config.py` 常量区 | `_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})`,紧邻 `_QUOTA_FULL` |
|
||||
| `GatewaySettings` | 新增字段 `circuit_open: str`,**无默认值**(与该类全部既有字段一致),位置紧随 `quota_full` |
|
||||
| `_validate_backends` | 校验元组加一行 `("circuit_open", _CIRCUIT_OPEN)` |
|
||||
| `from_env` | `circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")` |
|
||||
| `client.py` | `GatewayClient.__init__` 加 `circuit_open: str = "fail_fast"`;`from_settings` 透传 `settings.circuit_open` |
|
||||
| `admission.py` | 构造收 `circuit_open`,同 `quota_full` 做构造期域校验并抛 `ValueError` |
|
||||
| `embedding.py` / `ocr.py` | 两个客户端的构造签名与"从 GatewayClient 派生"路径(`embedding.py:561`、`ocr.py:574` 邻域)各透传一处 |
|
||||
|
||||
**缺省取 `fail_fast`**(人类 2026-08-19 决策): 保证控制流对存量下游不变。
|
||||
|
||||
**测试要求**(先失败后通过):
|
||||
- 缺省档: 不设该键时 `settings.circuit_open == "fail_fast"`
|
||||
- 合法域: 设为 `"nope"` 时 `from_env` 与直接构造**两条路**都抛 `ValueError` 且消息点出键名/字段名
|
||||
- 两条装配路一致: `from_env` 与直接构造同一取值产出同一行为
|
||||
- `dataclasses.replace(settings, circuit_open="wait")` 仍通过全部装配守卫
|
||||
- 透传链: 从 `GatewaySettings` 一路到三条循环的 `SourceAdmission` 实例上取值正确
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
|
||||
```
|
||||
|
||||
- [ ] 提交: `feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)`
|
||||
|
||||
---
|
||||
|
||||
### T5 — `on_no_runnable` 按原因分派 + `_nap`
|
||||
|
||||
**动**: `src/polygateway/middleware/admission.py`、`tests/unit/test_backpressure.py`。
|
||||
|
||||
**要实现的行为**: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:
|
||||
|
||||
1. `gate_rejections == len(sources)`(全部因熔断类原因被拒)时,`fail_fast` 抛 `CircuitOpenError`(现行为),`wait` 取 `hint = await breaker.retry_after_s(names)` 后**不抛**;
|
||||
2. 否则(至少一源是被配额/AIMD 挡的)走 `quota_full` 分支,`hint = 0.0`;
|
||||
3. 两条路汇合后统一判 `stalled()`,再 `await sleep(self._nap(hint, clock))`。
|
||||
|
||||
**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。
|
||||
|
||||
**可观测性**: `wait` 档每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
|
||||
|
||||
**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。
|
||||
|
||||
**取消穿透**: `_nap` 只做算术,睡眠是裸 `await self._sleep(...)`,不得包 `try/except`。
|
||||
|
||||
**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
|
||||
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
|
||||
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
|
||||
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons` 含 `circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
|
||||
- `circuit_open=wait` + 源持续 `force_open` → **`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
|
||||
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
|
||||
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
|
||||
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放
|
||||
- `circuit_open=fail_fast`(缺省)下,全部现有用例逐字绿
|
||||
- **备忘污染回归**(issue #14 §1.3): 探针成功后 `memo.active(源名)` 为 False,该源立即重新可选——此用例由 `/tmp/.../probe_repro.py` 的复现脚本转化而来,在 T2 之前必然 red
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
|
||||
conda run -n PolyGateway python -m pytest tests/ -q # 全套件
|
||||
```
|
||||
|
||||
- [ ] 提交: `feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死`
|
||||
|
||||
---
|
||||
|
||||
### T6 — `errors.py` 职责边界补写
|
||||
|
||||
**动**: `src/polygateway/errors.py`。
|
||||
|
||||
**要实现的行为**: 改写 `GatewayUnavailableError` 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同(ARCH §7.2 单层重试原则)。
|
||||
|
||||
`retry_after_s` 那句保留并补一句: 它是"距离确定可再试的时刻",`0` 表示无确定等待(可立即重试)。
|
||||
|
||||
**测试要求**: 纯 docstring,无行为变更。验收为 `tests/unit/test_errors.py` 保持绿。
|
||||
|
||||
**验证**: `conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q`
|
||||
|
||||
- [ ] 提交: `docs: 收回 GatewayUnavailableError 的重试职责边界`
|
||||
|
||||
---
|
||||
|
||||
### T7 — 文档同步
|
||||
|
||||
**动**: `research-wiki/ARCHITECTURE.md`、`README.md`、`CHANGELOG.md`、Gitea wiki。
|
||||
|
||||
| 目标 | 内容 |
|
||||
|---|---|
|
||||
| ARCH §7.4 | 增补本次决策: 三条缺陷的成因、`retry_after_s` 的契约定义(五个出口)、`circuit_open` 策略键与缺省理由 |
|
||||
| ARCH §9 配置面 | 登记 `{SCOPE}__CIRCUIT_OPEN` |
|
||||
| README | 配置表新增该键;**明写"单源 scope 建议配 `wait`"**——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改 |
|
||||
| CHANGELOG | 记 1.3.0,`retry_after_s` 语义变更给"请先读这一条"待遇(缺省档下 `CircuitOpenError.retry_after_s` 在全源 HALF_OPEN 时由探针租约剩余变为 0) |
|
||||
| Gitea wiki | 按 `research-wiki/docs-convention.md` §2 清单同步 |
|
||||
|
||||
**验证**: 人工逐项核对上表;`grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md` 各有命中。
|
||||
|
||||
- [ ] 提交: `docs: 记录熔断等待档与 retry_after_s 契约`
|
||||
|
||||
---
|
||||
|
||||
### T8 — 合并前独立验证
|
||||
|
||||
- [ ] 派**全新上下文** verifier subagent(`verification-before-completion`),逐条核对: 设计每一节是否有对应实现、五个 `retry_after_s` 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
|
||||
- [ ] `conda run -n PolyGateway make check` + `conda run -n PolyGateway lint-imports` 全绿(**不用 `make lint`**,它带 `--fix` 会改文件)
|
||||
- [ ] `conda run -n PolyGateway make test` 全套件绿 + 覆盖率 ≥ 80%
|
||||
- [ ] Redis integration 套件在真实 Redis 上绿,含 `-m slow` 的时间语义变体(默认 addopts 会排除它)
|
||||
- [ ] `requesting-code-review` 走一次整分支审查
|
||||
- [ ] `finishing-a-development-branch`: `--no-ff` 合并 main,合并后在 main 上重跑 lint 与全套件
|
||||
|
||||
**注**: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,**不在本计划范围**,需人类确认后单独执行。
|
||||
|
||||
## 自审记录
|
||||
|
||||
- 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
|
||||
- 无 TBD/TODO/"适当的错误处理"类占位
|
||||
- 跨任务消费的 `SourceAdmission` 签名、`settle_and_release`、`_nap` 公式已在"关键接口"写出实际代码
|
||||
- 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦
|
||||
@@ -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")。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: review
|
||||
node_id: review:issue14-branch-review
|
||||
title: "整分支审查: issue #14 熔断等待档"
|
||||
date: 2026-08-20
|
||||
---
|
||||
|
||||
# 整分支审查: issue #14 熔断等待档
|
||||
|
||||
|
||||
- **范围**: `feat/issue-14-circuit-open-policy`,296c765..5a025b6(8 提交,src 6 文件 + tests 5 文件)
|
||||
- **审查方**: Codex 全新上下文只读审查(两轮: 独立验收 + 整分支审查)
|
||||
- **结论**: **needs_changes → 修正后 approved**;Critical 0 项
|
||||
|
||||
## 发现与处置
|
||||
|
||||
| 级别 | 发现 | 核实 | 处置 |
|
||||
|---|---|---|---|
|
||||
| Important | `circuit_open=wait` + 持续 `force_open` 实际抛 `retry_exhausted` 而非文档声称的 `stalled` | **成立**。冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`;审查方以单源 + 连续 `SourceDeadError("401")` 复现,本地补测试复现一致 | **改文档不改代码**——该行为符合 issue #8 确立的"划分依据是谁消耗重试预算"。修正 CHANGELOG / README / 设计 §4 行为矩阵 / 计划 T5,并补 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉死 |
|
||||
| Minor | 计划要求进入/退出等待各一条日志,实现只有进入那条 | 成立 | **保持一条**,修计划措辞: 每轮等待各自留痕已可还原时间线,醒来后若仍被拒会立刻打下一条,补"醒来"只会让日志量翻倍 |
|
||||
| — | 上一轮独立验收挑出计划 `_nap` 伪码下界与实现不一致(`poll_interval_s` vs `jitter`) | 成立 | 实现是对的(用 `poll_interval_s` 会把既有 quota 轮询的 `rng→0` 半边从 `0.5p` 抬到 `1.0p`),已回填计划 |
|
||||
|
||||
审查方两轮均确认: T1 收敛行为等价、六个 `retry_after_s` 出口齐备、备忘污染闭合、取消穿透与 permit/pacer 配对无泄漏、缺省档控制流不变。
|
||||
|
||||
## 验证证据(本会话工具输出)
|
||||
|
||||
- 全套件 `pytest tests/ -q`: **980 passed, 25 skipped, 36 deselected**(基线 967 passed;+13 为新增用例)
|
||||
- 覆盖率 `make test`: 总 **94%**(`admission.py` 93%、`config.py` 99%、`memory/breaker.py` 96%)
|
||||
- Redis 时间语义全变体 `-m slow`: **18 passed in 1151s**(19 分 11 秒,真实等待不缩放),含本次新增 4 个
|
||||
- `make check` 与 `lint-imports`: 全绿,**Contracts: 1 kept, 0 broken**
|
||||
@@ -23,6 +23,7 @@ from polygateway.errors import (
|
||||
from polygateway.ocr import OcrClient
|
||||
from polygateway.pricing import ModelPrice, PricingTable
|
||||
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
|
||||
from polygateway.telemetry.schema import telemetry_schema_sql
|
||||
from polygateway.types import (
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
@@ -30,9 +31,10 @@ from polygateway.types import (
|
||||
OcrLayoutResult,
|
||||
OcrTextResult,
|
||||
SourceConfig,
|
||||
TelemetryStatus,
|
||||
)
|
||||
|
||||
__version__ = "1.2.1"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROFILES",
|
||||
@@ -60,8 +62,10 @@ __all__ = [
|
||||
"SourceConfig",
|
||||
"SourceDeadError",
|
||||
"SourceNotConfiguredError",
|
||||
"TelemetryStatus",
|
||||
"TransientError",
|
||||
"__version__",
|
||||
"gather_bounded",
|
||||
"register_provider",
|
||||
"telemetry_schema_sql",
|
||||
]
|
||||
|
||||
@@ -100,6 +100,22 @@ class InMemoryGate:
|
||||
streak = max(1, g.reopen_streak)
|
||||
return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s)
|
||||
|
||||
def _remaining(self, g: _SourceGate) -> float:
|
||||
"""距离**确定**可再试的时刻还有多久(issue #14 的契约定义)。
|
||||
|
||||
OPEN 的冷却截止是确定时刻;HALF_OPEN 下探针随时可能出结果,**不存在**
|
||||
确定时刻,故 `0.0`——`0 = 可立即重试` 是库既有约定。此前这里返回探针
|
||||
租约剩余,而租约长度是死锁保护参数(派生自 `2 × 最慢源 timeout`),与
|
||||
"源多久能恢复"无因果关系;它还被喂进源冷却备忘,而备忘 `set_until`
|
||||
取更晚者不可回退,于是门恢复 CLOSED 后本进程仍跳过该源整整一个租约。
|
||||
|
||||
三个出口(`try_enter` 拒绝、`_snapshot`、`retry_after_s`)共用本方法,
|
||||
避免同一语义在三处各算一遍而漂移。
|
||||
"""
|
||||
if g.state is GateState.OPEN:
|
||||
return max(0.0, g.open_until - self._now())
|
||||
return 0.0
|
||||
|
||||
def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision:
|
||||
g.state = GateState.HALF_OPEN
|
||||
g.probe_owner = owner
|
||||
@@ -140,7 +156,7 @@ class InMemoryGate:
|
||||
epoch=g.epoch,
|
||||
is_probe=False,
|
||||
probe_owner=None,
|
||||
retry_after_s=g.open_until - now,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
# HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群)
|
||||
if now >= g.probe_expires:
|
||||
@@ -152,7 +168,7 @@ class InMemoryGate:
|
||||
epoch=g.epoch,
|
||||
is_probe=False,
|
||||
probe_owner=None,
|
||||
retry_after_s=g.probe_expires - now,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
|
||||
def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool:
|
||||
@@ -172,9 +188,7 @@ class InMemoryGate:
|
||||
state=g.state,
|
||||
epoch=g.epoch,
|
||||
failure_count=g.fails,
|
||||
retry_after_s=max(0.0, g.open_until - self._now())
|
||||
if g.state is GateState.OPEN
|
||||
else 0.0,
|
||||
retry_after_s=self._remaining(g),
|
||||
)
|
||||
|
||||
def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None:
|
||||
@@ -258,14 +272,4 @@ class InMemoryGate:
|
||||
"""集合中最早可尝试时间;健康/到期返回 0。"""
|
||||
if not sources:
|
||||
raise ValueError("sources 不能为空")
|
||||
now = self._now()
|
||||
waits = []
|
||||
for name in sources:
|
||||
g = self._gate(name)
|
||||
if g.state is GateState.OPEN:
|
||||
waits.append(max(0.0, g.open_until - now))
|
||||
elif g.state is GateState.HALF_OPEN:
|
||||
waits.append(max(0.0, g.probe_expires - now))
|
||||
else:
|
||||
waits.append(0.0)
|
||||
return min(waits)
|
||||
return min(self._remaining(self._gate(name)) for name in sources)
|
||||
|
||||
@@ -42,7 +42,8 @@ if state == 'open' and now < open_until then
|
||||
return {0, state, epoch, 0, '', open_until - now}
|
||||
end
|
||||
if state == 'half_open' and now < probe_until then
|
||||
return {0, state, epoch, 0, '', probe_until - now}
|
||||
-- 探针在途: 无确定的可再试时刻 → 0(issue #14,与 memory `_remaining` 同口径)
|
||||
return {0, state, epoch, 0, '', 0}
|
||||
end
|
||||
|
||||
local next_probe_until = now + tonumber(ARGV[2])
|
||||
@@ -50,7 +51,7 @@ redis.call('HSET', KEYS[1],
|
||||
'state', 'half_open',
|
||||
'probe_owner', ARGV[1],
|
||||
'probe_until', next_probe_until)
|
||||
return {1, 'half_open', epoch, 1, ARGV[1], tonumber(ARGV[2])}
|
||||
return {1, 'half_open', epoch, 1, ARGV[1], 0}
|
||||
"""
|
||||
|
||||
# M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数)
|
||||
@@ -124,8 +125,6 @@ end
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
"""
|
||||
@@ -156,8 +155,6 @@ if not matches then
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
end
|
||||
@@ -255,8 +252,6 @@ end
|
||||
local deadline = 0
|
||||
if state == 'open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
|
||||
elseif state == 'half_open' then
|
||||
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
|
||||
end
|
||||
return {0, state, epoch, failures, math.max(deadline - now, 0)}
|
||||
"""
|
||||
@@ -272,9 +267,6 @@ for _, key in ipairs(KEYS) do
|
||||
if state == 'open' then
|
||||
local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0')
|
||||
remaining = math.max(deadline - now, 0)
|
||||
elseif state == 'half_open' then
|
||||
local deadline = tonumber(redis.call('HGET', key, 'probe_until') or '0')
|
||||
remaining = math.max(deadline - now, 0)
|
||||
end
|
||||
if minimum == nil or remaining < minimum then minimum = remaining end
|
||||
end
|
||||
@@ -367,7 +359,9 @@ class RedisGate:
|
||||
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 try_enter 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._decision(source_name, result)
|
||||
|
||||
async def record_success(
|
||||
@@ -385,7 +379,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_success 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def record_failure(
|
||||
@@ -407,7 +403,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 record_failure 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def release_probe(self, entry: GateDecision) -> GateUpdate:
|
||||
@@ -419,7 +417,9 @@ class RedisGate:
|
||||
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 release_probe 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return self._update(result)
|
||||
|
||||
async def retry_after_s(self, sources: tuple[str, ...]) -> float:
|
||||
@@ -429,7 +429,9 @@ class RedisGate:
|
||||
try:
|
||||
result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return int(result) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -247,7 +247,9 @@ class RedisLimiter:
|
||||
],
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 try_acquire 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
if ok != 1:
|
||||
return None
|
||||
return _RedisPermit(self, source_key, lease_id, est_tokens, window)
|
||||
@@ -265,7 +267,9 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._release_lua(keys=[gl, sl], args=[lease_id])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 release 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
|
||||
wk = self._window_keys(source_key, window)
|
||||
@@ -283,7 +287,9 @@ class RedisLimiter:
|
||||
wk = self._window_keys(source_key, window)
|
||||
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 source_stats 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return SourceStats(
|
||||
inflight=int(res[0]),
|
||||
rpm_used=max(0, int(res[1])),
|
||||
@@ -295,14 +301,18 @@ class RedisLimiter:
|
||||
try:
|
||||
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 mark_progress 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
|
||||
async def progress_age_s(self) -> float:
|
||||
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
|
||||
try:
|
||||
res = await self._progress_age_lua(keys=[self._progress_key()])
|
||||
except RedisError as exc:
|
||||
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc
|
||||
raise GovernanceBackendError(
|
||||
f"限流后端 progress_age_s 失败: {exc}", scope=self._scope
|
||||
) from exc
|
||||
return float("inf") if int(res) == -1 else int(res) / 1000.0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -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:
|
||||
await self._client.aclose()
|
||||
"""幂等释放自建客户端;注入的客户端归注入方管理。"""
|
||||
if self._owns_client:
|
||||
self._owns_client = False
|
||||
await self._client.aclose()
|
||||
|
||||
+114
-25
@@ -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% 场景。"""
|
||||
|
||||
@@ -134,8 +183,10 @@ class GatewayClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
cache: CacheBackend | None = None,
|
||||
cache_namespace: str | None = None,
|
||||
cache_ttl_s: int | None = None,
|
||||
@@ -146,7 +197,11 @@ class GatewayClient:
|
||||
sleep: Any = asyncio.sleep,
|
||||
rng: Any = random.random,
|
||||
) -> None:
|
||||
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None
|
||||
emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
|
||||
if telemetry is not None
|
||||
else None
|
||||
)
|
||||
terminal = RetryMW(
|
||||
scope=scope,
|
||||
sources=sources,
|
||||
@@ -157,6 +212,7 @@ class GatewayClient:
|
||||
retry=retry,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
cooldown_memo=SourceCooldownMemo(now=now),
|
||||
# AIMD ceiling 尊重源级静态并发上限(独立核验 I1: 不得静默钳制大于 64 的配置)
|
||||
pacer=AdaptivePacer(
|
||||
@@ -197,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]],
|
||||
@@ -254,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
|
||||
@@ -298,20 +375,22 @@ 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,
|
||||
quota_full=settings.quota_full,
|
||||
circuit_open=settings.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(settings),
|
||||
pricing=PricingTable.from_file(settings.pricing_path)
|
||||
if settings.pricing_path is not None
|
||||
else None,
|
||||
text_cap=settings.telemetry_text_cap,
|
||||
cache=cache if cache is not None else _build_cache(settings),
|
||||
cache_namespace=settings.cache_namespace,
|
||||
cache_ttl_s=settings.cache_ttl_s,
|
||||
@@ -319,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(
|
||||
@@ -400,11 +482,18 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
|
||||
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return PostgresRecorder(settings.telemetry_pg_dsn)
|
||||
return PostgresRecorder(
|
||||
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
|
||||
|
||||
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
|
||||
return SQLiteRecorder(settings.telemetry_sqlite_path)
|
||||
return SQLiteRecorder(
|
||||
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
|
||||
)
|
||||
|
||||
|
||||
def _build_structured(
|
||||
@@ -429,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` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
||||
@@ -438,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
|
||||
|
||||
|
||||
@@ -50,11 +50,28 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
|
||||
# "排队等自己的份额"(必然轮到),熔断开路是"等源恢复"(未必恢复),故分列两键
|
||||
_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})
|
||||
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
|
||||
_LIMITER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_BREAKER_BACKENDS = frozenset({"memory", "redis"})
|
||||
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
|
||||
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
|
||||
# 遥测 schema 档位(issue #13): auto 允许 recorder 给旧表 ALTER 补列,manual 不发 DDL
|
||||
_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
|
||||
@@ -123,6 +140,9 @@ class GatewaySettings:
|
||||
backpressure: BackpressurePolicy
|
||||
selector: str
|
||||
quota_full: str
|
||||
# 熔断全拒时是当场判死还是等冷却过去(issue #14);缺省 fail_fast 保持
|
||||
# 存量下游的控制流不变,单源 scope 应显式配 wait
|
||||
circuit_open: str
|
||||
limiter_backend: str
|
||||
breaker_backend: str
|
||||
cache_backend: str
|
||||
@@ -131,6 +151,23 @@ class GatewaySettings:
|
||||
telemetry_backend: str
|
||||
telemetry_sqlite_path: str | None
|
||||
telemetry_pg_dsn: str | None
|
||||
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);env 的三态
|
||||
# 派生只写在 `_load_schema_mode` 一处,不与 recorder 的类签名漂移。
|
||||
# backend=none 时恒 False 这条跨字段不变量则由 `_validate_telemetry`
|
||||
# 把关,对直接构造与 `dataclasses.replace` 同样生效
|
||||
telemetry_auto_migrate: bool
|
||||
# 遥测落库正文的字符上限(issue #12);None = 不截断,与本字段出现之前逐字节相同。
|
||||
# 缺省不截断是人类决策: 截断后的遥测不再是审计证据、也无法用于复现与重放,而
|
||||
# 既有下游正依赖这一行为。值域(> 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
|
||||
@@ -188,6 +225,7 @@ class GatewaySettings:
|
||||
("telemetry_backend", _TELEMETRY_BACKENDS),
|
||||
("selector", _SELECTORS),
|
||||
("quota_full", _QUOTA_FULL),
|
||||
("circuit_open", _CIRCUIT_OPEN),
|
||||
):
|
||||
value = getattr(self, field)
|
||||
if value not in allowed:
|
||||
@@ -211,7 +249,24 @@ class GatewaySettings:
|
||||
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
|
||||
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
|
||||
他有权知道库动了他给的值。
|
||||
|
||||
`telemetry_auto_migrate` 同理归一化而非报错: backend=none 时根本没有
|
||||
recorder 消费它,True 是个自相矛盾却无害的状态。`from_env` 那条路的派生
|
||||
已经给出 False,归一化是为了直接构造与 `dataclasses.replace` 也一致——
|
||||
不变量挂在构造期,才不用每加一个装配工厂就多一处要同步。
|
||||
|
||||
`telemetry_text_cap` 的值域则是**报错**而非归一化: 0 与负数都不是"不截断"
|
||||
的写法(不截断写 None),把它们悄悄改成 None 等于用默认值掩盖调用方的错误。
|
||||
报错文本同时点出字段名与 env 键名,两条装配路的调用方各看得懂自己那套。
|
||||
"""
|
||||
if self.telemetry_text_cap is not None and self.telemetry_text_cap <= 0:
|
||||
raise ValueError(
|
||||
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:
|
||||
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
|
||||
if self.telemetry_backend != "postgres":
|
||||
@@ -228,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)
|
||||
@@ -289,6 +362,7 @@ class GatewaySettings:
|
||||
backpressure=_load_backpressure(scope_u, env),
|
||||
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
|
||||
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
||||
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
|
||||
**_load_pgw(env),
|
||||
)
|
||||
|
||||
@@ -434,6 +508,7 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
redis_url = env.get("REDIS_URL") or None
|
||||
if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
|
||||
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
|
||||
auto_migrate = _load_schema_mode(env, telemetry_backend)
|
||||
return {
|
||||
"limiter_backend": limiter_backend,
|
||||
"breaker_backend": breaker_backend,
|
||||
@@ -444,6 +519,10 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
if telemetry_backend == "sqlite"
|
||||
else None,
|
||||
"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),
|
||||
@@ -451,6 +530,93 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _load_schema_mode(env: Mapping[str, str], telemetry_backend: str) -> bool:
|
||||
"""把 `PGW_TELEMETRY_SCHEMA_MODE` 的三态解成 `telemetry_auto_migrate`(issue #13)。
|
||||
|
||||
三态: 键未设 → 按后端**不对称**派生;显式 auto/manual → 两侧都可覆盖。
|
||||
不对称的理由是两个后端的风险量级不同: SQLite 是下游自己的本地文件(没有
|
||||
DBA、没有迁移工具、没有第二个系统碰它),ALTER 是毫秒级元数据操作,要求
|
||||
手工跑 SQL 是给零运维场景强加运维步骤;PG 是共享的生产表,ALTER 取
|
||||
ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询,而遥测是业务
|
||||
路径上的内联 await。
|
||||
|
||||
`_load_choice` 带 default,不能直接用来读这个键——default 会把"未设"和
|
||||
"设成默认值"抹平成同一种,三态就塌回两态,后端派生也就再没机会生效。故
|
||||
先用 `_first` 探"设没设",确认设了才交给 `_load_choice` 做值域校验(错误
|
||||
信息点出 env 键名这件事仍由它负责)。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
telemetry_backend: 已校验过值域的遥测后端名。
|
||||
|
||||
Returns:
|
||||
recorder 是否获准给旧表自动 ALTER 补列;backend=none 时无人消费,
|
||||
构造期守卫会再把它归一化为 False。
|
||||
"""
|
||||
if _first(env, _SCHEMA_MODE_KEY) is None:
|
||||
return telemetry_backend == "sqlite"
|
||||
return _load_choice(env, _SCHEMA_MODE_KEY, _SCHEMA_MODES, "auto") == "auto"
|
||||
|
||||
|
||||
def _load_text_cap(env: Mapping[str, str]) -> int | None:
|
||||
"""读 `PGW_TELEMETRY_TEXT_CAP`(issue #12);键未设即 None = 不截断。
|
||||
|
||||
与相邻的 `PGW_TELEMETRY_SCHEMA_MODE` 不同,这个键是**二态**而非三态:
|
||||
"未设"本身就是最终答案(不截断),没有需要按后端派生的第二种缺省,故不必像
|
||||
那边一样先探"设没设"再分两条路取值,读到什么解什么即可。
|
||||
|
||||
值域(> 0)刻意不在此处判: 构造期守卫那道同时覆盖直接构造与
|
||||
`dataclasses.replace`,而报错文本已点出本键名,env 路的调用方不会看丢。
|
||||
|
||||
Args:
|
||||
env: 已合并的环境映射。
|
||||
|
||||
Returns:
|
||||
遥测正文的字符上限;键未设或为空串时返回 None(不截断)。
|
||||
"""
|
||||
found = _first(env, _TEXT_CAP_KEY)
|
||||
if found is None:
|
||||
return 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 秒。
|
||||
|
||||
这个值同时是 connect、acquire 与整次写入的上界: 遥测是业务路径上的内联
|
||||
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("://")
|
||||
|
||||
@@ -25,10 +25,10 @@ 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,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
@@ -37,15 +37,16 @@ from polygateway.errors import (
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
EmbeddingResponse,
|
||||
LLMResponse,
|
||||
TelemetryStatus,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
)
|
||||
@@ -102,8 +103,10 @@ class EmbeddingClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None = None,
|
||||
batch_size: int,
|
||||
normalize: bool = False,
|
||||
expected_dim: int | None = None,
|
||||
@@ -113,31 +116,50 @@ class EmbeddingClient:
|
||||
) -> None:
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size 必须 ≥ 1")
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
if expected_dim is not None and expected_dim < 1:
|
||||
raise ValueError("expected_dim 必须 ≥ 1")
|
||||
self._scope = scope
|
||||
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
||||
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||
self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
|
||||
self._selector = selector
|
||||
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None
|
||||
self._emitter = (
|
||||
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
|
||||
self._expected_dim = expected_dim
|
||||
self._memo = SourceCooldownMemo(now=now)
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
async def embed(
|
||||
@@ -204,9 +226,9 @@ class EmbeddingClient:
|
||||
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
picked, gate_rejections = await self._pick_runnable(reasons)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, {})
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
@@ -225,62 +247,6 @@ class EmbeddingClient:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
for cand in self._selector.order(self._sources, stats):
|
||||
if self._memo.active(cand.name):
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
stall = self._bp.stall_window_s
|
||||
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
async def _attempt(
|
||||
self,
|
||||
batch: list[str],
|
||||
@@ -374,7 +340,7 @@ class EmbeddingClient:
|
||||
)
|
||||
return _FailedBatch(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit, actual)
|
||||
await settle_and_release(permit, actual)
|
||||
|
||||
# —— 辅助 ——
|
||||
|
||||
@@ -396,17 +362,6 @@ class EmbeddingClient:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("embedding permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
batch: list[str],
|
||||
@@ -500,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
|
||||
@@ -540,30 +502,37 @@ 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,
|
||||
quota_full=gw.quota_full,
|
||||
circuit_open=gw.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
||||
pricing=PricingTable.from_file(gw.pricing_path)
|
||||
if gw.pricing_path is not None
|
||||
else None,
|
||||
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
batch_size=settings.batch_size,
|
||||
normalize=settings.normalize,
|
||||
expected_dim=settings.expected_dim,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
|
||||
@@ -116,9 +116,20 @@ class ResultInvalidError(PolyGatewayError):
|
||||
|
||||
|
||||
class GatewayUnavailableError(PolyGatewayError):
|
||||
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式)。
|
||||
"""scope 级暂时不可用: 库的**调用级**预算已经耗尽。
|
||||
|
||||
`retry_after_s` 非可选(0 = 可立即重试),承 CHS ProviderUnavailableError。
|
||||
**职责边界(issue #14)**: 调用级的重试、退避、换源、等待冷却全部在库内,
|
||||
不需要下游再写一层——两边各写一份必然漂移(库调了退避曲线而下游不知道,
|
||||
下游改了等待上限而库的遥测算不进去),漂移之后"这次调用到底等了多久、
|
||||
试了几次"就没有单一事实源答得出来。本异常表示那份预算(重试预算或 stall
|
||||
预算)已经用完。下游据此再投是**任务级重试**,与调用级重试语义不同,由
|
||||
业务自行在库外包(ARCH §7.2 单层重试原则)。
|
||||
|
||||
熔断开路时是当场抛本类还是先等冷却过去,由 `{SCOPE}__CIRCUIT_OPEN`
|
||||
决定(缺省 fail_fast;单源 scope 建议配 wait)。
|
||||
|
||||
`retry_after_s` 非可选,语义是"距离**确定**可再试的时刻还有多久";
|
||||
`0` 表示不存在确定的等待时刻(可立即重试),承 CHS ProviderUnavailableError。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""SourceAdmission: 一次尝试的准入编排,三条治理循环(chat/embedding/ocr)共用一份。
|
||||
|
||||
**收敛缘由(issue #14)**: 本模块的两个方法此前在 `middleware/retry.py`、
|
||||
`embedding.py`、`ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义
|
||||
一直在演进——issue #8 改过 stall 口径、M2.5 加过 AIMD pacer、issue #14 要加
|
||||
熔断等待档——每演进一次就要三处同步,漏一处即行为分叉。三份复制正是库铁律
|
||||
痛斥的那种模式(遥测"三项目 4 处复制"的教训),只不过这次发生在库内部。
|
||||
|
||||
**职责边界**: 只管"挑出一个可跑的源"与"一个都挑不出来时怎么办";一次尝试
|
||||
本身(transport 调用、记账写回、逐次遥测)仍归各循环的 `_attempt`。
|
||||
|
||||
**共享而非持有**: `QuotaGate`/`BreakerGate`/`AdaptivePacer`/`SourceSelector` 由
|
||||
调用方构造后传入**同一实例**——三处 `_attempt` 仍要用它们做记账写回与
|
||||
`pacer.leave()`。pacer 尤其不能各建一个: 它有在途计数,分裂成两个计数器会让
|
||||
`admit`/`enter` 与 `leave` 记到不同账上。`SourceCooldownMemo` 只被准入消费,
|
||||
由本类独占。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.errors import AllSourcesExhausted, CircuitOpenError
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
|
||||
# 两个准入策略键共用的值域;校验只此一处,不在各客户端重复
|
||||
_POLICIES = frozenset({"wait", "fail_fast"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import StallClock
|
||||
from polygateway.ports import GateDecision, Permit, SourceSelector
|
||||
from polygateway.sources import AdaptivePacer
|
||||
from polygateway.types import BackpressurePolicy, SourceConfig
|
||||
|
||||
|
||||
async def settle_and_release(permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。
|
||||
|
||||
三条循环的 `_attempt` 与本模块的准入拒绝路径共用这一份(此前三处逐字复制,
|
||||
仅 warning 文案不同)。
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
|
||||
def _demote_call_failures(
|
||||
ordered: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float] | None,
|
||||
) -> list[SourceConfig]:
|
||||
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
|
||||
|
||||
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
|
||||
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
|
||||
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
|
||||
|
||||
`attempt_fails` 为空时恒等返回原列表对象——embedding/ocr 不维护调用内
|
||||
失败计数,故对它们这一步是零成本的空操作,无需在调用侧加分支。
|
||||
"""
|
||||
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
|
||||
if not demoted or len(demoted) == len(ordered):
|
||||
return ordered
|
||||
if health is None:
|
||||
return _move_to_tail(ordered, demoted)
|
||||
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
|
||||
|
||||
|
||||
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
|
||||
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
|
||||
names = {d.name for d in demoted}
|
||||
return [s for s in ordered if s.name not in names] + demoted
|
||||
|
||||
|
||||
def _health_gated_reorder(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
|
||||
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
|
||||
if not demoted:
|
||||
return ordered
|
||||
names = {d.name for d in demoted}
|
||||
rest = [s for s in ordered if s.name not in names]
|
||||
return _insert_after_credible(rest, demoted, health)
|
||||
|
||||
|
||||
def _insert_after_credible(
|
||||
rest: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
|
||||
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
|
||||
bar = 0.5 * max(health(d.name) for d in demoted)
|
||||
credible = [s for s in rest if health(s.name) >= bar]
|
||||
junk = [s for s in rest if health(s.name) < bar]
|
||||
return credible + demoted + junk
|
||||
|
||||
|
||||
def _credible_demotions(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
|
||||
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
|
||||
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
|
||||
|
||||
|
||||
class SourceAdmission:
|
||||
"""准入编排器(CHS `governance.py:107-285` 同款);时钟/睡眠/随机全部注入。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
sources: list[SourceConfig],
|
||||
selector: SourceSelector,
|
||||
quota: QuotaGate,
|
||||
breaker: BreakerGate,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str,
|
||||
circuit_open: str,
|
||||
memo: SourceCooldownMemo | None = None,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
health_view: Callable[[str], float] | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], object] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
for name, value in (("quota_full", quota_full), ("circuit_open", circuit_open)):
|
||||
if value not in _POLICIES:
|
||||
raise ValueError(f"{name} 必须是 wait|fail_fast: {value!r}")
|
||||
self._scope = scope
|
||||
self._sources = sources
|
||||
self._selector = selector
|
||||
self._quota = quota
|
||||
self._breaker = breaker
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._circuit_open = circuit_open
|
||||
self._memo = memo or SourceCooldownMemo(now=now)
|
||||
self._pacer = pacer
|
||||
self._health_view = health_view
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
|
||||
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
||||
|
||||
async def pick(
|
||||
self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
ordered = _demote_call_failures(
|
||||
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
||||
)
|
||||
for cand in ordered:
|
||||
if self._memo.active(cand.name):
|
||||
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
if self._pacer is not None and not self._pacer.admit(cand.name):
|
||||
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
|
||||
reasons.setdefault(cand.name, "adaptive_paced")
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
|
||||
if entry is None:
|
||||
await settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
if self._pacer is not None:
|
||||
self._pacer.enter(cand.name)
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
# —— 背压与 stall 判死(CHS governance.py:270-285)——
|
||||
|
||||
async def stalled(self, clock: StallClock) -> bool:
|
||||
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
|
||||
无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
|
||||
|
||||
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
|
||||
本地未超窗就不问后端,省一次 Redis 往返。
|
||||
"""
|
||||
stall = self._bp.stall_window_s
|
||||
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
|
||||
|
||||
async def on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
|
||||
) -> None:
|
||||
"""一个源都挑不出来时的处置: **按拒绝原因分派**到各自的策略。
|
||||
|
||||
分派而非串行是硬要求(issue #14): 串行写法下 `circuit_open=wait` 不抛
|
||||
之后会径直掉进配额分支,`quota_full=fail_fast` 的调用方于是收到一个
|
||||
`reason=quota_exhausted` 的异常——而配额其实是满的,坏的是熔断门。
|
||||
"""
|
||||
names = tuple(s.name for s in self._sources)
|
||||
if gate_rejections == len(self._sources):
|
||||
# 全部因熔断类原因(门开路 / 本地冷却备忘)被拒
|
||||
if self._circuit_open == "fail_fast":
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
# wait: 保护作用完整保留(这一轮照样一个请求都不发),改变的只是
|
||||
# 调用方当场死还是排队等——多源可换源故 fail-fast 对,单源无源可换
|
||||
hint = await self._breaker.retry_after_s(names)
|
||||
else:
|
||||
# 至少一个源是被配额/AIMD 挡的,归 quota_full 管
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
hint = 0.0
|
||||
if await self.stalled(clock):
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
nap = self._nap(hint, clock)
|
||||
if hint > 0:
|
||||
logger.info("熔断开路等待 {:.1f}s 后重试(scope={}, 原因={})", nap, self._scope, reasons)
|
||||
await self._sleep(nap)
|
||||
|
||||
def _nap(self, hint: float, clock: StallClock) -> float:
|
||||
"""本轮等待多久。**必须在 `stalled()` 判定之后调用**(预算可能已耗尽)。
|
||||
|
||||
`hint > 0`(熔断开路有确定的冷却截止)时睡到那个时刻,而不是按
|
||||
`poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次空转,内存后端
|
||||
只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用。抖动**上**加
|
||||
而非缩放(既有 quota 路径是 `[0.5p, 1.0p]`): 对一个确定的截止时刻提前
|
||||
醒来必然被再拒一次,白跑一趟。
|
||||
|
||||
两档都夹到剩余 stall 预算,故单次调用的最坏墙钟是 `stall_window_s`
|
||||
加一个 poll 间隔,不随 `max_cooldown_s` 漂移。多加的那一格是因为
|
||||
`stalled()` 判据是 `>` 而非 `>=`——恰好睡到窗口边界不判死,留这一格
|
||||
让下一轮必定判死。`hint == 0` 时整个式子退化为既有的 jitter 轮询。
|
||||
"""
|
||||
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
|
||||
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
|
||||
wait = hint + jitter if hint > 0 else jitter
|
||||
# 下界取 jitter 而非 poll_interval: 既有 quota 轮询是 [0.5p, 1.0p],用
|
||||
# poll_interval 兜底会把 rng→0 那半边抬上去。预算为负时(本地已超窗但
|
||||
# 全局仍在出餐,故 stalled() 不判死)靠它退回正常轮询节奏,不忙循环。
|
||||
return max(jitter, min(wait, budget))
|
||||
@@ -23,7 +23,6 @@ from loguru import logger
|
||||
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
@@ -32,10 +31,11 @@ from polygateway.errors import (
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.ports import OutcomeAwareSelector
|
||||
from polygateway.sources import AdaptivePacer, SourceCooldownMemo
|
||||
from polygateway.sources import AdaptivePacer
|
||||
from polygateway.streaming import StreamLivenessTimeout
|
||||
from polygateway.types import LLMResponse
|
||||
|
||||
@@ -50,6 +50,7 @@ if TYPE_CHECKING:
|
||||
SourceSelector,
|
||||
Transport,
|
||||
)
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import (
|
||||
BackpressurePolicy,
|
||||
ChatRequest,
|
||||
@@ -134,70 +135,6 @@ class StallClock:
|
||||
self._productive_s += self._now() - started
|
||||
|
||||
|
||||
def _demote_call_failures(
|
||||
ordered: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float] | None,
|
||||
) -> list[SourceConfig]:
|
||||
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
|
||||
|
||||
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
|
||||
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
|
||||
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
|
||||
"""
|
||||
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
|
||||
if not demoted or len(demoted) == len(ordered):
|
||||
return ordered
|
||||
if health is None:
|
||||
return _move_to_tail(ordered, demoted)
|
||||
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
|
||||
|
||||
|
||||
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
|
||||
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
|
||||
names = {d.name for d in demoted}
|
||||
return [s for s in ordered if s.name not in names] + demoted
|
||||
|
||||
|
||||
def _health_gated_reorder(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
|
||||
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
|
||||
if not demoted:
|
||||
return ordered
|
||||
names = {d.name for d in demoted}
|
||||
rest = [s for s in ordered if s.name not in names]
|
||||
return _insert_after_credible(rest, demoted, health)
|
||||
|
||||
|
||||
def _insert_after_credible(
|
||||
rest: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
|
||||
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
|
||||
bar = 0.5 * max(health(d.name) for d in demoted)
|
||||
credible = [s for s in rest if health(s.name) >= bar]
|
||||
junk = [s for s in rest if health(s.name) < bar]
|
||||
return credible + demoted + junk
|
||||
|
||||
|
||||
def _credible_demotions(
|
||||
ordered: list[SourceConfig],
|
||||
demoted: list[SourceConfig],
|
||||
attempt_fails: dict[str, int],
|
||||
health: Callable[[str], float],
|
||||
) -> list[SourceConfig]:
|
||||
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
|
||||
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
|
||||
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
|
||||
|
||||
|
||||
def _failure_reason(exc: PolyGatewayError) -> str:
|
||||
"""失败原因归类(CHS governance.py:169 同款)。"""
|
||||
if isinstance(exc, SourceDeadError):
|
||||
@@ -241,6 +178,7 @@ class RetryMW:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
cooldown_memo: SourceCooldownMemo | None = None,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
emitter: object | None = None,
|
||||
@@ -248,27 +186,39 @@ class RetryMW:
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
self._scope = scope
|
||||
self._sources = list(sources)
|
||||
self._selector = selector
|
||||
# 记账写回与 pacer 结算仍在 `_attempt` 内,故这三者由本类持有并与
|
||||
# `SourceAdmission` **共享同一实例**(pacer 有在途计数,不可分裂)
|
||||
self._quota = QuotaGate(limiter, scope=self._scope)
|
||||
self._breaker = BreakerGate(gate, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._memo = cooldown_memo or SourceCooldownMemo(now=now)
|
||||
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
|
||||
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
|
||||
self._health_view = self._outcome_sink.health if self._outcome_sink else None
|
||||
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
|
||||
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
|
||||
self._emitter = emitter
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
self._rng = rng
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
memo=cooldown_memo,
|
||||
pacer=self._pacer,
|
||||
health_view=self._outcome_sink.health if self._outcome_sink else None,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
async def __call__(self, request: ChatRequest) -> LLMResponse:
|
||||
"""执行治理调用;scope 级失败按 §6.1 携结构化字段上抛。"""
|
||||
@@ -283,16 +233,16 @@ class RetryMW:
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
# 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环
|
||||
if await self._stalled(clock):
|
||||
if await self._admission.stalled(clock):
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=self._retry.backoff_base_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
picked, gate_rejections = await self._pick_runnable(reasons, attempt_fails)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, attempt_fails)
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
async with clock.attempting() as attempt:
|
||||
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
|
||||
@@ -314,87 +264,6 @@ class RetryMW:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(self._backoff_delay(max(fails, 1), outcome.exc))
|
||||
|
||||
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
ordered = _demote_call_failures(
|
||||
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
||||
)
|
||||
for cand in ordered:
|
||||
if self._memo.active(cand.name):
|
||||
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
if not self._pacer.admit(cand.name):
|
||||
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
|
||||
reasons.setdefault(cand.name, "adaptive_paced")
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
self._pacer.enter(cand.name)
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit, 0)
|
||||
return None, gate_rejections
|
||||
|
||||
# —— 背压与 stall 判死(CHS governance.py:270-285)——
|
||||
|
||||
async def _stalled(self, clock: StallClock) -> bool:
|
||||
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
|
||||
无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
|
||||
|
||||
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
|
||||
本地未超窗就不问后端,省一次 Redis 往返。
|
||||
"""
|
||||
stall = self._bp.stall_window_s
|
||||
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if await self._stalled(clock):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
# jitter ∈ [0.5p, 1.0p] 防惊群(CHS governance.py:283-285)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
# —— 单次尝试(CHS run 200-268)——
|
||||
|
||||
async def _attempt(
|
||||
@@ -460,7 +329,7 @@ class RetryMW:
|
||||
return _Failed(exc, immediate=dead)
|
||||
finally:
|
||||
self._pacer.leave(source.name)
|
||||
await self._settle_and_release(permit, actual)
|
||||
await settle_and_release(permit, actual)
|
||||
|
||||
async def _on_rejected(
|
||||
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
|
||||
@@ -523,18 +392,6 @@ class RetryMW:
|
||||
reasoning_tokens=result.reasoning_tokens,
|
||||
)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
|
||||
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(actual)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
request: ChatRequest,
|
||||
|
||||
@@ -55,6 +55,44 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
|
||||
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
|
||||
def _cap_text(text: str, cap: int | None) -> str:
|
||||
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
|
||||
if cap is None or len(text) <= cap:
|
||||
return text
|
||||
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
|
||||
|
||||
|
||||
def _cap_part(part: Any, cap: int) -> Any:
|
||||
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
|
||||
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
return {**part, "text": _cap_text(part["text"], cap)}
|
||||
return part
|
||||
|
||||
|
||||
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
|
||||
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
|
||||
|
||||
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
|
||||
|
||||
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
|
||||
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
|
||||
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
|
||||
且全程无任何报错。
|
||||
"""
|
||||
if cap is None:
|
||||
return messages
|
||||
capped: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
capped.append({**msg, "content": _cap_text(content, cap)})
|
||||
elif isinstance(content, list):
|
||||
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
|
||||
else:
|
||||
capped.append(msg)
|
||||
return capped
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AttemptUsage:
|
||||
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
|
||||
@@ -96,9 +134,25 @@ class _AttemptUsage:
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
recorder: TelemetryRecorder,
|
||||
*,
|
||||
pricing: PricingTable | None = None,
|
||||
text_cap: int | None,
|
||||
) -> None:
|
||||
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
|
||||
|
||||
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
|
||||
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
|
||||
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
|
||||
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
|
||||
"""
|
||||
if text_cap is not None and text_cap <= 0:
|
||||
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
|
||||
self._recorder = recorder
|
||||
self._pricing = pricing
|
||||
self._text_cap = text_cap
|
||||
|
||||
async def emit_attempt(
|
||||
self,
|
||||
@@ -241,8 +295,12 @@ class TelemetryEmitter:
|
||||
)
|
||||
else:
|
||||
cost = None
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
|
||||
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
|
||||
messages_json = json.dumps(
|
||||
_cap_messages(digest_messages(request.messages), self._text_cap),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await self._recorder.record_llm_call(
|
||||
call_id=call_id,
|
||||
parent_call_id=request.parent_call_id,
|
||||
@@ -251,8 +309,8 @@ class TelemetryEmitter:
|
||||
provider=provider,
|
||||
source_name=source_name,
|
||||
messages=messages_json,
|
||||
response=response_text,
|
||||
thinking=thinking,
|
||||
response=_cap_text(response_text, self._text_cap),
|
||||
thinking=_cap_text(thinking, self._text_cap),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
usage_source=usage_source,
|
||||
|
||||
+60
-93
@@ -22,9 +22,9 @@ 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,
|
||||
CircuitOpenError,
|
||||
GovernanceBackendError,
|
||||
PolyGatewayError,
|
||||
RequestRejectedError,
|
||||
@@ -33,17 +33,18 @@ from polygateway.errors import (
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.ports import OutcomeAwareSelector
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
LLMResponse,
|
||||
OcrLayoutResult,
|
||||
OcrTextResult,
|
||||
TelemetryStatus,
|
||||
Usage,
|
||||
strip_unsupported_extra_body,
|
||||
validate_caller_dimensions,
|
||||
@@ -108,13 +109,13 @@ class OcrClient:
|
||||
retry: RetryPolicy,
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
text_cap: int | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
rng: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if quota_full not in ("wait", "fail_fast"):
|
||||
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
|
||||
self._scope = scope
|
||||
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
||||
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||
@@ -125,14 +126,34 @@ class OcrClient:
|
||||
self._breaker = BreakerGate(breaker, scope=self._scope)
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._bp = backpressure
|
||||
self._quota_full = quota_full
|
||||
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
|
||||
self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
|
||||
self._telemetry = telemetry
|
||||
self._memo = SourceCooldownMemo(now=now)
|
||||
# 限流/熔断后端在此之外只以 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
|
||||
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
|
||||
self._admission = SourceAdmission(
|
||||
scope=self._scope,
|
||||
sources=self._sources,
|
||||
selector=selector,
|
||||
quota=self._quota,
|
||||
breaker=self._breaker,
|
||||
backpressure=backpressure,
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
now=now,
|
||||
sleep=sleep,
|
||||
rng=rng,
|
||||
)
|
||||
self._closed = False
|
||||
|
||||
# —— 公共端口(OcrTextPort / OcrLayoutPort)——
|
||||
@@ -233,9 +254,9 @@ class OcrClient:
|
||||
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
|
||||
clock = StallClock(self._now)
|
||||
while True:
|
||||
picked, gate_rejections = await self._pick_runnable(reasons)
|
||||
picked, gate_rejections = await self._admission.pick(reasons, {})
|
||||
if picked is None:
|
||||
await self._on_no_runnable(gate_rejections, reasons, clock)
|
||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||
continue
|
||||
async with clock.attempting():
|
||||
outcome = await self._attempt(
|
||||
@@ -254,62 +275,6 @@ class OcrClient:
|
||||
if not outcome.immediate:
|
||||
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
|
||||
|
||||
async def _pick_runnable(
|
||||
self, reasons: dict[str, str]
|
||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||
gate_rejections = 0
|
||||
for cand in self._selector.order(self._sources, stats):
|
||||
if self._memo.active(cand.name):
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
continue
|
||||
entry = None
|
||||
try:
|
||||
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
|
||||
finally:
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit)
|
||||
if entry.allowed:
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
|
||||
await self._settle_and_release(permit)
|
||||
return None, gate_rejections
|
||||
|
||||
async def _on_no_runnable(
|
||||
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
|
||||
) -> None:
|
||||
if gate_rejections == len(self._sources):
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise CircuitOpenError(
|
||||
scope=self._scope,
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
if self._quota_full == "fail_fast":
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="quota_exhausted",
|
||||
retry_after_s=self._bp.poll_interval_s,
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
stall = self._bp.stall_window_s
|
||||
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
|
||||
names = tuple(s.name for s in self._sources)
|
||||
raise AllSourcesExhausted(
|
||||
scope=self._scope,
|
||||
reason="stalled",
|
||||
retry_after_s=await self._breaker.retry_after_s(names),
|
||||
per_source_reasons=reasons,
|
||||
)
|
||||
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
|
||||
|
||||
async def _attempt(
|
||||
self,
|
||||
kind: _OcrKind,
|
||||
@@ -397,7 +362,7 @@ class OcrClient:
|
||||
)
|
||||
return _FailedAttempt(exc, immediate=dead)
|
||||
finally:
|
||||
await self._settle_and_release(permit)
|
||||
await settle_and_release(permit, 0)
|
||||
|
||||
async def _invoke(
|
||||
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str
|
||||
@@ -434,18 +399,6 @@ class OcrClient:
|
||||
except (GovernanceBackendError, SourceNotConfiguredError) as exc:
|
||||
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
|
||||
|
||||
async def _settle_and_release(self, permit: Permit) -> None:
|
||||
"""settle 恒 0: OCR 无 token 计费(设计 §5 差异①)。"""
|
||||
try:
|
||||
try:
|
||||
await permit.settle(0)
|
||||
finally:
|
||||
await permit.release()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("OCR permit 结算/释放失败(不掩盖主异常): {}", exc)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
kind: _OcrKind,
|
||||
@@ -512,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
|
||||
@@ -551,6 +511,7 @@ class OcrClient:
|
||||
_build_limiter,
|
||||
_build_selector,
|
||||
_build_telemetry,
|
||||
_mark_owned_components,
|
||||
)
|
||||
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
||||
|
||||
@@ -561,18 +522,24 @@ 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,
|
||||
quota_full=gw.quota_full,
|
||||
circuit_open=gw.circuit_open,
|
||||
telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
|
||||
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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——否则总时长
|
||||
|
||||
@@ -1,123 +1,165 @@
|
||||
"""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
|
||||
|
||||
from polygateway.telemetry.schema import (
|
||||
COLUMNS,
|
||||
PG_BACKFILL,
|
||||
PG_DDL,
|
||||
insert_sql,
|
||||
missing_columns_warning,
|
||||
)
|
||||
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import asyncpg
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
|
||||
_BACKFILL = (
|
||||
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
|
||||
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
|
||||
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
|
||||
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
|
||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||
(
|
||||
"tenant_id",
|
||||
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''",
|
||||
),
|
||||
(
|
||||
"meta",
|
||||
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
),
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
# 环境级降级的冷却期(issue #15)。**不暴露配置**: 它的取值只影响"多久重试一次"
|
||||
# 这个内部节奏,任何取值都不改变对外承诺(降级可见、可自愈、有界成本),给出旋钮
|
||||
# 只会多一个下游要理解却调不对的东西(设计 §3.5)
|
||||
_DEGRADE_COOLDOWN_S = 60.0
|
||||
|
||||
_INSERT = (
|
||||
f"INSERT INTO llm_calls ({', '.join(_COLUMNS)}) "
|
||||
f"VALUES ({', '.join(f'${i + 1}' for i in range(len(_COLUMNS)))}) "
|
||||
"ON CONFLICT (call_id) DO NOTHING"
|
||||
)
|
||||
_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) -> 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:
|
||||
dsn: asyncpg 连接串(已剥驱动后缀)。
|
||||
pool: 外部注入的池;注入方自己负责关闭。
|
||||
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
|
||||
则一条 ALTER 都不发——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE
|
||||
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
|
||||
await。keyword-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 是否安装
|
||||
except ImportError as exc:
|
||||
@@ -127,18 +169,41 @@ class PostgresRecorder:
|
||||
self._dsn = dsn
|
||||
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
|
||||
@@ -148,43 +213,71 @@ 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:
|
||||
async with pool.acquire() as conn:
|
||||
writable = await self._prepare_table(conn)
|
||||
conn = await pool.acquire(timeout=self._write_timeout_s)
|
||||
try:
|
||||
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 not writable:
|
||||
self._failed = True
|
||||
if columns is None:
|
||||
# 表确定不存在且建不出来: 与本行数据无关(每行都会同样失败)且能被
|
||||
# 外部修好(DBA 建了表就该自愈)—— 判据第 2 句下的环境级
|
||||
self._status.enter_degraded(
|
||||
"表 llm_calls 不存在且建不出来(记录无处可落)",
|
||||
fatal=False,
|
||||
cooldown_s=_DEGRADE_COOLDOWN_S,
|
||||
)
|
||||
return None
|
||||
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
|
||||
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
|
||||
self._columns = columns
|
||||
self._insert = insert_sql("postgres", columns)
|
||||
self._schema_ready = True
|
||||
return pool
|
||||
|
||||
async def _prepare_table(self, conn: object) -> bool:
|
||||
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。
|
||||
async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
|
||||
"""备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
|
||||
|
||||
返回 None 仅表示表确定不存在且建不出来(调用方据此进环境级冷却降级)。
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
|
||||
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
|
||||
@@ -197,33 +290,77 @@ class PostgresRecorder:
|
||||
"""
|
||||
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
|
||||
if exists:
|
||||
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级
|
||||
return True
|
||||
return await self._resolve_columns(conn) # 旧表可能缺列
|
||||
try:
|
||||
await conn.execute(_DDL) # type: ignore[attr-defined]
|
||||
await conn.execute(PG_DDL) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
|
||||
return False
|
||||
return True # 新建表列已齐全,无需再走补列
|
||||
return None
|
||||
return COLUMNS # 新建表列已齐全,无需再走补列
|
||||
|
||||
async def _backfill_columns(self, conn: object) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`**。
|
||||
async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
|
||||
"""探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
|
||||
|
||||
两条纪律各有实测理由:
|
||||
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership
|
||||
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
|
||||
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。
|
||||
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS
|
||||
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
|
||||
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
**先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
|
||||
**先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
|
||||
内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
|
||||
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
|
||||
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
try:
|
||||
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
|
||||
for column, statement in _BACKFILL:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
return COLUMNS
|
||||
if self._auto_migrate:
|
||||
await self._backfill_columns(conn, existing)
|
||||
return COLUMNS
|
||||
return self._trim_columns(existing)
|
||||
|
||||
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
|
||||
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
|
||||
|
||||
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
|
||||
INSERT,`insert_sql` 会 ValueError,而 `_prepare_schema` 里那次调用在 try
|
||||
**之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡)
|
||||
——回落必须发生在把空列集交给它之前。
|
||||
"""
|
||||
effective = tuple(column for column in COLUMNS if column in existing)
|
||||
if not effective:
|
||||
logger.warning(
|
||||
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||
"现有列: {}",
|
||||
sorted(existing),
|
||||
)
|
||||
return COLUMNS
|
||||
missing = [column for column in COLUMNS if column not in existing]
|
||||
if missing:
|
||||
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
|
||||
logger.warning(
|
||||
"{}",
|
||||
missing_columns_warning("postgres", missing, alien_table="call_id" not in existing),
|
||||
)
|
||||
return effective
|
||||
|
||||
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
|
||||
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不让 recorder 降级**。
|
||||
|
||||
不降级的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
|
||||
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。降级会让
|
||||
整个 recorder 停写(环境级还要停满一个冷却期),与「补列失败只降级为逐行丢弃」的承诺相悖
|
||||
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
|
||||
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
|
||||
请显式选 manual。
|
||||
"""
|
||||
try:
|
||||
for column, statement in PG_BACKFILL:
|
||||
if column not in existing:
|
||||
await conn.execute(statement) # type: ignore[attr-defined]
|
||||
except asyncio.CancelledError:
|
||||
@@ -231,24 +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 _COLUMNS)
|
||||
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(_INSERT, *row)
|
||||
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="池")
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。
|
||||
|
||||
两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块。
|
||||
收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源——常量在
|
||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
|
||||
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
|
||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||
互不依赖(import-linter 契约执法)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
TABLE = "llm_calls"
|
||||
|
||||
# 支持的后端;`insert_sql` / `telemetry_schema_sql` 的取值域
|
||||
_BACKENDS = ("sqlite", "postgres")
|
||||
|
||||
SQLITE_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
|
||||
PG_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||
SQLITE_BACKFILL = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
|
||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
)
|
||||
|
||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||
# 下游的那份"的列集合与列定义**无法分叉**——本模块存在的全部理由就是不许漂移。
|
||||
_PG_BACKFILL_DECLS = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
|
||||
)
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||
# **库内执行的这份有意不带 `IF NOT EXISTS`**: PG 对它即便列已存在也会先取 ACCESS
|
||||
# EXCLUSIVE 锁,而遥测是业务路径上的内联 await,故库侧一律"先探测后 ALTER"
|
||||
# (postgres.py `_backfill_columns` 记有实测)。给人执行的那份见 `telemetry_schema_sql`。
|
||||
PG_BACKFILL = tuple(
|
||||
(column, f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}")
|
||||
for column, decl in _PG_BACKFILL_DECLS
|
||||
)
|
||||
|
||||
COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
|
||||
_COLUMN_SET = frozenset(COLUMNS)
|
||||
|
||||
|
||||
def insert_sql(backend: str, columns: Sequence[str]) -> str:
|
||||
"""按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。
|
||||
|
||||
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部
|
||||
字符串拼进 SQL(占位符只保护值,保护不了列名)。空集同样来自探测结果,而
|
||||
`INSERT INTO llm_calls () VALUES ()` 两端都语法非法——本函数自己拒,不把这个
|
||||
不变量押在调用方身上。sqlite 用 `?`、postgres 用 `$n`,
|
||||
两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)。
|
||||
|
||||
**PG 的 `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求
|
||||
分区表的唯一约束必须包含分区键,按 `created_at` 分区(issue #12 的保留期方案)后
|
||||
主键变成 `(call_id, created_at)`,带目标的语句匹配不到任何约束,PG 直接拒收
|
||||
("there is no unique or exclusion constraint matching the ON CONFLICT
|
||||
specification"),而遥测写失败只逐行 warning——分区部署下会全线静默丢数据。
|
||||
无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
columns: 要写入的列,顺序即占位符顺序(调用方须按同序取值)。
|
||||
|
||||
Returns:
|
||||
完整的 INSERT 语句。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
selected = tuple(columns)
|
||||
if not selected:
|
||||
raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL")
|
||||
unknown = [column for column in selected if column not in _COLUMN_SET]
|
||||
if unknown:
|
||||
raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}")
|
||||
names = ", ".join(selected)
|
||||
if backend == "sqlite":
|
||||
placeholders = ", ".join("?" for _ in selected)
|
||||
return f"INSERT OR IGNORE INTO {TABLE} ({names}) VALUES ({placeholders})"
|
||||
placeholders = ", ".join(f"${i + 1}" for i in range(len(selected)))
|
||||
return f"INSERT INTO {TABLE} ({names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
||||
|
||||
|
||||
# 缺列告警要打印的补列语句: 库内执行的那份怎么写,打印给人的就怎么写(同源不许漂移)。
|
||||
# SQLite 侧常量只有列定义,故在此按 TABLE 拼成整条 ALTER;PG 侧常量本就是整条语句。
|
||||
_ALTER_BY_BACKEND = {
|
||||
"sqlite": {
|
||||
column: f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}"
|
||||
for column, decl in SQLITE_BACKFILL
|
||||
},
|
||||
"postgres": dict(PG_BACKFILL),
|
||||
}
|
||||
|
||||
_BACKEND_LABELS = {"sqlite": "SQLite", "postgres": "Postgres"}
|
||||
|
||||
# PG 的 ALTER 取 ACCESS EXCLUSIVE 锁,执行时机得由 DBA 自己挑;SQLite 是下游本地文件,无此顾虑
|
||||
_EXECUTION_NOTES = {"sqlite": "", "postgres": "(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁)"}
|
||||
|
||||
|
||||
def missing_columns_warning(backend: str, missing: Sequence[str], *, alien_table: bool) -> str:
|
||||
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
|
||||
|
||||
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
|
||||
看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。
|
||||
|
||||
**住在本模块而不是两个 recorder 里**: 这条消息拼的是给人执行的 DDL,与库自己
|
||||
执行的 ALTER 必须同源——本模块存在的全部理由就是不许这两者漂移。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
missing: 缺失的列名(按 `COLUMNS` 保序)。
|
||||
alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。
|
||||
|
||||
Returns:
|
||||
单条 warning 的完整文本(库只在准备期发一次,不逐行发)。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
alters = _ALTER_BY_BACKEND[backend]
|
||||
selected = tuple(missing)
|
||||
statements = [f"{alters[column]};" for column in selected if column in alters]
|
||||
unknown = [column for column in selected if column not in alters]
|
||||
if unknown:
|
||||
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
|
||||
statements.append(
|
||||
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
|
||||
f'polygateway.telemetry_schema_sql("{backend}")'
|
||||
)
|
||||
label = _BACKEND_LABELS[backend]
|
||||
head = (
|
||||
f"{label} 遥测表 {TABLE} 缺主键列 call_id,很可能不是本库的遥测表"
|
||||
"(库不做二次判定,仍照常尝试写入)"
|
||||
if alien_table
|
||||
else f"{label} 遥测表 {TABLE} 缺列,且 auto_migrate=False(库不发任何 DDL)"
|
||||
)
|
||||
return (
|
||||
f"{head};以下维度不会被记录: {', '.join(selected)}。"
|
||||
f"补列请自行执行{_EXECUTION_NOTES[backend]}:\n" + "\n".join(statements)
|
||||
)
|
||||
|
||||
|
||||
def telemetry_schema_sql(backend: str) -> str:
|
||||
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。
|
||||
|
||||
给不愿意让库在自己的生产表上发 DDL 的下游用: 输出与库运行时执行的 DDL 同源,
|
||||
照它建完表,库探测到的列就是齐的。
|
||||
|
||||
**补列语句与库内执行的那份是两套文本,不是一份**: 这份给人执行,必须可重复执行,
|
||||
故 PG 变体带 `ADD COLUMN IF NOT EXISTS`(它会先取 ACCESS EXCLUSIVE 锁,但执行时机
|
||||
由 DBA 自己挑,锁风险可控);库内那份不带,靠先探测后 ALTER 规避锁。SQLite 没有
|
||||
`ADD COLUMN IF NOT EXISTS` 语法,只能以注释交代"仅当该列不存在时执行"。
|
||||
|
||||
Args:
|
||||
backend: `"sqlite"` 或 `"postgres"`。
|
||||
|
||||
Returns:
|
||||
含注释的完整 SQL 脚本。
|
||||
|
||||
Raises:
|
||||
ValueError: backend 不在取值域内。
|
||||
"""
|
||||
if backend not in _BACKENDS:
|
||||
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
|
||||
if backend == "sqlite":
|
||||
ddl = SQLITE_DDL
|
||||
notes = (
|
||||
f"-- 旧表补列(库升级后新增的列)。SQLite 无 ADD COLUMN IF NOT EXISTS 语法,\n"
|
||||
f"-- 以下每条**仅当该列不存在时执行**(先 PRAGMA table_info({TABLE}) 对照)。"
|
||||
)
|
||||
alters = [
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl};" for column, decl in SQLITE_BACKFILL
|
||||
]
|
||||
else:
|
||||
ddl = PG_DDL
|
||||
notes = (
|
||||
"-- 旧表补列(库升级后新增的列)。带 IF NOT EXISTS,整段可重复执行;\n"
|
||||
"-- 注意它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,请挑低峰执行。"
|
||||
)
|
||||
alters = [
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS {column} {decl};"
|
||||
for column, decl in _PG_BACKFILL_DECLS
|
||||
]
|
||||
header = (
|
||||
f"-- PolyGateway 遥测表 {TABLE}({backend})\n"
|
||||
f'-- 由 polygateway.telemetry_schema_sql("{backend}") 生成,与库运行时执行的 DDL 同源。\n'
|
||||
"-- 新建库执行整段;已有旧表则建表语句自动跳过,只需关注下方补列语句。"
|
||||
)
|
||||
return "\n".join([header, "", ddl.strip(), "", notes, *alters, ""])
|
||||
@@ -19,120 +19,118 @@ import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms REAL,
|
||||
max_inter_token_ms REAL,
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||
_BACKFILL_COLUMNS = (
|
||||
("cached_prompt_tokens", "INTEGER"),
|
||||
("model_reported", "TEXT"),
|
||||
("sampling", "TEXT"),
|
||||
("reasoning_tokens", "INTEGER"),
|
||||
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
|
||||
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
|
||||
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
|
||||
("meta", "TEXT NOT NULL DEFAULT '{}'"),
|
||||
from polygateway.telemetry.schema import (
|
||||
COLUMNS,
|
||||
SQLITE_BACKFILL,
|
||||
SQLITE_DDL,
|
||||
insert_sql,
|
||||
missing_columns_warning,
|
||||
)
|
||||
from polygateway.telemetry.status import TelemetryStatusTracker
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
"session_id",
|
||||
"model",
|
||||
"provider",
|
||||
"source_name",
|
||||
"messages",
|
||||
"response",
|
||||
"thinking",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"usage_source",
|
||||
"latency_ms",
|
||||
"ttft_ms",
|
||||
"max_inter_token_ms",
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
"sampling",
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
f"INSERT OR IGNORE INTO llm_calls ({', '.join(_COLUMNS)}) "
|
||||
f"VALUES ({', '.join('?' for _ in _COLUMNS)})"
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from polygateway.types import TelemetryStatus
|
||||
|
||||
|
||||
class SQLiteRecorder:
|
||||
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None:
|
||||
"""建连接与表,并按探测到的列定型本实例的 INSERT 语句。
|
||||
|
||||
Args:
|
||||
db_path: 库文件路径;父目录不存在会自动创建。
|
||||
auto_migrate: True 则给已存在的旧表自动补列(SQLite 侧的缺省档:
|
||||
下游本地文件,无 DBA 无迁移工具);False 则一条 ALTER 都不发,
|
||||
改为按现有列裁剪写入。keyword-only **必填**: 缺省规则只写在
|
||||
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
|
||||
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
|
||||
self._columns: tuple[str, ...] = COLUMNS
|
||||
self._insert = insert_sql("sqlite", COLUMNS)
|
||||
try:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute(_DDL)
|
||||
conn.execute(SQLITE_DDL)
|
||||
conn.commit()
|
||||
self._conn = conn
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||
self._backfill_columns()
|
||||
self._status.enter_degraded(f"初始化失败: {exc}", fatal=True, cooldown_s=None)
|
||||
self._prepare_columns()
|
||||
|
||||
def _backfill_columns(self) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||
@property
|
||||
def telemetry_status(self) -> TelemetryStatus:
|
||||
"""当前可写状态快照(ports.TelemetryStatusProvider)。"""
|
||||
return self._status.snapshot()
|
||||
|
||||
def _prepare_columns(self) -> None:
|
||||
"""探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
|
||||
|
||||
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。
|
||||
无守卫的探测会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
|
||||
"""
|
||||
if self._conn is None:
|
||||
return
|
||||
try:
|
||||
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc)
|
||||
logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
|
||||
return
|
||||
for column, decl in _BACKFILL_COLUMNS:
|
||||
if self._auto_migrate:
|
||||
self._backfill_columns(existing)
|
||||
return
|
||||
self._adopt_existing_columns(existing)
|
||||
|
||||
def _adopt_existing_columns(self, existing: set[str]) -> None:
|
||||
"""manual 档: 不发任何 DDL,按现有列裁剪 INSERT,并把缺列一次讲清楚。
|
||||
|
||||
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
|
||||
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。
|
||||
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
|
||||
INSERT,`insert_sql` 会 ValueError,而遥测构造期抛异常就是把"初始化失败静默
|
||||
降级"的铁律破成崩溃——回落必须发生在把空列集交给它之前。
|
||||
"""
|
||||
effective = tuple(column for column in COLUMNS if column in existing)
|
||||
if not effective:
|
||||
logger.warning(
|
||||
"SQLite 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
|
||||
"现有列: {}",
|
||||
sorted(existing),
|
||||
)
|
||||
return
|
||||
self._columns = effective
|
||||
self._insert = insert_sql("sqlite", effective)
|
||||
missing = [column for column in COLUMNS if column not in existing]
|
||||
if missing:
|
||||
# 单参数传入: 补列 SQL 里带 `'{}'` 字面量,拼进 format 模板会被当占位符
|
||||
logger.warning(
|
||||
"{}",
|
||||
missing_columns_warning("sqlite", missing, alien_table="call_id" not in existing),
|
||||
)
|
||||
|
||||
def _backfill_columns(self, existing: set[str]) -> None:
|
||||
"""auto 档: 给已存在的旧表补新列(issue #3);逐列独立 try,失败只降级为逐行丢弃。
|
||||
|
||||
补列失败绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。失败后写入沿用全量列(今天的行为): auto 档承诺的是
|
||||
"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
|
||||
"""
|
||||
assert self._conn is not None # 内部不变量: 调用方已判空
|
||||
for column, decl in SQLITE_BACKFILL:
|
||||
if column in existing:
|
||||
continue
|
||||
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
|
||||
@@ -145,19 +143,39 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
"""
|
||||
if self._conn is None:
|
||||
# 改前这里是**裸 return**: 初始化失败后每一行都无声消失,长跑进程里
|
||||
# 与"遥测正常"外观上完全一致(设计 §1.4 的直接钉子)
|
||||
self._status.record_drop(self._drop_reason())
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
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 # 内部不变量: 调用方已判空
|
||||
with self._lock:
|
||||
self._conn.execute(_INSERT, row)
|
||||
self._conn.execute(self._insert, row)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -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 后自动重试"
|
||||
@@ -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。"""
|
||||
|
||||
@@ -291,6 +291,50 @@ class TestRetryAfter:
|
||||
await _open_gate(gate, "s1") # s1 开路;s2 健康
|
||||
assert await gate.retry_after_s(("s1", "s2")) == 0.0
|
||||
|
||||
async def test_half_open_rejection_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""探针在途时被拒 → 0.0(issue #14): 探针随时可能出结果,不存在确定时刻。
|
||||
|
||||
旧行为返回探针租约剩余,而租约长度是**死锁保护参数**(派生自
|
||||
`2 × 最慢源 timeout`),与"这个源多久能恢复"没有因果关系。现场
|
||||
`TIMEOUT_S=300` 时它是 600s,而冷却期只有 60s。
|
||||
"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.is_probe
|
||||
blocked = await gate.try_enter("s1", "w2")
|
||||
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
|
||||
assert blocked.retry_after_s == 0.0
|
||||
|
||||
async def test_probe_grant_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""准入被允许 → 恒 0.0(现在就能试);此前 redis 侧返回探针 TTL。"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.allowed and probe.is_probe
|
||||
assert probe.retry_after_s == 0.0
|
||||
|
||||
async def test_retry_after_zero_while_probe_in_flight(self, gate_factory, clock):
|
||||
"""集合查询同口径: 探针在途的源不贡献等待时间。"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe
|
||||
assert await gate.retry_after_s(("s1",)) == 0.0
|
||||
|
||||
async def test_fenced_write_in_half_open_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""写回被 fencing 拒时的快照同口径;此前 redis 侧返回探针租约剩余。"""
|
||||
gate = gate_factory(_CFG)
|
||||
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
|
||||
await _open_gate(gate) # 他人开路,epoch 推进
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
|
||||
update = await gate.record_success(stale)
|
||||
assert not update.applied and update.state is GateState.HALF_OPEN
|
||||
assert update.retry_after_s == 0.0
|
||||
|
||||
|
||||
class TestConsecutiveSuppression:
|
||||
"""迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。"""
|
||||
|
||||
@@ -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(
|
||||
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
|
||||
)
|
||||
# 真实网关调用: 与 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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class TestBreakerRecoveryFullChain:
|
||||
|
||||
class TestCancellationThroughStack:
|
||||
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def hanging_handler(request):
|
||||
@@ -144,7 +144,7 @@ class TestCancellationThroughStack:
|
||||
|
||||
class TestTelemetryAcrossPaths:
|
||||
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
|
||||
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
|
||||
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
|
||||
@@ -155,7 +155,7 @@ class TestTelemetryAcrossPaths:
|
||||
assert hits == 1 and total == 2
|
||||
|
||||
async def test_transient_attempts_each_recorded(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(request):
|
||||
@@ -193,7 +193,7 @@ class TestRejectionReasonIsQueryable:
|
||||
)
|
||||
|
||||
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db")
|
||||
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
|
||||
client = _full_client(
|
||||
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
|
||||
)
|
||||
@@ -257,7 +257,7 @@ class TestSamplingThroughStack:
|
||||
return _sse()
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||
client = _full_client(handler, telemetry=recorder)
|
||||
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
|
||||
recorder.close()
|
||||
@@ -276,7 +276,7 @@ class TestSamplingThroughStack:
|
||||
|
||||
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
|
||||
db = tmp_path / "t.db"
|
||||
recorder = SQLiteRecorder(db)
|
||||
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||
client = GatewayClient(
|
||||
scope="llm",
|
||||
sources=[src],
|
||||
|
||||
@@ -14,12 +14,17 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.telemetry.postgres import PostgresRecorder
|
||||
from polygateway.telemetry.schema import COLUMNS, telemetry_schema_sql
|
||||
|
||||
_EXPECTED_COLUMNS = [
|
||||
"call_id",
|
||||
@@ -88,8 +93,13 @@ async def dsn():
|
||||
|
||||
async def _record_minimal(
|
||||
recorder: PostgresRecorder, call_id: str | None = None, **overrides
|
||||
) -> None:
|
||||
fields = {
|
||||
) -> dict[str, object]:
|
||||
"""记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。
|
||||
|
||||
返回值不是顺手加的: 逐列断言若在测试里另抄一份期望值,抄错的那一列会以
|
||||
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证。
|
||||
"""
|
||||
fields: dict[str, object] = {
|
||||
"call_id": call_id if call_id is not None else _cid("c1"),
|
||||
"parent_call_id": None,
|
||||
"session_id": "sess-1",
|
||||
@@ -118,6 +128,28 @@ async def _record_minimal(
|
||||
}
|
||||
fields.update(overrides)
|
||||
await recorder.record_llm_call(**fields)
|
||||
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):
|
||||
@@ -130,6 +162,17 @@ async def _fetch(dsn: str, sql: str, *args):
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _execute_script(dsn: str, sql: str) -> None:
|
||||
"""整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(sql)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
_LEGACY_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
@@ -184,7 +227,7 @@ class TestObservabilityColumns:
|
||||
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||
|
||||
async def test_values_round_trip(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
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)
|
||||
@@ -212,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)
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||
@@ -237,7 +280,7 @@ class TestObservabilityColumns:
|
||||
|
||||
class TestSchema:
|
||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder)
|
||||
rows = await _fetch(
|
||||
@@ -250,7 +293,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_call_id_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
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")
|
||||
@@ -262,7 +305,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_concurrent_writes_all_land(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
||||
@@ -277,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")
|
||||
"""服务端连不上 → 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)
|
||||
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"))
|
||||
@@ -301,12 +404,83 @@ class TestDegradation:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_aclose_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn)
|
||||
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 删除;非任何真实凭据
|
||||
|
||||
|
||||
@@ -320,7 +494,7 @@ async def least_privilege_dsn(dsn):
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
from polygateway.telemetry.postgres import _DDL
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
name = f"pgwtest_lp_{uuid4().hex[:8]}"
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
@@ -332,7 +506,7 @@ async def least_privilege_dsn(dsn):
|
||||
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
|
||||
await admin.execute(f"CREATE SCHEMA {name}")
|
||||
await admin.execute(f"SET search_path = {name}")
|
||||
await admin.execute(_DDL) # 表由**别的账号**建好,与现场一致
|
||||
await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
|
||||
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
|
||||
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
|
||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||
@@ -371,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)
|
||||
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",
|
||||
@@ -428,6 +602,15 @@ _PRE_TENANT_INSERT = (
|
||||
)
|
||||
|
||||
|
||||
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度
|
||||
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
|
||||
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。
|
||||
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")]
|
||||
|
||||
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
|
||||
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
@@ -533,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)
|
||||
recorder = _recorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
@@ -567,7 +750,7 @@ class TestCallerDimensionsAcceptance:
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn)
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
@@ -614,17 +797,568 @@ 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)
|
||||
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)
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
|
||||
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
|
||||
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
|
||||
_PARTITIONED_DDL = """
|
||||
CREATE TABLE {schema}.llm_calls (
|
||||
call_id TEXT NOT NULL,
|
||||
parent_call_id TEXT,
|
||||
session_id TEXT,
|
||||
model TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
messages TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
usage_source TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
ttft_ms DOUBLE PRECISION,
|
||||
max_inter_token_ms DOUBLE PRECISION,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT,
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
PRIMARY KEY (call_id, created_at)
|
||||
) PARTITION BY RANGE (created_at)
|
||||
"""
|
||||
|
||||
_PARTITION_DDL = (
|
||||
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls "
|
||||
"FOR VALUES FROM ('{start}') TO ('{end}')"
|
||||
)
|
||||
|
||||
|
||||
def _current_month_bounds() -> tuple[str, str]:
|
||||
"""当前月的 [月初, 下月初) 边界字面量;分区键落在区间外会因找不到分区而写失败。"""
|
||||
now = datetime.now(UTC)
|
||||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = (start + timedelta(days=32)).replace(day=1)
|
||||
fmt = "%Y-%m-%d %H:%M:%S%z"
|
||||
return start.strftime(fmt), end.strftime(fmt)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
|
||||
|
||||
与 legacy_schema 同款隔离: 绝不碰共享的 public.llm_calls,teardown 只 DROP
|
||||
自己建的 schema(CASCADE 连分区一并删)。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwtest_part_{uuid4().hex[:8]}"
|
||||
start, end = _current_month_bounds()
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(_PARTITIONED_DDL.format(schema=name))
|
||||
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end))
|
||||
finally:
|
||||
await conn.close()
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestConflictTargetFreeInsert:
|
||||
"""issue #13: INSERT 不绑定冲突目标,普通表与分区表两种形态都写得进去。"""
|
||||
|
||||
async def test_plain_table_still_dedupes_by_call_id(self, fresh_schema, captured_warnings):
|
||||
"""普通表上语义不变: 重复 call_id 仍只落一行,且不是被拒后丢弃。
|
||||
|
||||
表上只有主键这一个唯一约束,故无目标的 DO NOTHING 与 `(call_id)` 逐字等价;
|
||||
断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。
|
||||
"""
|
||||
fresh_dsn, _ = fresh_schema
|
||||
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")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
rows = await _fetch(
|
||||
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup")
|
||||
)
|
||||
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_partitioned_table_accepts_writes(self, partitioned_schema, captured_warnings):
|
||||
"""分区表上写入成功且能读回——改动前这里必红。
|
||||
|
||||
带目标的 `ON CONFLICT (call_id)` 在主键为 `(call_id, created_at)` 的表上
|
||||
匹配不到任何约束,PG 报 "there is no unique or exclusion constraint matching
|
||||
the ON CONFLICT specification";该错误被逐行降级吞成 warning,于是分区部署下
|
||||
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
|
||||
"""
|
||||
part_dsn, _ = partitioned_schema
|
||||
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] == []
|
||||
rows = await _fetch(
|
||||
part_dsn,
|
||||
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
|
||||
_cid("part"),
|
||||
)
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
class TestManualSchemaModeAcceptance:
|
||||
"""issue #13 manual 档的真实实例验收: 旧表原样不动,写入照常,缺列只作提示。
|
||||
|
||||
manual 档的承诺是"库一条 DDL 都不发"——单元测试只能验"没调用 execute",
|
||||
真表上才验得了"表结构确实没变"。两条用例分别覆盖有权补列却不补(纪律)与
|
||||
无权补列(现场),后者正是 auto 档会刷出 `补列失败` warning 的那张表。
|
||||
"""
|
||||
|
||||
async def test_manual_leaves_the_stale_table_untouched(
|
||||
self, pre_tenant_schema, captured_warnings
|
||||
):
|
||||
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的两维度静默不写。
|
||||
|
||||
与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
|
||||
同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = _recorder(schema_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
)
|
||||
cols = await _fetch(
|
||||
schema_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||
schema,
|
||||
)
|
||||
# 表结构逐字不动: 既没多出 tenant_id/meta,也没被顺手改了列序
|
||||
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
|
||||
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man")
|
||||
)
|
||||
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
|
||||
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
|
||||
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
|
||||
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
assert [m for m in captured_warnings if "补列失败" in m] == []
|
||||
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
|
||||
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
|
||||
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_manual_on_a_role_that_cannot_alter_emits_no_backfill_failure(
|
||||
self, least_privilege_pre_tenant_dsn, captured_warnings
|
||||
):
|
||||
"""缺列旧表 + 只授 SELECT/INSERT 的角色 + manual: 补列失败的 warning 彻底消失。
|
||||
|
||||
auto 档在这张表上会刷出 `补列失败` 再刷 `写入失败`(见
|
||||
`test_backfill_failure_degrades_per_row_not_wholesale`)——那是 issue #13 要
|
||||
消灭的噪声。manual 档下 ALTER 压根不发,取而代之的是一条点名缺列并附可直接
|
||||
执行的 ALTER 的提示,而遥测照常落库。
|
||||
"""
|
||||
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}'
|
||||
)
|
||||
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5)
|
||||
|
||||
assert [m for m in captured_warnings if "补列失败" in m] == []
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
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]
|
||||
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
|
||||
assert (
|
||||
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
|
||||
)
|
||||
assert (
|
||||
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb;"
|
||||
in notices[0]
|
||||
)
|
||||
|
||||
# 该角色无权 ALTER,表必然还是旧形态: 缺的两列确实没被写
|
||||
cols = await _fetch(
|
||||
least_privilege_pre_tenant_dsn,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = current_schema() AND table_name = 'llm_calls' "
|
||||
"ORDER BY ordinal_position",
|
||||
)
|
||||
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
|
||||
|
||||
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
|
||||
rows = await _fetch(
|
||||
least_privilege_pre_tenant_dsn,
|
||||
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
|
||||
f"{_RUN_PREFIX}-manlp%",
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")]
|
||||
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
|
||||
assert rows[1]["cost"] == 2.5
|
||||
finally:
|
||||
await recorder.aclose()
|
||||
|
||||
|
||||
_PHYSICAL_COLUMNS_SQL = (
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position"
|
||||
)
|
||||
|
||||
|
||||
class TestPublishedSchemaScript:
|
||||
"""issue #13: README 叫下游执行的那份脚本,在真实实例上必须建得出、且可重复执行。
|
||||
|
||||
这份脚本是 `telemetry_schema_sql("postgres")` 的输出,manual 档下游拿它建表,
|
||||
库随后靠列探测决定写哪些列——脚本与 `COLUMNS` 一旦漂移,表现是"照文档建完表,
|
||||
库仍报缺列"。人工核对不构成回归保护: 改一次 README 或 DDL 就会悄悄失去它。
|
||||
"""
|
||||
|
||||
async def test_script_builds_the_full_table_and_is_rerunnable(self, fresh_schema):
|
||||
"""空 schema 里执行一遍建出全部物理列;再执行一遍不报错。
|
||||
|
||||
第二遍是 `ADD COLUMN IF NOT EXISTS` 的幂等性验收: 去掉 IF NOT EXISTS 后,
|
||||
建表语句会被 `IF NOT EXISTS` 跳过而补列语句撞上 "column ... already exists",
|
||||
整段脚本第二次执行即失败——而"可重复执行"正是这份脚本对下游的承诺。
|
||||
"""
|
||||
fresh_dsn, schema = fresh_schema
|
||||
script = telemetry_schema_sql("postgres")
|
||||
|
||||
await _execute_script(fresh_dsn, script)
|
||||
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||
assert set(actual) == set(COLUMNS) | {"created_at"}
|
||||
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
||||
assert actual == _EXPECTED_COLUMNS
|
||||
|
||||
await _execute_script(fresh_dsn, script) # 可重复执行: 第二遍不得抛
|
||||
rerun = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||
assert rerun == actual # 且第二遍没有偷偷改动表结构
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# issue #12 Task 4: README 的生产部署 DDL 模板,逐条在真实 PG 上执行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 模板 SQL **只有一份**,在 README 里。测试从 README 解析出来跑,而不是在这里另抄
|
||||
# 一份: 抄一份就是两份会各自漂移的东西,而"README 里的 SQL 能跑"这个承诺恰恰只在
|
||||
# 同源时才成立(doctest / Rust doc tests / mdbook test 都是这个范式)。
|
||||
_README = Path(__file__).resolve().parents[2] / "README.md"
|
||||
|
||||
# 锚点写成 HTML 注释,渲染时不可见,比按章节标题或代码块序号定位稳固得多。
|
||||
_TEMPLATE_BLOCK = re.compile(r"<!-- pg-template:([a-z_]+) -->\s*\n```sql\n(.*?)\n```", re.DOTALL)
|
||||
|
||||
# 顺序即执行顺序;数量与名字都钉死——解析不到或多出一块必须当场红,
|
||||
# 绝不能退化成空列表让这条测试变成永远绿的摆设。
|
||||
_EXPECTED_TEMPLATE_BLOCKS = (
|
||||
"roles",
|
||||
"table",
|
||||
"partition",
|
||||
"grants",
|
||||
"immutable",
|
||||
"rls",
|
||||
"index",
|
||||
)
|
||||
|
||||
# README 里必须原样保留、由本测试做受控替换的标识符。README 那份是给下游照抄的,
|
||||
# 故占位符是**合法可执行的具体值**而不是 `<schema>` 之类的尖括号洞。
|
||||
_TEMPLATE_PLACEHOLDERS = (
|
||||
"polygateway_owner",
|
||||
"polygateway_app",
|
||||
"polygateway_report",
|
||||
"CHANGE_ME_APP",
|
||||
"CHANGE_ME_REPORT",
|
||||
"SCHEMA public",
|
||||
"llm_calls_2026_01",
|
||||
"'2026-01-01 00:00:00+00'",
|
||||
"'2026-02-01 00:00:00+00'",
|
||||
)
|
||||
|
||||
# 应用角色在生产里能发的唯一一类写语句(与库的 INSERT 同形,只列 NOT NULL 列)
|
||||
_TEMPLATE_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2)"
|
||||
)
|
||||
|
||||
|
||||
def _template_blocks() -> dict[str, str]:
|
||||
"""从 README 解析带锚点的 SQL 块;顺序即文中出现顺序。"""
|
||||
return dict(_TEMPLATE_BLOCK.findall(_README.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TemplateEnv:
|
||||
"""模板部署完成后的现场句柄:三个角色各自的连接串 + 当月分区名。"""
|
||||
|
||||
admin_dsn: str
|
||||
app_dsn: str
|
||||
report_dsn: str
|
||||
schema: str
|
||||
partition: str
|
||||
seeded: tuple[str, str] # (tenant-a 的行, tenant-b 的行)
|
||||
|
||||
|
||||
def _localize(sql: str, schema: str, roles: dict[str, str], month: datetime) -> str:
|
||||
"""把 README 里给下游照抄的标识符换成本次运行专属的临时对象。
|
||||
|
||||
替换规则写在测试里而不是让 README 变得不可直接复制: README 里那份必须是
|
||||
下游 `pip install` 后照抄就能用的,占位符因此都是合法 SQL 值。
|
||||
"""
|
||||
start = month.strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
end = (month + timedelta(days=32)).replace(day=1).strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
for placeholder, actual in (
|
||||
# 长名在前: 三个角色名互不为前缀,但顺序稳定便于排查
|
||||
("polygateway_owner", roles["owner"]),
|
||||
("polygateway_report", roles["report"]),
|
||||
("polygateway_app", roles["app"]),
|
||||
("CHANGE_ME_APP", _PROBE_PASSWORD),
|
||||
("CHANGE_ME_REPORT", _PROBE_PASSWORD),
|
||||
("SCHEMA public", f"SCHEMA {schema}"),
|
||||
("llm_calls_2026_01", f"llm_calls_{month:%Y_%m}"),
|
||||
("'2026-01-01 00:00:00+00'", f"'{start}'"),
|
||||
("'2026-02-01 00:00:00+00'", f"'{end}'"),
|
||||
):
|
||||
sql = sql.replace(placeholder, actual)
|
||||
return sql
|
||||
|
||||
|
||||
def _role_dsn(dsn: str, role: str, schema: str) -> str:
|
||||
low = re.sub(r"//[^@/]+@", f"//{role}:{_PROBE_PASSWORD}@", dsn, count=1)
|
||||
return _search_path_dsn(low, schema)
|
||||
|
||||
|
||||
async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -> None:
|
||||
"""删净临时 schema 与三个角色(角色是**全局**对象,漏删会跨 run 残留)。"""
|
||||
import asyncpg
|
||||
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
for role in roles.values():
|
||||
await admin.execute(f"DROP OWNED BY {role}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {role}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def production_template(dsn):
|
||||
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
|
||||
|
||||
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls`
|
||||
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
suffix = uuid4().hex[:8]
|
||||
schema = f"pgwtpl_{suffix}"
|
||||
roles = {
|
||||
"owner": f"pgwtpl_owner_{suffix}",
|
||||
"app": f"pgwtpl_app_{suffix}",
|
||||
"report": f"pgwtpl_report_{suffix}",
|
||||
}
|
||||
month = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
blocks = _template_blocks()
|
||||
# 解析不到就地红: 空 dict 会让下面的 for 一句不执行,测试变成"只验证了能连上库"
|
||||
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS), (
|
||||
f"README 的模板锚点与预期不符: {list(blocks)}"
|
||||
)
|
||||
|
||||
seeded = (_cid("tpl-a"), _cid("tpl-b"))
|
||||
admin_dsn = _search_path_dsn(dsn, schema)
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
|
||||
# 若它在下面的清理块内触发,清理会去 DROP 从未建过的角色而把 skip 盖掉
|
||||
can_create = await admin.fetchval(
|
||||
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
|
||||
)
|
||||
if not can_create:
|
||||
await admin.close()
|
||||
pytest.skip("当前账号无权建临时角色,跳过生产模板用例")
|
||||
try:
|
||||
await admin.execute(f"CREATE SCHEMA {schema}")
|
||||
await admin.execute(f"SET search_path = {schema}")
|
||||
# README §2 写明的前置步骤: 先用库自带脚本建出普通表当模子
|
||||
await admin.execute(telemetry_schema_sql("postgres"))
|
||||
for name in _EXPECTED_TEMPLATE_BLOCKS:
|
||||
await admin.execute(_localize(blocks[name], schema, roles, month))
|
||||
# 种两个租户的行(超级用户绕过 RLS,属于布景不属于被测行为)
|
||||
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
|
||||
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
|
||||
except BaseException:
|
||||
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
|
||||
# 而 `TestSchema` 那条按 table_name 查 information_schema 的用例不带
|
||||
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
|
||||
await admin.close()
|
||||
await _drop_template_objects(dsn, schema, roles)
|
||||
raise
|
||||
finally:
|
||||
if not admin.is_closed():
|
||||
await admin.close()
|
||||
|
||||
yield _TemplateEnv(
|
||||
admin_dsn=admin_dsn,
|
||||
app_dsn=_role_dsn(dsn, roles["app"], schema),
|
||||
report_dsn=_role_dsn(dsn, roles["report"], schema),
|
||||
schema=schema,
|
||||
partition=f"llm_calls_{month:%Y_%m}",
|
||||
seeded=seeded,
|
||||
)
|
||||
|
||||
admin = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
|
||||
for role in roles.values():
|
||||
await admin.execute(f"DROP OWNED BY {role}")
|
||||
await admin.execute(f"DROP ROLE IF EXISTS {role}")
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
class TestProductionTemplate:
|
||||
"""issue #12: README 的生产部署 DDL 模板必须逐条可执行,且行为与文中描述一致。
|
||||
|
||||
模板出错的代价全部落在下游身上(照抄就中招),而人工核对不构成回归保护——
|
||||
改一次 README 就会悄悄失去它。故这里从 README **直接解析** SQL 来执行。
|
||||
"""
|
||||
|
||||
def test_readme_exposes_exactly_the_expected_template_blocks(self):
|
||||
"""先钉死解析本身: 锚点没了、改名了、块数变了,这条当场红。
|
||||
|
||||
没有它,`production_template` 里解析出空 dict 时下面每条用例都会以
|
||||
"表不存在"之类的间接形态失败,真因(README 结构变了)要靠猜。
|
||||
"""
|
||||
blocks = _template_blocks()
|
||||
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS)
|
||||
assert all(sql.strip() for sql in blocks.values())
|
||||
joined = "\n".join(blocks.values())
|
||||
for placeholder in _TEMPLATE_PLACEHOLDERS:
|
||||
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
|
||||
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
|
||||
|
||||
async def test_app_can_insert_but_cannot_mutate(self, production_template):
|
||||
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
|
||||
|
||||
权限检查早于行级触发器,故这里拿到的必须是 InsufficientPrivilegeError——
|
||||
若换成触发器的 RaiseError,说明 REVOKE 那一块没生效,而"不可变"就只剩
|
||||
一层属主随手可关的兜底。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.app_dsn, timeout=10)
|
||||
try:
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute("UPDATE llm_calls SET response = 'x'")
|
||||
finally:
|
||||
await conn.close()
|
||||
rows = await _fetch(
|
||||
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
|
||||
)
|
||||
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
|
||||
|
||||
async def test_report_can_read_but_cannot_write(self, production_template):
|
||||
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.report_dsn, timeout=10)
|
||||
try:
|
||||
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
|
||||
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
|
||||
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[0], "tenant-a")]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_reads_are_fail_closed_until_the_tenant_guc_is_set(self, production_template):
|
||||
"""未设 `app.tenant_id` → 零行(fail-closed);设了 → 只看得到本租户。
|
||||
|
||||
两个断言缺一不可: 只验"设了能看到自己的"漏掉了 GUC 未设时全表泄露,
|
||||
只验"未设是零行"则一条永远返回 false 的 policy 也能通过。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.app_dsn, timeout=10)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
assert await conn.fetch("SELECT call_id FROM llm_calls") == []
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-b', true)")
|
||||
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
|
||||
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[1], "tenant-b")]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def test_rows_land_in_the_current_month_partition(self, production_template):
|
||||
"""分区表写入成功,且行确实落进当月分区(不是落进某个兜底分区)。"""
|
||||
env = production_template
|
||||
rows = await _fetch(
|
||||
env.admin_dsn,
|
||||
"SELECT tableoid::regclass::text AS part FROM llm_calls WHERE call_id = $1",
|
||||
env.seeded[0],
|
||||
)
|
||||
assert [r["part"].split(".")[-1] for r in rows] == [env.partition]
|
||||
|
||||
async def test_trigger_blocks_delete_while_drop_partition_still_works(
|
||||
self, production_template
|
||||
):
|
||||
"""兜底触发器拦得住 DELETE(连超级用户也拦),却拦不住 DROP PARTITION。
|
||||
|
||||
这正是 README 说"清理只能走 DROP PARTITION 而不是 DELETE"的机械化依据:
|
||||
既要对应用角色 REVOKE DELETE、又要能清理过期数据,分区是唯一不冲突的解。
|
||||
"""
|
||||
import asyncpg
|
||||
|
||||
env = production_template
|
||||
conn = await asyncpg.connect(env.admin_dsn, timeout=10)
|
||||
try:
|
||||
with pytest.raises(asyncpg.exceptions.RaiseError) as exc:
|
||||
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", env.seeded[0])
|
||||
assert "不可变审计表" in str(exc.value)
|
||||
await conn.execute(f"ALTER TABLE llm_calls DETACH PARTITION {env.partition}")
|
||||
await conn.execute(f"DROP TABLE {env.partition}")
|
||||
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -327,3 +327,49 @@ async def test_variant_probe_rate_limited_releases_not_hangs(redis_client):
|
||||
assert update.applied
|
||||
nxt = await gate.try_enter("s1", "w2")
|
||||
assert nxt.allowed and nxt.is_probe # 立即可再探,不等 probe_ttl
|
||||
|
||||
|
||||
# —— issue #14: retry_after_s = 距离**确定**可再试的时刻,HALF_OPEN 无确定时刻 ——
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_half_open_rejection_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.is_probe
|
||||
blocked = await gate.try_enter("s1", "w2")
|
||||
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
|
||||
assert blocked.retry_after_s == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_probe_grant_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.allowed and probe.is_probe
|
||||
assert probe.retry_after_s == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_retry_after_zero_while_probe_in_flight(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe
|
||||
assert await gate.retry_after_s(("s1",)) == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_fenced_write_in_half_open_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
|
||||
await _open_gate(gate) # 他人开路,epoch 推进
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
|
||||
update = await gate.record_success(stale)
|
||||
assert not update.applied and update.state is GateState.HALF_OPEN
|
||||
assert update.retry_after_s == 0.0
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
|
||||
|
||||
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。
|
||||
|
||||
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
|
||||
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path),
|
||||
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
|
||||
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.telemetry.schema import PG_DDL
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
|
||||
|
||||
_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
|
||||
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2, $3)"
|
||||
)
|
||||
|
||||
|
||||
def _partitioned_ddl() -> str:
|
||||
"""由库的真实 `PG_DDL` 派生一份 RANGE 分区版建表语句。
|
||||
|
||||
不另抄一份 DDL: 抄的那份与库的 schema 必然漂移,而漂移后本测试验的就不再是
|
||||
"库建的表被做成分区后脚本认不认得"。两处改动都是分区表的**硬性要求**——
|
||||
分区表上的唯一约束必须包含分区键,故 `call_id` 单列主键不再合法。
|
||||
"""
|
||||
body, count = re.subn(
|
||||
r"call_id(\s+)TEXT PRIMARY KEY", r"call_id\1TEXT NOT NULL", PG_DDL, count=1
|
||||
)
|
||||
if count != 1:
|
||||
raise AssertionError("PG_DDL 的 call_id 主键声明形态已变,分区版 DDL 需同步")
|
||||
body = body.strip().rstrip(";").strip()
|
||||
if not body.endswith(")"):
|
||||
raise AssertionError("PG_DDL 结尾形态已变,分区版 DDL 需同步")
|
||||
return (
|
||||
f"{body[:-1].rstrip()},\n"
|
||||
" PRIMARY KEY (call_id, created_at)\n"
|
||||
") PARTITION BY RANGE (created_at)"
|
||||
)
|
||||
|
||||
|
||||
def _dsn_value() -> str | None:
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
raw = merged.get("PGW_TELEMETRY_PG_DSN")
|
||||
if not raw:
|
||||
return None
|
||||
scheme, sep, rest = raw.partition("://")
|
||||
return f"{scheme.partition('+')[0]}{sep}{rest}"
|
||||
|
||||
|
||||
def _search_path_dsn(dsn: str, schema: str) -> str:
|
||||
sep = "&" if "?" in dsn else "?"
|
||||
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
|
||||
|
||||
|
||||
def _stamp(delta: timedelta) -> datetime:
|
||||
return datetime.now(UTC) + delta
|
||||
|
||||
|
||||
def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(_SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_ROOT,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dsn():
|
||||
value = _dsn_value()
|
||||
if value is None:
|
||||
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
|
||||
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
|
||||
if not value.rstrip("/").endswith("/polygateway"):
|
||||
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
|
||||
import asyncpg
|
||||
|
||||
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {name}")
|
||||
await conn.execute(f"SET search_path = {name}")
|
||||
await conn.execute(ddl)
|
||||
for statement in extra:
|
||||
await conn.execute(statement)
|
||||
finally:
|
||||
await conn.close()
|
||||
return name
|
||||
|
||||
|
||||
async def _drop_schema(dsn_value: str, name: str) -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA {name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(schema_dsn, timeout=10)
|
||||
try:
|
||||
await conn.executemany(_INSERT, rows)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _call_ids(schema_dsn: str) -> list[str]:
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(schema_dsn, timeout=10)
|
||||
try:
|
||||
rows = await conn.fetch("SELECT call_id FROM llm_calls ORDER BY call_id")
|
||||
finally:
|
||||
await conn.close()
|
||||
return [r["call_id"] for r in rows]
|
||||
|
||||
|
||||
async def _public_count(dsn_value: str) -> int:
|
||||
"""共享表的行数;本测试全程不得让它变动一行。"""
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(dsn_value, timeout=10)
|
||||
try:
|
||||
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
|
||||
return -1
|
||||
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partitioned_schema(dsn):
|
||||
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
|
||||
name = await _make_schema(
|
||||
dsn,
|
||||
"part",
|
||||
_partitioned_ddl(),
|
||||
extra=(
|
||||
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
|
||||
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
|
||||
),
|
||||
)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def plain_schema(dsn):
|
||||
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
|
||||
name = await _make_schema(dsn, "plain", PG_DDL)
|
||||
yield _search_path_dsn(dsn, name), name
|
||||
await _drop_schema(dsn, name)
|
||||
|
||||
|
||||
class TestPartitionedTarget:
|
||||
async def test_partitioned_table_exits_three_without_deleting_anything(
|
||||
self, partitioned_schema
|
||||
):
|
||||
schema_dsn, schema = partitioned_schema
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
[
|
||||
("part-old-1", "", _stamp(timedelta(days=-30))),
|
||||
("part-old-2", "acme", _stamp(timedelta(days=-20))),
|
||||
],
|
||||
)
|
||||
|
||||
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
|
||||
result = _run(
|
||||
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 3, (result.stdout, result.stderr)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "DROP PARTITION" in combined
|
||||
assert "DETACH" in combined
|
||||
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
|
||||
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
|
||||
|
||||
class TestPlainTableBatches:
|
||||
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
|
||||
schema_dsn, schema = plain_schema
|
||||
before_public = await _public_count(dsn)
|
||||
await _seed(
|
||||
schema_dsn,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-40))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-30))),
|
||||
("old-3", "acme", _stamp(timedelta(days=-20))),
|
||||
("old-4", "acme", _stamp(timedelta(days=-15))),
|
||||
("old-5", "", _stamp(timedelta(days=-10))),
|
||||
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||
],
|
||||
)
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--batch-size",
|
||||
"2",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
|
||||
assert f"{schema}.llm_calls" in result.stdout
|
||||
assert "将删除行数: 5" in result.stdout
|
||||
assert "'acme': 3" in result.stdout
|
||||
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
|
||||
assert "批次 1" in result.stdout
|
||||
assert "批次 3" in result.stdout
|
||||
assert "批次 4" not in result.stdout
|
||||
assert "已删除 5 行" in result.stdout
|
||||
assert await _public_count(dsn) == before_public
|
||||
|
||||
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
|
||||
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
||||
assert "将删除行数: 1" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
|
||||
|
||||
class TestMissingAsyncpg:
|
||||
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
|
||||
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
|
||||
|
||||
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
|
||||
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
|
||||
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
|
||||
"""
|
||||
schema_dsn, _ = plain_schema
|
||||
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
|
||||
stub = tmp_path / "stub"
|
||||
stub.mkdir()
|
||||
(stub / "asyncpg.py").write_text(
|
||||
'raise ImportError("asyncpg 未安装(测试构造)")\n', encoding="utf-8"
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": os.pathsep.join(
|
||||
[str(stub), *([p] if (p := os.environ.get("PYTHONPATH")) else [])]
|
||||
),
|
||||
}
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
schema_dsn,
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
env=env,
|
||||
)
|
||||
|
||||
assert result.returncode == 2, (result.stdout, result.stderr)
|
||||
assert "asyncpg" in result.stderr
|
||||
assert "pip install" in result.stderr
|
||||
assert await _call_ids(schema_dsn) == ["old-1"]
|
||||
@@ -13,8 +13,10 @@ from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CircuitOpenError,
|
||||
GatewayUnavailableError,
|
||||
GovernanceBackendError,
|
||||
SourceDeadError,
|
||||
SourceNotConfiguredError,
|
||||
TransientError,
|
||||
)
|
||||
@@ -62,6 +64,7 @@ def _mw(
|
||||
sleep,
|
||||
rng=lambda: 0.0,
|
||||
quota_full="wait",
|
||||
circuit_open="fail_fast",
|
||||
gate=None,
|
||||
transport=None,
|
||||
emitter=None,
|
||||
@@ -76,6 +79,7 @@ def _mw(
|
||||
retry=RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0),
|
||||
backpressure=BackpressurePolicy(stall_window_s=_STALL, poll_interval_s=0.01),
|
||||
quota_full=quota_full,
|
||||
circuit_open=circuit_open,
|
||||
cooldown_memo=SourceCooldownMemo(now=clock),
|
||||
emitter=emitter,
|
||||
now=clock,
|
||||
@@ -321,9 +325,7 @@ class TestStallBudget:
|
||||
async def advance(_n):
|
||||
clock.advance(_STALL)
|
||||
|
||||
mw = _mw(
|
||||
[src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport
|
||||
)
|
||||
mw = _mw([src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport)
|
||||
with pytest.raises(AllSourcesExhausted) as ei:
|
||||
await mw(_REQ)
|
||||
assert ei.value.reason == "stalled" # 不是 retry_exhausted: 429 确实没烧重试预算
|
||||
@@ -541,9 +543,7 @@ class TestUnknownSourceIsAssemblyDefect:
|
||||
"""
|
||||
src = make_source("s1")
|
||||
# 限流后端的源名单与治理循环拿到的源对不上 = 装配缺陷
|
||||
limiter = InMemoryLimiter(
|
||||
scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL
|
||||
)
|
||||
limiter = InMemoryLimiter(scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL)
|
||||
gate = QuotaGate(limiter, scope="llm")
|
||||
with pytest.raises(SourceNotConfiguredError) as ei:
|
||||
await getattr(gate, method)(src)
|
||||
@@ -597,3 +597,201 @@ class TestGateFailuresReachCallersAsScopeLevel:
|
||||
await QuotaGate(_Broken(), scope="LLM").progress_age_s()
|
||||
assert ei.value.scope == "llm"
|
||||
assert ei.value.reason == "governance_backend_down"
|
||||
|
||||
|
||||
class TestCircuitOpenPolicy:
|
||||
"""issue #14: 熔断全拒时是当场判死还是等冷却过去。
|
||||
|
||||
缺省 fail_fast 即历史行为(TestStallQuadrants 等既有用例照旧覆盖);
|
||||
本类钉的是 wait 档,以及两条策略互不串线。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _opened_gate(clock, cfg=_BREAKER):
|
||||
gate = InMemoryGate(config=cfg, now=clock)
|
||||
for _ in range(cfg.fail_threshold):
|
||||
entry = await gate.try_enter("s1", "w")
|
||||
await gate.record_failure(entry, "network_error", False)
|
||||
return gate
|
||||
|
||||
@staticmethod
|
||||
def _free_limiter(clock, src):
|
||||
return InMemoryLimiter(
|
||||
scope="llm",
|
||||
sources={"s1": src},
|
||||
global_limits=_NO_GLOBAL,
|
||||
lease_ttl_s=10_000.0,
|
||||
now=clock,
|
||||
)
|
||||
|
||||
async def test_fail_fast_is_the_default(self):
|
||||
"""缺省档逐字保持历史行为: 全源开路当场抛 CircuitOpenError。"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[],
|
||||
clock=clock,
|
||||
sleep=BoundedSleep(),
|
||||
gate=await self._opened_gate(clock),
|
||||
)
|
||||
with pytest.raises(CircuitOpenError) as ei:
|
||||
await mw(_REQ)
|
||||
assert ei.value.reason == "circuit_open"
|
||||
|
||||
async def test_wait_sleeps_out_the_cooldown_instead_of_dying(self):
|
||||
"""wait 档: 睡到冷却结束再来一轮,拿到探针后正常返回。
|
||||
|
||||
睡的是**冷却剩余**而不是 poll_interval——60 秒冷却用 10ms 轮询要空转
|
||||
6000 次,memory 后端只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用。
|
||||
"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
sleep = BoundedSleep()
|
||||
|
||||
async def advance(_n):
|
||||
clock.advance(sleep.delays[-1])
|
||||
|
||||
sleep._side_effect = advance
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[_ok()],
|
||||
clock=clock,
|
||||
sleep=sleep,
|
||||
gate=await self._opened_gate(clock),
|
||||
circuit_open="wait",
|
||||
)
|
||||
resp = await mw(_REQ)
|
||||
assert resp.content == "ok"
|
||||
# 一觉睡到冷却结束(jitter 上加,rng=0 → +0.5×poll),不是 poll 空转
|
||||
assert sleep.delays[0] == pytest.approx(_BREAKER.cooldown_s + 0.005)
|
||||
|
||||
async def test_wait_does_not_leak_into_the_quota_branch(self):
|
||||
"""两条策略互不串线: circuit_open=wait 配 quota_full=fail_fast 时,
|
||||
熔断等待**不得**被当成配额耗尽上报——串线会让调用方拿到一个
|
||||
reason=quota_exhausted 的异常,而配额其实是满的。"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
sleep = BoundedSleep()
|
||||
|
||||
async def advance(_n):
|
||||
clock.advance(sleep.delays[-1])
|
||||
|
||||
sleep._side_effect = advance
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[_ok()],
|
||||
clock=clock,
|
||||
sleep=sleep,
|
||||
gate=await self._opened_gate(clock),
|
||||
quota_full="fail_fast",
|
||||
circuit_open="wait",
|
||||
)
|
||||
assert (await mw(_REQ)).content == "ok"
|
||||
|
||||
async def test_wait_still_dies_when_cooldown_outlasts_the_stall_budget(self):
|
||||
"""等待有可解释的上界: 冷却比 stall 预算还长时,在窗口耗尽处判死。
|
||||
|
||||
单次睡眠夹到剩余 stall 预算,故最坏墙钟 = stall_window + 一个 poll,
|
||||
不随 max_cooldown_s 漂移。
|
||||
"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
long_cooldown = BreakerConfig(
|
||||
fail_threshold=3, cooldown_s=1000.0, probe_ttl_s=2000.0, max_cooldown_s=1000.0
|
||||
)
|
||||
sleep = BoundedSleep()
|
||||
|
||||
async def advance(_n):
|
||||
clock.advance(sleep.delays[-1])
|
||||
|
||||
sleep._side_effect = advance
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[],
|
||||
clock=clock,
|
||||
sleep=sleep,
|
||||
gate=await self._opened_gate(clock, long_cooldown),
|
||||
circuit_open="wait",
|
||||
)
|
||||
with pytest.raises(AllSourcesExhausted) as ei:
|
||||
await mw(_REQ)
|
||||
assert ei.value.reason == "stalled"
|
||||
assert ei.value.per_source_reasons == {"s1": "circuit_open"}
|
||||
assert sleep.delays[0] == pytest.approx(_STALL + 0.01) # 夹到预算 + 一个 poll
|
||||
|
||||
async def test_wait_loop_stays_cancellable(self):
|
||||
"""取消穿透(铁律): 熔断等待中的取消不得被吞。"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[],
|
||||
clock=clock,
|
||||
sleep=asyncio.sleep,
|
||||
gate=await self._opened_gate(clock),
|
||||
circuit_open="wait",
|
||||
)
|
||||
task = asyncio.create_task(mw(_REQ))
|
||||
await asyncio.sleep(0.03)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def test_wait_does_not_exempt_probes_from_the_retry_budget(self):
|
||||
"""wait 档不豁免重试预算: 探针是**真实尝试**,失败照样烧 max_attempts。
|
||||
|
||||
故 force_open 的源(401/403/欠费一击即熔,不看任何阈值)在 wait 档下并
|
||||
**不是**"等满 stall 窗口才死"——两个预算哪个先耗尽就以哪个的 reason
|
||||
失败。这里 max_attempts=3 而冷却只累计 120s < stall_window=300s,故
|
||||
先到的是重试预算。参数换成"冷却累计超过 stall 预算"则先到 stalled
|
||||
(见 test_wait_still_dies_when_cooldown_outlasts_the_stall_budget)。
|
||||
|
||||
这与 issue #8 确立的划分一致: 划分依据是"谁消耗重试预算",探针发出了
|
||||
真实请求,理应记在重试预算上而不是 stall 账上。
|
||||
"""
|
||||
clock = FakeClock()
|
||||
src = make_source()
|
||||
sleep = BoundedSleep()
|
||||
|
||||
async def advance(_n):
|
||||
clock.advance(sleep.delays[-1])
|
||||
|
||||
sleep._side_effect = advance
|
||||
mw = _mw(
|
||||
[src],
|
||||
self._free_limiter(clock, src),
|
||||
[SourceDeadError("401"), SourceDeadError("401"), SourceDeadError("401")],
|
||||
clock=clock,
|
||||
sleep=sleep,
|
||||
circuit_open="wait",
|
||||
)
|
||||
with pytest.raises(AllSourcesExhausted) as ei:
|
||||
await mw(_REQ)
|
||||
assert ei.value.reason == "retry_exhausted"
|
||||
assert clock.t - 1000.0 < _STALL # 远未等满 stall 窗口
|
||||
|
||||
async def test_half_open_rejection_does_not_blacklist_a_recovered_source(self):
|
||||
"""issue #14 §1.3 回归: 探针成功后本进程立即可再选该源。
|
||||
|
||||
此前 HALF_OPEN 拒绝把探针租约(派生自 2 × timeout,现场 600s)写进冷却
|
||||
备忘,而 `set_until` 取更晚者、不可回退——门恢复 CLOSED 之后本进程仍
|
||||
跳过该源整整一个租约,单源下每次调用照旧判死。多源部署同样中招,只是
|
||||
被别的源接住流量掩盖了。
|
||||
"""
|
||||
clock = FakeClock()
|
||||
cfg = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=600.0)
|
||||
gate = await self._opened_gate(clock, cfg)
|
||||
memo = SourceCooldownMemo(now=clock)
|
||||
clock.advance(cfg.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
blocked = await gate.try_enter("s1", "w2") # 并发调用撞上在途探针
|
||||
assert not blocked.allowed
|
||||
memo.set_until("s1", clock() + blocked.retry_after_s) # 准入路径的写法
|
||||
await gate.record_success(probe) # 探针成功 → 门恢复 CLOSED
|
||||
assert not memo.active("s1")
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.errors import ResultInvalidError, TransientError
|
||||
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
||||
from polygateway.types import ChatRequest, LLMResponse
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
|
||||
|
||||
_MSGS = [{"role": "user", "content": "hi"}]
|
||||
|
||||
@@ -327,3 +328,51 @@ class TestStructuredRehydration:
|
||||
key = build_cache_key("m", _MSGS, "proj", None)
|
||||
raw = await backend.get(key)
|
||||
assert raw is not None and "structured_data" not in json.loads(raw)
|
||||
|
||||
|
||||
class TestTelemetryCapDoesNotPoisonTheCacheKey:
|
||||
"""红线之一(issue #12): 遥测截断绝不能改到缓存 key。
|
||||
|
||||
`digest_messages` 对 content 非 list 的消息**原样透传同一个 dict 对象**
|
||||
(本文件上方公式测试依赖的也是这份对象),遥测拿到的与算 key 用的是同一份。
|
||||
就地截断会让同一组 messages 在遥测前后算出两个不同的 key——全量 miss、
|
||||
且没有任何报错。故这里测的是"截断没有就地改掉调用方的对象",不只是
|
||||
"截断函数是纯的"。
|
||||
"""
|
||||
|
||||
class _Rows:
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
async def record_llm_call(self, **fields):
|
||||
self.rows.append(fields)
|
||||
|
||||
async def test_key_is_byte_identical_across_a_capped_emit(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "合同正文" * 31},
|
||||
{"role": "user", "content": [{"type": "text", "text": "标书正文" * 30}]},
|
||||
]
|
||||
before = build_cache_key("m", messages, "proj", None)
|
||||
|
||||
rec = self._Rows()
|
||||
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
|
||||
request=ChatRequest(messages=messages),
|
||||
source=SourceConfig(
|
||||
name="s1",
|
||||
provider="p",
|
||||
base_url="https://gw.example/v1",
|
||||
api_key="sk",
|
||||
model="m",
|
||||
timeout_s=10.0,
|
||||
),
|
||||
call_id="c",
|
||||
latency_ms=1,
|
||||
response=_resp(),
|
||||
error=None,
|
||||
)
|
||||
# 截断确实发生了(否则本用例恒真)
|
||||
logged = json.loads(rec.rows[0]["messages"])
|
||||
assert "(略 116 字)" in logged[0]["content"]
|
||||
assert "(略 112 字)" in logged[1]["content"][0]["text"]
|
||||
|
||||
assert build_cache_key("m", messages, "proj", None) == before
|
||||
|
||||
@@ -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}}]})
|
||||
@@ -351,6 +365,84 @@ class TestFactories:
|
||||
assert isinstance(client, GatewayClient)
|
||||
|
||||
|
||||
class TestTelemetryTextCapWiring:
|
||||
"""`PGW_TELEMETRY_TEXT_CAP` 必须走通全部三条 `from_settings` 装配路(issue #12)。
|
||||
|
||||
三条链路写的是**同一张** `llm_calls` 表:只接通 chat,embed 与 OCR 的行就
|
||||
永远不受 cap 约束,同表内一半受控一半不受控——那正是本 issue 要消灭的状态。
|
||||
"""
|
||||
|
||||
_CAP_ENV = dict(_ENV, 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)
|
||||
client = GatewayClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._terminal._emitter._text_cap == 8
|
||||
# 对照组: 不设该键时 emitter 拿到的必须是 None,否则 8 可能是硬编码来的
|
||||
unset = GatewayClient.from_settings(
|
||||
GatewaySettings.from_env("LLM", env=_ENV), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._terminal._emitter._text_cap is None
|
||||
|
||||
def test_embedding_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import EmbeddingSettings
|
||||
from polygateway.embedding import EmbeddingClient
|
||||
|
||||
gateway = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
client = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=gateway, batch_size=2), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert client._emitter._text_cap == 8
|
||||
unset = EmbeddingClient.from_settings(
|
||||
EmbeddingSettings(gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2),
|
||||
telemetry=_MemoryRecorder(),
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
def test_ocr_from_settings_wires_the_cap(self):
|
||||
from polygateway.config import OcrSettings
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_CAP_ENV))
|
||||
client = OcrClient.from_settings(settings, telemetry=_MemoryRecorder())
|
||||
assert client._emitter._text_cap == 8
|
||||
no_cap = dict(self._OCR_CAP_ENV)
|
||||
no_cap.pop("PGW_TELEMETRY_TEXT_CAP")
|
||||
unset = OcrClient.from_settings(
|
||||
OcrSettings.from_env("OCR", env=no_cap), telemetry=_MemoryRecorder()
|
||||
)
|
||||
assert unset._emitter._text_cap is None
|
||||
|
||||
async def test_capped_body_reaches_the_recorder_end_to_end(self, monkeypatch):
|
||||
"""装配路通了还不够: 真跑一次 chat,落库的 messages 与 response 确已截断。
|
||||
|
||||
`from_settings` 自建 transport(没有 client_factory 入口),故在装配点
|
||||
换掉该类以接上 MockTransport——洋葱其余各层仍是 `from_settings` 装的真件。
|
||||
"""
|
||||
recorder = _MemoryRecorder()
|
||||
long_text = "甲乙丙丁戊己庚辛壬癸" # 10 字,cap=8 → 略 2 字
|
||||
monkeypatch.setattr(
|
||||
"polygateway.client.OpenAICompatTransport",
|
||||
lambda **kwargs: OpenAICompatTransport(
|
||||
client_factory=lambda source: httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(lambda request: _sse(content=long_text))
|
||||
)
|
||||
),
|
||||
)
|
||||
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
|
||||
async with GatewayClient.from_settings(settings, telemetry=recorder) as client:
|
||||
await client.chat([{"role": "user", "content": long_text}])
|
||||
row = recorder.rows[-1]
|
||||
assert json.loads(row["messages"])[0]["content"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
assert row["response"] == "甲乙丙丁戊己庚辛…(略 2 字)"
|
||||
|
||||
def test_non_positive_cap_rejected_on_the_direct_construction_path(self):
|
||||
"""直接构造是库承诺的另一条公共装配路;cap=0 会让每条正文只剩省略标记。"""
|
||||
with pytest.raises(ValueError, match="text_cap"):
|
||||
_client(telemetry=_MemoryRecorder(), text_cap=0)
|
||||
|
||||
|
||||
class TestSharedBackend:
|
||||
async def test_two_clients_share_global_concurrency_gate(self):
|
||||
"""VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。"""
|
||||
@@ -464,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)
|
||||
|
||||
@@ -170,6 +170,18 @@ class TestResilienceKeys:
|
||||
with pytest.raises(ValueError, match="probe"):
|
||||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))
|
||||
|
||||
def test_circuit_open_defaults_to_fail_fast(self):
|
||||
"""issue #14: 熔断拒绝的处置策略。
|
||||
|
||||
缺省**不跟随** quota_full 的 wait——把最坏墙钟从毫秒抬到 stall 窗口
|
||||
是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游。
|
||||
"""
|
||||
assert GatewaySettings.from_env("LLM", env=_env()).circuit_open == "fail_fast"
|
||||
waiting = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "wait"}))
|
||||
assert waiting.circuit_open == "wait"
|
||||
with pytest.raises(ValueError, match="CIRCUIT_OPEN"):
|
||||
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "block"}))
|
||||
|
||||
def test_selector_and_quota_full(self):
|
||||
# M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
|
||||
s = GatewaySettings.from_env("LLM", env=_env())
|
||||
@@ -331,6 +343,155 @@ class TestAssemblyGuards:
|
||||
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
|
||||
|
||||
|
||||
class TestTelemetrySchemaMode:
|
||||
"""PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。
|
||||
|
||||
键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA、
|
||||
没有迁移工具、没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
|
||||
PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
|
||||
而遥测是业务路径上的内联 await,故默认 manual。显式设置两侧都可覆盖——
|
||||
"可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它。
|
||||
"""
|
||||
|
||||
def _sqlite_env(self, **overrides):
|
||||
return _env(
|
||||
PGW_TELEMETRY_BACKEND="sqlite",
|
||||
PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
|
||||
**overrides,
|
||||
)
|
||||
|
||||
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_key_derives_auto_for_sqlite(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
|
||||
assert s.telemetry_auto_migrate is True
|
||||
|
||||
def test_unset_key_derives_manual_for_postgres(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._pg_env())
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_unset_key_derives_manual_for_none_backend(self):
|
||||
"""backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
|
||||
s = GatewaySettings.from_env("LLM", env=_env())
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_explicit_manual_overrides_sqlite_default(self):
|
||||
s = GatewaySettings.from_env(
|
||||
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
|
||||
)
|
||||
assert s.telemetry_auto_migrate is False
|
||||
|
||||
def test_explicit_auto_overrides_postgres_default(self):
|
||||
s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
|
||||
assert s.telemetry_auto_migrate is True
|
||||
|
||||
def test_invalid_mode_rejected_naming_the_env_key(self):
|
||||
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
|
||||
GatewaySettings.from_env(
|
||||
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
|
||||
)
|
||||
|
||||
|
||||
class TestTelemetryTextCap:
|
||||
"""`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。
|
||||
|
||||
与 `PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
|
||||
(不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套。
|
||||
"""
|
||||
|
||||
def test_unset_key_means_no_truncation(self):
|
||||
"""缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
|
||||
assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None
|
||||
|
||||
def test_positive_value_is_parsed_as_int(self):
|
||||
s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
|
||||
assert s.telemetry_text_cap == 2000
|
||||
|
||||
@pytest.mark.parametrize("raw", ["0", "-1"])
|
||||
def test_non_positive_rejected(self, raw):
|
||||
"""0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
|
||||
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))
|
||||
|
||||
def test_non_integer_rejected_naming_the_env_key(self):
|
||||
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
|
||||
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
|
||||
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 专用键。"""
|
||||
|
||||
@@ -507,6 +668,7 @@ class TestCrossFieldInvariants:
|
||||
("telemetry_backend", "redis"),
|
||||
("selector", "random"),
|
||||
("quota_full", "block"),
|
||||
("circuit_open", "block"),
|
||||
],
|
||||
)
|
||||
def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
|
||||
@@ -555,8 +717,24 @@ class TestCrossFieldInvariants:
|
||||
with pytest.raises(ValueError, match="telemetry_pg_dsn"):
|
||||
dataclasses.replace(base, telemetry_backend="postgres")
|
||||
|
||||
def test_none_backend_forces_auto_migrate_off(self):
|
||||
"""backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。
|
||||
|
||||
env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
|
||||
官方的装配路仍能把 True 传进来——不变量归位到构造期,三条路才一致。
|
||||
"""
|
||||
base = self._base() # telemetry_backend="none"
|
||||
replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
|
||||
assert replaced.telemetry_auto_migrate is False
|
||||
|
||||
# —— 标量域 ——
|
||||
|
||||
def test_non_positive_text_cap_rejected(self):
|
||||
"""env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="telemetry_text_cap"):
|
||||
dataclasses.replace(base, telemetry_text_cap=0)
|
||||
|
||||
def test_negative_structured_retries_rejected(self):
|
||||
base = self._base()
|
||||
with pytest.raises(ValueError, match="structured_max_retries"):
|
||||
|
||||
@@ -113,7 +113,7 @@ async def _recorded_cost(result, source):
|
||||
source_name=source.name,
|
||||
usage_source=result.usage_source,
|
||||
)
|
||||
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt(
|
||||
await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
|
||||
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
|
||||
source=source,
|
||||
call_id="cid-1",
|
||||
|
||||
@@ -25,3 +25,27 @@ def test_ocr_public_surface_exported():
|
||||
):
|
||||
assert hasattr(polygateway, name), name
|
||||
assert name in polygateway.__all__, name
|
||||
|
||||
|
||||
def test_telemetry_schema_sql_exported():
|
||||
"""issue #13: manual 档下游需要主动索取"库要求的最小 schema"的顶层入口。
|
||||
|
||||
同时钉住公共面**只增这一个名字**: `missing_columns_warning` 是 recorder 内部
|
||||
共用的文案构造函数,导出它等于多一份永久承诺(库承诺公共面只增不删)。
|
||||
"""
|
||||
assert "telemetry_schema_sql" in polygateway.__all__
|
||||
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__
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
|
||||
class TestEmitterCost:
|
||||
async def test_success_row_costed(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
@@ -182,13 +182,13 @@ class TestEmitterCost:
|
||||
|
||||
async def test_cache_hit_row_costs_zero(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
|
||||
assert rec.rows[0]["cost"] == 0.0
|
||||
|
||||
async def test_failure_row_cost_none(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
@@ -201,7 +201,7 @@ class TestEmitterCost:
|
||||
|
||||
async def test_unknown_model_none_without_blocking(self):
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE)
|
||||
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(model="mystery"),
|
||||
@@ -215,7 +215,7 @@ class TestEmitterCost:
|
||||
async def test_no_pricing_keeps_none(self):
|
||||
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
|
||||
rec = _MemoryRecorder()
|
||||
emitter = TelemetryEmitter(rec)
|
||||
emitter = TelemetryEmitter(rec, text_cap=None)
|
||||
await emitter.emit_attempt(
|
||||
request=_REQ,
|
||||
source=_source(),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""`tools/telemetry_retention.py` 的 SQLite 分支测试(issue #12 Task 3)。
|
||||
|
||||
一律经 `subprocess` 跑真实脚本 + 真实临时 SQLite 库文件: 脚本是独立运维工具、
|
||||
不被库 import,用 monkeypatch 或直接 import 私有函数测出来的"通过"与运维实际
|
||||
执行的那条路径不是同一条(退出码、argparse 行为、stdout 全都测不到)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from polygateway.telemetry.schema import SQLITE_DDL
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
|
||||
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 库写入 SQLite 的 created_at 是 UTC 的 'YYYY-MM-DD HH:MM:SS' 文本(schema 的
|
||||
# DEFAULT (datetime('now'))),测试数据必须同款,否则字符串比较的口径就假了
|
||||
_INSERT = (
|
||||
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
|
||||
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
|
||||
"VALUES (?, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, ?, ?)"
|
||||
)
|
||||
|
||||
|
||||
def _stamp(delta: timedelta) -> str:
|
||||
return (datetime.now(UTC) + delta).strftime(_TIME_FORMAT)
|
||||
|
||||
|
||||
def _make_db(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
|
||||
"""按库的真实 DDL 建临时库并灌入 (call_id, tenant_id, created_at) 三元组。"""
|
||||
path = tmp_path / "telemetry.db"
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
conn.executescript(SQLITE_DDL)
|
||||
conn.executemany(_INSERT, rows)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return path
|
||||
|
||||
|
||||
def _run(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(_SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_ROOT,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def _rows(path: Path) -> list[str]:
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return [r[0] for r in conn.execute("SELECT call_id FROM llm_calls ORDER BY call_id")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _aged_db(tmp_path: Path) -> Path:
|
||||
return _make_db(
|
||||
tmp_path,
|
||||
[
|
||||
("old-1", "", _stamp(timedelta(days=-30))),
|
||||
("old-2", "acme", _stamp(timedelta(days=-20))),
|
||||
("old-3", "acme", _stamp(timedelta(days=-10))),
|
||||
("fresh-1", "acme", _stamp(timedelta(days=-1))),
|
||||
("fresh-2", "", _stamp(timedelta(hours=-1))),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestSqliteDryRun:
|
||||
def test_dry_run_deletes_nothing_and_reports_counts_range_and_tenants(self, tmp_path):
|
||||
"""缺省(不带 --apply)是 dry-run: 一行不删,且报出足以判断"删的是不是我想删的"的三样。"""
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["fresh-1", "fresh-2", "old-1", "old-2", "old-3"]
|
||||
assert "将删除行数: 3" in result.stdout
|
||||
assert "created_at 范围:" in result.stdout
|
||||
assert "按 tenant_id 分布" in result.stdout
|
||||
# 空串是"未归属"的哨兵而非 NULL,repr 让它在输出里不被误读成缺失
|
||||
assert "'acme': 2" in result.stdout
|
||||
assert "'': 1" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
|
||||
def test_dry_run_reports_the_actual_created_at_window(self, tmp_path):
|
||||
"""时间范围报的必须是**命中行**的窗口,不是全表的。"""
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
low, high = conn.execute(
|
||||
"SELECT MIN(created_at), MAX(created_at) FROM llm_calls WHERE call_id LIKE 'old-%'"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert f"{low} ~ {high}" in result.stdout
|
||||
|
||||
|
||||
class TestSqliteApply:
|
||||
def test_apply_removes_only_expired_rows(self, tmp_path):
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(path), "--older-than-days", "7", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["fresh-1", "fresh-2"]
|
||||
assert "已删除 3 行" in result.stdout
|
||||
|
||||
def test_older_than_days_zero_deletes_everything_before_now(self, tmp_path):
|
||||
"""N=0 的边界: 截止时刻即"此刻",此刻之前的全删、之后的(未来戳)留下。"""
|
||||
path = _make_db(
|
||||
tmp_path,
|
||||
[
|
||||
("past", "", _stamp(timedelta(seconds=-5))),
|
||||
("future", "", _stamp(timedelta(hours=1))),
|
||||
],
|
||||
)
|
||||
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(path), "--older-than-days", "0", "--apply"
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _rows(path) == ["future"]
|
||||
|
||||
def test_vacuum_with_apply_rewrites_the_file(self, tmp_path):
|
||||
path = _aged_db(tmp_path)
|
||||
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(path),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "VACUUM" in result.stdout
|
||||
assert _rows(path) == ["fresh-1", "fresh-2"]
|
||||
|
||||
def test_deleting_from_a_db_without_the_table_is_a_backend_failure(self, tmp_path):
|
||||
"""连得上但没有 llm_calls: 属"目标不可用",退出码 2 且**不**静默当成 0 行。"""
|
||||
path = tmp_path / "empty.db"
|
||||
sqlite3.connect(path).close()
|
||||
|
||||
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "llm_calls" in result.stderr
|
||||
|
||||
def test_missing_db_file_exits_two(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(tmp_path / "nope.db"), "--older-than-days", "7"
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert "nope.db" in result.stderr
|
||||
|
||||
|
||||
class TestUsageErrors:
|
||||
"""参数层的一切错误都是退出码 1(argparse 默认的 2 已被本脚本改写,2 留给连接失败)。"""
|
||||
|
||||
def test_sqlite_with_dsn_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--dsn" in result.stderr
|
||||
|
||||
def test_sqlite_without_path_exits_one(self):
|
||||
result = _run("--backend", "sqlite", "--older-than-days", "7")
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--path" in result.stderr
|
||||
|
||||
def test_sqlite_with_batch_size_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--batch-size",
|
||||
"10",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--batch-size" in result.stderr
|
||||
|
||||
def test_postgres_with_vacuum_exits_one(self):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"postgres",
|
||||
"--dsn",
|
||||
"postgresql://x/y",
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--apply",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--vacuum" in result.stderr
|
||||
|
||||
def test_vacuum_without_apply_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend",
|
||||
"sqlite",
|
||||
"--path",
|
||||
str(tmp_path / "x.db"),
|
||||
"--older-than-days",
|
||||
"7",
|
||||
"--vacuum",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--apply" in result.stderr
|
||||
|
||||
def test_missing_older_than_days_exits_one(self, tmp_path):
|
||||
result = _run("--backend", "sqlite", "--path", str(tmp_path / "x.db"))
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
def test_negative_older_than_days_exits_one(self, tmp_path):
|
||||
result = _run(
|
||||
"--backend", "sqlite", "--path", str(tmp_path / "x.db"), "--older-than-days", "-1"
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "--older-than-days" in result.stderr
|
||||
|
||||
def test_unknown_backend_exits_one(self, tmp_path):
|
||||
result = _run("--backend", "mysql", "--path", str(tmp_path / "x.db"))
|
||||
|
||||
assert result.returncode == 1
|
||||
|
||||
|
||||
class TestHelp:
|
||||
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
||||
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
|
||||
result = _run("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "维护角色" in result.stdout
|
||||
assert "REVOKE" in result.stdout
|
||||
assert "PARTITION" in result.stdout
|
||||
@@ -629,7 +629,7 @@ class TestDemotionInsertPosition:
|
||||
|
||||
async def test_demoted_lands_before_junk_sources(self):
|
||||
# a 失败 2 次;b 可信(0.9)但会被跳过时,第三候选应是 a 而非垃圾源 c
|
||||
from polygateway.middleware.retry import _demote_call_failures
|
||||
from polygateway.middleware.admission import _demote_call_failures
|
||||
|
||||
srcs = [_src("a"), _src("b"), _src("c")]
|
||||
health = {"a": 0.9, "b": 0.9, "c": 0.05}.__getitem__
|
||||
@@ -637,7 +637,7 @@ class TestDemotionInsertPosition:
|
||||
assert [s.name for s in out] == ["b", "a", "c"]
|
||||
|
||||
async def test_health_blind_demotion_still_tail(self):
|
||||
from polygateway.middleware.retry import _demote_call_failures
|
||||
from polygateway.middleware.admission import _demote_call_failures
|
||||
|
||||
srcs = [_src("a"), _src("b"), _src("c")]
|
||||
out = _demote_call_failures(srcs, {"a": 2}, None)
|
||||
|
||||
+1406
-85
File diff suppressed because it is too large
Load Diff
@@ -254,7 +254,7 @@ def _resp(usage_source):
|
||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||
async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_attempt(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_src(),
|
||||
call_id="cid",
|
||||
@@ -268,7 +268,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
|
||||
async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_attempt(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
|
||||
request=_REQ,
|
||||
source=_src(),
|
||||
call_id="cid",
|
||||
@@ -282,14 +282,16 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
|
||||
@pytest.mark.parametrize("emitted", _DOMAIN)
|
||||
async def test_emit_cache_hit_stays_in_domain(emitted):
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_cache_hit(request=_REQ, response=_resp(emitted))
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit(
|
||||
request=_REQ, response=_resp(emitted)
|
||||
)
|
||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||
|
||||
|
||||
async def test_emit_terminal_failure_stays_in_domain():
|
||||
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
|
||||
recorder = _MemoryRecorder()
|
||||
await TelemetryEmitter(recorder).emit_terminal_failure(
|
||||
await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure(
|
||||
request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
|
||||
)
|
||||
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
|
||||
|
||||
@@ -127,7 +127,7 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
|
||||
env = _merged_env()
|
||||
run_id = args.run_id
|
||||
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
|
||||
recorder = SQLiteRecorder(telemetry_path)
|
||||
recorder = SQLiteRecorder(telemetry_path, auto_migrate=True)
|
||||
if args.scenario == "P7":
|
||||
from polygateway.ocr import OcrClient
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""遥测表 `llm_calls` 的保留期清理脚本(issue #12;独立运维工具,库本体不 import 它)。
|
||||
|
||||
**为什么是脚本而不是库能力**: 库对下游数据库只做 SELECT/INSERT 加可选建表,一切
|
||||
改结构与删数据的操作交给下游(ARCHITECTURE D15)。库若持有 DELETE 权限,就与生产
|
||||
部署模板推荐的 `REVOKE UPDATE, DELETE ON llm_calls FROM app` 直接冲突。
|
||||
|
||||
**默认 dry-run**: 本脚本会永久删除审计数据,故不带 `--apply` 时只统计不删,并把
|
||||
行数、`created_at` 窗口、`tenant_id` 分布三样一并打出——运维据此判断"删掉的是不是
|
||||
我想删的",判断不了就不该按下 `--apply`。
|
||||
|
||||
**失败方向与库相反**: 这是运维工具,缺依赖/连不上/表不存在一律明确报错退出,绝不
|
||||
静默降级成"删了 0 行"——静默的 0 行会被当成"已清理干净"。
|
||||
|
||||
用法见 `--help`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, NoReturn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
TABLE = "llm_calls"
|
||||
|
||||
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
||||
EXIT_OK = 0
|
||||
EXIT_USAGE = 1
|
||||
EXIT_BACKEND = 2
|
||||
EXIT_PARTITIONED = 3
|
||||
|
||||
# 库写 SQLite 的 created_at 是 UTC 文本(DEFAULT (datetime('now'))),故截止时刻
|
||||
# 也必须是同格式文本——该格式定长且高位在前,字符串比较与时间序等价。
|
||||
# PG 的 created_at 是 TIMESTAMPTZ,直接传 aware datetime,两端口径不可互换。
|
||||
_SQLITE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_EPILOG = """\
|
||||
退出码:
|
||||
0 正常完成(含 dry-run)
|
||||
1 参数错误
|
||||
2 连接/权限/目标表不可用(含缺少 asyncpg)
|
||||
3 目标是 PostgreSQL 分区表 —— 请改用 DETACH/DROP PARTITION,脚本不会 DELETE
|
||||
|
||||
权限: 请用**维护角色**(表属主)跑本脚本,不要用应用账号 —— 生产部署模板已对应用
|
||||
账号 REVOKE UPDATE, DELETE ON llm_calls(遥测表按不可变审计表对待)。
|
||||
|
||||
推荐路径(本脚本是存量兜底,不是首选):
|
||||
PostgreSQL 把 llm_calls 建成按 created_at 的 RANGE 分区表,过期靠
|
||||
ALTER TABLE ... DETACH PARTITION + DROP TABLE 做 O(1) 清理。
|
||||
SQLite 按天/按实验轮转库文件(如 runs/<date>.db),到期直接删文件。
|
||||
|
||||
时间口径: 截止时刻 = 当前 UTC 时刻 - N 天,删除 created_at < 截止时刻 的行;
|
||||
--older-than-days 0 即"删除此刻之前的全部行"。
|
||||
|
||||
示例:
|
||||
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
|
||||
--older-than-days 90 # dry-run,只看会删什么
|
||||
python tools/telemetry_retention.py --backend postgres --dsn "$DSN" \\
|
||||
--older-than-days 90 --apply --batch-size 1000
|
||||
"""
|
||||
|
||||
|
||||
class _Parser(argparse.ArgumentParser):
|
||||
"""把 argparse 的参数错误退出码从 2 改成 1。
|
||||
|
||||
2 在本脚本的契约里留给"连接/权限失败",两者混用会让调度器分不清"我写错了参数"
|
||||
与"数据库连不上"——后者要告警重试,前者不该重试。
|
||||
"""
|
||||
|
||||
def error(self, message: str) -> NoReturn:
|
||||
self.print_usage(sys.stderr)
|
||||
print(f"{self.prog}: 参数错误: {message}", file=sys.stderr)
|
||||
raise SystemExit(EXIT_USAGE)
|
||||
|
||||
|
||||
def _build_parser() -> _Parser:
|
||||
"""构造 CLI 解析器(参数契约见设计 §6.2)。"""
|
||||
parser = _Parser(
|
||||
prog="telemetry_retention.py",
|
||||
description="按 created_at 清理 PolyGateway 遥测表 llm_calls 的过期行(默认 dry-run)。",
|
||||
epilog=_EPILOG,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--backend", required=True, choices=("sqlite", "postgres"))
|
||||
parser.add_argument("--path", help="SQLite 库文件路径(--backend sqlite 必填)")
|
||||
parser.add_argument("--dsn", help="PostgreSQL DSN(--backend postgres 必填)")
|
||||
parser.add_argument(
|
||||
"--older-than-days",
|
||||
type=int,
|
||||
required=True,
|
||||
metavar="N",
|
||||
help="删除 created_at 早于 N 天前的行;N >= 0",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="真正执行删除;不给则只统计不删(默认)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
metavar="N",
|
||||
help="仅 postgres: 每批删除的行数,每批一个事务(默认 1000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vacuum",
|
||||
action="store_true",
|
||||
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _validate(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""校验参数组合;任何不合法组合以退出码 1 结束(P5: 不给默认值掩盖错误)。
|
||||
|
||||
**校验链的顺序就是错误消息的优先级**: 先两端通用,再按 backend 分支——同时给出
|
||||
多个错误参数时,报出的是链上最先命中的那条。
|
||||
"""
|
||||
_validate_shared(parser, args)
|
||||
if args.backend == "sqlite":
|
||||
_validate_sqlite(parser, args)
|
||||
return
|
||||
_validate_postgres(parser, args)
|
||||
|
||||
|
||||
def _validate_shared(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""两端通用的校验。
|
||||
|
||||
`--vacuum` 与 `--apply` 的联动归在这里(而不是 SQLite 分支): 它是"别在只想看看的
|
||||
时候重写整个库"这条安全约束,先于"这个参数属于哪个 backend"成立。
|
||||
"""
|
||||
if args.older_than_days < 0:
|
||||
parser.error("--older-than-days 必须 >= 0")
|
||||
if args.vacuum and not args.apply:
|
||||
parser.error("--vacuum 会重写整个库文件,必须与 --apply 同时给")
|
||||
|
||||
|
||||
def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""SQLite 分支: 必须有 --path,且拒绝一切 postgres 专属参数(不静默忽略)。"""
|
||||
if args.path is None:
|
||||
parser.error("--backend sqlite 需要 --path")
|
||||
if args.dsn is not None:
|
||||
parser.error("--backend sqlite 不接受 --dsn")
|
||||
if args.batch_size is not None:
|
||||
parser.error("--batch-size 仅用于 --backend postgres")
|
||||
|
||||
|
||||
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
|
||||
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
|
||||
if args.dsn is None:
|
||||
parser.error("--backend postgres 需要 --dsn")
|
||||
if args.path is not None:
|
||||
parser.error("--backend postgres 不接受 --path")
|
||||
if args.vacuum:
|
||||
parser.error("--vacuum 仅用于 --backend sqlite")
|
||||
if args.batch_size is None:
|
||||
args.batch_size = 1000
|
||||
elif args.batch_size < 1:
|
||||
parser.error("--batch-size 必须 >= 1")
|
||||
|
||||
|
||||
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
|
||||
"""打印将删除行数、created_at 窗口与按 tenant_id 的分布。
|
||||
|
||||
tenant_id 用 repr 打: 空串是"未归属"的哨兵(不是 NULL),裸打会与缺失混淆。
|
||||
"""
|
||||
print(f"将删除行数: {total}")
|
||||
print(f"created_at 范围: {low} ~ {high}" if total else "created_at 范围: (无匹配行)")
|
||||
print("按 tenant_id 分布:")
|
||||
if not tenants:
|
||||
print(" (无匹配行)")
|
||||
for tenant, count in tenants:
|
||||
print(f" {tenant!r}: {count}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- SQLite
|
||||
|
||||
|
||||
def _run_sqlite(path: str, cutoff: str, apply_: bool, vacuum: bool) -> int:
|
||||
"""SQLite 分支: 单条 DELETE(本地文件无长事务与锁膨胀问题),VACUUM 须显式要。"""
|
||||
file = Path(path)
|
||||
if not file.is_file():
|
||||
print(f"SQLite 库文件不存在: {file}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{file}?mode=rw", uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
print(f"打开 SQLite 库失败: {file}: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (TABLE,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
print(f"目标库里没有表 {TABLE}: {file}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
print(f"目标表: {file}::{TABLE}")
|
||||
total, low, high = conn.execute(
|
||||
f"SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM {TABLE} WHERE created_at < ?",
|
||||
(cutoff,),
|
||||
).fetchone()
|
||||
tenants = conn.execute(
|
||||
f"SELECT tenant_id, COUNT(*) FROM {TABLE} WHERE created_at < ? "
|
||||
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
_print_stats(total, low, high, tenants)
|
||||
if not apply_:
|
||||
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
|
||||
return EXIT_OK
|
||||
cursor = conn.execute(f"DELETE FROM {TABLE} WHERE created_at < ?", (cutoff,))
|
||||
conn.commit()
|
||||
print(f"已删除 {cursor.rowcount} 行。")
|
||||
if vacuum:
|
||||
print("执行 VACUUM(重写整个库文件,需要与库等量的空闲磁盘)…")
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
print("VACUUM 完成。")
|
||||
except sqlite3.Error as exc:
|
||||
print(f"SQLite 操作失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
finally:
|
||||
conn.close()
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- PostgreSQL
|
||||
|
||||
|
||||
def _quote(identifier: str) -> str:
|
||||
"""把 catalog 取回的 schema/表名包成合法标识符(库名含大写或特殊字符时必需)。"""
|
||||
escaped = identifier.replace('"', '""')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError as exc:
|
||||
print(
|
||||
f"--backend postgres 需要 asyncpg,当前不可用({exc});"
|
||||
"请 pip install 'polygateway[postgres]' 或 pip install asyncpg 后重试。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
except (OSError, asyncpg.PostgresError) as exc:
|
||||
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
try:
|
||||
return await _purge_postgres(conn, cutoff, apply_, batch_size)
|
||||
except asyncpg.PostgresError as exc:
|
||||
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
|
||||
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
|
||||
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
|
||||
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
|
||||
target = await conn.fetchrow(
|
||||
"SELECT n.nspname AS schema, c.relname AS name, "
|
||||
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
|
||||
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.oid = to_regclass($1)",
|
||||
TABLE,
|
||||
)
|
||||
if target is None:
|
||||
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
|
||||
return EXIT_BACKEND
|
||||
schema, name = target["schema"], target["name"]
|
||||
qualified = f"{_quote(schema)}.{_quote(name)}"
|
||||
print(f"目标表: {schema}.{name}")
|
||||
if target["partitioned"]:
|
||||
print(
|
||||
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
|
||||
"请改用 DETACH/DROP PARTITION —— ALTER TABLE ... DETACH PARTITION <子表> 后 "
|
||||
"DROP TABLE <子表>(或交给 pg_partman 的 retention)。\n"
|
||||
"那是 O(1) 的,而 DELETE 会全表扫描并留下等量膨胀。"
|
||||
)
|
||||
return EXIT_PARTITIONED
|
||||
|
||||
stats = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS total, MIN(created_at) AS low, MAX(created_at) AS high "
|
||||
f"FROM {qualified} WHERE created_at < $1",
|
||||
cutoff,
|
||||
)
|
||||
tenants = await conn.fetch(
|
||||
f"SELECT tenant_id, COUNT(*) AS total FROM {qualified} WHERE created_at < $1 "
|
||||
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
|
||||
cutoff,
|
||||
)
|
||||
_print_stats(
|
||||
stats["total"], stats["low"], stats["high"], [(r["tenant_id"], r["total"]) for r in tenants]
|
||||
)
|
||||
if not apply_:
|
||||
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
|
||||
return EXIT_OK
|
||||
|
||||
# 分批: 一条大 DELETE 会撑出长事务(阻塞 autovacuum、堆积 WAL、锁膨胀),
|
||||
# 中断后还得整批回滚重来。每批独立提交,中断只影响未删批次。
|
||||
deleted = 0
|
||||
batches = 0
|
||||
statement = (
|
||||
f"DELETE FROM {qualified} WHERE ctid IN "
|
||||
f"(SELECT ctid FROM {qualified} WHERE created_at < $1 ORDER BY created_at LIMIT $2)"
|
||||
)
|
||||
while True:
|
||||
async with conn.transaction():
|
||||
status = await conn.execute(statement, cutoff, batch_size)
|
||||
count = int(status.rsplit(" ", 1)[-1])
|
||||
if count == 0:
|
||||
break
|
||||
deleted += count
|
||||
batches += 1
|
||||
print(f" 批次 {batches}: 删除 {count} 行(已提交)")
|
||||
print(f"已删除 {deleted} 行,共 {batches} 批。")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- 入口
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""解析参数并分派到对应后端;返回值即进程退出码。"""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
_validate(parser, args)
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(days=args.older_than_days)
|
||||
print(f"后端: {args.backend}")
|
||||
print(
|
||||
f"截止时间(UTC): {cutoff.strftime(_SQLITE_TIME_FORMAT)}"
|
||||
f"(--older-than-days {args.older_than_days};删除 created_at 早于该时刻的行)"
|
||||
)
|
||||
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
|
||||
if args.backend == "sqlite":
|
||||
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
|
||||
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user