feat: stabilize multi-region runtime sync and worker orchestration

This commit is contained in:
root
2026-04-27 15:48:12 +08:00
parent 7cbde2aa78
commit 215a364891
137 changed files with 31931 additions and 1943 deletions

View File

@@ -7,6 +7,8 @@ from datetime import datetime
from pathlib import Path
from uuid import uuid4
import redis
from app.core.config import settings
from app.core.redis_client import get_redis
from app.services.runtime_settings_service import get_runtime_settings
@@ -16,6 +18,101 @@ WORKER_CONTROL_CHANNEL = "domain_tool:worker_control"
WORKER_PENDING_COMMAND_KEY = "domain_tool:worker_pending_command"
def _normalize_target_node_codes(payload: dict | None) -> list[str]:
if not isinstance(payload, dict):
return []
normalized_targets: list[str] = []
def append_target(raw_value: object) -> None:
normalized_value = str(raw_value or "").strip()
if normalized_value and normalized_value not in normalized_targets:
normalized_targets.append(normalized_value)
for key in ("target_node_codes", "node_codes"):
raw_value = payload.get(key)
if isinstance(raw_value, (list, tuple, set)):
for item in raw_value:
append_target(item)
elif isinstance(raw_value, str) and raw_value.strip():
for item in raw_value.split(","):
append_target(item)
if normalized_targets:
return normalized_targets
for key in ("target_node_code", "node_code"):
raw_value = payload.get(key)
if raw_value not in (None, ""):
append_target(raw_value)
if normalized_targets:
return normalized_targets
return normalized_targets
def _pending_command_keys(command_payload: dict) -> list[str]:
target_node_codes = _normalize_target_node_codes(command_payload)
if not target_node_codes:
return [WORKER_PENDING_COMMAND_KEY]
return [f"{WORKER_PENDING_COMMAND_KEY}:{node_code}" for node_code in target_node_codes]
def _dedupe_target_node_codes(node_codes: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for raw_value in list(node_codes or []):
normalized_value = str(raw_value or "").strip()
if not normalized_value or normalized_value in seen:
continue
seen.add(normalized_value)
deduped.append(normalized_value)
return deduped
def _expand_local_linux_worker_target_node_codes(service_name: str) -> list[str]:
base_node_code = str(settings.node_code or "").strip()
normalized_service_name = str(service_name or "").strip()
if not base_node_code or not normalized_service_name:
return []
target_node_codes = [base_node_code]
for unit in _expand_linux_worker_control_units(normalized_service_name):
normalized_unit = str(unit or "").strip()
if not normalized_unit:
continue
if normalized_unit.endswith(".service"):
normalized_unit = normalized_unit[:-8]
if normalized_unit == normalized_service_name:
continue
template_prefix = f"{normalized_service_name}@"
if not normalized_unit.startswith(template_prefix):
continue
instance_suffix = str(normalized_unit.split("@", 1)[1] or "").strip()
if instance_suffix:
target_node_codes.append(f"{base_node_code}-{instance_suffix}")
return _dedupe_target_node_codes(target_node_codes)
def _publish_worker_command(redis_client, *, serialized: str, pending_keys: list[str]) -> None:
for key in pending_keys:
redis_client.set(key, serialized, ex=120)
redis_client.publish(WORKER_CONTROL_CHANNEL, serialized)
def _build_direct_redis_client() -> redis.Redis:
return redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
password=settings.redis_password or None,
db=settings.redis_db,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True,
client_name=f"domain-api-workerctl:{settings.node_code}:{os.getpid()}",
)
def _domain_root() -> Path:
return Path(settings.domain_root)
@@ -37,6 +134,125 @@ def _run_shell(command: list[str], timeout: int = 20) -> subprocess.CompletedPro
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
def _parse_process_count_output(result: subprocess.CompletedProcess[str]) -> int | None:
raw_output = (result.stdout or "").strip()
if raw_output.isdigit():
return max(0, int(raw_output or 0))
return None
def _probe_linux_worker_process_count() -> int:
probe_commands = (
(["bash", "-lc", "pgrep -fc '[d]etect_worker.py' || true"], 8),
(["bash", "-lc", "ps -eo args= | grep '[d]etect_worker.py' | wc -l"], 12),
)
for command, timeout in probe_commands:
try:
result = _run_shell(command, timeout=timeout)
except Exception:
continue
parsed_count = _parse_process_count_output(result)
if parsed_count is not None:
return parsed_count
return 0
def _probe_linux_worker_instance_count(service_name: str) -> int:
normalized_service_name = str(service_name or "").strip()
if not normalized_service_name:
return 0
template_prefix = normalized_service_name[:-8] if normalized_service_name.endswith(".service") else normalized_service_name
try:
result = _run_systemctl(
[
"list-units",
f"{template_prefix}@*",
"--type=service",
"--all",
"--no-legend",
"--plain",
],
timeout=12,
require_sudo=False,
)
except Exception:
return 0
if result.returncode != 0:
return 0
count = 0
for raw_line in (result.stdout or "").splitlines():
line = str(raw_line or "").strip()
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
if parts[2] != "active" or parts[3] != "running":
continue
count += 1
return count
def _dedupe_units(units: list[str]) -> list[str]:
deduped: list[str] = []
seen: set[str] = set()
for item in units:
normalized = str(item or "").strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
return deduped
def _list_linux_worker_instance_units(service_name: str) -> list[str]:
normalized_service_name = str(service_name or "").strip()
if not normalized_service_name:
return []
template_prefix = normalized_service_name[:-8] if normalized_service_name.endswith(".service") else normalized_service_name
units: list[str] = []
try:
result = _run_systemctl(
[
"list-units",
f"{template_prefix}@*",
"--type=service",
"--all",
"--no-legend",
"--plain",
],
timeout=12,
require_sudo=False,
)
for raw_line in (result.stdout or "").splitlines():
parts = str(raw_line or "").strip().split()
if parts:
units.append(str(parts[0] or "").strip())
except Exception:
pass
managed_prefix = f"{normalized_service_name}-"
try:
for candidate in Path("/etc/default").iterdir():
if not candidate.is_file():
continue
if not candidate.name.startswith(managed_prefix):
continue
suffix = str(candidate.name[len(managed_prefix):] or "").strip()
if suffix:
units.append(f"{normalized_service_name}@{suffix}")
except Exception:
pass
return _dedupe_units(units)
def _expand_linux_worker_control_units(service_name: str) -> list[str]:
normalized_service_name = str(service_name or "").strip()
if not normalized_service_name:
return []
return _dedupe_units([normalized_service_name, *_list_linux_worker_instance_units(normalized_service_name)])
def _run_systemctl(
command: list[str],
timeout: int = 20,
@@ -192,7 +408,36 @@ def _windows_runtime() -> dict:
def _linux_runtime() -> dict:
runtime = _runtime_config()
service_name = runtime["worker_service_name"]
return probe_systemd_service(service_name, mode="linux-systemd")
service_probe = probe_systemd_service(service_name, mode="linux-systemd")
instance_count = _probe_linux_worker_instance_count(service_name)
process_count = _probe_linux_worker_process_count()
if process_count <= 0 and instance_count <= 0:
return service_probe
latest_start_time = str(service_probe.get("latest_start_time") or "").strip()
if service_probe.get("running", False) or instance_count > 0:
message = service_probe.get("message") or f"multi-instance active ({process_count})"
if instance_count > 0 and not service_probe.get("running", False):
message = f"template instances active ({instance_count})"
return {
**service_probe,
"running": True,
"process_count": process_count,
"latest_start_time": latest_start_time,
"message": message,
}
message = str(service_probe.get("message") or "").strip()
if message:
message = f"{message}; detected {process_count} unmanaged worker processes"
else:
message = f"detected {process_count} unmanaged worker processes"
return {
**service_probe,
"process_count": process_count,
"latest_start_time": latest_start_time,
"message": message,
}
def detect_sync_agent_runtime() -> dict:
@@ -232,9 +477,13 @@ def start_worker() -> tuple[bool, str]:
worker_mode = runtime["worker_mode"]
service_name = runtime["worker_service_name"]
if worker_mode == "linux-systemd":
result = _run_systemctl(["start", service_name], timeout=30)
units = _expand_linux_worker_control_units(service_name)
result = _run_systemctl(["start", *units], timeout=max(30, 15 * max(1, len(units))))
if result.returncode != 0:
return False, normalize_systemctl_error(result.stderr or result.stdout or "启动 Linux Worker 失败", service_name=service_name)
extra_units = max(0, len(units) - 1)
if extra_units > 0:
return True, f"Linux Worker 启动命令已发送: {service_name},附带 {extra_units} 个实例"
return True, f"Linux Worker 启动命令已发送: {service_name}"
if os.name != "nt":
@@ -260,9 +509,13 @@ def stop_worker() -> tuple[bool, str]:
worker_mode = runtime["worker_mode"]
service_name = runtime["worker_service_name"]
if worker_mode == "linux-systemd":
result = _run_systemctl(["stop", service_name], timeout=30)
units = _expand_linux_worker_control_units(service_name)
result = _run_systemctl(["stop", *units], timeout=max(30, 15 * max(1, len(units))))
if result.returncode != 0:
return False, normalize_systemctl_error(result.stderr or result.stdout or "停止 Linux Worker 失败", service_name=service_name)
extra_units = max(0, len(units) - 1)
if extra_units > 0:
return True, f"Linux Worker 停止命令已发送: {service_name},附带 {extra_units} 个实例"
return True, f"Linux Worker 停止命令已发送: {service_name}"
if os.name != "nt":
@@ -289,15 +542,42 @@ def stop_worker() -> tuple[bool, str]:
def send_worker_command(action: str, payload: dict | None = None) -> tuple[bool, str]:
command_payload = {"action": action}
if payload:
command_payload.update(payload)
runtime = _runtime_config()
explicit_targets = _normalize_target_node_codes(command_payload)
if not explicit_targets and str(runtime.get("worker_mode") or "").strip() == "linux-systemd":
expanded_targets = _expand_local_linux_worker_target_node_codes(
str(runtime.get("worker_service_name") or "").strip()
)
if expanded_targets:
command_payload["target_node_codes"] = expanded_targets
command_payload["request_id"] = str(command_payload.get("request_id") or f"workerctl-{uuid4().hex[:12]}")
serialized = json.dumps(command_payload, ensure_ascii=False)
pending_keys = _pending_command_keys(command_payload)
target_node_codes = _normalize_target_node_codes(command_payload)
direct_client = None
try:
redis_client = get_redis()
command_payload = {"action": action}
if payload:
command_payload.update(payload)
command_payload["request_id"] = str(command_payload.get("request_id") or f"workerctl-{uuid4().hex[:12]}")
serialized = json.dumps(command_payload, ensure_ascii=False)
redis_client.set(WORKER_PENDING_COMMAND_KEY, serialized, ex=120)
redis_client.publish(WORKER_CONTROL_CHANNEL, serialized)
return True, f"已发送 Worker 控制指令: {action}"
try:
_publish_worker_command(get_redis(), serialized=serialized, pending_keys=pending_keys)
except Exception:
direct_client = _build_direct_redis_client()
_publish_worker_command(direct_client, serialized=serialized, pending_keys=pending_keys)
if pending_keys == [WORKER_PENDING_COMMAND_KEY]:
return True, f"已发送 Worker 控制指令: {action}"
if len(target_node_codes) <= 4:
target_summary = ",".join(target_node_codes)
else:
target_summary = f"{len(target_node_codes)} targets"
return True, f"已发送 Worker 控制指令: {action} -> {target_summary}"
except Exception as exc:
return False, f"发送 Worker 控制指令失败: {exc}"
finally:
if direct_client is not None:
try:
direct_client.close()
except Exception:
pass