Spaces:
Running
Running
File size: 8,024 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 | """
backend/api/event_store.py β Event Store (persistenza, Fase 1 ADR-S26-S30)
ResponsabilitΓ : SALVARE tutti gli eventi per replayability, debugging, benchmark.
NON instrada β per pub/sub usa event_bus.py.
Schema Supabase (tabella `event_store`, auto-created se non esiste):
id UUID PK default gen_random_uuid()
topic TEXT NOT NULL
payload JSONB NOT NULL default '{}'
correlation_id TEXT
session_id TEXT
source TEXT
created_at TIMESTAMPTZ NOT NULL default now()
Invarianti ADR:
S26: ogni workflow (e ogni evento del workflow) Γ¨ persistente
S27: correlation_id garantisce tracciabilitΓ cross-componente
Endpoints:
POST /api/events/store β salva evento (auth: MACHINE)
GET /api/events/replay β replay eventi filtrati (auth: MACHINE)
GET /api/events/store/{id} β recupera evento singolo (auth: MACHINE)
GET /api/events/store/status β diagnostica store (auth: MACHINE)
"""
import json, time, uuid, logging, os
from typing import Any
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel, Field
from .auth_guard import require_role, AuthRole
from .state import _sb
_logger = logging.getLogger("api.event_store")
router = APIRouter(
prefix="/api/events",
tags=["event-store"],
dependencies=[Depends(require_role(AuthRole.MACHINE))],
)
_TABLE = "event_store"
# ββ Auto-create table (best-effort, richiede service role key) βββββββββββββββββ
_TABLE_CREATED = False
async def _ensure_table() -> bool:
"""Crea la tabella event_store su Supabase se non esiste. Best-effort."""
global _TABLE_CREATED
if _TABLE_CREATED:
return True
if not _sb:
return False
try:
# Prova una SELECT β se la tabella non esiste, Supabase ritorna un errore
res = _sb.table(_TABLE).select("id").limit(1).execute()
_TABLE_CREATED = True
return True
except Exception as exc:
_logger.warning("[event_store] tabella '%s' non raggiungibile: %s β "
"crea manualmente con migration Supabase", _TABLE, exc)
return False
# ββ Pydantic models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class StoreEventRequest(BaseModel):
topic: str
payload: dict = Field(default_factory=dict)
correlation_id: str | None = None
session_id: str | None = None
source: str | None = None
class StoredEvent(BaseModel):
id: str
topic: str
payload: dict
correlation_id: str | None
session_id: str | None
source: str | None
created_at: str | None
# ββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("/store", summary="Salva evento nello store")
async def store_event(req: StoreEventRequest) -> StoredEvent:
"""
Persiste un evento nel Supabase Event Store.
Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
dai componenti che vogliono garantire persistenza.
"""
await _ensure_table()
if not _sb:
raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
record = {
"topic": req.topic,
"payload": req.payload,
"correlation_id": req.correlation_id or str(uuid.uuid4()),
"session_id": req.session_id,
"source": req.source,
}
try:
res = _sb.table(_TABLE).insert(record).execute()
row = res.data[0] if res.data else {**record, "id": str(uuid.uuid4()), "created_at": None}
return StoredEvent(**{
"id": row.get("id", ""),
"topic": row.get("topic", req.topic),
"payload": row.get("payload", req.payload),
"correlation_id": row.get("correlation_id"),
"session_id": row.get("session_id"),
"source": row.get("source"),
"created_at": str(row.get("created_at", "")),
})
except Exception as exc:
_logger.error("[event_store] insert failed: %s", exc)
raise HTTPException(500, detail=f"Event Store insert error: {exc}")
@router.get("/replay", summary="Replay eventi filtrati")
async def replay_events(
topic: str | None = Query(None, description="Filtra per topic"),
session_id: str | None = Query(None, description="Filtra per session_id"),
correlation_id: str | None = Query(None, description="Filtra per correlation_id"),
from_ts: float | None = Query(None, description="Unix timestamp minimo (created_at >=)"),
limit: int = Query(100, ge=1, le=1000),
):
"""
Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
test di regressione e audit trail.
"""
await _ensure_table()
if not _sb:
raise HTTPException(503, detail="Event Store non disponibile")
try:
q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
if topic: q = q.eq("topic", topic)
if session_id: q = q.eq("session_id", session_id)
if correlation_id: q = q.eq("correlation_id", correlation_id)
if from_ts:
import datetime
dt = datetime.datetime.utcfromtimestamp(from_ts).isoformat() + "Z"
q = q.gte("created_at", dt)
res = q.execute()
return {
"events": res.data or [],
"count": len(res.data or []),
"filters": {
"topic": topic, "session_id": session_id,
"correlation_id": correlation_id, "from_ts": from_ts, "limit": limit,
},
}
except Exception as exc:
_logger.error("[event_store] replay failed: %s", exc)
raise HTTPException(500, detail=f"Event Store query error: {exc}")
@router.get("/store/{event_id}", summary="Recupera evento singolo")
async def get_event(event_id: str) -> StoredEvent:
"""Recupera un evento specifico per ID."""
await _ensure_table()
if not _sb:
raise HTTPException(503, detail="Event Store non disponibile")
try:
res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
if not res.data:
raise HTTPException(404, detail=f"Evento {event_id} non trovato")
row = res.data[0]
return StoredEvent(**{
"id": row.get("id", event_id),
"topic": row.get("topic", ""),
"payload": row.get("payload", {}),
"correlation_id": row.get("correlation_id"),
"session_id": row.get("session_id"),
"source": row.get("source"),
"created_at": str(row.get("created_at", "")),
})
except HTTPException:
raise
except Exception as exc:
raise HTTPException(500, detail=f"Event Store get error: {exc}")
@router.get("/store/status", summary="Diagnostica Event Store")
async def store_status():
"""Verifica connettivitΓ dello store e restituisce statistiche."""
if not _sb:
return {"status": "unavailable", "reason": "Supabase non configurato"}
try:
res = _sb.table(_TABLE).select("topic", count="exact").execute()
total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
return {
"status": "ok",
"component": "event_store",
"total_events": total,
"table": _TABLE,
}
except Exception as exc:
return {"status": "error", "detail": str(exc)}
|