2 Commits
Author SHA1 Message Date
xsl 12a825315a docs: 9/15-9/21 网关调用统计 + 接口2空图失败统计两篇 2026-09-22 23:48:24 +08:00
xsl f1b8b7bd82 feat(gateway): worker 池主备分组派发,主全挂才用备机
- pool.py: WorkerState 增加 primary 标记;acquire_worker 分级派发——
  主组有在线只在主组内挑空闲(全忙排队,不退备组),主组全部下线才用备机
- config.py: 新增可选 primary_workers 配置(须为 workers 子集,自动过滤+告警);
  不配置/空列表 = 全部视为主 worker,兼容旧行为
- config.json(不入 git): 3 台 GPU worker 设为主,max_global_concurrency 1→3
- app.py: /health 增加 workers_primary_total / workers_primary_healthy
- tests: 新增 4 条主备派发单测(主优先/主全挂用备/主全忙不走备/无配置兼容)
- docs(实现说明): 补充 primary_workers 配置说明,改配置需重启网关生效
2026-09-22 23:48:21 +08:00
10 changed files with 378 additions and 8 deletions
@@ -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** 次。
+2
View File
@@ -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 历史)。
+3
View File
@@ -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。
---
*本文只做观测统计,不包含代码或配置变更。*
+2
View File
@@ -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),
+1
View File
@@ -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",
+10
View File
@@ -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
View File
@@ -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}
+82
View File
@@ -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"