"""HTTP Polling 降级机制 — SSE/WS 不可用时的备选路径。 按 session_id 维护响应队列,前端通过 HTTP GET 定时轮询获取回复。 与 SSE 端点协同使用,形成三级降级链:SSE → Polling → 单次 fetch。 Usage: polling = PollingFallback() await polling.push("session_abc", {"type": "delta", "data": {"content": "hello"}}) events = await polling.poll("session_abc") """ from __future__ import annotations import asyncio import logging import time as _time logger = logging.getLogger(__name__) DEFAULT_POLL_TTL = 600 class PollingFallback: def __init__(self, max_queue_size: int = 100, ttl_seconds: int = DEFAULT_POLL_TTL): self._queues: dict[str, asyncio.Queue[dict]] = {} self._ttl = ttl_seconds self._last_active: dict[str, float] = {} self._max_queue_size = max_queue_size self._lock = asyncio.Lock() async def ensure(self, session_id: str) -> None: async with self._lock: if session_id not in self._queues: self._queues[session_id] = asyncio.Queue(maxsize=self._max_queue_size) self._last_active[session_id] = _time.monotonic() async def push(self, session_id: str, event: dict) -> None: async with self._lock: self._last_active[session_id] = _time.monotonic() q = self._queues.get(session_id) if q is None: q = asyncio.Queue(maxsize=self._max_queue_size) self._queues[session_id] = q try: q.put_nowait(event) except asyncio.QueueFull: logger.warning("Polling queue full for session %s, dropping event", session_id) async def poll(self, session_id: str) -> list[dict]: async with self._lock: self._last_active[session_id] = _time.monotonic() q = self._queues.get(session_id) if q is None: return [] events: list[dict] = [] while not q.empty(): try: events.append(q.get_nowait()) except asyncio.QueueEmpty: break return events async def cleanup_stale(self) -> int: async with self._lock: now = _time.monotonic() stale = [sid for sid, ts in self._last_active.items() if now - ts > self._ttl] for sid in stale: self._queues.pop(sid, None) self._last_active.pop(sid, None) if stale: logger.info("PollingFallback cleaned up %d stale sessions", len(stale)) return len(stale) def active_sessions(self) -> int: return len(self._queues) polling_fallback = PollingFallback() async def _polling_cleanup_loop(interval: int = 300) -> None: while True: await asyncio.sleep(interval) await polling_fallback.cleanup_stale() _cleanup_task: asyncio.Task | None = None def start_polling_cleanup(interval: int = 300) -> None: global _cleanup_task if _cleanup_task is None or _cleanup_task.done(): _cleanup_task = asyncio.ensure_future(_polling_cleanup_loop(interval)) logger.info("PollingFallback cleanup loop started (interval=%ds)", interval) def stop_polling_cleanup() -> None: global _cleanup_task if _cleanup_task and not _cleanup_task.done(): _cleanup_task.cancel() _cleanup_task = None