Spaces:
Running
Running
File size: 9,038 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """
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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("/publish", response_model=PublishResponse, summary="Pubblica evento sul bus")
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
)
@router.get("/stream/{topic}", summary="SSE stream eventi per topic")
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",
},
)
@router.get("/bus/status", summary="Diagnostica Event Bus")
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),
}
|