feat(hub,stability): bounded outbound queue、worker liveness 检测、事件循环 watchdog 及 DB 操作异步化

- aether-hub: unbounded channel 改为 bounded channel (BoundedOutbound),队列满时标记拥塞并主动关闭连接,防止内存无限增长
- aether-hub: worker idle timeout 从命令行参数改为基于心跳的 liveness 检测,默认 60 秒
- aether-hub: 新增 ConnConfig 统一管理连接配置,新增 outbound_queue_capacity 参数
- hub_transport: 新增事件循环 watchdog,检测 lag 超过阈值时临时降级暂停新流
- gunicorn_conf: 启用 faulthandler,worker abort 时自动 dump 全部线程栈用于诊断
- health/endpoint_checker/recording: 同步 DB 操作移至 asyncio.to_thread,避免阻塞事件循环
- Dockerfile: 移除 --worker-idle-timeout 0 命令行参数,改由环境变量和默认值控制
This commit is contained in:
fawney19
2026-03-12 12:19:04 +08:00
parent 4d338ebd3d
commit 71ae1a2307
14 changed files with 676 additions and 280 deletions

View File

@@ -4,6 +4,7 @@ Endpoint 健康监控 API
from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -33,6 +34,60 @@ from src.services.health.monitor import HealthMonitor, health_monitor
router = APIRouter(tags=["Endpoint Health"])
def _recover_key_health_sync(db: Session, key_id: str, api_format: str | None) -> dict[str, Any]:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException(f"Key {key_id} 不存在")
success = health_monitor.reset_health(db, key_id=key_id, api_format=api_format)
if not success:
raise Exception("重置健康度失败")
if not key.is_active:
key.is_active = True # type: ignore[assignment]
db.commit()
return {
"is_active": bool(key.is_active),
"api_format": api_format,
}
def _recover_all_keys_health_sync(db: Session) -> list[dict[str, Any]]:
candidates = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.circuit_breaker_by_format.isnot(None),
ProviderAPIKey.circuit_breaker_by_format != "{}",
)
.all()
)
circuit_open_keys = [
key
for key in candidates
if any(cb.get("open") for cb in (key.circuit_breaker_by_format or {}).values())
]
recovered_keys: list[dict[str, Any]] = []
for key in circuit_open_keys:
key.health_by_format = {} # type: ignore[assignment]
key.circuit_breaker_by_format = {} # type: ignore[assignment]
recovered_keys.append(
{
"key_id": key.id,
"key_name": key.name,
"provider_id": key.provider_id,
"api_formats": key.api_formats,
}
)
if recovered_keys:
db.commit()
return recovered_keys
def _format_str(api_format_enum: Any) -> str:
"""将 DB 查询返回的 api_format可能是 enum 或 str统一转为 str。"""
return api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
@@ -491,20 +546,7 @@ class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
if not key:
raise NotFoundException(f"Key {self.key_id} 不存在")
# 使用 health_monitor.reset_health 重置健康度
success = health_monitor.reset_health(db, key_id=self.key_id, api_format=self.api_format)
if not success:
raise Exception("重置健康度失败")
# 如果 Key 被禁用,重新启用
if not key.is_active:
key.is_active = True # type: ignore[assignment]
db.commit()
await asyncio.to_thread(_recover_key_health_sync, db, self.key_id, self.api_format)
if self.api_format:
logger.info(f"管理员恢复Key健康状态: {self.key_id}/{self.api_format}")
@@ -534,47 +576,15 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
recovered_keys = await asyncio.to_thread(_recover_all_keys_health_sync, db)
# 粗过滤:仅加载 circuit_breaker_by_format 非空的 Key避免全表扫描
candidates = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.circuit_breaker_by_format.isnot(None),
ProviderAPIKey.circuit_breaker_by_format != "{}",
)
.all()
)
# 精确筛选有任何格式熔断的 Key
circuit_open_keys = [
key
for key in candidates
if any(cb.get("open") for cb in (key.circuit_breaker_by_format or {}).values())
]
if not circuit_open_keys:
if not recovered_keys:
return {
"message": "没有需要恢复的 Key",
"recovered_count": 0,
"recovered_keys": [],
}
recovered_keys = []
for key in circuit_open_keys:
# 重置所有格式的健康度
key.health_by_format = {} # type: ignore[assignment]
key.circuit_breaker_by_format = {} # type: ignore[assignment]
recovered_keys.append(
{
"key_id": key.id,
"key_name": key.name,
"provider_id": key.provider_id,
"api_formats": key.api_formats,
}
)
db.commit()
# 重置健康监控器的熔断计数
HealthMonitor.reset_open_circuit_count()

