feat: stabilize multi-region runtime sync and worker orchestration
This commit is contained in:
401
tools/runtime_observer.py
Executable file
401
tools/runtime_observer.py
Executable file
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
DEFAULT_API_BASE = "http://127.0.0.1:8100/api/v1"
|
||||
|
||||
|
||||
def _now_text() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _safe_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _safe_float(value: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value or 0.0)
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
|
||||
def _safe_text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _fetch_api(api_base: str, path: str) -> dict:
|
||||
url = f"{api_base.rstrip('/')}/{path.lstrip('/')}"
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "domaincheck-runtime-observer/0.1"})
|
||||
with urllib.request.urlopen(request, timeout=8) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", errors="ignore"))
|
||||
if isinstance(payload, dict):
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return payload
|
||||
return {"_error": f"unexpected payload type: {type(payload).__name__}", "_url": url}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="ignore")
|
||||
return {"_error": f"http {exc.code}", "_body": body[:1000], "_url": url}
|
||||
except Exception as exc:
|
||||
return {"_error": str(exc), "_url": url}
|
||||
|
||||
|
||||
def _observe_payload(api_base: str) -> dict:
|
||||
runtime_status = _fetch_api(api_base, "runtime/status")
|
||||
direct_active_job = _fetch_api(api_base, "detect/job/active")
|
||||
runtime_active_job = dict(((runtime_status.get("detect") or {}).get("active_job") or {}))
|
||||
readiness = _fetch_api(api_base, "runtime/readiness")
|
||||
sync_summary = _fetch_api(api_base, "runtime/sync-summary")
|
||||
active_job = _merge_active_job_payloads(direct_active_job, runtime_active_job)
|
||||
return {
|
||||
"runtime_status": runtime_status,
|
||||
"active_job": active_job,
|
||||
"direct_active_job": direct_active_job,
|
||||
"runtime_active_job": runtime_active_job,
|
||||
"readiness": readiness,
|
||||
"sync_summary": sync_summary,
|
||||
}
|
||||
|
||||
|
||||
def _real_node_count(active_job: dict) -> int:
|
||||
count = 0
|
||||
for item in list((active_job or {}).get("node_stats") or []):
|
||||
node_code = _safe_text((item or {}).get("node_code"))
|
||||
if node_code and node_code != "unassigned":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _merge_active_job_payloads(direct_active_job: dict, runtime_active_job: dict) -> dict:
|
||||
direct_active_job = dict(direct_active_job or {})
|
||||
runtime_active_job = dict(runtime_active_job or {})
|
||||
if not runtime_active_job:
|
||||
return direct_active_job
|
||||
if not direct_active_job:
|
||||
return runtime_active_job
|
||||
|
||||
merged = dict(direct_active_job)
|
||||
same_job = (
|
||||
_safe_int(direct_active_job.get("job_id")) > 0
|
||||
and _safe_int(direct_active_job.get("job_id")) == _safe_int(runtime_active_job.get("job_id"))
|
||||
) or (
|
||||
_safe_text(direct_active_job.get("job_code"))
|
||||
and _safe_text(direct_active_job.get("job_code")) == _safe_text(runtime_active_job.get("job_code"))
|
||||
)
|
||||
if not same_job:
|
||||
return direct_active_job
|
||||
|
||||
preferred_runtime_keys = {
|
||||
"node_stats",
|
||||
"distributed_node_stats",
|
||||
"display_items_claimed",
|
||||
"display_items_running",
|
||||
"display_current_load",
|
||||
"display_active_threads",
|
||||
"display_max_threads",
|
||||
"display_items_completed",
|
||||
"display_items_failed",
|
||||
"display_active_node_codes",
|
||||
"processed_recent",
|
||||
"processed_per_minute",
|
||||
"completed_recent",
|
||||
"failed_recent",
|
||||
"blacklisted_recent",
|
||||
"runtime_snapshot_job_id",
|
||||
"runtime_snapshot_job_code",
|
||||
"runtime_snapshot_queue",
|
||||
}
|
||||
runtime_is_richer = _real_node_count(runtime_active_job) >= _real_node_count(direct_active_job)
|
||||
for key in preferred_runtime_keys:
|
||||
if key in runtime_active_job and runtime_is_richer:
|
||||
merged[key] = runtime_active_job.get(key)
|
||||
return merged
|
||||
|
||||
|
||||
def _active_node_rows(active_job: dict) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for raw_item in list(active_job.get("node_stats") or []):
|
||||
item = dict(raw_item or {})
|
||||
node_code = _safe_text(item.get("node_code"))
|
||||
if not node_code or node_code == "unassigned":
|
||||
continue
|
||||
current_load = max(
|
||||
_safe_int(item.get("current_load")),
|
||||
_safe_int(item.get("active_threads")),
|
||||
_safe_int(item.get("display_running")),
|
||||
_safe_int(item.get("items_running")),
|
||||
)
|
||||
processed_recent = _safe_int(item.get("processed_recent"))
|
||||
items_claimed = _safe_int(item.get("items_claimed"))
|
||||
status = _safe_text(item.get("status"))
|
||||
if current_load <= 0 and processed_recent <= 0 and items_claimed <= 0 and status not in {"busy", "online"}:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"status": status or "-",
|
||||
"region": _safe_text(item.get("region")) or "-",
|
||||
"role": _safe_text(item.get("role")) or "-",
|
||||
"current_load": current_load,
|
||||
"display_running": _safe_int(item.get("display_running")),
|
||||
"active_threads": _safe_int(item.get("active_threads")),
|
||||
"max_threads": _safe_int(item.get("max_threads")),
|
||||
"items_claimed": items_claimed,
|
||||
"items_running": _safe_int(item.get("items_running")),
|
||||
"processed_recent": processed_recent,
|
||||
"processed_per_minute": _safe_float(item.get("processed_per_minute")),
|
||||
"completed_recent": _safe_int(item.get("completed_recent")),
|
||||
"failed_recent": _safe_int(item.get("failed_recent")),
|
||||
"blacklisted_recent": _safe_int(item.get("blacklisted_recent")),
|
||||
"last_heartbeat_at": _safe_text(item.get("last_heartbeat_at")),
|
||||
}
|
||||
)
|
||||
rows.sort(
|
||||
key=lambda item: (
|
||||
-int(item.get("current_load", 0)),
|
||||
-int(item.get("processed_recent", 0)),
|
||||
-int(item.get("items_claimed", 0)),
|
||||
str(item.get("node_code") or ""),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _step_rows(active_job: dict) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for raw_item in list(active_job.get("step_stats") or []):
|
||||
item = dict(raw_item or {})
|
||||
rows.append(
|
||||
{
|
||||
"step_code": _safe_text(item.get("step_code")),
|
||||
"step_name": _safe_text(item.get("step_name")),
|
||||
"pending": _safe_int(item.get("items_pending")),
|
||||
"running": _safe_int(item.get("items_running")),
|
||||
"completed": _safe_int(item.get("items_completed")),
|
||||
"failed": _safe_int(item.get("items_failed")),
|
||||
"blacklisted": _safe_int(item.get("items_blacklisted")),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda item: (-int(item["pending"]), -int(item["running"]), item["step_name"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _summary_numbers(payload: dict) -> dict:
|
||||
runtime_status = dict(payload.get("runtime_status") or {})
|
||||
active_job = dict(payload.get("active_job") or {})
|
||||
active_nodes = _active_node_rows(active_job)
|
||||
active_processes = len(active_nodes)
|
||||
active_threads = max(
|
||||
_safe_int(active_job.get("display_active_threads")),
|
||||
_safe_int(active_job.get("display_items_running")),
|
||||
sum(max(_safe_int(item.get("current_load")), _safe_int(item.get("display_running"))) for item in active_nodes),
|
||||
)
|
||||
max_threads = max(
|
||||
_safe_int(active_job.get("display_max_threads")),
|
||||
sum(_safe_int(item.get("max_threads")) for item in active_nodes if _safe_int(item.get("max_threads")) > 0),
|
||||
)
|
||||
processed_recent = _safe_int(active_job.get("processed_recent"))
|
||||
failed_recent = _safe_int(active_job.get("failed_recent"))
|
||||
blacklisted_recent = _safe_int(active_job.get("blacklisted_recent"))
|
||||
items_pending = _safe_int(active_job.get("items_pending"))
|
||||
items_claimed = _safe_int(active_job.get("items_claimed"))
|
||||
items_running = _safe_int(active_job.get("items_running"))
|
||||
items_completed = _safe_int(active_job.get("items_completed"))
|
||||
items_failed = _safe_int(active_job.get("items_failed"))
|
||||
items_blacklisted = _safe_int(active_job.get("items_blacklisted"))
|
||||
return {
|
||||
"job_id": _safe_int(active_job.get("job_id")),
|
||||
"job_code": _safe_text(active_job.get("job_code")),
|
||||
"job_status": _safe_text(active_job.get("status")) or "-",
|
||||
"progress_percent": _safe_float(active_job.get("progress_percent")),
|
||||
"active_processes": active_processes,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"processed_recent": processed_recent,
|
||||
"processed_per_minute": _safe_float(active_job.get("processed_per_minute")),
|
||||
"completed_recent": _safe_int(active_job.get("completed_recent")),
|
||||
"failed_recent": failed_recent,
|
||||
"blacklisted_recent": blacklisted_recent,
|
||||
"items_pending": items_pending,
|
||||
"items_claimed": items_claimed,
|
||||
"items_running": items_running,
|
||||
"items_completed": items_completed,
|
||||
"items_failed": items_failed,
|
||||
"items_blacklisted": items_blacklisted,
|
||||
"online_worker_nodes": _safe_int(((runtime_status.get("cluster_summary") or {}).get("online_worker_nodes"))),
|
||||
"online_control_nodes": _safe_int(((runtime_status.get("cluster_summary") or {}).get("online_control_nodes"))),
|
||||
}
|
||||
|
||||
|
||||
def _diagnose_state(summary: dict) -> tuple[str, str]:
|
||||
items_pending = int(summary["items_pending"])
|
||||
active_processes = int(summary["active_processes"])
|
||||
active_threads = int(summary["active_threads"])
|
||||
processed_recent = int(summary["processed_recent"])
|
||||
items_running = int(summary["items_running"])
|
||||
|
||||
if active_processes > 0 and active_threads > 0 and processed_recent > 0:
|
||||
return "真跑中", "已经有真实执行面和最近吞吐,不是只剩显示残影。"
|
||||
if active_processes > 0 and active_threads > 0 and items_running > 0:
|
||||
return "在跑但偏慢", "有执行面,也有运行中任务,但最近吞吐还没完全拉起来。"
|
||||
if items_pending > 0 and active_processes == 0 and active_threads == 0:
|
||||
return "没跑起来", "队列还有积压,但当前没看到真实执行面在消化。"
|
||||
if items_pending > 0 and processed_recent == 0:
|
||||
return "疑似残影", "队列还有积压,但最近没有看到处理增量,需要继续查执行链。"
|
||||
return "观察中", "当前口径没有明确异常,但还需要继续看最近吞吐是否持续变化。"
|
||||
|
||||
|
||||
def _line(char: str = "-") -> str:
|
||||
width = max(60, min(120, shutil.get_terminal_size((100, 20)).columns))
|
||||
return char * width
|
||||
|
||||
|
||||
def _render_summary(payload: dict, *, top_nodes: int) -> str:
|
||||
runtime_status = dict(payload.get("runtime_status") or {})
|
||||
active_job = dict(payload.get("active_job") or {})
|
||||
readiness = dict(payload.get("readiness") or {})
|
||||
sync_summary = dict(payload.get("sync_summary") or {})
|
||||
summary = _summary_numbers(payload)
|
||||
state_label, state_reason = _diagnose_state(summary)
|
||||
active_nodes = _active_node_rows(active_job)
|
||||
step_rows = _step_rows(active_job)
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(_line("="))
|
||||
lines.append(f"domainCheck 运行观察面板 {_now_text()}")
|
||||
lines.append(_line("="))
|
||||
lines.append(f"状态判断: {state_label}")
|
||||
lines.append(f"判断理由: {state_reason}")
|
||||
lines.append(
|
||||
f"当前任务: job_id={summary['job_id']} job_code={summary['job_code'] or '-'} status={summary['job_status']} 进度={summary['progress_percent']:.2f}%"
|
||||
)
|
||||
lines.append(
|
||||
f"就绪状态: { _safe_text(readiness.get('status')) or '-' } 摘要: { _safe_text(readiness.get('summary')) or '-' }"
|
||||
)
|
||||
lines.append(
|
||||
f"集群在线: mainland worker={summary['online_worker_nodes']} mainland controller={summary['online_control_nodes']}"
|
||||
)
|
||||
|
||||
lines.append(_line())
|
||||
lines.append("一眼先看这 4 组数:")
|
||||
lines.append(
|
||||
f"任务积压: pending={summary['items_pending']} claimed={summary['items_claimed']} running={summary['items_running']}"
|
||||
)
|
||||
lines.append(
|
||||
f"结果产出: completed={summary['items_completed']} failed={summary['items_failed']} blacklisted={summary['items_blacklisted']}"
|
||||
)
|
||||
lines.append(
|
||||
f"执行面: active_processes={summary['active_processes']} active_threads={summary['active_threads']} max_threads={summary['max_threads']}"
|
||||
)
|
||||
lines.append(
|
||||
f"最近吞吐: processed_recent={summary['processed_recent']} per_minute={summary['processed_per_minute']:.2f} failed_recent={summary['failed_recent']} blacklisted_recent={summary['blacklisted_recent']}"
|
||||
)
|
||||
|
||||
detect_runtime = dict(((runtime_status.get("detect") or {}).get("active_job") or {}))
|
||||
if detect_runtime:
|
||||
lines.append(_line())
|
||||
lines.append(
|
||||
"运行口径提示: "
|
||||
f"display_running={_safe_int(detect_runtime.get('display_items_running'))} "
|
||||
f"display_threads={_safe_int(detect_runtime.get('display_active_threads'))} "
|
||||
f"display_max={_safe_int(detect_runtime.get('display_max_threads'))}"
|
||||
)
|
||||
|
||||
lines.append(_line())
|
||||
lines.append("当前真正有动作的节点:")
|
||||
if not active_nodes:
|
||||
lines.append("- 暂时没看到有真实负载或最近吞吐的节点。")
|
||||
else:
|
||||
for item in active_nodes[: max(1, int(top_nodes or 8))]:
|
||||
lines.append(
|
||||
"- "
|
||||
f"{item['node_code']} "
|
||||
f"load={item['current_load']} "
|
||||
f"running={item['display_running']} "
|
||||
f"max={item['max_threads']} "
|
||||
f"processed_recent={item['processed_recent']} "
|
||||
f"failed_recent={item['failed_recent']} "
|
||||
f"blacklisted_recent={item['blacklisted_recent']} "
|
||||
f"status={item['status']}"
|
||||
)
|
||||
|
||||
lines.append(_line())
|
||||
lines.append("步骤分布:")
|
||||
for item in step_rows:
|
||||
lines.append(
|
||||
"- "
|
||||
f"{item['step_name'] or item['step_code']} "
|
||||
f"pending={item['pending']} running={item['running']} "
|
||||
f"completed={item['completed']} failed={item['failed']} blacklisted={item['blacklisted']}"
|
||||
)
|
||||
|
||||
detect_batches = dict((sync_summary.get("detect_result_batches") or {}))
|
||||
if sync_summary:
|
||||
lines.append(_line())
|
||||
lines.append(
|
||||
"结果回传: "
|
||||
f"enabled={_safe_text(sync_summary.get('enabled')) or '-'} "
|
||||
f"pending_batches={_safe_int(detect_batches.get('pending'))} "
|
||||
f"pushing_batches={_safe_int(detect_batches.get('pushing'))} "
|
||||
f"failed_batches={_safe_int(detect_batches.get('failed'))} "
|
||||
f"synced_batches={_safe_int(detect_batches.get('synced'))}"
|
||||
)
|
||||
|
||||
warnings = list(readiness.get("warnings") or [])
|
||||
info = list(readiness.get("info") or [])
|
||||
if warnings or info:
|
||||
lines.append(_line())
|
||||
lines.append("当前提示:")
|
||||
for text in warnings[:5]:
|
||||
lines.append(f"- warning: {_safe_text(text)}")
|
||||
for text in info[:3]:
|
||||
lines.append(f"- info: {_safe_text(text)}")
|
||||
|
||||
lines.append(_line("="))
|
||||
lines.append("建议:")
|
||||
lines.append("- 先盯 `任务积压 / 执行面 / 最近吞吐`,这三组一起动,才算真跑。")
|
||||
lines.append("- `日志` 只拿来辅助定位,不要拿日志多少判断是不是在跑。")
|
||||
lines.append("- 如果 `pending` 很高,但 `active_threads` 和 `processed_recent` 都接近 0,就是没跑起来。")
|
||||
lines.append("- 如果 `failed_recent` 很高而 `completed_recent`、`blacklisted_recent` 很低,说明更像外部步骤超时,不是黑名单在推进。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Render a human-friendly runtime observation panel.")
|
||||
parser.add_argument("--api-base", default=os.getenv("DOMAINCHECK_API_BASE", DEFAULT_API_BASE))
|
||||
parser.add_argument("--watch", type=int, default=0, help="refresh interval in seconds; 0 means run once")
|
||||
parser.add_argument("--top-nodes", type=int, default=10)
|
||||
parser.add_argument("--json", action="store_true", help="print raw normalized payload instead of panel")
|
||||
args = parser.parse_args()
|
||||
|
||||
while True:
|
||||
payload = _observe_payload(args.api_base)
|
||||
if args.json:
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
||||
else:
|
||||
if int(args.watch or 0) > 0:
|
||||
print("\033[2J\033[H", end="")
|
||||
print(_render_summary(payload, top_nodes=max(1, int(args.top_nodes or 10))))
|
||||
if int(args.watch or 0) <= 0:
|
||||
return 0
|
||||
time.sleep(max(2, int(args.watch)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user