hy-app-monitor/monitor/service_update_jobs.py
2026-04-22 18:57:31 +08:00

250 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
# 返回状态给 HTTP 层时直接用 deepcopy 最稳。
import copy
# 后台更新任务需要用线程跑,避免 HTTP 请求一直阻塞。
import threading
# operationId 用 uuid 生成最直接。
import uuid
from typing import Any
from .config import utc_now
from .service_release import deploy_updated_services_payload
# 内存里最多保留最近若干次更新记录,避免 deploy 机常驻进程越跑越大。
MAX_UPDATE_OPERATIONS = 20
# 单次更新最多保留最近这些日志行,超出后丢弃最旧的。
MAX_UPDATE_LOG_LINES = 1200
# 统一保护任务状态的锁。
UPDATE_JOB_LOCK = threading.Lock()
# 当前进程内的更新任务表。
UPDATE_JOBS: dict[str, dict[str, Any]] = {}
# 记录任务插入顺序,便于淘汰最旧项。
UPDATE_JOB_ORDER: list[str] = []
def normalize_services(service_names: list[str]) -> list[str]:
# 请求里允许重复服务名,这里统一去重并保序。
normalized = [str(item or "").strip() for item in service_names if str(item or "").strip()]
return list(dict.fromkeys(normalized))
def create_job_payload(
service_names: list[str],
*,
java_git_ref: str,
go_git_ref: str,
image_tag: str,
skip_maven_build: bool,
) -> dict[str, Any]:
# 后台任务一创建就落一份初始状态,前端可以马上开始轮询。
normalized = normalize_services(service_names)
operation_id = uuid.uuid4().hex[:12]
now = utc_now()
payload = {
"operationId": operation_id,
"kind": "service-update",
"status": "queued",
"stage": "queued",
"message": "service update queued",
"error": "",
"startedAt": now,
"updatedAt": now,
"finishedAt": "",
"logDroppedCount": 0,
"logs": [],
"result": None,
"request": {
"services": normalized,
"javaGitRef": str(java_git_ref or "").strip(),
"goGitRef": str(go_git_ref or "").strip(),
"imageTag": str(image_tag or "").strip(),
"skipMavenBuild": bool(skip_maven_build),
},
}
with UPDATE_JOB_LOCK:
UPDATE_JOBS[operation_id] = payload
UPDATE_JOB_ORDER.append(operation_id)
trim_jobs_unlocked()
return snapshot_job(operation_id)
def trim_jobs_unlocked() -> None:
# 只保留最近的若干次任务,老数据直接淘汰。
while len(UPDATE_JOB_ORDER) > MAX_UPDATE_OPERATIONS:
operation_id = UPDATE_JOB_ORDER.pop(0)
UPDATE_JOBS.pop(operation_id, None)
def append_job_log(operation_id: str, stage: str, message: str, *, level: str = "info") -> None:
# 每条日志都带时间和阶段,页面可以直接展示。
text = str(message or "").rstrip()
if not text:
return
with UPDATE_JOB_LOCK:
payload = UPDATE_JOBS.get(operation_id)
if payload is None:
return
payload["updatedAt"] = utc_now()
payload["stage"] = str(stage or payload.get("stage") or "running")
payload["logs"].append(
{
"time": payload["updatedAt"],
"stage": str(stage or "").strip(),
"level": str(level or "info").strip() or "info",
"message": text,
}
)
if len(payload["logs"]) > MAX_UPDATE_LOG_LINES:
overflow = len(payload["logs"]) - MAX_UPDATE_LOG_LINES
payload["logs"] = payload["logs"][overflow:]
payload["logDroppedCount"] = int(payload.get("logDroppedCount") or 0) + overflow
def patch_job(operation_id: str, **fields: Any) -> None:
# 任务元信息更新统一走这里,避免漏掉 updatedAt。
with UPDATE_JOB_LOCK:
payload = UPDATE_JOBS.get(operation_id)
if payload is None:
return
payload.update(fields)
payload["updatedAt"] = utc_now()
def finish_job(operation_id: str, *, result: dict[str, Any], status: str, error: str = "") -> None:
# 结束态统一收口,前端轮询时不用自己猜。
now = utc_now()
with UPDATE_JOB_LOCK:
payload = UPDATE_JOBS.get(operation_id)
if payload is None:
return
payload["status"] = status
payload["stage"] = status
payload["message"] = (
str(result.get("message") or "").strip()
or ("service update complete" if status == "success" else "service update failed")
)
payload["error"] = str(error or "").strip()
payload["finishedAt"] = now
payload["updatedAt"] = now
payload["result"] = result
def snapshot_job(operation_id: str) -> dict[str, Any]:
# HTTP 返回前复制一份,避免把锁外的可变对象直接暴露出去。
with UPDATE_JOB_LOCK:
payload = UPDATE_JOBS.get(operation_id)
if payload is None:
raise KeyError(operation_id)
return {
"ok": True,
"operation": {
"operationId": payload["operationId"],
"kind": payload["kind"],
"status": payload["status"],
"stage": payload["stage"],
"message": payload["message"],
"error": payload["error"],
"startedAt": payload["startedAt"],
"updatedAt": payload["updatedAt"],
"finishedAt": payload["finishedAt"],
"logDroppedCount": payload["logDroppedCount"],
"logs": copy.deepcopy(payload.get("logs") or []),
"result": copy.deepcopy(payload.get("result")),
"request": copy.deepcopy(payload.get("request") or {}),
},
}
def job_progress_logger(operation_id: str):
# service_release 侧只关心 stage / level / message落盘细节都交给这里。
def _logger(stage: str, level: str, message: str) -> None:
append_job_log(operation_id, stage, message, level=level)
return _logger
def run_update_job(
operation_id: str,
service_names: list[str],
*,
java_git_ref: str,
go_git_ref: str,
image_tag: str,
skip_maven_build: bool,
) -> None:
# 真正的更新逻辑放后台线程里执行。
patch_job(operation_id, status="running", stage="prepare", message="service update running")
append_job_log(operation_id, "prepare", f"开始更新服务:{', '.join(normalize_services(service_names))}")
try:
result = deploy_updated_services_payload(
service_names,
java_git_ref=java_git_ref,
go_git_ref=go_git_ref,
image_tag=image_tag,
skip_maven_build=skip_maven_build,
progress=job_progress_logger(operation_id),
)
if result.get("ok"):
append_job_log(operation_id, "success", "服务更新完成")
finish_job(operation_id, result=result, status="success")
return
error = str(result.get("error") or "service update failed").strip()
append_job_log(operation_id, "failed", error, level="error")
finish_job(operation_id, result=result, status="failed", error=error)
except Exception as exc: # noqa: BLE001
error = str(exc)
append_job_log(operation_id, "failed", error, level="error")
finish_job(
operation_id,
result={"ok": False, "updatedAt": utc_now(), "error": error},
status="failed",
error=error,
)
def start_update_job(
service_names: list[str],
*,
java_git_ref: str,
go_git_ref: str,
image_tag: str,
skip_maven_build: bool,
) -> dict[str, Any]:
# 创建任务后立刻拉起后台线程,并把 operationId 返回给前端。
payload = create_job_payload(
service_names,
java_git_ref=java_git_ref,
go_git_ref=go_git_ref,
image_tag=image_tag,
skip_maven_build=skip_maven_build,
)
operation_id = payload["operation"]["operationId"]
worker = threading.Thread(
target=run_update_job,
kwargs={
"operation_id": operation_id,
"service_names": list(service_names),
"java_git_ref": java_git_ref,
"go_git_ref": go_git_ref,
"image_tag": image_tag,
"skip_maven_build": skip_maven_build,
},
name=f"service-update-{operation_id}",
daemon=True,
)
worker.start()
return snapshot_job(operation_id)
def get_update_job_payload(operation_id: str) -> dict[str, Any]:
# 供 HTTP 轮询接口直接读取任务状态。
return snapshot_job(operation_id)