Terminal / api /database_router.py
Baida-A's picture
multi-sync: 171 file from Baida98/AI@9b697a97 (2026-07-05 14:21)
02f6342 verified
Raw
History Blame Contribute Delete
11.4 kB
"""
database_router.py — Router Query Intelligente per Separazione Workload Supabase A/B/C/D
Architettura:
A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
B: Sync/State/Transazioni (stato globale, sincronizzazione cluster — PRIMARY)
C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
D: Audit/Logging/Compliance (event log, audit trail, compliance records)
Routing Logic:
1. Query di LETTURA (SELECT) → Preferisci A (read replica), fallback a B
2. Query di SCRITTURA (INSERT/UPDATE) → Usa B (PRIMARY)
3. Query su MEMORIA/RAG (skill_memory, embeddings, conversations) → Usa C
4. Query su AUDIT/LOG (audit_events, compliance_log) → Usa D
5. Query di SINCRONIZZAZIONE (cluster_state, global_state) → Usa B
"""
import asyncio
import os
import logging
import re as _re
from typing import Optional, Literal
from enum import Enum
from fastapi import APIRouter, HTTPException, Request, Depends
from .auth_guard import require_role, AuthRole
from pydantic import BaseModel
router = APIRouter(prefix="/api/database", tags=["database"])
_logger = logging.getLogger("database_router")
# ─── Enumerazione Nodi Supabase ───────────────────────────────────────────
class SupabaseNode(str, Enum):
A = "A" # Analytics/Cache
B = "B" # PRIMARY (Sync/State)
C = "C" # Memory/RAG
D = "D" # Audit/Logging
# ─── Configurazione Nodi ──────────────────────────────────────────────────
SUPABASE_CONFIG = {
"A": {
"url": os.getenv("SUPABASE_URL_A", ""),
"key": os.getenv("SUPABASE_KEY_A", ""),
"role": "Analytics/Cache (Read-Heavy)",
"priority": 1, # Preferito per letture
},
"B": {
"url": os.getenv("SUPABASE_URL", ""), # PRIMARY
"key": os.getenv("SUPABASE_KEY", ""),
"role": "Sync/State (PRIMARY)",
"priority": 0, # Fallback universale
},
"C": {
"url": os.getenv("SUPABASE_URL_C", ""),
"key": os.getenv("SUPABASE_KEY_C", ""),
"role": "Memory/RAG/Embeddings",
"priority": 2,
},
"D": {
"url": os.getenv("SUPABASE_URL_D", ""),
"key": os.getenv("SUPABASE_KEY_D", ""),
"role": "Audit/Logging/Compliance",
"priority": 3,
},
}
# ─── Keyword Pericolosi (per read-only) ───────────────────────────────────
_DANGEROUS = frozenset(
{"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"}
)
_MAX_ROWS = 500
# ─── Modelli Pydantic ─────────────────────────────────────────────────────
class QueryRequest(BaseModel):
sql: str
params: list = []
read_only: bool = True
preferred_node: Optional[SupabaseNode] = None # Override routing logic
class QueryResponse(BaseModel):
ok: bool
rows: list = []
columns: list = []
count: int = 0
truncated: bool = False
node_used: Optional[str] = None
error: Optional[str] = None
# ─── Funzioni Utility ─────────────────────────────────────────────────────
def _is_dangerous(sql: str) -> Optional[str]:
"""Rilevamento keyword pericolose robusto contro CTE e multi-spazio."""
s_norm = " ".join(sql.strip().lower().split())
tokens = _re.split(r"[\s\(\),;]+", s_norm)
for tok in tokens:
if tok in _DANGEROUS:
return tok
for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm):
first_word = m.group(1).lower()
if first_word in _DANGEROUS:
return first_word
return None
def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]:
"""Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER)."""
s_norm = " ".join(sql.strip().upper().split())
if s_norm.startswith("SELECT"):
return "SELECT"
elif s_norm.startswith("INSERT"):
return "INSERT"
elif s_norm.startswith("UPDATE"):
return "UPDATE"
elif s_norm.startswith("DELETE"):
return "DELETE"
return "OTHER"
def _detect_table_context(sql: str) -> Optional[str]:
"""Rileva il contesto della tabella per routing intelligente."""
sql_lower = sql.lower()
# Tabelle di memoria/RAG → Nodo C
if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]):
return "C"
# Tabelle di audit/logging → Nodo D
if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]):
return "D"
# Tabelle di stato globale → Nodo B
if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]):
return "B"
return None
def _choose_node(
query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"],
table_context: Optional[str],
preferred_node: Optional[SupabaseNode],
) -> SupabaseNode:
"""
Logica di routing intelligente per scegliere il nodo Supabase.
Priorità:
1. preferred_node (override esplicito)
2. table_context (rilevamento tabella)
3. query_type (tipo di query)
4. Fallback a B (PRIMARY)
"""
# 1. Override esplicito
if preferred_node:
return preferred_node
# 2. Routing per contesto tabella
if table_context:
return SupabaseNode(table_context)
# 3. Routing per tipo query
if query_type == "SELECT":
# Preferisci A (read replica) se disponibile, altrimenti B
if SUPABASE_CONFIG["A"]["url"]:
return SupabaseNode.A
return SupabaseNode.B
elif query_type in ("INSERT", "UPDATE", "DELETE"):
# Sempre su B (PRIMARY)
return SupabaseNode.B
# 4. Fallback a B (PRIMARY)
return SupabaseNode.B
# ─── Endpoint Principale ──────────────────────────────────────────────────
@router.post("/query", response_model=QueryResponse)
async def database_query(
req: QueryRequest,
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
):
"""
Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
Parametri:
- sql: query SQL
- params: parametri query
- read_only: blocca query pericolose (default: true)
- preferred_node: forza un nodo specifico (opzionale)
Ritorna:
- ok: successo
- rows: righe risultato
- columns: nomi colonne
- count: numero righe
- truncated: se risultato è stato troncato
- node_used: nodo Supabase utilizzato
"""
# Rileva tipo query e contesto
query_type = _detect_query_type(req.sql)
table_context = _detect_table_context(req.sql)
# Scegli nodo
chosen_node = _choose_node(query_type, table_context, req.preferred_node)
# Verifica configurazione nodo
node_config = SUPABASE_CONFIG.get(chosen_node.value)
if not node_config or not node_config["url"]:
# Fallback a B se nodo non configurato
if chosen_node != SupabaseNode.B:
_logger.warning(
f"Nodo {chosen_node.value} non configurato, fallback a B. "
f"Configura SUPABASE_URL_{chosen_node.value} e SUPABASE_KEY_{chosen_node.value}."
)
chosen_node = SupabaseNode.B
node_config = SUPABASE_CONFIG["B"]
if not node_config["url"]:
return QueryResponse(
ok=False,
error=f"Nodo {chosen_node.value} non configurato. Imposta SUPABASE_URL_{chosen_node.value}.",
node_used=chosen_node.value,
)
# ── GAP-DB-QUERY-PUBLIC fix: forziamo read_only basandoci sul contenuto ──
# Non ci fidiamo di req.read_only dal client per la sicurezza.
_sql_upper = req.sql.upper()
_is_write = any(kw in _sql_upper for kw in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"])
# Se la query contiene keyword di scrittura, richiediamo esplicitamente permessi OPERATOR o superiore
# In questo endpoint MACHINE (default) permettiamo solo SELECT.
if _is_write and role < AuthRole.OPERATOR:
raise HTTPException(403, "Permessi insufficienti per query di scrittura (richiesto OPERATOR)")
# Verifica read-only
if req.read_only or not _is_write:
kw = _is_dangerous(req.sql)
if kw:
return QueryResponse(
ok=False,
error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
node_used=chosen_node.value,
)
# Esegui query
try:
result = await _execute_query(
node_config["url"],
node_config["key"],
req.sql,
req.params,
)
result["node_used"] = chosen_node.value
return QueryResponse(**result)
except Exception as e:
_logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}")
return QueryResponse(
ok=False,
error=str(e)[:400],
node_used=chosen_node.value,
)
# ─── Esecuzione Query (Supabase PostgreSQL) ───────────────────────────────
async def _execute_query(url: str, key: str, sql: str, params: list) -> dict:
"""Esegue query su Supabase PostgreSQL."""
try:
import psycopg2
import psycopg2.extras
except ImportError:
return {
"ok": False,
"error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.",
}
def _run():
conn = psycopg2.connect(url)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params or None)
try:
rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
cols = [d.name for d in (cur.description or [])]
except psycopg2.ProgrammingError:
rows, cols = [], []
conn.commit()
finally:
conn.close()
return rows, cols
rows, cols = await asyncio.to_thread(_run)
return {
"ok": True,
"rows": rows,
"columns": cols,
"count": len(rows),
"truncated": len(rows) == _MAX_ROWS,
}
# ─── Endpoint Debug (info nodi) ───────────────────────────────────────────
@router.get("/nodes/status")
async def nodes_status():
"""Ritorna lo stato di configurazione di tutti i nodi Supabase."""
status = {}
for node_id, config in SUPABASE_CONFIG.items():
status[node_id] = {
"role": config["role"],
"configured": bool(config["url"]),
"url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET",
}
return {"nodes": status}