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),
    }