103 lines
3.4 KiB
Python
103 lines
3.4 KiB
Python
|
|
"""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
|