"""Server batch job live state in Redis (2h TTL).""" from __future__ import annotations import json import logging from datetime import datetime, timezone from typing import Any, Optional from server.infrastructure.redis.client import get_redis logger = logging.getLogger("nexus.server_batch_store") REDIS_KEY_PREFIX = "server_batch:" REDIS_NEXT_ID = "server_batch:next_id" REDIS_TTL_SECONDS = 7200 TERMINAL_STATUSES = frozenset({"completed", "failed", "partial", "cancelled"}) def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() async def next_job_id() -> int: redis = get_redis() return int(await redis.incr(REDIS_NEXT_ID)) def _key(job_id: int) -> str: return f"{REDIS_KEY_PREFIX}{job_id}" def progress_stats(live: dict[str, Any]) -> dict[str, Any]: server_ids = live.get("server_ids") or [] results = live.get("results") or {} total = len(server_ids) done = 0 success = 0 failed = 0 for sid in server_ids: entry = results.get(str(sid)) if not entry: continue done += 1 if entry.get("success"): success += 1 else: failed += 1 remaining = max(0, total - done) return { "total": total, "done": done, "remaining": remaining, "success": success, "failed": failed, "progress": f"{success}/{total}" if total else "—", } async def save_live(live: dict[str, Any]) -> None: redis = get_redis() job_id = int(live["id"]) live["updated_at"] = _now_iso() await redis.set(_key(job_id), json.dumps(live, ensure_ascii=False), ex=REDIS_TTL_SECONDS) async def get_live(job_id: int) -> Optional[dict[str, Any]]: redis = get_redis() raw = await redis.get(_key(job_id)) if not raw: return None try: return json.loads(raw) except json.JSONDecodeError: logger.warning("Corrupt server batch live state job_id=%s", job_id) return None def init_live( *, job_id: int, op: str, label: str, server_ids: list[int], operator: str, params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: return { "id": job_id, "op": op, "label": label, "status": "running", "server_ids": list(server_ids), "results": {}, "operator": operator, "params": params or {}, "created_at": _now_iso(), "updated_at": _now_iso(), } def record_result(live: dict[str, Any], item: dict[str, Any]) -> None: results = live.setdefault("results", {}) results[str(item["server_id"])] = item def finalize_status(live: dict[str, Any]) -> str: stats = progress_stats(live) total = stats["total"] success = stats["success"] failed = stats["failed"] if total == 0: status = "failed" elif failed == 0: status = "completed" elif success == 0: status = "failed" else: status = "partial" live["status"] = status return status def live_to_api(live: dict[str, Any]) -> dict[str, Any]: stats = progress_stats(live) results = live.get("results") or {} server_ids = live.get("server_ids") or [] ordered = [] for sid in server_ids: entry = results.get(str(sid)) if entry: ordered.append(entry) return { "job_id": int(live["id"]), "op": live.get("op") or "", "label": live.get("label") or "", "status": live.get("status") or "running", "operator": live.get("operator") or "", **stats, "results": ordered, }