ForcePilot/backend/package/yuxi/channel/gateway/lanes.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

115 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
DEFAULT_MAX_LANES = 5
DEFAULT_GLOBAL_MAX_LANES = 50
class ChannelLane:
"""单个渠道账户的并发通道。
每个账户一个独立 lane控制该账户下同时运行的 Agent 数量。
"""
def __init__(self, max_lanes: int = DEFAULT_MAX_LANES):
self._semaphore = asyncio.Semaphore(max_lanes)
self._active_runs: int = 0
self._busy: bool = False
self._max_lanes = max_lanes
@asynccontextmanager
async def acquire(self) -> AsyncIterator[None]:
async with self._semaphore:
self._active_runs += 1
if self._active_runs >= self._max_lanes:
self._busy = True
try:
yield
finally:
self._active_runs = max(0, self._active_runs - 1)
if self._active_runs < self._max_lanes:
self._busy = False
@property
def active_runs(self) -> int:
return self._active_runs
@property
def is_busy(self) -> bool:
return self._busy
@property
def available_slots(self) -> int:
return max(0, self._max_lanes - self._active_runs)
class LaneManager:
"""并发通道管理器。
按账户维度管理并发通道,同时提供全局上限保护。
"""
def __init__(
self,
per_account_max: int = DEFAULT_MAX_LANES,
global_max: int = DEFAULT_GLOBAL_MAX_LANES,
):
self._per_account_max = per_account_max
self._lanes: dict[str, ChannelLane] = {}
self._global_semaphore = asyncio.Semaphore(global_max)
def _key(self, channel_type: str, account_id: str) -> str:
return f"{channel_type}:{account_id}"
def get_lane(self, channel_type: str, account_id: str) -> ChannelLane:
key = self._key(channel_type, account_id)
if key not in self._lanes:
self._lanes[key] = ChannelLane(max_lanes=self._per_account_max)
return self._lanes[key]
@asynccontextmanager
async def run_with_lane(self, channel_type: str, account_id: str) -> AsyncIterator[ChannelLane]:
lane = self.get_lane(channel_type, account_id)
if lane.is_busy:
logger.warning(
"Channel lane busy: %s/%s, active=%d",
channel_type,
account_id,
lane.active_runs,
)
async with lane.acquire():
async with self._global_semaphore:
yield lane
def remove_lane(self, channel_type: str, account_id: str) -> None:
key = self._key(channel_type, account_id)
self._lanes.pop(key, None)
def get_stats(self) -> dict[str, dict]:
return {
key: {
"active_runs": lane.active_runs,
"busy": lane.is_busy,
"available_slots": lane.available_slots,
}
for key, lane in self._lanes.items()
}
def get_total_active(self) -> int:
return sum(lane.active_runs for lane in self._lanes.values())
def cleanup_idle(self) -> int:
idle_keys = [key for key, lane in self._lanes.items() if lane.active_runs == 0]
for key in idle_keys:
del self._lanes[key]
if idle_keys:
logger.debug("LaneManager cleaned up %d idle lanes", len(idle_keys))
return len(idle_keys)
lane_manager = LaneManager()