Compare commits
2
Commits
6b24bf0b0d
...
12a825315a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a825315a | ||
|
|
f1b8b7bd82 |
@@ -0,0 +1,99 @@
|
||||
# 2026-09-19~09-20 接口2 空图失败统计
|
||||
|
||||
- **统计窗口**:2026-09-19 00:00:00 ~ 2026-09-20 23:59:59(北京时间)
|
||||
- **生成时间**:2026-09-21 10:45
|
||||
|
||||
## 1. 要抓的失败
|
||||
|
||||
接口2(`POST /api/v1/hair/grow`)在 GPU worker 内部出错时,仍返回 HTTP 200、`code=0`,只把 `grown_image_*` 置空。只看业务码会当成成功。
|
||||
|
||||
| 判定 | 条件 |
|
||||
|------|------|
|
||||
| **真成功(出图)** | `code=0` 且至少一张生发图非空 |
|
||||
| **空图失败** | `code=0` 且有 `results`,但生发图全空 |
|
||||
| **业务码失败** | `code≠0` |
|
||||
|
||||
抓取:jsonl 里看 `results[].grown_image_url|base64`;9/19 18:53 之前 jsonl 已轮转,用 journal 的 `base64→URL: grown_image_base64` 与 `POST /hair/grow` 次数对照(白天每小时 rewrite ≥ 请求数,说明在出图)。
|
||||
|
||||
空图耗时中位约 **2.2~2.5s**;正常出图中位约 **16~23s**。
|
||||
|
||||
## 2. 两天接口2 合计
|
||||
|
||||
| 类别 | 9/19 | 9/20 | 两天合计 |
|
||||
|------|------|------|----------|
|
||||
| 调用量 | 4,958 | 3,352 | **8,310** |
|
||||
| 真成功(有生发图) | 1,968 | 701 | **2,669** |
|
||||
| **空图失败** | **2,988** | **2,649** | **5,637** |
|
||||
| 业务码失败 | 2 | 2 | 4 |
|
||||
| 按业务码成功率 | 99.96% | 99.94% | 99.95% |
|
||||
| **有效出图成功率** | **39.7%** | **20.9%** | **32.1%** |
|
||||
|
||||
9/19 调用量来自 journal(全日 4,958);空图 2,988 全部落在 jsonl 仍在的 18:53 之后,白天 journal 显示仍在出图,故空图数可视为全日完整。9/20 全日 jsonl。
|
||||
|
||||
把空图算失败后:两天有效成功 **2,669**,失败 **5,641**(空图 5,637 + 业务码 4)。
|
||||
|
||||
## 3. 连续故障段
|
||||
|
||||
不是两天各坏一段时间,而是**一段连续故障**:
|
||||
|
||||
| 节点 | 时间 |
|
||||
|------|------|
|
||||
| 9/19 白天~22:20 | 正常出图 |
|
||||
| 9/19 **22:30** | 开始大量空图(该 10 分钟 165 次里 19 张有图、146 张空) |
|
||||
| 9/19 **22:34:49** | 故障前最后一张生发图 |
|
||||
| 9/19 22:40 ~ 9/20 13:36 | **全部空图** |
|
||||
| 9/20 **13:36:54** | 恢复后第一张生发图 |
|
||||
| 9/20 13:40 以后 | 基本正常(偶发单次空图) |
|
||||
|
||||
故障段内空图:
|
||||
|
||||
| 时段 | 空图次数 |
|
||||
|------|----------|
|
||||
| 9/19 22:30–22:39 | 146(另 19 次仍出图,过渡) |
|
||||
| 9/19 22:40–24:00 | 2,835 |
|
||||
| 9/20 00:00–13:36 | 2,625 |
|
||||
| **连续故障空图合计** | **约 5,606** |
|
||||
|
||||
其中 9/20 00:00–00:50 有 2,127 次,来源 IP `47.93.63.201`(客户侧重试/批量)。
|
||||
|
||||
## 4. 按小时(接口2)
|
||||
|
||||
### 9/19
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00–21 | 1,840 | ≈1,832 | 7(零星) | 正常(journal + 18:53 后 jsonl) |
|
||||
| 22 | 849 | 136 | 713 | 22:30 起故障 |
|
||||
| 23 | 2,269 | 0 | 2,269 | 故障 |
|
||||
|
||||
18–21 点 jsonl 可核验:854 次出图、仅 7 次零星空图。
|
||||
|
||||
### 9/20
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00 | 2,127 | 0 | 2,127 | 故障 |
|
||||
| 01–12 | 417 | 0 | 417 | 故障 |
|
||||
| 13 | 125 | 32 | 93 | 13:36 恢复 |
|
||||
| 14–18 | 354 | 354 | 0 | 正常 |
|
||||
| 19–20 | 177 | 165 | 12 | 正常,少量单次空图 |
|
||||
| 21–23 | 152 | 150 | 0 | 正常(另 2 次业务码失败) |
|
||||
|
||||
## 5. 9/20 其它接口(业务码,供对照)
|
||||
|
||||
| 接口 | 调用量 | `code=0` | 业务码失败 |
|
||||
|------|--------|----------|------------|
|
||||
| 接口1 四庭七眼 | 380 | 361 | 19 |
|
||||
| 接口4 特征 | 347 | 345 | 2 |
|
||||
| 接口99 特殊点 | 344 | 344 | 0 |
|
||||
| 接口5 发际线 | 248 | 248 | 0(但 247 次无生发图,全天) |
|
||||
| 接口6 测量 v2 | 105 | 75 | 30 |
|
||||
|
||||
9/20 按业务码成功率 98.89%,同样会漏掉接口2 空图。接口5 当天几乎也无生发图,主结论仍以接口2 为准。
|
||||
|
||||
## 6. 结论
|
||||
|
||||
1. 故障从 **9/19 22:30** 开始,到 **9/20 13:36** 结束,约 **15 小时**。
|
||||
2. 两天空图失败 **5,637** 次(9/19 的 2,988 + 9/20 的 2,649),占两天接口2 的 **67.8%**。
|
||||
3. 有效出图 **2,669** 次,有效成功率 **32.1%**;只看 `code=0` 则是 99.95%。
|
||||
4. 9/19 白天(22:30 前)journal 显示仍在落盘生发图,空图集中在当晚 22:30 之后并跨到次日下午。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 调用成败统计(含接口2「秒回无图」)
|
||||
|
||||
本文已并入两天统计,请看:
|
||||
|
||||
**[20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md)**
|
||||
|
||||
覆盖 9/19~9/20。故障从 **9/19 22:30** 持续到 **9/20 13:36**,两天空图失败合计 **5,637** 次。
|
||||
@@ -9,6 +9,8 @@
|
||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
||||
| [20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md) | 9/19~9/20 接口2 `code=0` 但无生发图(GPU 内部错误,跨午夜连续故障)。 |
|
||||
|
||||
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
|
||||
> (细节可查 git 历史)。
|
||||
|
||||
@@ -147,6 +147,9 @@
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
可选 `primary_workers`(`workers` 的子集)启用主备分组:主 worker 全部下线时才派发备用 worker;
|
||||
主组并行度由 `dispatch.max_global_concurrency` 控制(N 台主 worker 就设 N,依赖单进程 uvicorn)。
|
||||
修改配置需重启 `hair-gateway.service` 生效(配置启动时加载一次)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 最近一周接口调用统计
|
||||
|
||||
- **统计窗口**:2026-09-15 00:00:00 ~ 2026-09-21 10:13:00(北京时间,共 7 个自然日;21 日为截至上午的部分数据)
|
||||
- **统计对象**:网关 `hair.xiangsilian.com` 对外业务接口(`POST /api/v1/...`)
|
||||
- **不含**:健康检查、静态页、扫描探测(如 `GET /api/.env`)
|
||||
- **生成时间**:2026-09-21 10:13
|
||||
|
||||
## 1. 口径
|
||||
|
||||
| 指标 | 定义 |
|
||||
|------|------|
|
||||
| 调用量 | 网关访问日志中,下列业务路径的完成次数 |
|
||||
| 业务成功 | HTTP 200 **且** 响应 JSON `code=0`(或无 `code`) |
|
||||
| 业务失败 | 非上述情况(含业务错误码 1001/1003/1008,以及 HTTP 405 等) |
|
||||
| HTTP 完成 | uvicorn 记录的 HTTP 200,**不区分**业务码 |
|
||||
|
||||
`request_log.jsonl` 已轮转,**9/15–9/18 全日、9/19 白天**只剩 systemd journal,这些天的调用量按 journal 统计;业务成功率仅在仍保留 jsonl 的时段可精确计算(见第 4 节)。
|
||||
|
||||
业务接口清单:
|
||||
|
||||
| 接口 | 路径 |
|
||||
|------|------|
|
||||
| 1 四庭七眼测量 | `/api/v1/face/measure` |
|
||||
| 2 C 端生发 | `/api/v1/hair/grow` |
|
||||
| 3 B 端生发 | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征分析 | `/api/v1/face/features` |
|
||||
| 5 发际线 PNG | `/api/v1/hairline/generate` |
|
||||
| 6 四庭七眼 v2 | `/api/v1/face/measure-v2` |
|
||||
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2`(已弃用,基本无流量) |
|
||||
|
||||
## 2. 总览
|
||||
|
||||
| 项 | 数值 |
|
||||
|----|------|
|
||||
| 7 日合计调用量 | **20,104** |
|
||||
| 完整 6 日(9/15–9/20)合计 | **19,824** |
|
||||
| 完整 6 日日均 | **3,304** |
|
||||
| 峰值日 | **9/19(周六)8,234** |
|
||||
| 最低完整日 | **9/15(周二)1,258** |
|
||||
| 9/21 截至 10:13 | **280** |
|
||||
|
||||
主接口构成(7 日合计):
|
||||
|
||||
| 接口 | 次数 | 占比 |
|
||||
|------|------|------|
|
||||
| 接口2 C 端生发 | 12,470 | 62.0% |
|
||||
| 接口4 特征分析 | 3,025 | 15.0% |
|
||||
| 接口1 四庭七眼 | 2,800 | 13.9% |
|
||||
| 接口5 发际线 | 1,243 | 6.2% |
|
||||
| 接口6 四庭七眼 v2 | 562 | 2.8% |
|
||||
| 接口3 / 接口7 等 | 4 | <0.1% |
|
||||
|
||||
## 3. 按日调用量
|
||||
|
||||
| 日期 | 星期 | 调用量 | 接口2 生发 | 接口4 特征 | 接口1 测量 | 接口5 发际线 | 接口6 测量v2 | 数据来源 |
|
||||
|------|------|--------|------------|------------|------------|--------------|--------------|----------|
|
||||
| 9/15 | 周二 | 1,258 | 643 | 171 | 174 | 170 | 100 | journal |
|
||||
| 9/16 | 周三 | 1,688 | 921 | 236 | 285 | 169 | 75 | journal |
|
||||
| 9/17 | 周四 | 1,789 | 1,069 | 254 | 232 | 183 | 51 | journal |
|
||||
| 9/18 | 周五 | 2,429 | 1,350 | 432 | 381 | 189 | 77 | journal |
|
||||
| 9/19 | 周六 | **8,234** | 4,959 | 1,531 | 1,302 | 287 | 153 | journal |
|
||||
| 9/20 | 周日 | 4,426 | 3,350 | 347 | 380 | 244 | 105 | jsonl / journal 互核 |
|
||||
| 9/21 | 周一 | 280 | 178 | 54 | 46 | 0 | 1 | journal(截至 10:13) |
|
||||
| **合计** | | **20,104** | **12,470** | **3,025** | **2,800** | **1,243** | **562** | |
|
||||
|
||||
9/16 另有接口3 共 2 次;9/19 有接口3、接口7 各 1 次(均为 405,见失败说明)。
|
||||
|
||||
工作日(9/15–9/18)在 1.3k~2.4k / 天,周六冲到 8.2k,周日回落到 4.4k,仍高于工作日。增量主要来自接口2。
|
||||
|
||||
## 4. 成功率
|
||||
|
||||
分两层看:HTTP 几乎全部 200;**业务是否成功要以 `code=0` 为准**。
|
||||
|
||||
### 4.1 HTTP 完成率(全窗口,journal)
|
||||
|
||||
20,104 次中 HTTP 200 为 **20,098**,**99.97%**。非 200 仅 **6 次 HTTP 405**(均为外部对业务路径发 GET,不是客户 POST)。
|
||||
|
||||
| 日期 | 调用量 | HTTP 200 | HTTP 成功率 |
|
||||
|------|--------|----------|-------------|
|
||||
| 9/15 | 1,258 | 1,258 | 100% |
|
||||
| 9/16 | 1,688 | 1,688 | 100% |
|
||||
| 9/17 | 1,789 | 1,789 | 100% |
|
||||
| 9/18 | 2,429 | 2,429 | 100% |
|
||||
| 9/19 | 8,234 | 8,228 | 99.93%(6 次 405) |
|
||||
| 9/20 | 4,426 | 4,426 | 100% |
|
||||
| 9/21 截至 10:13 | 280 | 280 | 100% |
|
||||
|
||||
### 4.2 业务成功率(`code=0`,jsonl 可核验时段)
|
||||
|
||||
jsonl 当前只覆盖 **9/19 18:53 之后、9/20 全日、9/21 截至 10:13**,共 **9,720** 次:
|
||||
|
||||
| 时段 | 调用量 | 业务成功 | 业务失败 | 业务成功率 |
|
||||
|------|--------|----------|----------|------------|
|
||||
| 9/19 18:53–24:00 | 5,011 | 4,991 | 20 | 99.60% |
|
||||
| 9/20 全日 | 4,432 | 4,379 | 53 | 98.80% |
|
||||
| 9/21 00:00–10:13 | 277 | 267 | 10 | 96.39% |
|
||||
| **可核验合计** | **9,720** | **9,637** | **83** | **99.15%** |
|
||||
|
||||
按接口(同一 jsonl 窗口):
|
||||
|
||||
| 接口 | 调用量 | 业务成功 | 失败 | 成功率 |
|
||||
|------|--------|----------|------|--------|
|
||||
| 接口2 C 端生发 | 7,373 | 7,369 | 4 | 99.95% |
|
||||
| 接口5 发际线 | 354 | 353 | 1 | 99.72% |
|
||||
| 接口4 特征分析 | 670 | 661 | 9 | 98.66% |
|
||||
| 接口1 四庭七眼 | 1,157 | 1,120 | 37 | 96.80% |
|
||||
| 接口6 四庭七眼 v2 | 164 | 134 | 30 | 81.71% |
|
||||
|
||||
接口6 成功率偏低,失败几乎全是 `1003 角度问题,请上传正面照`(照片侧脸/角度,不是服务宕机)。接口1 同类原因也较多。
|
||||
|
||||
### 4.3 可核验窗口内的失败原因(83 次)
|
||||
|
||||
| 次数 | HTTP | 业务码 | 含义 |
|
||||
|------|------|--------|------|
|
||||
| 60 | 200 | 1003 | 角度问题,请上传正面照 |
|
||||
| 15 | 200 | 1001 | 无法识别人像 |
|
||||
| 6 | 405 | — | 对业务路径发 GET(扫描,非客户) |
|
||||
| 2 | 200 | 1008 | 图片下载失败或格式不支持 |
|
||||
|
||||
没有出现批量 `1007 后端服务暂不可用` / 排队超时。失败以**入图不合规**为主,不是网关或 GPU worker 整段掉线。
|
||||
|
||||
## 5. 接口2:业务成功 ≠ 一定出了生发图
|
||||
|
||||
接口2 在 ComfyUI / change_hair 失败时仍返回 `code=0`,只是 `grown_image_*` 为 `null`(测试页会显示「ComfyUI 未就绪或生成失败」)。因此第 4 节的成功率**不能**当成「生发图都生成了」。
|
||||
|
||||
jsonl 可核验的接口2 出图情况:
|
||||
|
||||
| 日期 | 接口2 次数 | 业务成功 | 含生发图 | 生发图为空 |
|
||||
|------|------------|----------|----------|------------|
|
||||
| 9/19 18:53 后 | 3,844 | 3,842 | 854 | 2,988 |
|
||||
| 9/20 | 3,352 | 3,350 | 701 | 2,649 |
|
||||
| 9/21 截至 10:13 | 178 | 178 | **178** | **0** |
|
||||
|
||||
9/19–9/20 大量请求只返回了发际线叠图、没有生发效果图(与当时 GPU 上 change_hair「用户图像加载失败」一致)。**9/21 上午接口2 已全部带生发图**。
|
||||
|
||||
## 6. 数据来源与局限
|
||||
|
||||
- 调用量:`journalctl -u hair-gateway` 中 uvicorn 访问日志(`POST /api/v1/...`)。
|
||||
- 业务码 / 失败原因 / 是否含 `grown_image`:`gateway/request_log.jsonl`(及 `.jsonl.1`)。轮转后目前最早一条为 **2026-09-19 18:53**。
|
||||
- 9/15–9/18 没有业务码明细,不能把 HTTP 100% 等同于业务 100%。
|
||||
- 接口4 在网关本机执行(豆包),不占 GPU worker;其余表内接口转发 worker。
|
||||
|
||||
---
|
||||
|
||||
*本文只做观测统计,不包含代码或配置变更。*
|
||||
@@ -123,6 +123,8 @@ async def gateway_health():
|
||||
"workers_total": status["total"],
|
||||
"workers_healthy": status["healthy"],
|
||||
"workers_busy": status["busy"],
|
||||
"workers_primary_total": status.get("primary_total", 0),
|
||||
"workers_primary_healthy": status.get("primary_healthy", 0),
|
||||
"global_max": status.get("global_max", 1),
|
||||
"global_busy": status.get("global_busy", 0),
|
||||
"global_waiting": status.get("global_waiting", 0),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
],
|
||||
"primary_workers": [],
|
||||
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
|
||||
@@ -15,6 +15,7 @@ CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
|
||||
DEFAULTS = {
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
"primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
@@ -83,6 +84,15 @@ def load_config() -> dict:
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
|
||||
|
||||
primary_workers: List[str] = cfg.get("primary_workers", [])
|
||||
for i, w in enumerate(primary_workers):
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: primary_workers[{i}] 必须是非空字符串")
|
||||
for w in primary_workers:
|
||||
if w not in workers:
|
||||
logger.warning("⚠ primary_workers 中的 %s 不在 workers 列表里,将被忽略", w)
|
||||
cfg["primary_workers"] = [w for w in primary_workers if w in workers]
|
||||
|
||||
password: str = cfg.get("shared_password", "")
|
||||
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
|
||||
logger.warning(
|
||||
|
||||
+27
-8
@@ -46,6 +46,7 @@ class WorkerState:
|
||||
url: str
|
||||
online: bool = False # 初始 offline,等健康检查通过后上线
|
||||
busy: bool = False
|
||||
primary: bool = True # 主 worker(True 优先派发);全部主 worker 下线才用备组
|
||||
consecutive_failures: int = 0
|
||||
consecutive_successes: int = 0
|
||||
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
|
||||
@@ -149,7 +150,12 @@ async def init_pool(cfg: dict) -> None:
|
||||
"""初始化 worker 池并启动健康检查后台任务。"""
|
||||
global _workers, _pool_condition, _health_task, _shutdown_event
|
||||
|
||||
_workers = {url: WorkerState(url=url) for url in cfg["workers"]}
|
||||
# 主 worker 分组:配置了 primary_workers 则按其标记,否则全部视为主 worker
|
||||
primary_set = set(cfg.get("primary_workers") or cfg["workers"])
|
||||
|
||||
_workers = {url: WorkerState(url=url, primary=url in primary_set)
|
||||
for url in cfg["workers"]}
|
||||
n_primary = sum(1 for w in _workers.values() if w.primary)
|
||||
_pool_condition = asyncio.Condition()
|
||||
_shutdown_event = asyncio.Event()
|
||||
|
||||
@@ -179,7 +185,8 @@ async def init_pool(cfg: dict) -> None:
|
||||
# 对于 healthy_threshold == 1 的情况,已在上面的检查中上线
|
||||
# 标记初始在线状态
|
||||
online_count = sum(1 for w in _workers.values() if w.online)
|
||||
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d", len(_workers), online_count)
|
||||
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d | 主worker=%d",
|
||||
len(_workers), online_count, n_primary)
|
||||
|
||||
# 启动后台健康检查
|
||||
_health_task = asyncio.create_task(_health_check_loop(cfg))
|
||||
@@ -273,6 +280,8 @@ async def mark_worker_unhealthy(w: WorkerState) -> None:
|
||||
async def acquire_worker(cfg: dict) -> WorkerState:
|
||||
"""从在线池中获取一个空闲 worker。
|
||||
|
||||
主备分组:只要还有主 worker(primary)在线,就只在主组内挑空闲
|
||||
(全忙则排队等待,不用备组);只有主组全部下线时才用备用 worker。
|
||||
全忙则排队等待,最多 queue_wait_seconds;池空立即抛异常。
|
||||
"""
|
||||
queue_wait = cfg["dispatch"]["queue_wait_seconds"]
|
||||
@@ -280,11 +289,13 @@ async def acquire_worker(cfg: dict) -> WorkerState:
|
||||
|
||||
async with _pool_condition:
|
||||
while True:
|
||||
# 在线 + 空闲
|
||||
idle = [w for w in _workers.values() if w.online and not w.busy]
|
||||
online = [w for w in _workers.values() if w.online]
|
||||
primary_online = [w for w in online if w.primary]
|
||||
# 主组有在线 → 只在主组内挑;主组全挂 → 才轮到备组
|
||||
candidates = primary_online if primary_online else online
|
||||
idle = [w for w in candidates if not w.busy]
|
||||
|
||||
if not idle:
|
||||
online = [w for w in _workers.values() if w.online]
|
||||
if not online:
|
||||
raise NoWorkerAvailable("后端服务暂不可用,请稍后重试")
|
||||
|
||||
@@ -293,7 +304,8 @@ async def acquire_worker(cfg: dict) -> WorkerState:
|
||||
if remaining <= 0:
|
||||
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
|
||||
|
||||
logger.info("所有 worker 全忙(%d 在线),等待 %.1fs...", len(online), remaining)
|
||||
logger.info("所有可用 worker 全忙(%d 在线),等待 %.1fs...",
|
||||
len(candidates), remaining)
|
||||
try:
|
||||
await asyncio.wait_for(_pool_condition.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -303,6 +315,8 @@ async def acquire_worker(cfg: dict) -> WorkerState:
|
||||
# 取第一个空闲 worker
|
||||
w = idle[0]
|
||||
w.busy = True
|
||||
if not w.primary:
|
||||
logger.info("主 worker 全部下线,使用备用 worker: %s", w.url)
|
||||
logger.debug("Worker 分配: %s", w.url)
|
||||
return w
|
||||
|
||||
@@ -327,8 +341,13 @@ def get_pool_status() -> dict:
|
||||
"global_waiting": _global_waiting,
|
||||
}
|
||||
if not _workers:
|
||||
return {"total": 0, "healthy": 0, "busy": 0, **global_info}
|
||||
return {"total": 0, "healthy": 0, "busy": 0,
|
||||
"primary_total": 0, "primary_healthy": 0, **global_info}
|
||||
total = len(_workers)
|
||||
healthy = sum(1 for w in _workers.values() if w.online)
|
||||
busy = sum(1 for w in _workers.values() if w.busy)
|
||||
return {"total": total, "healthy": healthy, "busy": busy, **global_info}
|
||||
primary_total = sum(1 for w in _workers.values() if w.primary)
|
||||
primary_healthy = sum(1 for w in _workers.values() if w.primary and w.online)
|
||||
return {"total": total, "healthy": healthy, "busy": busy,
|
||||
"primary_total": primary_total, "primary_healthy": primary_healthy,
|
||||
**global_info}
|
||||
|
||||
@@ -152,3 +152,85 @@ def test_cooldown_seconds_falls_back_to_60(monkeypatch):
|
||||
from gateway import config as gw_config
|
||||
monkeypatch.setattr(gw_config, "_config_cache", None) # get_config 会抛 RuntimeError
|
||||
assert pool._cooldown_seconds() == 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主备分组派发(primary_workers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DISPATCH_CFG = {
|
||||
"dispatch": {
|
||||
"queue_wait_seconds": 0.05, # 测试用:快速超时
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_pool(specs):
|
||||
"""specs: [(url, online, busy, primary), ...] → 构造 pool._workers。"""
|
||||
return {
|
||||
url: WorkerState(url=url, online=online, busy=busy, primary=primary)
|
||||
for url, online, busy, primary in specs
|
||||
}
|
||||
|
||||
|
||||
def _run_acquire(workers):
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
orig = pool._workers
|
||||
pool._workers = workers
|
||||
try:
|
||||
return await pool.acquire_worker(_DISPATCH_CFG)
|
||||
finally:
|
||||
pool._workers = orig
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_primary_preferred_over_backup():
|
||||
"""主 worker 在线时空闲主优先于备 worker。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", True, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://primary:1"
|
||||
assert w.busy is True
|
||||
|
||||
|
||||
def test_backup_used_only_when_all_primary_down():
|
||||
"""主 worker 全部下线时才用备 worker。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", False, False, True),
|
||||
("http://primary:2", False, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://backup:1"
|
||||
|
||||
|
||||
def test_backup_not_used_when_primary_busy():
|
||||
"""主 worker 在线但全忙时不退到备 worker(等待后超时拒绝)。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", True, True, True),
|
||||
])
|
||||
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
try:
|
||||
await pool.acquire_worker(_DISPATCH_CFG)
|
||||
except pool.NoWorkerAvailable:
|
||||
return True
|
||||
return False
|
||||
|
||||
assert asyncio.run(run()) is True, "主组全忙时不得使用备 worker"
|
||||
assert workers["http://backup:1"].busy is False
|
||||
|
||||
|
||||
def test_no_primary_config_all_are_primary():
|
||||
"""未配置 primary_workers 时全部视为主 worker(兼容旧行为)。"""
|
||||
workers = _make_pool([
|
||||
("http://w1:1", True, False, True),
|
||||
("http://w2:1", True, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://w1:1"
|
||||
|
||||
Reference in New Issue
Block a user