Terminal / api /load_balancer.py
Baida—-
fix(registry): unterminated string literal line 692 — force re-sync [skip ci]
889ea3f verified
Raw
History Blame Contribute Delete
3.92 kB
"""
backend/api/load_balancer.py — Dynamic Load Balancer (S951)
Analizza il carico dei nodi (A, B, C, D) e decide a chi assegnare il task.
Supporta il Cross-Node Offloading: se il nodo target è saturo, delega al nodo più scarico.
M1-FIX: timeout Redis configurabile via env REDIS_TIMEOUT_S (default 5.0s, allineato a job_queue e state_sync).
"""
import json
import logging
import os
import time
from typing import Optional, Dict, Any, List
import httpx
_logger = logging.getLogger("agente_ai.balancer")
# M1-FIX: timeout Redis allineato agli altri moduli (job_queue: 5.0s, state_sync: 5.0s)
_REDIS_TIMEOUT = float(os.getenv("REDIS_TIMEOUT_S", "5.0"))
# Ruoli del cluster
ROLES = ["brain", "hands", "memory", "audit"]
class LoadBalancer:
def __init__(self):
self.redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
self.redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
self.internal_token = os.getenv("INTERNAL_TOKEN", "")
async def _rcmd(self, cmd: List[Any]) -> Optional[Dict[str, Any]]:
if not self.redis_url or not self.redis_token: return None
try:
async with httpx.AsyncClient() as client:
r = await client.post(
self.redis_url,
headers={"Authorization": f"Bearer {self.redis_token}"},
json=cmd,
timeout=_REDIS_TIMEOUT, # M1-FIX: era hardcodato 2.0
)
return r.json()
except Exception as e:
_logger.error("[S951] Redis error: %s", e)
return None
async def get_node_load(self, role: str) -> Optional[Dict[str, Any]]:
"""Recupera le metriche di carico per un nodo specifico."""
res = await self._rcmd(["GET", f"jq:load:{role}"])
if res and res.get("result"):
try:
return json.loads(res["result"])
except (json.JSONDecodeError, TypeError):
return None
return None
async def get_best_node(self, preferred_role: str = "hands") -> str:
"""
Determina il miglior nodo per un task.
Se il preferred_role è sotto soglia di carico, lo mantiene.
Altrimenti cerca il nodo con meno active_tasks.
"""
pref_load = await self.get_node_load(preferred_role)
if pref_load:
active = pref_load.get("active_tasks", 0)
if active < 3:
return preferred_role
_logger.info("[S951] Nodo %s saturo o offline. Ricerca miglior nodo alternativo...", preferred_role)
best_role = preferred_role
min_load = 999
for role in ROLES:
load = await self.get_node_load(role)
if load:
active = load.get("active_tasks", 0)
ts = load.get("ts", 0)
if (time.time() * 1000) - ts < 90000:
if active < min_load:
min_load = active
best_role = role
if best_role != preferred_role:
_logger.info("[S951] Offloading dinamico: %s -> %s (carico: %d)", preferred_role, best_role, min_load)
return best_role
async def get_cluster_status(self) -> Dict[str, Any]:
"""Ritorna lo stato di salute di tutto il cluster."""
status = {}
for role in ROLES:
load = await self.get_node_load(role)
if load:
ts = load.get("ts", 0)
is_online = (time.time() * 1000) - ts < 90000
status[role] = {
"online": is_online,
"active_tasks": load.get("active_tasks", 0),
"mem_mb": load.get("mem", 0),
"last_seen": ts,
}
else:
status[role] = {"online": False}
return status
# Singleton instance
balancer = LoadBalancer()