Spaces:
Running
Running
| """ | |
| backend/api/event_bus.py β Event Bus pub/sub (Fase 1 ADR-S26-S30) | |
| ResponsabilitΓ : TRASPORTARE eventi tra componenti in modo asincrono ed efimero. | |
| NON persiste β per la persistenza usa event_store.py. | |
| Architettura: | |
| Publisher β Event Bus β [Subscriber1, Subscriber2, ...] | |
| β | |
| Redis fanout (Upstash REST) per multi-process pub/sub | |
| Topics predefiniti (espandibili via publish): | |
| task.created | task.completed | task.failed | |
| tool.started | tool.finished | |
| memory.updated | |
| response.generated | |
| session.started | session.ended | |
| workflow.started | workflow.step | workflow.completed | workflow.failed | |
| Invarianti ADR: | |
| S27: ogni evento pubblicato ha correlation_id tracciabile | |
| S30: nessuna dipendenza da provider LLM specifico | |
| Endpoints: | |
| POST /api/events/publish β pubblica evento (auth: MACHINE) | |
| GET /api/events/stream/{topic} β SSE stream (auth: MACHINE) | |
| GET /api/events/bus/status β diagnostica (auth: MACHINE) | |
| """ | |
| import asyncio, json, time, uuid, logging, os | |
| from typing import AsyncIterator | |
| from fastapi import APIRouter, Depends, Request | |
| from fastapi.responses import StreamingResponse | |
| from pydantic import BaseModel, Field | |
| from .auth_guard import require_role, AuthRole | |
| from .state import safe_json_dumps | |
| _logger = logging.getLogger("api.event_bus") | |
| router = APIRouter( | |
| prefix="/api/events", | |
| tags=["event-bus"], | |
| dependencies=[Depends(require_role(AuthRole.MACHINE))], | |
| ) | |
| # ββ In-memory subscriber registry ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # topic β set of asyncio.Queue (one per active SSE subscriber) | |
| _subscribers: dict[str, set[asyncio.Queue]] = {} | |
| _subscribers_lock = asyncio.Lock() | |
| # ββ Predefined topics (open set β publishers can add arbitrary topics) βββββββββ | |
| BUILTIN_TOPICS = { | |
| "task.created", "task.completed", "task.failed", | |
| "tool.started", "tool.finished", | |
| "memory.updated", | |
| "response.generated", | |
| "session.started", "session.ended", | |
| "workflow.started", "workflow.step", "workflow.completed", "workflow.failed", | |
| } | |
| # ββ Max queue depth per subscriber (prevents memory leak on slow clients) ββββββ | |
| _MAX_QUEUE_DEPTH = int(os.getenv("EVENT_BUS_QUEUE_DEPTH", "256")) | |
| # ββ Pydantic models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class BusEvent(BaseModel): | |
| topic: str = Field(..., description="Topic dell'evento (es. task.created)") | |
| payload: dict = Field(default_factory=dict) | |
| correlation_id: str | None = Field(None, description="ID tracciabilitΓ cross-componente (S27)") | |
| session_id: str | None = None | |
| source: str | None = None # componente mittente (es. 'planner', 'executor') | |
| class PublishResponse(BaseModel): | |
| event_id: str | |
| topic: str | |
| delivered_to: int # numero di subscriber locali notificati | |
| redis_fanout: bool # True se fanout Redis riuscito | |
| # ββ Redis fanout (fire-and-forget, non bloccante) βββββββββββββββββββββββββββββββ | |
| async def _redis_fanout(topic: str, event_payload: dict) -> bool: | |
| """Pubblica l'evento su Redis LIST per fanout multi-process. Non lancia mai.""" | |
| try: | |
| import httpx | |
| redis_url = os.getenv("UPSTASH_REDIS_REST_URL", "") | |
| redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") | |
| if not redis_url or not redis_token: | |
| return False | |
| key = f"eb:{topic}" | |
| data = json.dumps(event_payload) | |
| async with httpx.AsyncClient(timeout=1.5) as c: | |
| await c.post( | |
| redis_url, | |
| json=["LPUSH", key, data], | |
| headers={"Authorization": f"Bearer {redis_token}"}, | |
| ) | |
| # TTL 60s: un evento non consumato entro 60s viene scartato | |
| await c.post( | |
| redis_url, | |
| json=["EXPIRE", key, 60], | |
| headers={"Authorization": f"Bearer {redis_token}"}, | |
| ) | |
| return True | |
| except Exception as exc: | |
| _logger.debug("[event_bus] redis fanout skip: %s", exc) | |
| return False | |
| # ββ Core publish βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def publish( | |
| topic: str, | |
| payload: dict, | |
| correlation_id: str | None = None, | |
| session_id: str | None = None, | |
| source: str | None = None, | |
| ) -> dict: | |
| """ | |
| Pubblica un evento sul bus. Chiamabile internamente da qualsiasi modulo. | |
| Restituisce il dizionario evento con event_id assegnato. | |
| """ | |
| event = { | |
| "event_id": str(uuid.uuid4()), | |
| "topic": topic, | |
| "payload": payload, | |
| "correlation_id": correlation_id or str(uuid.uuid4()), | |
| "session_id": session_id, | |
| "source": source, | |
| "timestamp": time.time(), | |
| } | |
| delivered = 0 | |
| async with _subscribers_lock: | |
| queues = _subscribers.get(topic, set()).copy() | |
| for q in queues: | |
| try: | |
| q.put_nowait(event) | |
| delivered += 1 | |
| except asyncio.QueueFull: | |
| _logger.warning("[event_bus] subscriber queue full on topic=%s β drop event", topic) | |
| # Redis fanout asincrono (non attende) | |
| asyncio.create_task(_redis_fanout(topic, event)) | |
| _logger.debug("[event_bus] published topic=%s event_id=%s delivered_local=%d", | |
| topic, event["event_id"][:8], delivered) | |
| return event | |
| # ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def publish_event(evt: BusEvent) -> PublishResponse: | |
| """ | |
| Pubblica un evento su un topic. I subscriber SSE attivi ricevono l'evento | |
| immediatamente. Redis fanout notifica i processi remoti. | |
| """ | |
| result = await publish( | |
| topic=evt.topic, | |
| payload=evt.payload, | |
| correlation_id=evt.correlation_id, | |
| session_id=evt.session_id, | |
| source=evt.source, | |
| ) | |
| async with _subscribers_lock: | |
| n = len(_subscribers.get(evt.topic, set())) | |
| return PublishResponse( | |
| event_id=result["event_id"], | |
| topic=evt.topic, | |
| delivered_to=n, | |
| redis_fanout=True, # ottimistico β fanout Γ¨ fire-and-forget | |
| ) | |
| async def stream_events(topic: str, request: Request): | |
| """ | |
| Server-Sent Events stream per un topic specifico. | |
| Il client rimane connesso e riceve ogni evento pubblicato su quel topic. | |
| La connessione si chiude quando il client disconnette. | |
| """ | |
| q: asyncio.Queue = asyncio.Queue(maxsize=_MAX_QUEUE_DEPTH) | |
| async with _subscribers_lock: | |
| if topic not in _subscribers: | |
| _subscribers[topic] = set() | |
| _subscribers[topic].add(q) | |
| _logger.info("[event_bus] SSE subscribe topic=%s (total=%d)", | |
| topic, len(_subscribers.get(topic, set()))) | |
| async def generator() -> AsyncIterator[str]: | |
| try: | |
| yield f"data: {json.dumps({'type': 'connected', 'topic': topic})}\n\n" | |
| while True: | |
| if await request.is_disconnected(): | |
| break | |
| try: | |
| event = await asyncio.wait_for(q.get(), timeout=15.0) | |
| yield f"data: {safe_json_dumps(event)}\n\n" | |
| except asyncio.TimeoutError: | |
| # heartbeat keepalive | |
| yield f": keepalive {int(time.time())}\n\n" | |
| finally: | |
| async with _subscribers_lock: | |
| _subscribers.get(topic, set()).discard(q) | |
| _logger.info("[event_bus] SSE unsubscribe topic=%s", topic) | |
| return StreamingResponse( | |
| generator(), | |
| media_type="text/event-stream", | |
| headers={ | |
| "Cache-Control": "no-cache", | |
| "X-Accel-Buffering": "no", | |
| "Connection": "keep-alive", | |
| }, | |
| ) | |
| async def bus_status(): | |
| """Restituisce il numero di subscriber attivi per topic.""" | |
| async with _subscribers_lock: | |
| status = {t: len(qs) for t, qs in _subscribers.items()} | |
| total = sum(status.values()) | |
| return { | |
| "status": "ok", | |
| "component": "event_bus", | |
| "topics": status, | |
| "total_subscribers": total, | |
| "builtin_topics": sorted(BUILTIN_TOPICS), | |
| } | |