Files
hair/gateway/config.py
T
UbuntuandClaude bb9f55e93c feat(gateway): 全局串行化(并发=1) + 记录入参/出参/耗时日志
并发模型从「每worker并发1 + 多worker并行」改为全局串行:同一时间
只处理1个请求,其余排队;多 worker 仅作热备(主 worker 坏了才用备机)。
接口4(face/features) 走豆包、不占 GPU,不纳入串行。

- pool: asyncio.Semaphore(max_global_concurrency=1) + acquire/release_global_slot
  (依赖单进程 uvicorn 部署,已在注释中标注)
- forward: proxy_request 最外层 acquire 全局槽、try/finally 全路径释放;
  入参 multipart 解析挂 request.state;原重试/故障转移逻辑抽到 _dispatch_with_retries
- reqlog(新): 标量入参保留;图片(file/base64)存盘转URL,绝不内嵌base64;
  出参递归摘要截断(landmarks/长串/大数组)
- logging_middleware: RequestLogEntry 加 request_params/response_data,
  jsonl 全量记录;_load_from_logfile 同步映射防重启丢字段;get_stats recent 暴露
- /gateway-health 暴露 global_max/global_busy/global_waiting
- config: dispatch 新增 max_global_concurrency / max_queue_wait_seconds
- tests: test_reqlog + test_gateway_serialization(9 用例)

顺带提交此前未提交的网关统计(daily stats 接口)与耗时看板(api_timing_dashboard.html)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 22:56:48 +08:00

137 lines
4.3 KiB
Python

"""网关配置加载与校验。
从 gateway/config.json 读取配置,缺字段给默认值,启动时校验必填项。
"""
import json
import logging
from pathlib import Path
from typing import List
logger = logging.getLogger("gateway.config")
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
DEFAULTS = {
"public_base_url": "https://hair.xiangsilian.com",
"static_dir": "static/annotations",
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1,
},
"dispatch": {
"per_worker_concurrency": 1,
"max_global_concurrency": 1,
"max_queue_wait_seconds": 590,
"queue_wait_seconds": 30,
"request_timeout_seconds": 600,
"retry_on_failure": True,
"max_retries": 1,
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24,
},
"request_log": {
"enabled": True,
"log_file": "gateway/request_log.jsonl",
"buffer_size": 2000,
"max_file_lines": 10000,
"max_file_age_days": 7,
},
}
_config_cache = None
def _deep_merge(defaults: dict, overrides: dict) -> dict:
"""递归合并:overrides 中的值覆盖 defaults,嵌套 dict 递归处理。"""
result = defaults.copy()
for key, value in overrides.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config() -> dict:
"""加载并校验配置文件,结果缓存仅加载一次。"""
global _config_cache
if _config_cache is not None:
return _config_cache
if not CONFIG_PATH.exists():
raise FileNotFoundError(
f"配置文件不存在: {CONFIG_PATH}\n"
f"请从 gateway/config.example.json 复制并修改: "
f"cp gateway/config.example.json gateway/config.json"
)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
raw = json.load(f)
# 合并默认值
cfg = _deep_merge(DEFAULTS, raw)
# --- 校验 ---
workers: List[str] = cfg.get("workers", [])
if not workers:
raise ValueError("配置错误: workers 列表不能为空,至少需要一个 worker 地址")
for i, w in enumerate(workers):
if not isinstance(w, str) or not w:
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
password: str = cfg.get("shared_password", "")
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
logger.warning(
"⚠ 安全警告: shared_password 未设置或仍为占位值 "
"'REPLACE_ME_ROTATE_PERIODICALLY',请立即更换!"
)
if len(password) < 8:
logger.warning("⚠ 安全警告: shared_password 长度不足 8 位,建议使用更长的密码")
public_base_url: str = cfg.get("public_base_url", "")
if public_base_url.endswith("/"):
cfg["public_base_url"] = public_base_url.rstrip("/")
logger.warning("public_base_url 末尾含 '/',已自动去除")
# 确保 static_dir 是绝对路径
static_dir = Path(cfg["static_dir"])
if not static_dir.is_absolute():
cfg["static_dir"] = str(Path(__file__).resolve().parents[1] / static_dir)
logger.info(
"配置加载完成 | workers=%s | public_base_url=%s | "
"hc_interval=%ds | dispatch_timeout=%ds | queue_wait=%ds | "
"global_concurrency=%d | max_queue_wait=%ds",
cfg["workers"],
cfg["public_base_url"],
cfg["health_check"]["interval_seconds"],
cfg["dispatch"]["request_timeout_seconds"],
cfg["dispatch"]["queue_wait_seconds"],
cfg["dispatch"]["max_global_concurrency"],
cfg["dispatch"]["max_queue_wait_seconds"],
)
_config_cache = cfg
return cfg
def get_config() -> dict:
"""获取已加载的配置(必须先调用 load_config)。"""
if _config_cache is None:
raise RuntimeError("配置尚未加载,请先调用 load_config()")
return _config_cache
def reload_config() -> dict:
"""强制重新加载配置(用于热更新)。"""
global _config_cache
_config_cache = None
return load_config()