271 lines
7.5 KiB
Python
271 lines
7.5 KiB
Python
"""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
|
|
RECONCILE_INTERVAL_SECONDS = 60
|
|
STUCK_NO_PROGRESS_SECONDS = 3600
|
|
INTERRUPTED_MESSAGE = "服务重启导致任务中断"
|
|
STUCK_MESSAGE = "执行超时无响应(系统自动标记)"
|
|
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 list_running_live_jobs() -> list[dict[str, Any]]:
|
|
"""Scan Redis for all server batch jobs still in running status."""
|
|
redis = get_redis()
|
|
running: list[dict[str, Any]] = []
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = await redis.scan(cursor, match=f"{REDIS_KEY_PREFIX}*", count=50)
|
|
for key in keys:
|
|
if key == REDIS_NEXT_ID:
|
|
continue
|
|
suffix = key.replace(REDIS_KEY_PREFIX, "", 1)
|
|
try:
|
|
job_id = int(suffix)
|
|
except ValueError:
|
|
continue
|
|
live = await get_live(job_id)
|
|
if live and live.get("status") == "running":
|
|
running.append(live)
|
|
if cursor == 0:
|
|
break
|
|
return running
|
|
|
|
|
|
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 parse_iso(ts: str) -> Optional[datetime]:
|
|
try:
|
|
return datetime.fromisoformat(ts)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def pending_server_ids(live: dict[str, Any]) -> list[int]:
|
|
"""Server IDs in the job with no result row yet."""
|
|
server_ids = live.get("server_ids") or []
|
|
results = live.get("results") or {}
|
|
pending: list[int] = []
|
|
for sid in server_ids:
|
|
if str(sid) not in results:
|
|
pending.append(int(sid))
|
|
return pending
|
|
|
|
|
|
def apply_pending_failures(
|
|
live: dict[str, Any],
|
|
*,
|
|
server_ids: list[int],
|
|
error: str,
|
|
auto_stuck: bool = False,
|
|
server_name_fallback: str = "未知服务器",
|
|
) -> int:
|
|
"""Mark pending servers as failed; returns count of rows added."""
|
|
if not server_ids:
|
|
return 0
|
|
results = live.setdefault("results", {})
|
|
added = 0
|
|
for sid in server_ids:
|
|
key = str(sid)
|
|
if key in results:
|
|
continue
|
|
item: dict[str, Any] = {
|
|
"server_id": sid,
|
|
"server_name": server_name_fallback,
|
|
"success": False,
|
|
"stdout": "",
|
|
"error": error,
|
|
}
|
|
if auto_stuck:
|
|
item["auto_stuck"] = True
|
|
results[key] = item
|
|
added += 1
|
|
return added
|
|
|
|
|
|
def is_stale_running(live: dict[str, Any], now: Optional[datetime] = None) -> bool:
|
|
"""True when a running job has had no Redis update for STUCK_NO_PROGRESS_SECONDS."""
|
|
if live.get("status") != "running":
|
|
return False
|
|
now = now or datetime.now(timezone.utc)
|
|
updated = parse_iso(live.get("updated_at", ""))
|
|
if not updated:
|
|
updated = parse_iso(live.get("created_at", ""))
|
|
if not updated:
|
|
return False
|
|
return (now - updated).total_seconds() >= STUCK_NO_PROGRESS_SECONDS
|
|
|
|
|
|
def live_from_mysql_row(row: Any) -> dict[str, Any]:
|
|
"""Rebuild minimal Redis-shaped live dict from a ServerBatchJob row."""
|
|
import json
|
|
|
|
try:
|
|
server_ids = json.loads(row.server_ids or "[]")
|
|
except json.JSONDecodeError:
|
|
server_ids = []
|
|
try:
|
|
raw_results = json.loads(row.results or "[]")
|
|
except json.JSONDecodeError:
|
|
raw_results = []
|
|
results: dict[str, Any] = {}
|
|
if isinstance(raw_results, dict):
|
|
results = dict(raw_results)
|
|
elif isinstance(raw_results, list):
|
|
for entry in raw_results:
|
|
if isinstance(entry, dict) and entry.get("server_id") is not None:
|
|
results[str(entry["server_id"])] = entry
|
|
try:
|
|
params = json.loads(row.params or "{}") if row.params else {}
|
|
except json.JSONDecodeError:
|
|
params = {}
|
|
if not isinstance(params, dict):
|
|
params = {}
|
|
started = row.started_at.isoformat() if row.started_at else _now_iso()
|
|
return {
|
|
"id": int(row.id),
|
|
"op": row.op or "",
|
|
"label": row.label or "",
|
|
"status": row.status or "running",
|
|
"server_ids": list(server_ids),
|
|
"results": results,
|
|
"operator": row.operator or "",
|
|
"params": params,
|
|
"created_at": started,
|
|
"updated_at": started,
|
|
}
|
|
|
|
|
|
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,
|
|
}
|