Terminal / api /job_queue.py
Pulka
sync: 2 changed, 0 deleted — ff540cf8 (2026-06-23 16:45)
d6fb6dc verified
Raw
History Blame
18.6 kB
"""
backend/api/job_queue.py — Redis coordination BRAIN↔HANDS (S-DUAL-2)
Questo modulo implementa tre livelli di coordinamento via Upstash Redis:
1. LOAD EXCHANGE — entrambi gli Space pubblicano metriche ogni 30s su Redis.
Il CF router legge via /api/jq/load/{role} senza HTTP probe costosi.
Chiavi: jq:load:brain jq:load:hands (TTL 90s)
2. WAKE-UP — BRAIN segnala HANDS di scaldarsi prima che il circuito si chiuda.
HANDS consumer legge i segnali e chiama /health su se stesso.
Chiave: jq:wake (LIST, RPOP, TTL 30s per elemento)
3. TASK DELEGATION — BRAIN accoda task, HANDS consuma ed esegue.
Chiave: jq:tasks:pending (LIST, LPUSH/RPOP)
Chiave: jq:result:{taskId} (STRING, TTL 300s)
Chiave: jq:events:{taskId} (LIST, TTL 300s)
Chiave: jq:consumer:alive (STRING, TTL 30s — heartbeat HANDS consumer)
Abilitazione:
JQ_ENABLED=1 + UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN + SPACE_ROLE
Se JQ_ENABLED non è impostato → modulo in standby (load publish attivo, queue no)
Endpoints:
GET /api/jq/status — diagnostica queue
GET /api/jq/load/{role} — metriche load da Redis (brain|hands)
POST /api/jq/wake — segnala wake-up HANDS (solo BRAIN)
POST /api/jq/submit — sottometti job a HANDS via Redis (solo BRAIN)
GET /api/jq/result/{taskId} — leggi risultato job da Redis
GET /api/jq/events/{taskId} — leggi eventi SSE da Redis
"""
import os, asyncio, json, time, uuid, logging
from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
_logger = logging.getLogger("api.job_queue")
router = APIRouter(prefix="/api/jq", tags=["job-queue"])
# ── Config ─────────────────────────────────────────────────────────────────────
_SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown") # brain | hands | unknown
_JQ_ENABLED = os.getenv("JQ_ENABLED", "0").strip() == "1"
_LOAD_TTL = 90 # s — TTL metriche load su Redis
_RESULT_TTL = 300 # s — TTL risultato job su Redis
_EVENTS_TTL = 300 # s — TTL lista eventi SSE su Redis
_WAKE_TTL = 30 # s — TTL singolo wake signal
_CONSUMER_HB_TTL = 30 # s — TTL heartbeat consumer HANDS
# ── Redis keys ─────────────────────────────────────────────────────────────────
_K_LOAD = lambda role: f"jq:load:{role}" # STRING — metriche load
_K_WAKE = "jq:wake" # LIST — wake signals
_K_PENDING = "jq:tasks:pending" # LIST — job queue
_K_RESULT = lambda tid: f"jq:result:{tid}" # STRING — risultato job
_K_EVENTS = lambda tid: f"jq:events:{tid}" # LIST — eventi SSE
_K_CONSUMER = "jq:consumer:alive" # STRING — HB consumer
# ── Bootstrap check ────────────────────────────────────────────────────────────
def _redis_ok() -> bool:
"""Verifica che Redis sia configurato (non controlla la connettività)."""
return bool(
os.getenv("UPSTASH_REDIS_REST_URL") and
os.getenv("UPSTASH_REDIS_REST_TOKEN")
)
async def _rcmd(command: list, timeout: float = 3.0) -> dict | None:
"""Esegue comando Redis via modulo centralizzato backend/redis.py."""
try:
from redis import redis_cmd
return await redis_cmd(command, timeout=timeout)
except Exception as exc:
_logger.debug("[jq] redis_cmd error: %s", exc)
return None
async def _rpush(key: str, value: str, ttl: int | None = None) -> bool:
"""LPUSH key value + EXPIRE key ttl (se ttl != None)."""
r = await _rcmd(["LPUSH", key, value])
if r and ttl:
await _rcmd(["EXPIRE", key, ttl])
return r is not None
async def _rpop(key: str) -> str | None:
"""RPOP key. Ritorna None se lista vuota o errore."""
r = await _rcmd(["RPOP", key])
if r and r.get("result") is not None:
return r["result"]
return None
async def _llen(key: str) -> int:
"""LLEN key."""
r = await _rcmd(["LLEN", key])
return int(r.get("result", 0)) if r else 0
async def _lrange(key: str, start: int = 0, stop: int = -1) -> list[str]:
"""LRANGE key start stop."""
r = await _rcmd(["LRANGE", key, start, stop])
return r.get("result", []) if r else []
# ── Load metric exchange ───────────────────────────────────────────────────────
async def publish_load_metrics(role: str | None = None) -> bool:
"""
Pubblica le metriche di carico di questo Space su Redis.
Chiamato ogni 30s dal background loop _load_publisher_loop().
Complementa /api/health/load (HTTP) con uno snapshot Redis consultabile
senza HTTP call dall'altro Space.
"""
if not _redis_ok():
return False
_role = role or _SPACE_ROLE
try:
from api.priority import get_load_metrics as _glm
metrics = _glm()
except Exception:
metrics = {}
try:
from api.state import _agent_tasks
active = sum(1 for t in _agent_tasks.values() if t.get("status") in ("RUNNING", "running"))
except Exception:
active = 0
payload = json.dumps({
"space_role": _role,
"active_agent_tasks": active,
"realtime_active": metrics.get("realtime_active", 0),
"background_active": metrics.get("background_active", 0),
"realtime_available": metrics.get("realtime_available", 6),
"consumer_enabled": _JQ_ENABLED,
"ts": int(time.time() * 1000),
})
r = await _rcmd(["SET", _K_LOAD(_role), payload, "EX", _LOAD_TTL])
return r is not None
async def get_remote_load(role: str) -> dict | None:
"""Legge le metriche load dell'altro Space da Redis. Ritorna None se stale."""
if not _redis_ok():
return None
r = await _rcmd(["GET", _K_LOAD(role)])
if not r or r.get("result") is None:
return None
try:
return json.loads(r["result"])
except Exception:
return None
# ── Wake-up signaling ──────────────────────────────────────────────────────────
async def publish_wake_signal(reason: str = "circuit_open") -> bool:
"""
BRAIN pubblica un segnale di wake-up per HANDS.
HANDS consumer legge il segnale e chiama /health su se stesso
per prevenire il cold start di HF Space free tier.
"""
if not _redis_ok():
return False
payload = json.dumps({"reason": reason, "ts": int(time.time() * 1000), "from": _SPACE_ROLE})
return await _rpush(_K_WAKE, payload, ttl=_WAKE_TTL)
async def _consume_wake_signals() -> int:
"""HANDS: legge e processa tutti i wake signal in coda. Ritorna il count."""
count = 0
while True:
raw = await _rpop(_K_WAKE)
if raw is None:
break
try:
sig = json.loads(raw)
_logger.info("[jq] wake signal from %s — reason: %s", sig.get("from"), sig.get("reason"))
except Exception:
pass
count += 1
return count
# ── Task delegation ────────────────────────────────────────────────────────────
class JobPayload(BaseModel):
goal: str
session_id: str = ""
context: dict = {}
priority: str = "realtime" # realtime | background
max_steps: int = 20
task_id: str = "" # se vuoto → generato da BRAIN
async def submit_job(job: JobPayload) -> dict:
"""
BRAIN: accoda un task per HANDS via Redis.
Ritorna {taskId, status, stream_url, queue_depth}.
Il client usa stream_url per connettersi direttamente a HANDS (via CF routing)
e ricevere gli eventi SSE una volta che HANDS ha consumato il job.
"""
if not _redis_ok():
raise HTTPException(503, "Redis non configurato — job queue non disponibile")
task_id = job.task_id or str(uuid.uuid4())
payload = json.dumps({
"taskId": task_id,
"goal": job.goal,
"session_id": job.session_id,
"context": job.context,
"priority": job.priority,
"max_steps": job.max_steps,
"submitted_at": time.time(),
"submitted_by": _SPACE_ROLE,
})
ok = await _rpush(_K_PENDING, payload)
if not ok:
raise HTTPException(503, "Impossibile accodare il task su Redis")
depth = await _llen(_K_PENDING)
_logger.info("[jq] job queued taskId=%s depth=%d", task_id, depth)
return {
"taskId": task_id,
"status": "queued",
"queue_depth": depth,
"stream_url": f"/api/agent/tasks/{task_id}/stream",
}
async def _execute_queued_job(job: dict) -> None:
"""
HANDS: esegue un job prelevato dalla coda Redis.
Crea il task nel registro locale e avvia unified_loop.
Gli eventi SSE vengono scritti sia nel registro locale (per streaming diretto)
sia su Redis (per relay da BRAIN).
"""
task_id = job.get("taskId", str(uuid.uuid4()))
goal = job.get("goal", "")
_logger.info("[jq] executing queued job taskId=%s goal=%.60s", task_id, goal)
try:
from api.state import _agent_tasks
_agent_tasks[task_id] = {
"status": "QUEUED",
"goal": goal,
"session_id": job.get("session_id", ""),
"created_at": time.time() * 1000,
"source": "jq",
}
# Pubblica evento di start su Redis
await _rcmd(["LPUSH", _K_EVENTS(task_id), json.dumps({
"type": "task_queued", "taskId": task_id, "ts": int(time.time() * 1000)
})])
await _rcmd(["EXPIRE", _K_EVENTS(task_id), _EVENTS_TTL])
# Lancia il loop tramite agent.py create_agent_task
try:
from api.agent import _create_task_internal
await _create_task_internal(task_id=task_id, goal=goal, job=job)
except (ImportError, AttributeError):
# Fallback: usa unified_loop direttamente
from agents.unified_loop import UnifiedLoop
loop = UnifiedLoop()
result = await loop.run(
goal=goal,
context=json.dumps(job.get("context", {})),
max_steps=job.get("max_steps", 20),
session_id=job.get("session_id", ""),
)
# Pubblica risultato
await _rcmd(["SET", _K_RESULT(task_id), json.dumps({
"taskId": task_id,
"status": "success" if result.get("success") else "error",
"output": result.get("output", ""),
"error": result.get("error"),
"completed_at": time.time(),
}), "EX", _RESULT_TTL])
_agent_tasks[task_id]["status"] = "SUCCESS" if result.get("success") else "ERROR"
except Exception as exc:
_logger.error("[jq] job execution failed taskId=%s: %s", task_id, exc, exc_info=True)
await _rcmd(["SET", _K_RESULT(task_id), json.dumps({
"taskId": task_id,
"status": "error",
"error": str(exc),
"completed_at": time.time(),
}), "EX", _RESULT_TTL])
try:
from api.state import _agent_tasks
_agent_tasks[task_id]["status"] = "ERROR"
except Exception:
pass
# ── Background loops ───────────────────────────────────────────────────────────
async def _load_publisher_loop() -> None:
"""Background loop: pubblica metriche load ogni 30s su Redis."""
_logger.info("[jq] load publisher avviato (role=%s)", _SPACE_ROLE)
while True:
await asyncio.sleep(30)
try:
await publish_load_metrics()
except Exception as exc:
_logger.debug("[jq] load publish error: %s", exc)
async def _hands_consumer_loop() -> None:
"""
Background loop HANDS: ogni 1s legge dalla queue Redis.
- Consuma wake signals (log + no-op — siamo già svegli)
- Consuma job dalla coda e li esegue in background
- Pubblica heartbeat consumer ogni 10s
"""
_logger.info("[jq] HANDS job consumer avviato")
_hb_tick = 0
while True:
await asyncio.sleep(1)
try:
# Wake signals — siamo già svegli ma logghiamo
await _consume_wake_signals()
# Heartbeat consumer ogni ~10s
_hb_tick += 1
if _hb_tick % 10 == 0:
await _rcmd(["SET", _K_CONSUMER, str(int(time.time())), "EX", _CONSUMER_HB_TTL])
if not _JQ_ENABLED:
continue # load publisher attivo, job consumer no
# Preleva job dalla coda
raw = await _rpop(_K_PENDING)
if raw is None:
continue
try:
job = json.loads(raw)
except Exception:
_logger.warning("[jq] invalid job payload — skipping")
continue
# Esegui in background — non blocca il loop consumer
t = asyncio.create_task(_execute_queued_job(job))
t.add_done_callback(lambda task: (
_logger.error("[jq] job task crashed: %s", task.exception(), exc_info=task.exception())
if not task.cancelled() and task.exception() else None
))
except Exception as exc:
_logger.debug("[jq] consumer tick error: %s", exc)
async def start_job_queue_consumer() -> None:
"""
Punto di ingresso per main.py _on_startup().
Avvia:
- _load_publisher_loop() (sempre, su tutti gli Space)
- _hands_consumer_loop() (solo se SPACE_ROLE=hands o unknown)
"""
if not _redis_ok():
_logger.warning("[jq] Redis non configurato — job queue disabilitato")
return
# Load publisher su tutti gli Space
asyncio.create_task(_load_publisher_loop())
# Consumer solo su HANDS (o se role non impostato per compatibilità)
if _SPACE_ROLE in ("hands", "unknown"):
asyncio.create_task(_hands_consumer_loop())
else:
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (solo load publisher)", _SPACE_ROLE)
# Pubblica subito le metriche al boot
await publish_load_metrics()
# ── FastAPI endpoints ──────────────────────────────────────────────────────────
@router.get("/status")
async def jq_status():
"""Diagnostica completa della job queue."""
_redis_configured = _redis_ok()
result = {
"space_role": _SPACE_ROLE,
"jq_enabled": _JQ_ENABLED,
"redis_configured": _redis_configured,
"ts": int(time.time() * 1000),
}
if _redis_configured:
result["queue_depth"] = await _llen(_K_PENDING)
result["wake_pending"] = await _llen(_K_WAKE)
_hb = await _rcmd(["GET", _K_CONSUMER])
result["consumer_alive"] = bool(_hb and _hb.get("result"))
result["brain_load"] = await get_remote_load("brain")
result["hands_load"] = await get_remote_load("hands")
return result
@router.get("/load/{role}")
async def jq_load(role: str):
"""Legge le metriche load di uno Space da Redis. role: brain | hands"""
if role not in ("brain", "hands"):
raise HTTPException(400, "role deve essere 'brain' o 'hands'")
data = await get_remote_load(role)
if data is None:
raise HTTPException(404, f"Metriche {role} non disponibili (stale o Redis non configurato)")
return data
@router.post("/wake")
async def jq_wake(request: Request):
"""BRAIN: invia segnale wake-up a HANDS. Solo da BRAIN o con X-Internal-Token."""
_tok = os.getenv("INTERNAL_TOKEN", "")
if _tok and request.headers.get("X-Internal-Token", "") != _tok:
raise HTTPException(401, "Unauthorized")
ok = await publish_wake_signal(reason="manual_wake")
return {"sent": ok, "ts": int(time.time() * 1000)}
@router.post("/submit")
async def jq_submit(job: JobPayload, request: Request):
"""
BRAIN: sottomette un job a HANDS via Redis.
Richiede X-Internal-Token.
Ritorna {taskId, status, stream_url} — il client usa stream_url per SSE.
"""
_tok = os.getenv("INTERNAL_TOKEN", "")
if _tok and request.headers.get("X-Internal-Token", "") != _tok:
raise HTTPException(401, "Unauthorized")
if not _JQ_ENABLED:
raise HTTPException(503, "JQ_ENABLED non impostato — job queue disabilitato")
return await submit_job(job)
@router.get("/result/{task_id}")
async def jq_result(task_id: str):
"""Legge il risultato di un job delegato da Redis. Disponibile per ~5 min post-completamento."""
if not _redis_ok():
raise HTTPException(503, "Redis non configurato")
r = await _rcmd(["GET", _K_RESULT(task_id)])
if not r or r.get("result") is None:
raise HTTPException(404, f"Risultato per {task_id} non trovato (non ancora pronto o scaduto)")
try:
return json.loads(r["result"])
except Exception:
raise HTTPException(500, "Risultato malformato in Redis")
@router.get("/events/{task_id}")
async def jq_events(task_id: str, from_idx: int = 0):
"""
Legge gli eventi SSE di un task da Redis (per relay da BRAIN).
from_idx: indice da cui iniziare (0 = tutti).
"""
if not _redis_ok():
raise HTTPException(503, "Redis non configurato")
events_raw = await _lrange(_K_EVENTS(task_id), from_idx)
events = []
for e in events_raw:
try:
events.append(json.loads(e))
except Exception:
events.append({"raw": e})
return {"taskId": task_id, "events": events, "count": len(events), "from_idx": from_idx}