View File

@@ -161,29 +161,43 @@ async def _calculate_and_record_usage(
from src.services.request.candidate import RequestCandidateService
from src.services.usage.service import UsageService
# 获取Provider API Key对象不是用户API Key
provider_api_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == api_key_id).first()
def _load_usage_context() -> tuple[Any, Any, Any]:
# 获取Provider API Key对象不是用户API Key
provider_api_key_local = (
db.query(ProviderAPIKey).filter(ProviderAPIKey.id == api_key_id).first()
)
if not provider_api_key_local:
return None, None, None
provider_endpoint_local = None
if api_format and provider_api_key_local.provider_id:
from src.models.database import Provider
provider = (
db.query(Provider).filter(Provider.id == provider_api_key_local.provider_id).first()
)
if provider:
for ep in provider.endpoints:
if ep.api_format == api_format:
provider_endpoint_local = ep
break
user_api_key_local = None
if user:
try:
user_api_key_local = db.query(ApiKey).filter(ApiKey.user_id == user.id).first()
except Exception:
user_api_key_local = None
return provider_api_key_local, provider_endpoint_local, user_api_key_local
provider_api_key, provider_endpoint, user_api_key = await asyncio.to_thread(_load_usage_context)
if not provider_api_key:
logger.warning(f"Provider API Key not found for usage calculation: {api_key_id}")
return {"error": "Provider API Key not found"}
# 获取Provider Endpoint信息通过 api_format 查找)
provider_endpoint = None
if api_format and provider_api_key.provider_id:
from src.models.database import Provider
provider = db.query(Provider).filter(Provider.id == provider_api_key.provider_id).first()
if provider:
for ep in provider.endpoints:
if ep.api_format == api_format:
provider_endpoint = ep
break
# 获取用户的API Key用于记录关联即使实际使用的是Provider API Key
user_api_key = None
if user:
try:
user_api_key = db.query(ApiKey).filter(ApiKey.user_id == user.id).first()
logger.info(
f"[endpoint_check] User API Key found: {user_api_key.id if user_api_key else None}"
)
@@ -317,45 +331,46 @@ async def _calculate_and_record_usage(
# 创建RequestCandidate记录用于监控追踪API
try:
# 首先创建候选记录
candidate = RequestCandidateService.create_candidate(
db=db,
request_id=f"test_{request_id}",
candidate_index=0, # 测试请求只有一个候选
user_id=user.id if user else None,
api_key_id=user_api_key.id if user_api_key else None,
provider_id=provider_id,
endpoint_id=provider_endpoint.id if provider_endpoint else None,
key_id=api_key_id,
status="available",
extra_data={"model_name": model_name, "request_type": "endpoint_test"},
)
# 立即标记为开始执行
RequestCandidateService.mark_candidate_started(db, candidate.id)
# 根据结果标记为成功或失败
if status_code == 200:
RequestCandidateService.mark_candidate_success(
def _record_candidate_sync() -> str:
candidate = RequestCandidateService.create_candidate(
db=db,
candidate_id=candidate.id,
status_code=status_code,
latency_ms=response_time_ms,
extra_data={"model_name": model_name, "api_format": api_format},
)
else:
RequestCandidateService.mark_candidate_failed(
db=db,
candidate_id=candidate.id,
error_type="http_error" if status_code > 0 else "network_error",
error_message=error_message or "Unknown error",
status_code=status_code,
latency_ms=response_time_ms,
extra_data={"model_name": model_name, "api_format": api_format},
request_id=f"test_{request_id}",
candidate_index=0, # 测试请求只有一个候选
user_id=user.id if user else None,
api_key_id=user_api_key.id if user_api_key else None,
provider_id=provider_id,
endpoint_id=provider_endpoint.id if provider_endpoint else None,
key_id=api_key_id,
status="available",
extra_data={"model_name": model_name, "request_type": "endpoint_test"},
)
RequestCandidateService.mark_candidate_started(db, candidate.id)
if status_code == 200:
RequestCandidateService.mark_candidate_success(
db=db,
candidate_id=candidate.id,
status_code=status_code,
latency_ms=response_time_ms,
extra_data={"model_name": model_name, "api_format": api_format},
)
else:
RequestCandidateService.mark_candidate_failed(
db=db,
candidate_id=candidate.id,
error_type="http_error" if status_code > 0 else "network_error",
error_message=error_message or "Unknown error",
status_code=status_code,
latency_ms=response_time_ms,
extra_data={"model_name": model_name, "api_format": api_format},
)
return str(candidate.id)
candidate_id = await asyncio.to_thread(_record_candidate_sync)
logger.info(
f"[endpoint_check] RequestCandidate created | request_id=test_{request_id}, candidate_id={candidate.id}"
f"[endpoint_check] RequestCandidate created | request_id=test_{request_id}, candidate_id={candidate_id}"
)
except Exception as e:
logger.warning(f"[endpoint_check] Failed to create RequestCandidate: {e}")

View File

@@ -30,6 +30,12 @@ if TYPE_CHECKING:
_TUNNEL_COMPRESS_MIN_SIZE = 512
_RECONNECT_DELAYS_SECONDS: tuple[float, ...] = (0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
_HEARTBEAT_DEDUP_TTL_SECONDS = 600
_LOOP_WATCHDOG_INTERVAL_SECONDS = 1.0
_LOOP_LAG_WARNING_SECONDS = 1.0
_LOOP_LAG_DEGRADE_SECONDS = 3.0
_LOOP_LAG_DEGRADE_MIN_COOLDOWN_SECONDS = 10.0
_LOOP_LAG_DEGRADE_MAX_COOLDOWN_SECONDS = 30.0
_LOOP_LAG_WARNING_LOG_INTERVAL_SECONDS = 10.0
_HOP_BY_HOP_HEADERS = frozenset(
{
@@ -65,9 +71,12 @@ class HubConnectionManager:
self._reader_task: asyncio.Task[None] | None = None
self._ping_task: asyncio.Task[None] | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._watchdog_task: asyncio.Task[None] | None = None
self._background_tasks: set[asyncio.Task[None]] = set()
self._closing = False
self._degraded_until: float = 0.0
self._last_loop_lag_warning_ts: float = 0.0
self._disconnect_count = 0 # 连续断开计数,用于抑制重复日志
# 连续快速断开退避:防止 Hub 端持续发送 GOAWAY 时产生重连风暴
@@ -85,6 +94,7 @@ class HubConnectionManager:
task.add_done_callback(self._background_tasks.discard)
async def ensure_connected(self) -> None:
self._ensure_watchdog_running()
if self._closing:
raise TunnelStreamError("hub connection manager is shutting down")
if self.is_connected:
@@ -142,6 +152,55 @@ class HubConnectionManager:
)
self._disconnect_count = 0
def _ensure_watchdog_running(self) -> None:
if self._closing:
return
if self._watchdog_task is not None and not self._watchdog_task.done():
return
self._watchdog_task = asyncio.create_task(self._loop_watchdog())
def _record_loop_lag(self, lag_seconds: float) -> None:
if lag_seconds < _LOOP_LAG_WARNING_SECONDS:
return
now = _time.monotonic()
if lag_seconds >= _LOOP_LAG_DEGRADE_SECONDS:
cooldown = min(
_LOOP_LAG_DEGRADE_MAX_COOLDOWN_SECONDS,
max(_LOOP_LAG_DEGRADE_MIN_COOLDOWN_SECONDS, lag_seconds * 3.0),
)
degraded_until = now + cooldown
self._degraded_until = max(self._degraded_until, degraded_until)
logger.warning(
"Hub worker event loop lag detected: lag={:.2f}s, pausing new streams for {:.1f}s",
lag_seconds,
cooldown,
)
return
if now - self._last_loop_lag_warning_ts >= _LOOP_LAG_WARNING_LOG_INTERVAL_SECONDS:
self._last_loop_lag_warning_ts = now
logger.warning("Hub worker event loop lag observed: lag={:.2f}s", lag_seconds)
def _raise_if_degraded(self) -> None:
remaining = self._degraded_until - _time.monotonic()
if remaining <= 0:
return
raise TunnelStreamError(f"hub worker event loop degraded, retry in {remaining:.1f}s")
async def _loop_watchdog(self) -> None:
interval = _LOOP_WATCHDOG_INTERVAL_SECONDS
expected_at = _time.monotonic() + interval
try:
while not self._closing:
await asyncio.sleep(interval)
now = _time.monotonic()
lag_seconds = max(0.0, now - expected_at)
expected_at = now + interval
self._record_loop_lag(lag_seconds)
except asyncio.CancelledError:
return
def _start_reconnect_loop(self) -> None:
if self._closing:
return
@@ -530,6 +589,7 @@ class HubConnectionManager:
timeout: float = 60.0,
) -> _StreamState:
await self.ensure_connected()
self._raise_if_degraded()
if len(self._pending_streams) >= self._config.max_streams:
raise TunnelStreamError(
@@ -587,6 +647,8 @@ class HubConnectionManager:
self._reader_task.cancel()
if self._ping_task is not None:
self._ping_task.cancel()
if self._watchdog_task is not None:
self._watchdog_task.cancel()
tasks = list(self._background_tasks)
for task in tasks:

View File

@@ -102,6 +102,7 @@ def _increment_provider_api_key_totals(
return
from sqlalchemy import func as sql_func
token_increment = int(total_tokens or 0)
cost_increment = to_money_decimal(total_cost)
@@ -329,57 +330,60 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost)
# 创建 Usage 记录
usage = Usage(**usage_params)
db.add(usage)
def _sync_record() -> Usage:
# 创建 Usage 记录与相关统计;同步 SQLAlchemy 操作统一移到线程池,避免阻塞事件循环。
usage = Usage(**usage_params)
db.add(usage)
# 更新 GlobalModel 使用计数(原子操作)
from sqlalchemy import update
# 更新 GlobalModel 使用计数(原子操作)
from sqlalchemy import update
from src.models.database import GlobalModel
from src.models.database import GlobalModel
db.execute(
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
# 更新 Provider 月度使用量(原子操作)
if provider_id:
actual_total_cost = Decimal(str(usage_params["actual_total_cost_usd"]))
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
accounted, _charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
finalized_at=finalized_at,
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
if accounted:
_increment_provider_api_key_totals(
# 更新 Provider 月度使用量(原子操作)
if provider_id:
actual_total_cost = Decimal(str(usage_params["actual_total_cost_usd"]))
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
accounted, _charge_applied = cls._finalize_usage_billing(
db,
provider_api_key_id,
total_tokens=int(usage_params.get("total_tokens") or 0),
total_cost=_get_actual_total_cost_usd(usage_params),
usage=usage,
total_cost=total_cost,
status=status,
finalized_at=finalized_at,
)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
response_headers=response_headers,
db=db,
)
if accounted:
_increment_provider_api_key_totals(
db,
provider_api_key_id,
total_tokens=int(usage_params.get("total_tokens") or 0),
total_cost=_get_actual_total_cost_usd(usage_params),
)
db.commit() # 立即提交事务,释放数据库锁
return usage
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
response_headers=response_headers,
db=db,
)
db.commit() # 立即提交事务,释放数据库锁
return usage
return await asyncio.to_thread(_sync_record)
@classmethod
async def record_usage(