feat: stabilize multi-region runtime sync and worker orchestration
This commit is contained in:
@@ -164,6 +164,9 @@ def _expected_route_paths() -> dict[str, str]:
|
||||
"ops_contracts": f"{prefix}/ops/contracts",
|
||||
"ops_contract_detail": f"{prefix}/ops/contracts/{{contract_key}}",
|
||||
"ops_stack_diagnosis": f"{prefix}/ops/stack-diagnosis",
|
||||
"ops_migration_source_profile": f"{prefix}/ops/migration/source-profile",
|
||||
"ops_migration_preview": f"{prefix}/ops/migration/preview",
|
||||
"ops_migration_execute": f"{prefix}/ops/migration/execute",
|
||||
"ops_node_handover": f"{prefix}/ops/nodes/{{node_code}}/handover",
|
||||
"ops_node_onboarding": f"{prefix}/ops/nodes/{{node_code}}/onboarding",
|
||||
"ops_node_onboarding_bootstrap_preview": f"{prefix}/ops/nodes/{{node_code}}/onboarding/bootstrap/preview",
|
||||
|
||||
@@ -5,6 +5,8 @@ import socket
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import db_read_retry, get_db
|
||||
|
||||
@@ -63,6 +65,22 @@ CREATE TABLE IF NOT EXISTS detect_job_items (
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_status_lease
|
||||
ON detect_job_items(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_ready
|
||||
ON detect_job_items(status, create_time, id)
|
||||
WHERE step_code <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_job_ready
|
||||
ON detect_job_items(job_id, status, create_time, id)
|
||||
WHERE step_code <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_step_ready
|
||||
ON detect_job_items(status, step_code, lease_expires_at, create_time, id)
|
||||
WHERE step_code <> '' AND status IN ('pending', 'failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_claim_job_step_ready
|
||||
ON detect_job_items(job_id, status, step_code, lease_expires_at, create_time, id)
|
||||
WHERE step_code <> '' AND status IN ('pending', 'failed');
|
||||
|
||||
ALTER TABLE detect_jobs
|
||||
ADD COLUMN IF NOT EXISTS task_mode VARCHAR(32) NOT NULL DEFAULT 'domain_pipeline',
|
||||
ADD COLUMN IF NOT EXISTS step_code VARCHAR(64) NOT NULL DEFAULT '';
|
||||
@@ -108,11 +126,100 @@ CREATE TABLE IF NOT EXISTS detect_sync_records (
|
||||
|
||||
_STALE_AFTER_SECONDS = 90
|
||||
_OFFLINE_AFTER_MINUTES = 5
|
||||
_IMPORTED_RUNTIME_STALE_AFTER_MINUTES = 10
|
||||
_IMPORTED_RUNTIME_OFFLINE_AFTER_MINUTES = 30
|
||||
_PRUNE_IMPORTED_AFTER_MINUTES = 30
|
||||
_PRUNE_GENERAL_AFTER_HOURS = 6
|
||||
_RUNTIME_SCHEMA_READY = False
|
||||
_RUNTIME_SCHEMA_LOCK = threading.Lock()
|
||||
_RUNTIME_SCHEMA_ADVISORY_LOCK_ID = 62021001
|
||||
_RUNTIME_SCHEMA_INDEX_ADVISORY_LOCK_ID = 62021002
|
||||
_DISABLED_MANAGED_NODE_CACHE: set[str] = set()
|
||||
_DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = 0.0
|
||||
_RUNTIME_REQUIRED_TABLES = (
|
||||
"detect_worker_nodes",
|
||||
"detect_jobs",
|
||||
"detect_job_items",
|
||||
"detect_run_events",
|
||||
"detect_sync_records",
|
||||
)
|
||||
_RUNTIME_REQUIRED_COLUMNS = {
|
||||
"detect_jobs": {"task_mode", "step_code"},
|
||||
"detect_job_items": {"step_code", "step_payload_json", "result_payload_json"},
|
||||
}
|
||||
_RUNTIME_REQUIRED_INDEXES = (
|
||||
"idx_detect_job_items_job_domain_step",
|
||||
"idx_detect_job_items_claim_step_ready",
|
||||
"idx_detect_job_items_claim_job_step_ready",
|
||||
"idx_detect_sync_records_scope_created",
|
||||
"idx_detect_sync_records_source_record_created",
|
||||
"idx_detect_sync_records_source_record_hash_created",
|
||||
"idx_detect_sync_records_runtime_push_lookup",
|
||||
)
|
||||
_RUNTIME_REQUIRED_INDEX_TABLES = {
|
||||
"idx_detect_job_items_job_domain_step": "detect_job_items",
|
||||
"idx_detect_job_items_claim_step_ready": "detect_job_items",
|
||||
"idx_detect_job_items_claim_job_step_ready": "detect_job_items",
|
||||
"idx_detect_sync_records_scope_created": "detect_sync_records",
|
||||
"idx_detect_sync_records_source_record_created": "detect_sync_records",
|
||||
"idx_detect_sync_records_source_record_hash_created": "detect_sync_records",
|
||||
"idx_detect_sync_records_runtime_push_lookup": "detect_sync_records",
|
||||
}
|
||||
_RUNTIME_REQUIRED_INDEX_DDL = {
|
||||
"idx_detect_job_items_job_domain_step": """
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_job_domain_step
|
||||
ON detect_job_items(job_id, domain_id, step_code)
|
||||
""",
|
||||
"idx_detect_job_items_claim_step_ready": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_claim_step_ready
|
||||
ON detect_job_items(status, step_code, lease_expires_at, create_time, id)
|
||||
WHERE step_code <> '' AND status IN ('pending', 'failed')
|
||||
""",
|
||||
"idx_detect_job_items_claim_job_step_ready": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_claim_job_step_ready
|
||||
ON detect_job_items(job_id, status, step_code, lease_expires_at, create_time, id)
|
||||
WHERE step_code <> '' AND status IN ('pending', 'failed')
|
||||
""",
|
||||
"idx_detect_sync_records_scope_created": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_scope_created
|
||||
ON detect_sync_records(sync_type, source_region, target_region, created_at DESC, id DESC)
|
||||
""",
|
||||
"idx_detect_sync_records_source_record_created": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_source_record_created
|
||||
ON detect_sync_records(
|
||||
sync_type,
|
||||
source_region,
|
||||
target_region,
|
||||
((payload_json->>'source_record_id')),
|
||||
created_at DESC,
|
||||
id DESC
|
||||
)
|
||||
""",
|
||||
"idx_detect_sync_records_source_record_hash_created": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_source_record_hash_created
|
||||
ON detect_sync_records(
|
||||
sync_type,
|
||||
source_region,
|
||||
target_region,
|
||||
((payload_json->>'source_record_id')),
|
||||
((payload_json->>'projection_hash')),
|
||||
created_at DESC,
|
||||
id DESC
|
||||
)
|
||||
""",
|
||||
"idx_detect_sync_records_runtime_push_lookup": """
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_sync_records_runtime_push_lookup
|
||||
ON detect_sync_records(
|
||||
source_region,
|
||||
target_region,
|
||||
((payload_json->>'sync_type')),
|
||||
((payload_json->>'source_record_id')),
|
||||
created_at DESC,
|
||||
id DESC
|
||||
)
|
||||
WHERE sync_type = 'runtime_push'
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_local_ip() -> str:
|
||||
@@ -133,6 +240,17 @@ def _decode_json(value: object) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_runtime_timestamp(value: object) -> datetime | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
normalized = raw.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _control_node_supports_worker(*, region: object, metadata: dict | None) -> bool:
|
||||
normalized_region = str(region or "").strip()
|
||||
runtime_metadata = dict(metadata or {})
|
||||
@@ -198,6 +316,35 @@ def _load_managed_node_overlays() -> dict[str, dict]:
|
||||
return overlays
|
||||
|
||||
|
||||
def _load_disabled_managed_node_codes() -> set[str]:
|
||||
global _DISABLED_MANAGED_NODE_CACHE, _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT
|
||||
now_ts = datetime.now().timestamp()
|
||||
if now_ts < _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT:
|
||||
return set(_DISABLED_MANAGED_NODE_CACHE)
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code
|
||||
FROM ops_managed_nodes
|
||||
WHERE is_enabled = FALSE
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
return set(_DISABLED_MANAGED_NODE_CACHE)
|
||||
|
||||
disabled_codes = {
|
||||
str(row[0] or "").strip()
|
||||
for row in list(rows or [])
|
||||
if str(row[0] or "").strip()
|
||||
}
|
||||
_DISABLED_MANAGED_NODE_CACHE = disabled_codes
|
||||
_DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = now_ts + 5.0
|
||||
return set(disabled_codes)
|
||||
|
||||
|
||||
def ensure_runtime_schema() -> None:
|
||||
global _RUNTIME_SCHEMA_READY
|
||||
if _RUNTIME_SCHEMA_READY:
|
||||
@@ -206,14 +353,131 @@ def ensure_runtime_schema() -> None:
|
||||
if _RUNTIME_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_RUNTIME_SCHEMA_ADVISORY_LOCK_ID,))
|
||||
cur.execute(_RUNTIME_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
if _runtime_schema_basics_present(cur):
|
||||
missing_indexes = list(_runtime_missing_indexes(cur))
|
||||
if not missing_indexes:
|
||||
_RUNTIME_SCHEMA_READY = True
|
||||
return
|
||||
else:
|
||||
missing_indexes = []
|
||||
if missing_indexes:
|
||||
_ensure_runtime_schema_indexes(missing_indexes)
|
||||
with conn.cursor() as cur:
|
||||
if _runtime_schema_basics_present(cur) and not list(_runtime_missing_indexes(cur)):
|
||||
_RUNTIME_SCHEMA_READY = True
|
||||
return
|
||||
else:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_RUNTIME_SCHEMA_ADVISORY_LOCK_ID,))
|
||||
cur.execute(_RUNTIME_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
recoverable = isinstance(exc, (errors.DeadlockDetected, errors.LockNotAvailable))
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if not recoverable:
|
||||
raise
|
||||
with conn.cursor() as cur:
|
||||
if not _runtime_schema_basics_present(cur):
|
||||
raise
|
||||
_RUNTIME_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _runtime_schema_basics_present(cur) -> bool:
|
||||
for table_name in _RUNTIME_REQUIRED_TABLES:
|
||||
cur.execute("SELECT to_regclass(%s)", (f"public.{table_name}",))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return False
|
||||
|
||||
for table_name, required_columns in _RUNTIME_REQUIRED_COLUMNS.items():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
existing_columns = {str(row[0] or "").strip() for row in list(cur.fetchall() or [])}
|
||||
if not set(required_columns).issubset(existing_columns):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _runtime_missing_indexes(cur):
|
||||
states = _runtime_index_states(cur)
|
||||
for index_name in _RUNTIME_REQUIRED_INDEXES:
|
||||
if not bool((states.get(index_name) or {}).get("valid")):
|
||||
yield index_name
|
||||
|
||||
|
||||
def _runtime_index_states(cur) -> dict[str, dict[str, bool]]:
|
||||
table_names = sorted(set(_RUNTIME_REQUIRED_INDEX_TABLES.values()))
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
idx.relname AS index_name,
|
||||
pg_index.indisvalid AS is_valid,
|
||||
pg_index.indisready AS is_ready,
|
||||
pg_index.indislive AS is_live
|
||||
FROM pg_class AS idx
|
||||
JOIN pg_index ON pg_index.indexrelid = idx.oid
|
||||
JOIN pg_class AS tbl ON tbl.oid = pg_index.indrelid
|
||||
JOIN pg_namespace AS ns ON ns.oid = tbl.relnamespace
|
||||
WHERE ns.nspname = 'public'
|
||||
AND tbl.relname = ANY(%s)
|
||||
AND idx.relname = ANY(%s)
|
||||
""",
|
||||
(table_names, list(_RUNTIME_REQUIRED_INDEXES)),
|
||||
)
|
||||
states = {
|
||||
index_name: {"valid": False, "ready": False, "live": False}
|
||||
for index_name in _RUNTIME_REQUIRED_INDEXES
|
||||
}
|
||||
for row in list(cur.fetchall() or []):
|
||||
index_name = str(row[0] or "").strip()
|
||||
if index_name not in states:
|
||||
continue
|
||||
states[index_name] = {
|
||||
"valid": bool(row[1]),
|
||||
"ready": bool(row[2]),
|
||||
"live": bool(row[3]),
|
||||
}
|
||||
return states
|
||||
|
||||
|
||||
def _ensure_runtime_schema_indexes(index_names: list[str] | tuple[str, ...]) -> None:
|
||||
normalized_indexes = [
|
||||
index_name
|
||||
for index_name in list(index_names or [])
|
||||
if str(index_name or "").strip() in _RUNTIME_REQUIRED_INDEX_DDL
|
||||
]
|
||||
if not normalized_indexes:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_try_advisory_lock(%s)", (_RUNTIME_SCHEMA_INDEX_ADVISORY_LOCK_ID,))
|
||||
row = cur.fetchone()
|
||||
if not bool((row or [False])[0]):
|
||||
return
|
||||
try:
|
||||
current_states = _runtime_index_states(cur)
|
||||
for index_name in normalized_indexes:
|
||||
index_state = current_states.get(index_name) or {}
|
||||
if bool(index_state.get("valid")):
|
||||
continue
|
||||
if bool(index_state.get("ready")) or bool(index_state.get("live")):
|
||||
cur.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {index_name}")
|
||||
cur.execute(_RUNTIME_REQUIRED_INDEX_DDL[index_name])
|
||||
finally:
|
||||
cur.execute("SELECT pg_advisory_unlock(%s)", (_RUNTIME_SCHEMA_INDEX_ADVISORY_LOCK_ID,))
|
||||
|
||||
|
||||
def register_node_heartbeat(
|
||||
*,
|
||||
node_code: str,
|
||||
@@ -351,14 +615,11 @@ def prune_expired_runtime_nodes() -> None:
|
||||
|
||||
|
||||
def register_local_control_heartbeat() -> None:
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
worker_runtime = detect_worker_runtime()
|
||||
worker_online = bool(worker_runtime.get("running", False))
|
||||
detect_status = get_detect_status()
|
||||
active_job = get_active_detect_job_summary(event_limit=5) or {}
|
||||
worker_online = bool(detect_status.get("worker_online", False))
|
||||
active_job = dict(detect_status.get("active_job") or {})
|
||||
node_stats = list(active_job.get("node_stats") or [])
|
||||
local_bucket = next(
|
||||
(item for item in node_stats if str(item.get("node_code") or "").strip() == settings.node_code),
|
||||
@@ -385,7 +646,7 @@ def register_local_control_heartbeat() -> None:
|
||||
"api_port": settings.api_port,
|
||||
"worker_mode": settings.worker_mode,
|
||||
"worker_online": worker_online,
|
||||
"worker_process_count": int(worker_runtime.get("process_count", 0) or 0),
|
||||
"worker_process_count": int(detect_status.get("worker_process_count", 0) or 0),
|
||||
"detect_participating": detect_participating,
|
||||
"active_job_code": str(active_job.get("job_code") or ""),
|
||||
"active_job_status": str(active_job.get("status") or ""),
|
||||
@@ -395,6 +656,15 @@ def register_local_control_heartbeat() -> None:
|
||||
"job_items_completed": items_completed,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"available_proxy_count": int(detect_status.get("available_proxy_count", 0) or 0),
|
||||
"proxy_runtime_label": str(detect_status.get("proxy_runtime_label") or "").strip(),
|
||||
"proxy_runtime_reason": str(detect_status.get("proxy_runtime_reason") or "").strip(),
|
||||
"proxy_last_refresh_status": str(detect_status.get("proxy_last_refresh_status") or "").strip(),
|
||||
"proxy_last_refresh_time": str(detect_status.get("proxy_last_refresh_time") or "").strip(),
|
||||
"proxy_last_refresh_source_count": int(detect_status.get("proxy_last_refresh_source_count", 0) or 0),
|
||||
"proxy_last_refresh_total_items": int(detect_status.get("proxy_last_refresh_total_items", 0) or 0),
|
||||
"proxy_last_validated_count": int(detect_status.get("proxy_last_validated_count", 0) or 0),
|
||||
"proxy_last_available_count": int(detect_status.get("proxy_last_available_count", 0) or 0),
|
||||
"phase_label": str(detect_status.get("phase_label") or ""),
|
||||
"phase_detail": str(detect_status.get("phase_detail") or ""),
|
||||
"updated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
@@ -402,15 +672,42 @@ def register_local_control_heartbeat() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_node_status(raw_status: str, last_heartbeat_at: datetime | None) -> str:
|
||||
def _resolve_effective_runtime_heartbeat(
|
||||
*,
|
||||
metadata: dict | None,
|
||||
last_heartbeat_at: datetime | None,
|
||||
update_time: datetime | None,
|
||||
) -> datetime | None:
|
||||
effective_last_heartbeat = last_heartbeat_at
|
||||
runtime_metadata = dict(metadata or {})
|
||||
if str(runtime_metadata.get("service") or "").strip() != "runtime-ingest":
|
||||
return effective_last_heartbeat
|
||||
|
||||
metadata_updated_at = _parse_runtime_timestamp(runtime_metadata.get("updated_at"))
|
||||
for candidate in (update_time, metadata_updated_at):
|
||||
if not candidate:
|
||||
continue
|
||||
if effective_last_heartbeat is None or candidate > effective_last_heartbeat:
|
||||
effective_last_heartbeat = candidate
|
||||
return effective_last_heartbeat
|
||||
|
||||
|
||||
def _normalize_node_status(raw_status: str, last_heartbeat_at: datetime | None, *, metadata: dict | None = None) -> str:
|
||||
status = str(raw_status or "").strip() or "unknown"
|
||||
if not last_heartbeat_at:
|
||||
return status
|
||||
now = datetime.now(last_heartbeat_at.tzinfo) if last_heartbeat_at.tzinfo else datetime.now()
|
||||
age = now - last_heartbeat_at
|
||||
if age > timedelta(minutes=_OFFLINE_AFTER_MINUTES):
|
||||
runtime_metadata = dict(metadata or {})
|
||||
service_name = str(runtime_metadata.get("service") or "").strip()
|
||||
stale_after = timedelta(seconds=_STALE_AFTER_SECONDS)
|
||||
offline_after = timedelta(minutes=_OFFLINE_AFTER_MINUTES)
|
||||
if service_name == "runtime-ingest":
|
||||
stale_after = timedelta(minutes=_IMPORTED_RUNTIME_STALE_AFTER_MINUTES)
|
||||
offline_after = timedelta(minutes=_IMPORTED_RUNTIME_OFFLINE_AFTER_MINUTES)
|
||||
if age > offline_after:
|
||||
return "offline"
|
||||
if age > timedelta(seconds=_STALE_AFTER_SECONDS):
|
||||
if age > stale_after:
|
||||
return "stale"
|
||||
return status
|
||||
|
||||
@@ -420,11 +717,12 @@ def get_cluster_snapshot() -> dict:
|
||||
prune_expired_runtime_nodes()
|
||||
register_local_control_heartbeat()
|
||||
managed_overlays = _load_managed_node_overlays()
|
||||
disabled_node_codes = _load_disabled_managed_node_codes()
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, region, role, hostname, ip, status, worker_version, current_load, metadata_json, last_heartbeat_at
|
||||
SELECT node_code, region, role, hostname, ip, status, worker_version, current_load, metadata_json, last_heartbeat_at, update_time
|
||||
FROM detect_worker_nodes
|
||||
ORDER BY
|
||||
CASE WHEN status = 'busy' THEN 0 WHEN status = 'online' THEN 1 ELSE 2 END,
|
||||
@@ -434,14 +732,33 @@ def get_cluster_snapshot() -> dict:
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
rows = [
|
||||
row
|
||||
for row in list(cur.fetchall() or [])
|
||||
if str((row or [""])[0] or "").strip() not in disabled_node_codes
|
||||
]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, region, role, hostname, ip, status, worker_version, current_load, metadata_json, last_heartbeat_at, update_time
|
||||
FROM detect_worker_nodes
|
||||
ORDER BY
|
||||
CASE WHEN status = 'busy' THEN 0 WHEN status = 'online' THEN 1 ELSE 2 END,
|
||||
region ASC,
|
||||
role ASC,
|
||||
node_code ASC
|
||||
"""
|
||||
)
|
||||
summary_rows = [
|
||||
row
|
||||
for row in list(cur.fetchall() or [])
|
||||
if str((row or [""])[0] or "").strip() not in disabled_node_codes
|
||||
]
|
||||
cur.execute("SELECT count(*) FROM detect_jobs")
|
||||
jobs_total = cur.fetchone()[0]
|
||||
cur.execute("SELECT count(*) FROM detect_job_items WHERE status IN ('pending', 'claimed', 'running')")
|
||||
active_items = cur.fetchone()[0]
|
||||
|
||||
nodes = []
|
||||
for row in rows:
|
||||
def _build_node_payload(row: tuple) -> dict:
|
||||
node_code = str(row[0] or "").strip()
|
||||
metadata = _decode_json(row[8])
|
||||
current_load = int(row[7] or 0)
|
||||
@@ -452,14 +769,20 @@ def get_cluster_snapshot() -> dict:
|
||||
metadata["detect_participating"] = False
|
||||
metadata["sanitized_runtime_state"] = "idle_phase_zeroed"
|
||||
runtime_last_heartbeat = row[9]
|
||||
runtime_update_time = row[10]
|
||||
managed_overlay = managed_overlays.get(node_code) or {}
|
||||
managed_last_seen = managed_overlay.get("last_seen_at")
|
||||
effective_last_heartbeat = _resolve_effective_runtime_heartbeat(
|
||||
metadata=metadata,
|
||||
last_heartbeat_at=runtime_last_heartbeat,
|
||||
update_time=runtime_update_time,
|
||||
)
|
||||
overlay_is_newer = bool(
|
||||
managed_last_seen
|
||||
and (not runtime_last_heartbeat or managed_last_seen > runtime_last_heartbeat)
|
||||
and (not effective_last_heartbeat or managed_last_seen > effective_last_heartbeat)
|
||||
)
|
||||
effective_last_heartbeat = managed_last_seen if overlay_is_newer else runtime_last_heartbeat
|
||||
normalized_status = _normalize_node_status(row[5], effective_last_heartbeat)
|
||||
effective_last_heartbeat = managed_last_seen if overlay_is_newer else effective_last_heartbeat
|
||||
normalized_status = _normalize_node_status(row[5], effective_last_heartbeat, metadata=metadata)
|
||||
if sanitized_idle_runtime and normalized_status == "busy":
|
||||
normalized_status = "online"
|
||||
if overlay_is_newer and normalized_status in {"offline", "stale"}:
|
||||
@@ -468,22 +791,23 @@ def get_cluster_snapshot() -> dict:
|
||||
metadata["agent_last_seen_at"] = managed_last_seen.isoformat(sep=" ", timespec="seconds")
|
||||
if overlay_is_newer:
|
||||
metadata["cluster_status_source"] = "managed-agent-overlay"
|
||||
nodes.append(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"region": row[1],
|
||||
"role": row[2],
|
||||
"hostname": row[3],
|
||||
"ip": row[4],
|
||||
"status": normalized_status,
|
||||
"worker_version": row[6],
|
||||
"current_load": current_load,
|
||||
"metadata": metadata,
|
||||
"last_heartbeat_at": effective_last_heartbeat.isoformat(sep=" ", timespec="seconds")
|
||||
if effective_last_heartbeat
|
||||
else "",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"node_code": node_code,
|
||||
"region": row[1],
|
||||
"role": row[2],
|
||||
"hostname": row[3],
|
||||
"ip": row[4],
|
||||
"status": normalized_status,
|
||||
"worker_version": row[6],
|
||||
"current_load": current_load,
|
||||
"metadata": metadata,
|
||||
"last_heartbeat_at": effective_last_heartbeat.isoformat(sep=" ", timespec="seconds")
|
||||
if effective_last_heartbeat
|
||||
else "",
|
||||
}
|
||||
|
||||
nodes = [_build_node_payload(row) for row in rows]
|
||||
summary_nodes = [_build_node_payload(row) for row in summary_rows]
|
||||
status_counts: dict[str, int] = {}
|
||||
role_counts: dict[str, int] = {}
|
||||
region_counts: dict[str, int] = {}
|
||||
@@ -494,7 +818,7 @@ def get_cluster_snapshot() -> dict:
|
||||
dedicated_online_worker_nodes = 0
|
||||
online_control_nodes = 0
|
||||
|
||||
for node in nodes:
|
||||
for node in summary_nodes:
|
||||
node_status = str(node.get("status") or "unknown")
|
||||
node_role = str(node.get("role") or "unknown")
|
||||
node_region = str(node.get("region") or "unknown")
|
||||
@@ -534,9 +858,25 @@ def get_cluster_snapshot() -> dict:
|
||||
effective_worker and (metadata.get("detect_participating", False) or node_current_load > 0)
|
||||
)
|
||||
|
||||
summary_node_map = {
|
||||
str(item.get("node_code") or "").strip(): item
|
||||
for item in summary_nodes
|
||||
if str(item.get("node_code") or "").strip()
|
||||
}
|
||||
for node in nodes:
|
||||
summary_node = summary_node_map.get(str(node.get("node_code") or "").strip())
|
||||
if not summary_node:
|
||||
continue
|
||||
node["current_load"] = summary_node.get("current_load", node.get("current_load", 0))
|
||||
node["status"] = summary_node.get("status", node.get("status", "unknown"))
|
||||
node["metadata"] = summary_node.get("metadata", node.get("metadata") or {})
|
||||
node["last_heartbeat_at"] = summary_node.get("last_heartbeat_at", node.get("last_heartbeat_at", ""))
|
||||
node["is_effective_worker"] = bool(summary_node.get("is_effective_worker", False))
|
||||
node["detect_participating"] = bool(summary_node.get("detect_participating", False))
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"nodes_total": len(nodes),
|
||||
"nodes_total": len(summary_nodes),
|
||||
"jobs_total": jobs_total,
|
||||
"active_job_items": active_items,
|
||||
"summary": {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.cluster_runtime_service import get_cluster_snapshot
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.detect_job_service import (
|
||||
_build_step_bucket,
|
||||
order_step_buckets,
|
||||
get_active_detect_job_summary,
|
||||
get_detect_capacity_plan,
|
||||
get_detect_queue_health,
|
||||
)
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
|
||||
def _empty_active_jobs_aggregate(window_minutes: int) -> dict:
|
||||
@@ -93,16 +98,7 @@ def _merge_step_queues_with_runtime_activity(
|
||||
round(processed_recent / safe_window_minutes, 2),
|
||||
)
|
||||
|
||||
return sorted(
|
||||
step_map.values(),
|
||||
key=lambda item: (
|
||||
-int(item.get("items_pending", 0) or 0),
|
||||
-int(item.get("items_running", 0) or 0),
|
||||
-int(item.get("started_recent", 0) or 0),
|
||||
-int(item.get("processed_recent", 0) or 0),
|
||||
str(item.get("step_code") or ""),
|
||||
),
|
||||
)[:normalized_limit]
|
||||
return order_step_buckets(list(step_map.values()), limit=normalized_limit)
|
||||
|
||||
|
||||
def _align_active_jobs_aggregate_with_runtime(
|
||||
@@ -169,6 +165,58 @@ def _align_active_jobs_aggregate_with_runtime(
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_dashboard_runtime_summary(*, queue_health: dict) -> dict:
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_runtime = detect_worker_runtime()
|
||||
worker_expected_on_this_node = not (
|
||||
str(settings.node_region or "").strip() == "overseas"
|
||||
and str(settings.node_role or "").strip() == "control"
|
||||
)
|
||||
return {
|
||||
"node": {
|
||||
"region": settings.node_region,
|
||||
"role": settings.node_role,
|
||||
},
|
||||
"worker": {
|
||||
"running": bool(worker_runtime.get("running", False)),
|
||||
"mode": worker_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||
"expected_on_this_node": worker_expected_on_this_node,
|
||||
},
|
||||
"cluster": get_cluster_snapshot(),
|
||||
"detect": {
|
||||
"backlog": dict((queue_health or {}).get("runtime_snapshot_backlog") or {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_server_code(node_code: str | None) -> str:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return ""
|
||||
parent_node_code, separator, suffix = normalized_node_code.rpartition("-")
|
||||
if separator and parent_node_code and suffix.isalpha() and len(suffix) <= 3:
|
||||
if any(char.isdigit() for char in parent_node_code):
|
||||
return parent_node_code
|
||||
return normalized_node_code
|
||||
|
||||
|
||||
def _count_active_execution_servers(rows: list[dict] | None) -> int:
|
||||
active_servers: set[str] = set()
|
||||
for item in list(rows or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
server_code = _resolve_server_code(item.get("node_code"))
|
||||
if not server_code:
|
||||
continue
|
||||
if (
|
||||
int(item.get("items_running", 0) or 0) > 0
|
||||
or int(item.get("items_claimed", 0) or 0) > 0
|
||||
or int(item.get("processed_recent", 0) or 0) > 0
|
||||
):
|
||||
active_servers.add(server_code)
|
||||
return len(active_servers)
|
||||
|
||||
|
||||
def _fetch_active_jobs_aggregate(window_minutes: int = 15) -> dict:
|
||||
safe_window_minutes = max(5, min(int(window_minutes or 15), 120))
|
||||
payload = _empty_active_jobs_aggregate(safe_window_minutes)
|
||||
@@ -303,7 +351,7 @@ def _fetch_active_jobs_aggregate(window_minutes: int = 15) -> dict:
|
||||
}
|
||||
)
|
||||
steps.append(bucket)
|
||||
payload["steps"] = steps
|
||||
payload["steps"] = order_step_buckets(steps, limit=8)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -409,8 +457,28 @@ def fetch_overview() -> dict:
|
||||
active_jobs_aggregate = _fetch_active_jobs_aggregate(window_minutes=window_minutes)
|
||||
active_job = get_active_detect_job_summary(event_limit=20) or {}
|
||||
aggregate_queue = active_jobs_aggregate.get("queue") or {}
|
||||
active_job_display_claimed = int(active_job.get("display_items_claimed", active_job.get("items_claimed", 0)) or 0)
|
||||
active_job_display_running = int(
|
||||
active_job.get("display_items_running", active_job.get("display_active_threads", active_job.get("items_running", 0)))
|
||||
or 0
|
||||
)
|
||||
active_job_display_active_threads = int(
|
||||
active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0)))
|
||||
or 0
|
||||
)
|
||||
active_job_display_max_threads = int(active_job.get("display_max_threads", 0) or 0)
|
||||
active_job_distributed_node_stats = [
|
||||
dict(item)
|
||||
for item in list(active_job.get("distributed_node_stats") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
runtime = get_runtime_status()
|
||||
queue_health = get_detect_queue_health(window_minutes=window_minutes)
|
||||
runtime = _build_dashboard_runtime_summary(queue_health=queue_health)
|
||||
try:
|
||||
detect_status = get_detect_status()
|
||||
except Exception:
|
||||
detect_status = {}
|
||||
cluster_summary = ((runtime.get("cluster") or {}).get("summary") or {})
|
||||
online_worker_nodes = int(cluster_summary.get("online_worker_nodes", 0) or 0)
|
||||
dedicated_online_worker_nodes = int(cluster_summary.get("dedicated_online_worker_nodes", 0) or 0)
|
||||
@@ -425,7 +493,6 @@ def fetch_overview() -> dict:
|
||||
result["node_region"] = runtime["node"]["region"]
|
||||
result["node_role"] = runtime["node"]["role"]
|
||||
|
||||
queue_health = get_detect_queue_health(window_minutes=window_minutes)
|
||||
active_jobs_aggregate = _align_active_jobs_aggregate_with_runtime(
|
||||
active_jobs_aggregate,
|
||||
runtime=runtime,
|
||||
@@ -460,6 +527,25 @@ def fetch_overview() -> dict:
|
||||
throughput_payload = queue_health.get("throughput") or {}
|
||||
runtime_job_code = str(job_payload.get("runtime_job_code") or "").strip()
|
||||
display_job_code = runtime_job_code or str(job_payload.get("job_code") or "")
|
||||
active_job_matches_display_job = display_job_code == str(active_job.get("job_code") or "").strip()
|
||||
queue_nodes = [
|
||||
dict(item)
|
||||
for item in list(queue_health.get("nodes") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
summary_display_running = max(
|
||||
int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
active_job_display_running if active_job_matches_display_job else 0,
|
||||
)
|
||||
summary_display_active_threads = max(
|
||||
int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
active_job_display_active_threads if active_job_matches_display_job else 0,
|
||||
)
|
||||
summary_display_max_threads = max(
|
||||
sum(int(item.get("max_threads", 0) or 0) for item in queue_nodes),
|
||||
active_job_display_max_threads if active_job_matches_display_job else 0,
|
||||
)
|
||||
summary_distributed_node_stats = active_job_distributed_node_stats if active_job_matches_display_job else queue_nodes
|
||||
active_job_summary = {
|
||||
"job_id": int(job_payload.get("job_id", 0) or 0),
|
||||
"job_code": display_job_code,
|
||||
@@ -469,9 +555,19 @@ def fetch_overview() -> dict:
|
||||
"progress_percent": float(job_payload.get("progress_percent", 0) or 0),
|
||||
"items_total": int(queue_payload.get("items_total", 0) or 0),
|
||||
"items_pending": int(queue_payload.get("pending", 0) or 0),
|
||||
"items_claimed": int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0),
|
||||
"items_running": int(queue_payload.get("running", 0) or 0),
|
||||
"items_display_running": int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
"items_claimed": max(
|
||||
int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0),
|
||||
active_job_display_claimed if active_job_matches_display_job else 0,
|
||||
),
|
||||
"items_running": max(
|
||||
int(queue_payload.get("running", 0) or 0),
|
||||
int(active_job.get("items_running", 0) or 0) if active_job_matches_display_job else 0,
|
||||
),
|
||||
"items_display_running": summary_display_running,
|
||||
"display_items_running": summary_display_running,
|
||||
"display_active_threads": summary_display_active_threads,
|
||||
"display_max_threads": summary_display_max_threads,
|
||||
"distributed_node_stats": summary_distributed_node_stats,
|
||||
"items_completed": int(queue_payload.get("completed", 0) or 0),
|
||||
"items_blacklisted": int(queue_payload.get("blacklisted", 0) or 0),
|
||||
"items_failed": int(queue_payload.get("failed", 0) or 0),
|
||||
@@ -482,6 +578,33 @@ def fetch_overview() -> dict:
|
||||
"blacklisted_recent": int(throughput_payload.get("blacklisted_recent", 0) or 0),
|
||||
"active_jobs_total": int(active_jobs_aggregate.get("active_jobs_total", 0) or 0),
|
||||
}
|
||||
elif active_job:
|
||||
active_job_summary = {
|
||||
"job_id": int(active_job.get("job_id", 0) or 0),
|
||||
"job_code": str(active_job.get("job_code") or active_job.get("runtime_job_code") or ""),
|
||||
"db_job_code": str(active_job.get("job_code") or ""),
|
||||
"runtime_job_code": str(active_job.get("runtime_job_code") or ""),
|
||||
"status": str(active_job.get("status") or ""),
|
||||
"progress_percent": float(active_job.get("progress_percent", 0) or 0),
|
||||
"items_total": int(active_job.get("items_total", 0) or 0),
|
||||
"items_pending": int(active_job.get("items_pending", 0) or 0),
|
||||
"items_claimed": active_job_display_claimed,
|
||||
"items_running": int(active_job.get("items_running", 0) or 0),
|
||||
"items_display_running": active_job_display_running,
|
||||
"display_items_running": active_job_display_running,
|
||||
"display_active_threads": active_job_display_active_threads,
|
||||
"display_max_threads": active_job_display_max_threads,
|
||||
"distributed_node_stats": active_job_distributed_node_stats,
|
||||
"items_completed": int(active_job.get("items_completed", 0) or 0),
|
||||
"items_blacklisted": int(active_job.get("items_blacklisted", 0) or 0),
|
||||
"items_failed": int(active_job.get("items_failed", 0) or 0),
|
||||
"processed_per_minute": float((active_jobs_aggregate.get("throughput") or {}).get("processed_per_minute", 0) or 0),
|
||||
"processed_recent": int((active_jobs_aggregate.get("throughput") or {}).get("processed_recent", 0) or 0),
|
||||
"completed_recent": int((active_jobs_aggregate.get("throughput") or {}).get("completed_recent", 0) or 0),
|
||||
"failed_recent": int((active_jobs_aggregate.get("throughput") or {}).get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int((active_jobs_aggregate.get("throughput") or {}).get("blacklisted_recent", 0) or 0),
|
||||
"active_jobs_total": int(active_jobs_aggregate.get("active_jobs_total", 0) or 0),
|
||||
}
|
||||
|
||||
queue_pending_total = 0
|
||||
queue_claimed_total = 0
|
||||
@@ -514,9 +637,15 @@ def fetch_overview() -> dict:
|
||||
if queue_health.get("has_active_job"):
|
||||
queue_payload = queue_health.get("queue") or {}
|
||||
queue_pending_total = int(queue_payload.get("pending", 0) or 0)
|
||||
queue_claimed_total = int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0)
|
||||
queue_running_total = int(queue_payload.get("running", 0) or 0)
|
||||
queue_display_running_total = int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0)
|
||||
queue_claimed_total = max(
|
||||
int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0),
|
||||
active_job_display_claimed,
|
||||
)
|
||||
queue_running_total = max(int(queue_payload.get("running", 0) or 0), int(active_job.get("items_running", 0) or 0))
|
||||
queue_display_running_total = max(
|
||||
int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
active_job_display_running,
|
||||
)
|
||||
queue_completed_total = int(queue_payload.get("completed", 0) or 0)
|
||||
queue_blacklist_total = int(queue_payload.get("blacklisted", 0) or 0)
|
||||
queue_failed_total = int(queue_payload.get("failed", 0) or 0)
|
||||
@@ -558,7 +687,7 @@ def fetch_overview() -> dict:
|
||||
}
|
||||
for item in list(active_jobs_aggregate.get("steps") or [])[:8]
|
||||
]
|
||||
aggregate_node_throughput = [
|
||||
aggregate_node_throughput_all = [
|
||||
{
|
||||
"node_code": str(item.get("node_code") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
@@ -574,8 +703,9 @@ def fetch_overview() -> dict:
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
}
|
||||
for item in list(active_jobs_aggregate.get("nodes") or [])[:8]
|
||||
for item in list(active_jobs_aggregate.get("nodes") or [])
|
||||
]
|
||||
aggregate_node_throughput = aggregate_node_throughput_all[:8]
|
||||
queue_step_queue = [
|
||||
{
|
||||
"step_code": str(item.get("step_code") or ""),
|
||||
@@ -600,7 +730,7 @@ def fetch_overview() -> dict:
|
||||
limit=8,
|
||||
)
|
||||
]
|
||||
queue_node_throughput = [
|
||||
queue_node_throughput_all = [
|
||||
{
|
||||
"node_code": str(item.get("node_code") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
@@ -616,15 +746,18 @@ def fetch_overview() -> dict:
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
}
|
||||
for item in list(queue_health.get("nodes") or [])[:8]
|
||||
for item in list(queue_health.get("nodes") or [])
|
||||
]
|
||||
queue_node_throughput = queue_node_throughput_all[:8]
|
||||
step_queue = aggregate_step_queue
|
||||
node_throughput = aggregate_node_throughput
|
||||
active_execution_node_source = aggregate_node_throughput_all
|
||||
aggregate_ppm = float((active_jobs_aggregate.get("throughput") or {}).get("processed_per_minute", 0) or 0)
|
||||
queue_ppm = float((queue_health.get("throughput") or {}).get("processed_per_minute", 0) or 0)
|
||||
if queue_health.get("has_active_job") or queue_ppm > aggregate_ppm:
|
||||
step_queue = queue_step_queue
|
||||
node_throughput = queue_node_throughput
|
||||
active_execution_node_source = queue_node_throughput_all
|
||||
if step_queue:
|
||||
bottleneck_step = max(
|
||||
step_queue,
|
||||
@@ -668,13 +801,7 @@ def fetch_overview() -> dict:
|
||||
"recommended_additional_workers": int(capacity_plan.get("recommended_additional_workers", 0) or 0),
|
||||
"online_worker_nodes": online_worker_nodes,
|
||||
"dedicated_online_worker_nodes": dedicated_online_worker_nodes,
|
||||
"active_execution_nodes": sum(
|
||||
1
|
||||
for item in node_throughput
|
||||
if int(item.get("items_running", 0) or 0) > 0
|
||||
or int(item.get("items_claimed", 0) or 0) > 0
|
||||
or int(item.get("processed_recent", 0) or 0) > 0
|
||||
),
|
||||
"active_execution_nodes": _count_active_execution_servers(active_execution_node_source),
|
||||
}
|
||||
result["processed_per_minute"] = ops_processed_per_minute
|
||||
result["processed_recent"] = ops_processed_recent
|
||||
@@ -686,12 +813,26 @@ def fetch_overview() -> dict:
|
||||
result["queue_claimed_total"] = queue_claimed_total
|
||||
result["queue_running_total"] = queue_running_total
|
||||
result["queue_display_running_total"] = max(queue_display_running_total, queue_running_total)
|
||||
result["queue_display_max_threads"] = max(
|
||||
int((active_job_summary or {}).get("display_max_threads", 0) or 0),
|
||||
sum(int(item.get("max_threads", 0) or 0) for item in list(queue_health.get("nodes") or []) if isinstance(item, dict)),
|
||||
)
|
||||
result["queue_completed_total"] = queue_completed_total
|
||||
result["queue_blacklist_total"] = queue_blacklist_total
|
||||
result["queue_failed_total"] = queue_failed_total
|
||||
result["current_job_blacklisted"] = queue_blacklist_total
|
||||
result["recent_blacklisted_total"] = ops_blacklisted_recent
|
||||
result["cumulative_blacklisted_total"] = int(result.get("blacklist_total", 0) or 0)
|
||||
result["backlog_pending_total"] = max(backlog_pending_total, queue_pending_total)
|
||||
result["backlog_claimed_total"] = max(backlog_claimed_total, queue_claimed_total)
|
||||
result["backlog_running_total"] = max(backlog_running_total, queue_running_total)
|
||||
result["backlog_register_pending_total"] = backlog_register_pending_total
|
||||
result["backlog_downstream_pending_total"] = backlog_downstream_pending_total
|
||||
result["cluster_proxy_available_count"] = int(detect_status.get("available_proxy_count", 0) or 0)
|
||||
result["cluster_proxy_runtime_label"] = str(detect_status.get("proxy_runtime_label") or "").strip()
|
||||
result["cluster_proxy_runtime_detail"] = str(detect_status.get("proxy_runtime_detail") or "").strip()
|
||||
result["cluster_proxy_last_refresh_status"] = str(detect_status.get("proxy_last_refresh_status") or "").strip()
|
||||
result["aggregate_process_count"] = int(detect_status.get("aggregate_process_count", 0) or 0)
|
||||
result["aggregate_participating_node_count"] = int(detect_status.get("aggregate_participating_node_count", 0) or 0)
|
||||
result["aggregate_active_thread_count"] = int(detect_status.get("active_thread_count", 0) or 0)
|
||||
return result
|
||||
|
||||
@@ -242,6 +242,105 @@ def _normalize_worker_log_event(debug_event: dict) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_debug_event_job_identity(payload: dict | None) -> dict:
|
||||
normalized_payload = dict(payload or {}) if isinstance(payload, dict) else {}
|
||||
nested_job = normalized_payload.get("job") if isinstance(normalized_payload.get("job"), dict) else {}
|
||||
|
||||
raw_job_id = normalized_payload.get("job_id")
|
||||
if raw_job_id in (None, "", 0, "0"):
|
||||
raw_job_id = normalized_payload.get("target_job_id")
|
||||
if raw_job_id in (None, "", 0, "0"):
|
||||
raw_job_id = nested_job.get("job_id")
|
||||
try:
|
||||
job_id = int(raw_job_id or 0)
|
||||
except Exception:
|
||||
job_id = 0
|
||||
|
||||
job_code = str(
|
||||
normalized_payload.get("job_code")
|
||||
or normalized_payload.get("target_job_code")
|
||||
or nested_job.get("job_code")
|
||||
or ""
|
||||
).strip()
|
||||
cycle_token = str(normalized_payload.get("cycle_token") or nested_job.get("cycle_token") or "").strip()
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"job_code": job_code,
|
||||
"cycle_token": cycle_token,
|
||||
"has_identity": bool(job_id > 0 or job_code),
|
||||
}
|
||||
|
||||
|
||||
def _load_detect_job_summary_by_job_code(job_code: str, *, event_limit: int = 1) -> dict | None:
|
||||
normalized_job_code = str(job_code or "").strip()
|
||||
if not normalized_job_code:
|
||||
return None
|
||||
|
||||
from app.services.detect_job_service import get_detect_job_summary
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM detect_jobs
|
||||
WHERE job_code = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_job_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return get_detect_job_summary(int(row[0]), event_limit=event_limit)
|
||||
|
||||
|
||||
def _resolve_target_job_for_debug_event(event_payload: dict | None) -> tuple[dict | None, str]:
|
||||
from app.services.detect_job_service import get_active_detect_job_summary, get_detect_job_summary
|
||||
|
||||
identity = _normalize_debug_event_job_identity(event_payload)
|
||||
if not identity["has_identity"]:
|
||||
return None, "missing_job_identity"
|
||||
|
||||
payload_job_id = int(identity["job_id"] or 0)
|
||||
payload_job_code = str(identity["job_code"] or "").strip()
|
||||
payload_cycle_token = str(identity["cycle_token"] or "").strip()
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=1) or {}
|
||||
active_job_id = int(active_job.get("job_id") or 0)
|
||||
active_job_code = str(active_job.get("job_code") or active_job.get("runtime_job_code") or "").strip()
|
||||
|
||||
target_job: dict | None = None
|
||||
if payload_job_id > 0:
|
||||
if active_job_id == payload_job_id:
|
||||
target_job = active_job
|
||||
else:
|
||||
target_job = get_detect_job_summary(payload_job_id, event_limit=1)
|
||||
elif payload_job_code:
|
||||
if active_job_code and active_job_code == payload_job_code:
|
||||
target_job = active_job
|
||||
else:
|
||||
target_job = _load_detect_job_summary_by_job_code(payload_job_code, event_limit=1)
|
||||
|
||||
if not target_job:
|
||||
return None, "job_not_found"
|
||||
|
||||
target_job_id = int(target_job.get("job_id") or 0)
|
||||
target_job_code = str(target_job.get("job_code") or target_job.get("runtime_job_code") or "").strip()
|
||||
target_cycle_token = str(target_job.get("current_cycle_token") or "").strip()
|
||||
|
||||
if payload_job_id > 0 and target_job_id > 0 and target_job_id != payload_job_id:
|
||||
return None, "job_mismatch"
|
||||
if payload_job_code and target_job_code and target_job_code != payload_job_code:
|
||||
return None, "job_mismatch"
|
||||
if payload_cycle_token and target_cycle_token and payload_cycle_token != target_cycle_token:
|
||||
return None, "cycle_mismatch"
|
||||
|
||||
return target_job, "matched"
|
||||
|
||||
|
||||
def _ingest_worker_log_into_active_job(debug_event: dict) -> dict:
|
||||
if str(debug_event.get("event_type") or "").strip() != "worker_log":
|
||||
return {"imported": False, "reason": "not_worker_log"}
|
||||
@@ -249,15 +348,16 @@ def _ingest_worker_log_into_active_job(debug_event: dict) -> dict:
|
||||
normalized_event = _normalize_worker_log_event(debug_event)
|
||||
if not normalized_event:
|
||||
return {"imported": False, "reason": "not_domain_progress_event"}
|
||||
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
from app.services.sync_push_service import (
|
||||
_apply_detect_result_event_to_domain,
|
||||
_apply_detect_result_event_to_job_item,
|
||||
)
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=1) or {}
|
||||
target_job_id = int(active_job.get("job_id") or 0)
|
||||
target_job, resolve_reason = _resolve_target_job_for_debug_event(normalized_event.get("payload"))
|
||||
if not target_job:
|
||||
return {"imported": False, "reason": resolve_reason}
|
||||
|
||||
target_job_id = int(target_job.get("job_id") or 0)
|
||||
if target_job_id <= 0:
|
||||
return {"imported": False, "reason": "no_active_job"}
|
||||
|
||||
@@ -334,6 +434,7 @@ def _ingest_worker_log_into_active_job(debug_event: dict) -> dict:
|
||||
"imported": True,
|
||||
"reason": "imported",
|
||||
"target_job_id": target_job_id,
|
||||
"target_job_code": str(target_job.get("job_code") or ""),
|
||||
"detect_run_event_id": detect_run_event_id,
|
||||
"updated_job_items": updated_job_items,
|
||||
"event_type": normalized_event["event_type"],
|
||||
@@ -847,7 +948,9 @@ def get_debug_handoff_report(
|
||||
def ingest_debug_event(payload: dict, *, shared_token: str | None = None) -> tuple[bool, str, dict]:
|
||||
configured_token = str(settings.sync_shared_token or "").strip()
|
||||
incoming_token = str(shared_token or "").strip()
|
||||
if configured_token and incoming_token != configured_token:
|
||||
if not configured_token:
|
||||
return False, "调试事件共享 token 未配置,拒绝远端写入", {"configuration_required": True}
|
||||
if incoming_token != configured_token:
|
||||
return False, "调试事件 token 校验失败", {}
|
||||
|
||||
record_id = append_debug_event(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -10,6 +11,7 @@ _MAX_LOG_LINES = 240
|
||||
_LOG_TAIL_LINES = 1200
|
||||
_ACTIVE_STATUSES = {"starting", "running", "stopping"}
|
||||
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S")
|
||||
_DETECT_RUNS_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -234,192 +236,196 @@ def _sync_record(
|
||||
|
||||
|
||||
def create_detect_run_snapshot(message: str, runtime: dict, progress: dict, settings_summary: dict) -> dict:
|
||||
records = _load()
|
||||
active = _find_active(records)
|
||||
current_logs = _capture_worker_logs()
|
||||
if active:
|
||||
if active.get("status") == "stopping" and runtime.get("running"):
|
||||
active["status"] = "running"
|
||||
with _DETECT_RUNS_LOCK:
|
||||
records = _load()
|
||||
active = _find_active(records)
|
||||
current_logs = _capture_worker_logs()
|
||||
if active:
|
||||
if active.get("status") == "stopping" and runtime.get("running"):
|
||||
active["status"] = "running"
|
||||
_sync_record(
|
||||
active,
|
||||
status=active.get("status", "starting"),
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
)
|
||||
_save(records)
|
||||
return dict(active)
|
||||
|
||||
initial_started_at = runtime.get("latest_start_time") or _now()
|
||||
record = {
|
||||
"run_id": uuid4().hex,
|
||||
"status": "starting",
|
||||
"message": message,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"started_at": initial_started_at,
|
||||
"completed_at": "",
|
||||
"runtime": runtime,
|
||||
"progress": progress,
|
||||
"settings_summary": settings_summary,
|
||||
"phase_label": "",
|
||||
"phase_detail": "",
|
||||
"phase_history": [],
|
||||
"logs": [],
|
||||
}
|
||||
_sync_record(
|
||||
active,
|
||||
status=active.get("status", "starting"),
|
||||
record,
|
||||
status="starting",
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
)
|
||||
records.insert(0, record)
|
||||
_save(records)
|
||||
return dict(active)
|
||||
|
||||
initial_started_at = runtime.get("latest_start_time") or _now()
|
||||
record = {
|
||||
"run_id": uuid4().hex,
|
||||
"status": "starting",
|
||||
"message": message,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"started_at": initial_started_at,
|
||||
"completed_at": "",
|
||||
"runtime": runtime,
|
||||
"progress": progress,
|
||||
"settings_summary": settings_summary,
|
||||
"phase_label": "",
|
||||
"phase_detail": "",
|
||||
"phase_history": [],
|
||||
"logs": [],
|
||||
}
|
||||
_sync_record(
|
||||
record,
|
||||
status="starting",
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
)
|
||||
records.insert(0, record)
|
||||
_save(records)
|
||||
return dict(record)
|
||||
return dict(record)
|
||||
|
||||
|
||||
def finalize_detect_run(message: str, runtime: dict, progress: dict, settings_summary: dict, active_job: dict | None = None) -> dict | None:
|
||||
records = _load()
|
||||
target = _find_active(records)
|
||||
if not target:
|
||||
return None
|
||||
final_status = "stopped" if target.get("status") == "stopping" else "failed"
|
||||
_sync_record(
|
||||
target,
|
||||
status=final_status,
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=_capture_worker_logs(),
|
||||
active_job=active_job,
|
||||
)
|
||||
_save(records)
|
||||
return dict(target)
|
||||
|
||||
|
||||
def sync_detect_runs(runtime: dict, progress: dict, settings_summary: dict, active_job: dict | None = None) -> list[dict]:
|
||||
records = _load()
|
||||
active = _find_active(records)
|
||||
current_logs = _capture_worker_logs()
|
||||
active_job = active_job or {}
|
||||
runtime_detecting = bool(runtime.get("detecting", False))
|
||||
active_job_status = str(active_job.get("status", "") or "").strip()
|
||||
active_job_open = active_job_status in {"pending", "running"}
|
||||
execution_active = runtime_detecting or active_job_open or int((progress or {}).get("running", 0) or 0) > 0
|
||||
|
||||
if runtime.get("running") and execution_active:
|
||||
if active and not _same_session(active, runtime):
|
||||
_sync_record(
|
||||
active,
|
||||
status="stopped",
|
||||
message="检测服务已重启,上一轮会话已归档",
|
||||
runtime=active.get("runtime") or runtime,
|
||||
progress=active.get("progress") or progress,
|
||||
settings_summary=active.get("settings_summary") or settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active.get("active_job") or active_job,
|
||||
)
|
||||
active = None
|
||||
if active:
|
||||
next_status = "running" if active.get("status") != "stopping" else "stopping"
|
||||
_sync_record(
|
||||
active,
|
||||
status=next_status,
|
||||
message=runtime.get("message") or active.get("message") or "检测服务运行中",
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active_job,
|
||||
)
|
||||
else:
|
||||
started_at = runtime.get("latest_start_time") or _now()
|
||||
record = {
|
||||
"run_id": uuid4().hex,
|
||||
"status": "running",
|
||||
"message": runtime.get("message") or "检测服务运行中",
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"started_at": started_at,
|
||||
"completed_at": "",
|
||||
"runtime": runtime,
|
||||
"progress": progress,
|
||||
"settings_summary": settings_summary,
|
||||
"phase_label": "",
|
||||
"phase_detail": "",
|
||||
"phase_history": [],
|
||||
"logs": [],
|
||||
"active_job": active_job,
|
||||
}
|
||||
_sync_record(
|
||||
record,
|
||||
status="running",
|
||||
message=record["message"],
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active_job,
|
||||
)
|
||||
records.insert(0, record)
|
||||
elif active:
|
||||
if runtime.get("running") and not execution_active:
|
||||
if active.get("status") == "stopping":
|
||||
final_status = "stopped"
|
||||
final_message = runtime.get("message") or "检测任务已停止,Worker 保持待命"
|
||||
elif active_job_status == "partial_failed":
|
||||
final_status = "partial_failed"
|
||||
final_message = "检测任务已结束,存在部分失败项"
|
||||
elif active_job_status == "failed":
|
||||
final_status = "failed"
|
||||
final_message = "检测任务已结束,任务结果为失败"
|
||||
else:
|
||||
final_status = "completed"
|
||||
final_message = "检测任务已自然完成,Worker 保持待命"
|
||||
else:
|
||||
final_status = "stopped" if active.get("status") == "stopping" else "failed"
|
||||
final_message = runtime.get("message") or ("检测服务已停止" if final_status == "stopped" else "检测服务异常退出")
|
||||
with _DETECT_RUNS_LOCK:
|
||||
records = _load()
|
||||
target = _find_active(records)
|
||||
if not target:
|
||||
return None
|
||||
final_status = "stopped" if target.get("status") == "stopping" else "failed"
|
||||
_sync_record(
|
||||
active,
|
||||
target,
|
||||
status=final_status,
|
||||
message=final_message,
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
log_lines=_capture_worker_logs(),
|
||||
active_job=active_job,
|
||||
)
|
||||
_save(records)
|
||||
return dict(target)
|
||||
|
||||
if records:
|
||||
records[0]["logs"] = _merge_logs(
|
||||
records[0].get("logs"),
|
||||
_filter_logs_since(current_logs, records[0].get("started_at")),
|
||||
)
|
||||
|
||||
_save(records)
|
||||
return records
|
||||
def sync_detect_runs(runtime: dict, progress: dict, settings_summary: dict, active_job: dict | None = None) -> list[dict]:
|
||||
with _DETECT_RUNS_LOCK:
|
||||
records = _load()
|
||||
active = _find_active(records)
|
||||
current_logs = _capture_worker_logs()
|
||||
active_job = active_job or {}
|
||||
runtime_detecting = bool(runtime.get("detecting", False))
|
||||
active_job_status = str(active_job.get("status", "") or "").strip()
|
||||
active_job_open = active_job_status in {"pending", "running"}
|
||||
execution_active = runtime_detecting or active_job_open or int((progress or {}).get("running", 0) or 0) > 0
|
||||
|
||||
if runtime.get("running") and execution_active:
|
||||
if active and not _same_session(active, runtime):
|
||||
_sync_record(
|
||||
active,
|
||||
status="stopped",
|
||||
message="检测服务已重启,上一轮会话已归档",
|
||||
runtime=active.get("runtime") or runtime,
|
||||
progress=active.get("progress") or progress,
|
||||
settings_summary=active.get("settings_summary") or settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active.get("active_job") or active_job,
|
||||
)
|
||||
active = None
|
||||
if active:
|
||||
next_status = "running" if active.get("status") != "stopping" else "stopping"
|
||||
_sync_record(
|
||||
active,
|
||||
status=next_status,
|
||||
message=runtime.get("message") or active.get("message") or "检测服务运行中",
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active_job,
|
||||
)
|
||||
else:
|
||||
started_at = runtime.get("latest_start_time") or _now()
|
||||
record = {
|
||||
"run_id": uuid4().hex,
|
||||
"status": "running",
|
||||
"message": runtime.get("message") or "检测服务运行中",
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"started_at": started_at,
|
||||
"completed_at": "",
|
||||
"runtime": runtime,
|
||||
"progress": progress,
|
||||
"settings_summary": settings_summary,
|
||||
"phase_label": "",
|
||||
"phase_detail": "",
|
||||
"phase_history": [],
|
||||
"logs": [],
|
||||
"active_job": active_job,
|
||||
}
|
||||
_sync_record(
|
||||
record,
|
||||
status="running",
|
||||
message=record["message"],
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active_job,
|
||||
)
|
||||
records.insert(0, record)
|
||||
elif active:
|
||||
if runtime.get("running") and not execution_active:
|
||||
if active.get("status") == "stopping":
|
||||
final_status = "stopped"
|
||||
final_message = runtime.get("message") or "检测任务已停止,Worker 保持待命"
|
||||
elif active_job_status == "partial_failed":
|
||||
final_status = "partial_failed"
|
||||
final_message = "检测任务已结束,存在部分失败项"
|
||||
elif active_job_status == "failed":
|
||||
final_status = "failed"
|
||||
final_message = "检测任务已结束,任务结果为失败"
|
||||
else:
|
||||
final_status = "completed"
|
||||
final_message = "检测任务已自然完成,Worker 保持待命"
|
||||
else:
|
||||
final_status = "stopped" if active.get("status") == "stopping" else "failed"
|
||||
final_message = runtime.get("message") or ("检测服务已停止" if final_status == "stopped" else "检测服务异常退出")
|
||||
_sync_record(
|
||||
active,
|
||||
status=final_status,
|
||||
message=final_message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=current_logs,
|
||||
active_job=active_job,
|
||||
)
|
||||
|
||||
if records:
|
||||
records[0]["logs"] = _merge_logs(
|
||||
records[0].get("logs"),
|
||||
_filter_logs_since(current_logs, records[0].get("started_at")),
|
||||
)
|
||||
|
||||
_save(records)
|
||||
return records
|
||||
|
||||
|
||||
def mark_detect_run_stopping(message: str, runtime: dict, progress: dict, settings_summary: dict) -> dict | None:
|
||||
records = _load()
|
||||
target = _find_active(records)
|
||||
if not target:
|
||||
return None
|
||||
_sync_record(
|
||||
target,
|
||||
status="stopping",
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=_capture_worker_logs(),
|
||||
active_job=target.get("active_job") or {},
|
||||
)
|
||||
_save(records)
|
||||
return dict(target)
|
||||
with _DETECT_RUNS_LOCK:
|
||||
records = _load()
|
||||
target = _find_active(records)
|
||||
if not target:
|
||||
return None
|
||||
_sync_record(
|
||||
target,
|
||||
status="stopping",
|
||||
message=message,
|
||||
runtime=runtime,
|
||||
progress=progress,
|
||||
settings_summary=settings_summary,
|
||||
log_lines=_capture_worker_logs(),
|
||||
active_job=target.get("active_job") or {},
|
||||
)
|
||||
_save(records)
|
||||
return dict(target)
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
@@ -12,13 +14,16 @@ from app.services.debug_event_service import list_debug_events
|
||||
from app.services.cluster_runtime_service import ensure_runtime_schema
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.detect_run_service import sync_detect_runs
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
from app.services.settings_service import get_settings_payload, resolve_thread_count
|
||||
from app.services.detect_job_service import get_active_detect_job_summary, get_detect_queue_health
|
||||
from app.services.settings_service import get_settings_payload, resolve_process_count, resolve_thread_count
|
||||
from app.services.sync_record_service import append_detect_result_projection_if_changed
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
|
||||
_PROXY_COUNT_RE = re.compile(r"当前可用代理数[::]\s*(\d+)")
|
||||
_PROXY_REFRESH_COUNT_RE = re.compile(r"代理池刷新完成,共\s*(\d+)\s*个可用代理")
|
||||
_PROXY_CACHE_COUNT_RE = re.compile(r"继续沿用缓存\s*(\d+)\s*个")
|
||||
_PROXY_SHARED_SNAPSHOT_COUNT_RE = re.compile(r"(?:复用共享代理快照|共享代理快照)\s*(\d+)\s*个")
|
||||
_THREAD_COUNT_RE = re.compile(r"当前实际线程数量[::]\s*(\d+)\s*/\s*(\d+)")
|
||||
_STEP_TRACE_DOMAIN_RE = re.compile(r"domain=([^\s|]+)")
|
||||
_REGISTER_DOMAIN_RE = re.compile(r"检测注册状态[::]\s*([^\s]+)")
|
||||
@@ -38,6 +43,89 @@ _REMOTE_DEBUG_EVENT_TYPES = {
|
||||
"task_pull_failed",
|
||||
"queue_overdue_leases",
|
||||
}
|
||||
_DETECT_STATUS_CACHE_LOCK = threading.Lock()
|
||||
_DETECT_STATUS_CACHE_TTL_SECONDS = 3.0
|
||||
_DETECT_STATUS_CACHE_VALUE: dict | None = None
|
||||
_DETECT_STATUS_CACHE_EXPIRES_AT = 0.0
|
||||
_AGGREGATE_RUNTIME_NODE_STALE_AFTER = timedelta(seconds=90)
|
||||
_DISABLED_MANAGED_NODE_CACHE: set[str] = set()
|
||||
_DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = 0.0
|
||||
|
||||
|
||||
def _clone_detect_status_payload(value: dict | None) -> dict:
|
||||
try:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False))
|
||||
except Exception:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
def _extract_debug_event_job_identity(payload: dict | None) -> dict:
|
||||
normalized_payload = dict(payload or {}) if isinstance(payload, dict) else {}
|
||||
nested_job = normalized_payload.get("job") if isinstance(normalized_payload.get("job"), dict) else {}
|
||||
|
||||
raw_job_id = normalized_payload.get("job_id")
|
||||
if raw_job_id in (None, "", 0, "0"):
|
||||
raw_job_id = normalized_payload.get("target_job_id")
|
||||
if raw_job_id in (None, "", 0, "0"):
|
||||
raw_job_id = nested_job.get("job_id")
|
||||
try:
|
||||
job_id = int(raw_job_id or 0)
|
||||
except Exception:
|
||||
job_id = 0
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"job_code": str(
|
||||
normalized_payload.get("job_code")
|
||||
or normalized_payload.get("target_job_code")
|
||||
or nested_job.get("job_code")
|
||||
or ""
|
||||
).strip(),
|
||||
"cycle_token": str(normalized_payload.get("cycle_token") or nested_job.get("cycle_token") or "").strip(),
|
||||
"has_identity": bool(job_id > 0 or str(
|
||||
normalized_payload.get("job_code")
|
||||
or normalized_payload.get("target_job_code")
|
||||
or nested_job.get("job_code")
|
||||
or ""
|
||||
).strip()),
|
||||
}
|
||||
|
||||
|
||||
def _debug_event_matches_active_job(record: dict, active_job: dict | None) -> bool:
|
||||
normalized_active_job = dict(active_job or {})
|
||||
active_job_id = int(normalized_active_job.get("job_id") or 0)
|
||||
active_job_code = str(
|
||||
normalized_active_job.get("runtime_job_code")
|
||||
or normalized_active_job.get("job_code")
|
||||
or ""
|
||||
).strip()
|
||||
active_cycle_token = str(normalized_active_job.get("current_cycle_token") or "").strip()
|
||||
|
||||
if active_job_id <= 0 and not active_job_code and not active_cycle_token:
|
||||
return True
|
||||
|
||||
identity = _extract_debug_event_job_identity(record.get("payload"))
|
||||
if not identity["has_identity"]:
|
||||
return False
|
||||
|
||||
event_job_id = int(identity["job_id"] or 0)
|
||||
event_job_code = str(identity["job_code"] or "").strip()
|
||||
event_cycle_token = str(identity["cycle_token"] or "").strip()
|
||||
|
||||
if active_cycle_token and event_cycle_token and event_cycle_token != active_cycle_token:
|
||||
return False
|
||||
if active_job_id > 0 and event_job_id > 0 and event_job_id != active_job_id:
|
||||
return False
|
||||
if active_job_code and event_job_code and event_job_code != active_job_code:
|
||||
return False
|
||||
|
||||
if active_job_id > 0 and event_job_id == active_job_id:
|
||||
return True
|
||||
if active_job_code and event_job_code and event_job_code == active_job_code:
|
||||
return True
|
||||
if active_cycle_token and event_cycle_token and event_cycle_token == active_cycle_token:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_remote_log_node_code(line: str) -> str:
|
||||
@@ -95,6 +183,225 @@ def _runtime_state_key(node_code: str | None = None) -> str:
|
||||
return f"{_RUNTIME_STATE_KEY}:{normalized_node_code}"
|
||||
|
||||
|
||||
def _local_worker_expected_on_this_node() -> bool:
|
||||
return not (
|
||||
str(settings.node_region or "").strip() == "overseas"
|
||||
and str(settings.node_role or "").strip() == "control"
|
||||
)
|
||||
|
||||
|
||||
def _int_value(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _max_runtime_metric(*values: object) -> int:
|
||||
return max((_int_value(value) for value in values), default=0)
|
||||
|
||||
|
||||
def _resolve_capacity_node_code(node_code: str, settings_payload: dict) -> tuple[str, bool]:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return "", False
|
||||
|
||||
node_thread_counts = dict(settings_payload.get("node_thread_counts") or {})
|
||||
node_process_counts = dict(settings_payload.get("node_process_counts") or {})
|
||||
parent_node_code, separator, suffix = normalized_node_code.rpartition("-")
|
||||
if (
|
||||
separator
|
||||
and parent_node_code
|
||||
and suffix.isalpha()
|
||||
and len(suffix) <= 3
|
||||
and (
|
||||
parent_node_code in node_thread_counts
|
||||
or parent_node_code in node_process_counts
|
||||
or bool(re.search(r"\d$", parent_node_code))
|
||||
)
|
||||
):
|
||||
return parent_node_code, True
|
||||
|
||||
if normalized_node_code in node_thread_counts or normalized_node_code in node_process_counts:
|
||||
return normalized_node_code, False
|
||||
return normalized_node_code, False
|
||||
|
||||
|
||||
def _is_current_participant_bucket(item: dict | None) -> bool:
|
||||
payload = dict(item or {})
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code or node_code == "unassigned":
|
||||
return False
|
||||
return any(
|
||||
_int_value(payload.get(field)) > 0
|
||||
for field in ("items_claimed", "items_running", "display_running", "current_load", "active_threads")
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_runtime_node_is_live(item: dict | None) -> bool:
|
||||
payload = dict(item or {})
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
return False
|
||||
if node_code == "unassigned":
|
||||
return True
|
||||
status = str(payload.get("status") or "").strip().lower()
|
||||
if status in {"stale", "offline"}:
|
||||
return False
|
||||
last_heartbeat_at = _parse_time(payload.get("last_heartbeat_at"))
|
||||
if last_heartbeat_at is None:
|
||||
return True
|
||||
reference_now = datetime.now(last_heartbeat_at.tzinfo) if last_heartbeat_at.tzinfo else datetime.now()
|
||||
return (reference_now - last_heartbeat_at) <= _AGGREGATE_RUNTIME_NODE_STALE_AFTER
|
||||
|
||||
|
||||
def _filter_live_aggregate_runtime_nodes(node_rows: list[dict] | None) -> list[dict]:
|
||||
disabled_node_codes = _load_disabled_managed_node_codes(
|
||||
[
|
||||
str(item.get("node_code") or "").strip()
|
||||
for item in list(node_rows or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
)
|
||||
return [
|
||||
dict(item)
|
||||
for item in list(node_rows or [])
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("node_code") or "").strip() not in disabled_node_codes
|
||||
and _aggregate_runtime_node_is_live(item)
|
||||
]
|
||||
|
||||
|
||||
def _build_aggregate_detect_capacity(*, active_job: dict | None, settings_payload: dict) -> dict:
|
||||
node_rows = _filter_live_aggregate_runtime_nodes(
|
||||
list((active_job or {}).get("distributed_node_stats") or (active_job or {}).get("node_stats") or [])
|
||||
)
|
||||
node_thread_counts = dict(settings_payload.get("node_thread_counts") or {})
|
||||
node_process_counts = dict(settings_payload.get("node_process_counts") or {})
|
||||
participant_node_codes: list[str] = []
|
||||
process_count_total = 0
|
||||
max_threads_total = 0
|
||||
representative_thread_count = 0
|
||||
child_parent_codes: set[str] = set()
|
||||
|
||||
for raw_item in node_rows:
|
||||
if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item):
|
||||
continue
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
capacity_node_code, is_child_instance = _resolve_capacity_node_code(node_code, settings_payload)
|
||||
if is_child_instance and capacity_node_code:
|
||||
child_parent_codes.add(capacity_node_code)
|
||||
|
||||
for raw_item in node_rows:
|
||||
if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item):
|
||||
continue
|
||||
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
capacity_node_code, is_child_instance = _resolve_capacity_node_code(node_code, settings_payload)
|
||||
if not capacity_node_code:
|
||||
continue
|
||||
if not is_child_instance and node_code in child_parent_codes:
|
||||
continue
|
||||
if node_code not in participant_node_codes:
|
||||
participant_node_codes.append(node_code)
|
||||
|
||||
thread_resolution_node_code = node_code if node_code in node_thread_counts else capacity_node_code
|
||||
thread_resolution = resolve_thread_count(node_code=thread_resolution_node_code, settings_payload=settings_payload)
|
||||
per_process_thread_count = max(1, int(thread_resolution["effective_thread_count"] or 1))
|
||||
if representative_thread_count <= 0:
|
||||
representative_thread_count = per_process_thread_count
|
||||
|
||||
if is_child_instance:
|
||||
process_count = 1
|
||||
else:
|
||||
if node_code in node_process_counts:
|
||||
process_resolution = resolve_process_count(node_code=node_code, settings_payload=settings_payload)
|
||||
process_count = max(1, int(process_resolution["effective_process_count"] or 1))
|
||||
else:
|
||||
process_count = 1
|
||||
|
||||
process_count_total += process_count
|
||||
max_threads_total += process_count * per_process_thread_count
|
||||
|
||||
return {
|
||||
"participant_node_codes": participant_node_codes,
|
||||
"participant_node_count": len(participant_node_codes),
|
||||
"process_count": process_count_total,
|
||||
"max_threads": max_threads_total,
|
||||
"per_process_thread_count": representative_thread_count,
|
||||
}
|
||||
|
||||
|
||||
def _merge_aggregate_active_job_with_queue_health(active_job: dict | None, queue_health: dict | None) -> dict | None:
|
||||
normalized_active_job = dict(active_job or {})
|
||||
normalized_queue_health = dict(queue_health or {})
|
||||
if not normalized_queue_health.get("has_active_job"):
|
||||
return normalized_active_job or active_job
|
||||
|
||||
queue_payload = dict(normalized_queue_health.get("queue") or {})
|
||||
queue_job = dict(normalized_queue_health.get("job") or {})
|
||||
raw_queue_nodes = [dict(item) for item in list(normalized_queue_health.get("nodes") or []) if isinstance(item, dict)]
|
||||
queue_nodes = _filter_live_aggregate_runtime_nodes(raw_queue_nodes)
|
||||
if not queue_payload and not queue_nodes and not queue_job:
|
||||
return normalized_active_job or active_job
|
||||
|
||||
queue_display_running = sum(
|
||||
_max_runtime_metric(
|
||||
item.get("display_running"),
|
||||
item.get("current_load"),
|
||||
item.get("active_threads"),
|
||||
item.get("items_running"),
|
||||
)
|
||||
for item in queue_nodes
|
||||
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
|
||||
)
|
||||
if raw_queue_nodes:
|
||||
display_max_threads = sum(_int_value(item.get("max_threads")) for item in queue_nodes)
|
||||
display_items_running = queue_display_running
|
||||
display_active_threads = queue_display_running
|
||||
else:
|
||||
display_max_threads = _max_runtime_metric(
|
||||
sum(_int_value(item.get("max_threads")) for item in queue_nodes),
|
||||
normalized_active_job.get("display_max_threads"),
|
||||
)
|
||||
display_items_running = _max_runtime_metric(
|
||||
queue_display_running,
|
||||
queue_payload.get("display_running"),
|
||||
normalized_active_job.get("display_items_running"),
|
||||
normalized_active_job.get("display_active_threads"),
|
||||
)
|
||||
display_active_threads = _max_runtime_metric(
|
||||
queue_display_running,
|
||||
queue_payload.get("display_running"),
|
||||
normalized_active_job.get("display_active_threads"),
|
||||
normalized_active_job.get("display_items_running"),
|
||||
)
|
||||
|
||||
merged = dict(normalized_active_job)
|
||||
merged.update(
|
||||
{
|
||||
"job_id": queue_job.get("job_id", merged.get("job_id")),
|
||||
"job_code": queue_job.get("job_code", merged.get("job_code")),
|
||||
"status": queue_job.get("status", merged.get("status")),
|
||||
"progress_percent": queue_job.get("progress_percent", merged.get("progress_percent", 0)),
|
||||
"items_total": _int_value(queue_payload.get("items_total", merged.get("items_total"))),
|
||||
"items_pending": _int_value(queue_payload.get("pending", merged.get("items_pending"))),
|
||||
"items_claimed": _int_value(queue_payload.get("claimed", merged.get("items_claimed"))),
|
||||
"items_running": _int_value(queue_payload.get("running", merged.get("items_running"))),
|
||||
"items_completed": _int_value(queue_payload.get("completed", merged.get("items_completed"))),
|
||||
"items_blacklisted": _int_value(queue_payload.get("blacklisted", merged.get("items_blacklisted"))),
|
||||
"items_failed": _int_value(queue_payload.get("failed", merged.get("items_failed"))),
|
||||
"display_items_running": display_items_running,
|
||||
"display_active_threads": display_active_threads,
|
||||
"display_max_threads": display_max_threads,
|
||||
}
|
||||
)
|
||||
if raw_queue_nodes:
|
||||
merged["node_stats"] = list(queue_nodes)
|
||||
merged["distributed_node_stats"] = list(queue_nodes)
|
||||
return merged
|
||||
|
||||
|
||||
def _extract_dependency_alerts(lines: list[str]) -> list[dict]:
|
||||
alerts: list[dict] = []
|
||||
recent_lines = lines[-120:] if lines else []
|
||||
@@ -140,9 +447,29 @@ def _extract_dependency_alerts(lines: list[str]) -> list[dict]:
|
||||
|
||||
def _extract_available_proxy_count(lines: list[str]) -> int:
|
||||
for line in reversed(lines):
|
||||
match = _PROXY_COUNT_RE.search(line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
count = _extract_available_proxy_count_from_text(line)
|
||||
if count > 0:
|
||||
return count
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_available_proxy_count_from_text(text: str) -> int:
|
||||
normalized_text = str(text or "").strip()
|
||||
if not normalized_text:
|
||||
return 0
|
||||
for pattern in (
|
||||
_PROXY_COUNT_RE,
|
||||
_PROXY_REFRESH_COUNT_RE,
|
||||
_PROXY_CACHE_COUNT_RE,
|
||||
_PROXY_SHARED_SNAPSHOT_COUNT_RE,
|
||||
):
|
||||
match = pattern.search(normalized_text)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
return int(match.group(1) or 0)
|
||||
except Exception:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
@@ -492,6 +819,8 @@ def _build_remote_log_snapshot_from_debug_events(
|
||||
node_code = str(record.get("node_code") or "").strip() or "unknown"
|
||||
if participating_node_codes and node_code not in participating_node_codes:
|
||||
continue
|
||||
if not _debug_event_matches_active_job(record, active_job):
|
||||
continue
|
||||
message = str(record.get("message") or "").strip()
|
||||
if not message:
|
||||
continue
|
||||
@@ -665,6 +994,55 @@ def _load_runtime_state() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_disabled_managed_node_codes(node_codes: list[str] | None = None) -> set[str]:
|
||||
global _DISABLED_MANAGED_NODE_CACHE, _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT
|
||||
normalized_codes = [
|
||||
str(item or "").strip()
|
||||
for item in list(node_codes or [])
|
||||
if str(item or "").strip()
|
||||
]
|
||||
now_ts = time.time()
|
||||
if not normalized_codes and now_ts < _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT:
|
||||
return set(_DISABLED_MANAGED_NODE_CACHE)
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if normalized_codes:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code
|
||||
FROM ops_managed_nodes
|
||||
WHERE is_enabled = FALSE
|
||||
AND node_code = ANY(%s)
|
||||
""",
|
||||
(normalized_codes,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code
|
||||
FROM ops_managed_nodes
|
||||
WHERE is_enabled = FALSE
|
||||
"""
|
||||
)
|
||||
rows = list(cur.fetchall() or [])
|
||||
except Exception:
|
||||
if normalized_codes:
|
||||
return {code for code in normalized_codes if code in _DISABLED_MANAGED_NODE_CACHE}
|
||||
return set(_DISABLED_MANAGED_NODE_CACHE)
|
||||
|
||||
disabled_codes = {
|
||||
str(row[0] or "").strip()
|
||||
for row in rows
|
||||
if str(row[0] or "").strip()
|
||||
}
|
||||
if normalized_codes:
|
||||
return disabled_codes
|
||||
_DISABLED_MANAGED_NODE_CACHE = disabled_codes
|
||||
_DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = now_ts + 5.0
|
||||
return set(disabled_codes)
|
||||
|
||||
|
||||
def _load_runtime_state_from_cluster_node() -> dict:
|
||||
try:
|
||||
with get_db() as conn:
|
||||
@@ -706,6 +1084,359 @@ def _load_runtime_state_from_cluster_node() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_runtime_states_from_cluster_nodes(node_codes: list[str] | tuple[str, ...]) -> dict[str, dict]:
|
||||
normalized_node_codes = [
|
||||
str(item or "").strip()
|
||||
for item in list(node_codes or [])
|
||||
if str(item or "").strip()
|
||||
]
|
||||
if not normalized_node_codes:
|
||||
return {}
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, current_load, metadata_json, last_heartbeat_at
|
||||
FROM detect_worker_nodes
|
||||
WHERE node_code = ANY(%s)
|
||||
""",
|
||||
(normalized_node_codes,),
|
||||
)
|
||||
rows = list(cur.fetchall() or [])
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
payload: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
node_code = str(row[0] or "").strip()
|
||||
if not node_code:
|
||||
continue
|
||||
current_load = int(row[1] or 0)
|
||||
metadata_json = row[2]
|
||||
last_heartbeat_at = row[3]
|
||||
metadata = metadata_json if isinstance(metadata_json, dict) else {}
|
||||
payload[node_code] = {
|
||||
"node_code": node_code,
|
||||
"current_load": current_load,
|
||||
"last_heartbeat_at": (
|
||||
last_heartbeat_at.isoformat(sep=" ", timespec="seconds")
|
||||
if hasattr(last_heartbeat_at, "isoformat")
|
||||
else str(last_heartbeat_at or "").strip()
|
||||
),
|
||||
"available_proxy_count": int(
|
||||
metadata.get("available_proxy_count", metadata.get("proxy_last_available_count", 0)) or 0
|
||||
),
|
||||
"proxy_runtime_label": str(metadata.get("proxy_runtime_label") or "").strip(),
|
||||
"proxy_runtime_reason": str(metadata.get("proxy_runtime_reason") or "").strip(),
|
||||
"proxy_last_refresh_status": str(metadata.get("proxy_last_refresh_status") or "").strip(),
|
||||
"proxy_last_refresh_time": str(metadata.get("proxy_last_refresh_time") or "").strip(),
|
||||
"proxy_last_refresh_source_count": int(metadata.get("proxy_last_refresh_source_count", 0) or 0),
|
||||
"proxy_last_refresh_total_items": int(metadata.get("proxy_last_refresh_total_items", 0) or 0),
|
||||
"proxy_last_validated_count": int(metadata.get("proxy_last_validated_count", 0) or 0),
|
||||
"active_threads": int(metadata.get("active_threads", 0) or 0),
|
||||
"max_threads": int(metadata.get("max_threads", 0) or 0),
|
||||
"detect_participating": bool(metadata.get("detect_participating", False) or current_load > 0),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _load_recent_proxy_debug_events(node_codes: list[str] | tuple[str, ...], *, window_minutes: int = 20) -> list[dict]:
|
||||
normalized_node_codes = [
|
||||
str(item or "").strip()
|
||||
for item in list(node_codes or [])
|
||||
if str(item or "").strip() and str(item or "").strip() != "unassigned"
|
||||
]
|
||||
if not normalized_node_codes:
|
||||
return []
|
||||
|
||||
safe_window_minutes = max(5, min(int(window_minutes or 20), 120))
|
||||
created_after = datetime.now() - timedelta(minutes=safe_window_minutes)
|
||||
safe_limit = max(80, min(len(normalized_node_codes) * 20, 800))
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, message, payload_json, created_at
|
||||
FROM detect_debug_events
|
||||
WHERE event_type = 'worker_log'
|
||||
AND node_code = ANY(%s)
|
||||
AND created_at >= %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(normalized_node_codes, created_after, safe_limit),
|
||||
)
|
||||
rows = list(cur.fetchall() or [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"event_type": "worker_log",
|
||||
"node_code": str(row[0] or "").strip(),
|
||||
"message": str(row[1] or "").strip(),
|
||||
"payload": row[2] if isinstance(row[2], dict) else {},
|
||||
"created_at": (
|
||||
row[3].isoformat(sep=" ", timespec="seconds")
|
||||
if hasattr(row[3], "isoformat")
|
||||
else str(row[3] or "").strip()
|
||||
),
|
||||
}
|
||||
for row in rows
|
||||
if str(row[0] or "").strip() and str(row[1] or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def _is_proxy_runtime_message(message: str) -> bool:
|
||||
normalized_message = str(message or "").strip()
|
||||
if not normalized_message:
|
||||
return False
|
||||
if _extract_available_proxy_count_from_text(normalized_message) > 0:
|
||||
return True
|
||||
lowered_message = normalized_message.lower()
|
||||
return any(
|
||||
keyword in normalized_message or keyword in lowered_message
|
||||
for keyword in (
|
||||
"代理",
|
||||
"proxy",
|
||||
"cooldown",
|
||||
"rate limited",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _infer_proxy_runtime_label_from_message(message: str, *, available_proxy_count: int) -> tuple[str, str]:
|
||||
normalized_message = str(message or "").strip()
|
||||
lowered_message = normalized_message.lower()
|
||||
if available_proxy_count > 0:
|
||||
return "代理正常", "aggregate_log_healthy"
|
||||
if "rate limited" in lowered_message or "cooldown" in lowered_message or "冷却" in normalized_message:
|
||||
return "代理源暂时冷却中", "aggregate_log_cooldown"
|
||||
if any(keyword in normalized_message for keyword in ("未取到新代理", "未取到可用代理数据", "无可用代理", "未返回可用代理数据")):
|
||||
return "代理待补货", "aggregate_log_empty"
|
||||
if "等待首刷" in normalized_message:
|
||||
return "等待首刷", "aggregate_log_waiting"
|
||||
return "", ""
|
||||
|
||||
|
||||
def _build_aggregate_proxy_runtime_rows_from_events(
|
||||
*,
|
||||
active_job: dict | None,
|
||||
settings_payload: dict,
|
||||
participant_node_codes: list[str],
|
||||
participant_server_codes: list[str],
|
||||
) -> dict[str, dict]:
|
||||
normalized_active_job = dict(active_job or {})
|
||||
events = list(normalized_active_job.get("current_cycle_events") or normalized_active_job.get("recent_events") or [])
|
||||
events.extend(_load_recent_proxy_debug_events(participant_node_codes))
|
||||
if not events:
|
||||
return {}
|
||||
|
||||
current_cycle_token = str(normalized_active_job.get("current_cycle_token") or "").strip()
|
||||
allowed_server_codes = {str(item or "").strip() for item in list(participant_server_codes or []) if str(item or "").strip()}
|
||||
rows: dict[str, dict] = {}
|
||||
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
if str(event.get("event_type") or "").strip() != "worker_log":
|
||||
continue
|
||||
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
event_cycle_token = str(payload.get("cycle_token") or "").strip()
|
||||
if current_cycle_token and event_cycle_token and event_cycle_token != current_cycle_token:
|
||||
continue
|
||||
|
||||
node_code = str(event.get("node_code") or "").strip()
|
||||
if not node_code or node_code == "unassigned":
|
||||
continue
|
||||
capacity_node_code, _ = _resolve_capacity_node_code(node_code, settings_payload)
|
||||
server_code = str(capacity_node_code or node_code or "").strip()
|
||||
if not server_code or (allowed_server_codes and server_code not in allowed_server_codes):
|
||||
continue
|
||||
|
||||
message = str(event.get("message") or "").strip()
|
||||
if not _is_proxy_runtime_message(message):
|
||||
continue
|
||||
|
||||
created_at = str(event.get("created_at") or "").strip()
|
||||
available_proxy_count = _extract_available_proxy_count_from_text(message)
|
||||
label, reason = _infer_proxy_runtime_label_from_message(
|
||||
message,
|
||||
available_proxy_count=available_proxy_count,
|
||||
)
|
||||
|
||||
row = rows.setdefault(
|
||||
server_code,
|
||||
{
|
||||
"node_code": server_code,
|
||||
"available_proxy_count": 0,
|
||||
"proxy_runtime_label": "",
|
||||
"proxy_runtime_reason": "",
|
||||
"proxy_last_refresh_status": "",
|
||||
"proxy_last_refresh_time": "",
|
||||
"proxy_last_refresh_source_count": 0,
|
||||
"proxy_last_refresh_total_items": 0,
|
||||
"proxy_last_validated_count": 0,
|
||||
"_count_seen_at": "",
|
||||
"_status_seen_at": "",
|
||||
},
|
||||
)
|
||||
|
||||
if available_proxy_count > 0 and created_at >= str(row.get("_count_seen_at") or ""):
|
||||
row["available_proxy_count"] = int(available_proxy_count or 0)
|
||||
row["_count_seen_at"] = created_at
|
||||
|
||||
if created_at >= str(row.get("_status_seen_at") or ""):
|
||||
row["proxy_last_refresh_status"] = message
|
||||
row["proxy_last_refresh_time"] = created_at
|
||||
row["_status_seen_at"] = created_at
|
||||
if label:
|
||||
row["proxy_runtime_label"] = label
|
||||
if reason:
|
||||
row["proxy_runtime_reason"] = reason
|
||||
|
||||
return {
|
||||
server_code: {
|
||||
key: value
|
||||
for key, value in row.items()
|
||||
if not str(key).startswith("_")
|
||||
}
|
||||
for server_code, row in rows.items()
|
||||
}
|
||||
|
||||
|
||||
def _build_aggregate_proxy_runtime(
|
||||
*,
|
||||
active_job: dict | None,
|
||||
settings_payload: dict,
|
||||
fallback_available_proxy_count: int,
|
||||
fallback_proxy_runtime: dict,
|
||||
) -> tuple[int, dict]:
|
||||
node_rows = list((active_job or {}).get("distributed_node_stats") or (active_job or {}).get("node_stats") or [])
|
||||
participant_node_codes: list[str] = []
|
||||
participant_server_codes: list[str] = []
|
||||
for raw_item in node_rows:
|
||||
if not isinstance(raw_item, dict) or not _is_current_participant_bucket(raw_item):
|
||||
continue
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
if node_code and node_code != "unassigned" and node_code not in participant_node_codes:
|
||||
participant_node_codes.append(node_code)
|
||||
capacity_node_code, _ = _resolve_capacity_node_code(node_code, settings_payload)
|
||||
normalized_server_code = str(capacity_node_code or node_code or "").strip()
|
||||
if not normalized_server_code or normalized_server_code == "unassigned":
|
||||
continue
|
||||
if normalized_server_code not in participant_server_codes:
|
||||
participant_server_codes.append(normalized_server_code)
|
||||
|
||||
if not participant_server_codes:
|
||||
return fallback_available_proxy_count, fallback_proxy_runtime
|
||||
|
||||
event_runtime = _build_aggregate_proxy_runtime_rows_from_events(
|
||||
active_job=active_job,
|
||||
settings_payload=settings_payload,
|
||||
participant_node_codes=participant_node_codes,
|
||||
participant_server_codes=participant_server_codes,
|
||||
)
|
||||
cluster_runtime = _load_runtime_states_from_cluster_nodes(participant_server_codes)
|
||||
rows: list[dict] = []
|
||||
for code in participant_server_codes:
|
||||
runtime_row = dict(cluster_runtime.get(code) or {})
|
||||
event_row = dict(event_runtime.get(code) or {})
|
||||
if not runtime_row and not event_row:
|
||||
continue
|
||||
merged_row = {
|
||||
"node_code": code,
|
||||
"available_proxy_count": int(runtime_row.get("available_proxy_count", 0) or 0),
|
||||
"proxy_runtime_label": str(runtime_row.get("proxy_runtime_label") or "").strip(),
|
||||
"proxy_runtime_reason": str(runtime_row.get("proxy_runtime_reason") or "").strip(),
|
||||
"proxy_last_refresh_status": str(runtime_row.get("proxy_last_refresh_status") or "").strip(),
|
||||
"proxy_last_refresh_time": str(runtime_row.get("proxy_last_refresh_time") or runtime_row.get("last_heartbeat_at") or "").strip(),
|
||||
"proxy_last_refresh_source_count": int(runtime_row.get("proxy_last_refresh_source_count", 0) or 0),
|
||||
"proxy_last_refresh_total_items": int(runtime_row.get("proxy_last_refresh_total_items", 0) or 0),
|
||||
"proxy_last_validated_count": int(runtime_row.get("proxy_last_validated_count", 0) or 0),
|
||||
}
|
||||
if int(merged_row.get("available_proxy_count", 0) or 0) <= 0 and int(event_row.get("available_proxy_count", 0) or 0) > 0:
|
||||
merged_row["available_proxy_count"] = int(event_row.get("available_proxy_count", 0) or 0)
|
||||
if not str(merged_row.get("proxy_runtime_label") or "").strip():
|
||||
merged_row["proxy_runtime_label"] = str(event_row.get("proxy_runtime_label") or "").strip()
|
||||
if not str(merged_row.get("proxy_runtime_reason") or "").strip():
|
||||
merged_row["proxy_runtime_reason"] = str(event_row.get("proxy_runtime_reason") or "").strip()
|
||||
if not str(merged_row.get("proxy_last_refresh_status") or "").strip():
|
||||
merged_row["proxy_last_refresh_status"] = str(event_row.get("proxy_last_refresh_status") or "").strip()
|
||||
if not str(merged_row.get("proxy_last_refresh_time") or "").strip():
|
||||
merged_row["proxy_last_refresh_time"] = str(event_row.get("proxy_last_refresh_time") or "").strip()
|
||||
if int(merged_row.get("proxy_last_refresh_source_count", 0) or 0) <= 0:
|
||||
merged_row["proxy_last_refresh_source_count"] = int(event_row.get("proxy_last_refresh_source_count", 0) or 0)
|
||||
if int(merged_row.get("proxy_last_refresh_total_items", 0) or 0) <= 0:
|
||||
merged_row["proxy_last_refresh_total_items"] = int(event_row.get("proxy_last_refresh_total_items", 0) or 0)
|
||||
if int(merged_row.get("proxy_last_validated_count", 0) or 0) <= 0:
|
||||
merged_row["proxy_last_validated_count"] = int(event_row.get("proxy_last_validated_count", 0) or 0)
|
||||
rows.append(merged_row)
|
||||
if not rows:
|
||||
return fallback_available_proxy_count, fallback_proxy_runtime
|
||||
|
||||
total_available_proxy_count = sum(max(0, int(item.get("available_proxy_count", 0) or 0)) for item in rows)
|
||||
latest_refresh_time = max((str(item.get("proxy_last_refresh_time") or "") for item in rows), default="")
|
||||
source_count = sum(int(item.get("proxy_last_refresh_source_count", 0) or 0) for item in rows)
|
||||
raw_items = sum(int(item.get("proxy_last_refresh_total_items", 0) or 0) for item in rows)
|
||||
validated_count = sum(int(item.get("proxy_last_validated_count", 0) or 0) for item in rows)
|
||||
refresh_status_parts = [
|
||||
f"{str(item.get('node_code') or '')}:{str(item.get('proxy_last_refresh_status') or '').strip()}"
|
||||
for item in rows
|
||||
if str(item.get("proxy_last_refresh_status") or "").strip()
|
||||
]
|
||||
refresh_status = ";".join(refresh_status_parts[:6])
|
||||
if len(refresh_status_parts) > 6:
|
||||
refresh_status = f"{refresh_status} 等 {len(refresh_status_parts)} 台"
|
||||
|
||||
if total_available_proxy_count > 0:
|
||||
return total_available_proxy_count, {
|
||||
"state": "healthy",
|
||||
"label": "集群代理正常",
|
||||
"detail": (
|
||||
f"参与服务器 {len(rows)} 台,共可用 {total_available_proxy_count} 个代理"
|
||||
+ (f";最近状态:{refresh_status}" if refresh_status else "")
|
||||
),
|
||||
"direct_fallback_active": False,
|
||||
"reason": "aggregate_healthy",
|
||||
"last_refresh_status": refresh_status,
|
||||
"last_refresh_time": latest_refresh_time,
|
||||
"source_count": source_count,
|
||||
"raw_items": raw_items,
|
||||
"validated_count": validated_count,
|
||||
"available_count": total_available_proxy_count,
|
||||
"source_stats": [],
|
||||
"supplier_empty": False,
|
||||
}
|
||||
|
||||
fallback_label = next((str(item.get("proxy_runtime_label") or "").strip() for item in rows if str(item.get("proxy_runtime_label") or "").strip()), "")
|
||||
fallback_reason = next((str(item.get("proxy_runtime_reason") or "").strip() for item in rows if str(item.get("proxy_runtime_reason") or "").strip()), "")
|
||||
if fallback_label:
|
||||
return 0, {
|
||||
"state": "warming_up",
|
||||
"label": fallback_label,
|
||||
"detail": (
|
||||
f"参与服务器 {len(rows)} 台,当前尚未汇总到可用代理"
|
||||
+ (f";最近状态:{refresh_status}" if refresh_status else "")
|
||||
),
|
||||
"direct_fallback_active": bool(fallback_proxy_runtime.get("direct_fallback_active", False)),
|
||||
"reason": fallback_reason or "aggregate_proxy_unavailable",
|
||||
"last_refresh_status": refresh_status,
|
||||
"last_refresh_time": latest_refresh_time,
|
||||
"source_count": source_count,
|
||||
"raw_items": raw_items,
|
||||
"validated_count": validated_count,
|
||||
"available_count": 0,
|
||||
"source_stats": [],
|
||||
"supplier_empty": bool(fallback_proxy_runtime.get("supplier_empty", False)),
|
||||
}
|
||||
|
||||
return fallback_available_proxy_count, fallback_proxy_runtime
|
||||
|
||||
|
||||
def _normalize_recent_warning(runtime_state: dict, recent_lines: list[str], available_proxy_count: int) -> str:
|
||||
runtime_warning = str(runtime_state.get("recent_warning", "") or "").strip()
|
||||
if runtime_warning:
|
||||
@@ -869,6 +1600,13 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
|
||||
|
||||
def get_detect_status() -> dict:
|
||||
global _DETECT_STATUS_CACHE_EXPIRES_AT, _DETECT_STATUS_CACHE_VALUE
|
||||
|
||||
now_ts = time.monotonic()
|
||||
with _DETECT_STATUS_CACHE_LOCK:
|
||||
if _DETECT_STATUS_CACHE_VALUE is not None and now_ts < _DETECT_STATUS_CACHE_EXPIRES_AT:
|
||||
return _clone_detect_status_payload(_DETECT_STATUS_CACHE_VALUE)
|
||||
|
||||
try:
|
||||
ensure_runtime_schema()
|
||||
except Exception:
|
||||
@@ -901,12 +1639,18 @@ def get_detect_status() -> dict:
|
||||
pass
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
worker_expected_on_this_node = _local_worker_expected_on_this_node()
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_online, last_log_time, recent_lines = _load_recent_worker_lines(runtime_settings, max_lines=160)
|
||||
runtime = detect_worker_runtime()
|
||||
runtime_state = _load_runtime_state()
|
||||
if not runtime_state:
|
||||
runtime_state = _load_runtime_state_from_cluster_node()
|
||||
if not worker_expected_on_this_node:
|
||||
worker_online = False
|
||||
last_log_time = ""
|
||||
recent_lines = []
|
||||
runtime_state = {}
|
||||
runtime_started_at = runtime.get("latest_start_time", "")
|
||||
recent_lines = _filter_lines_since(recent_lines, runtime_started_at)
|
||||
available_proxy_count = _extract_available_proxy_count(recent_lines)
|
||||
@@ -961,13 +1705,49 @@ def get_detect_status() -> dict:
|
||||
active_job = get_active_detect_job_summary(event_limit=240)
|
||||
except Exception:
|
||||
active_job = None
|
||||
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
aggregate_detect_view = bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job)
|
||||
aggregate_queue_health = {}
|
||||
if aggregate_detect_view:
|
||||
try:
|
||||
aggregate_queue_health = get_detect_queue_health(window_minutes=15)
|
||||
except Exception:
|
||||
aggregate_queue_health = {}
|
||||
active_job = _merge_aggregate_active_job_with_queue_health(active_job, aggregate_queue_health)
|
||||
aggregate_capacity = (
|
||||
_build_aggregate_detect_capacity(active_job=active_job, settings_payload=settings_payload)
|
||||
if aggregate_detect_view
|
||||
else {
|
||||
"participant_node_codes": [],
|
||||
"participant_node_count": 0,
|
||||
"process_count": 0,
|
||||
"max_threads": 0,
|
||||
"per_process_thread_count": 0,
|
||||
}
|
||||
)
|
||||
if aggregate_detect_view:
|
||||
raw_aggregate_node_rows = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
aggregate_node_rows = _filter_live_aggregate_runtime_nodes(raw_aggregate_node_rows)
|
||||
aggregate_display_running = sum(
|
||||
_max_runtime_metric(
|
||||
item.get("display_running"),
|
||||
item.get("current_load"),
|
||||
item.get("active_threads"),
|
||||
item.get("items_running"),
|
||||
)
|
||||
for item in aggregate_node_rows
|
||||
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
|
||||
)
|
||||
if raw_aggregate_node_rows:
|
||||
display_running = aggregate_display_running
|
||||
else:
|
||||
display_running = _max_runtime_metric(
|
||||
active_job.get("display_active_threads"),
|
||||
active_job.get("display_items_running"),
|
||||
(aggregate_queue_health.get("queue") or {}).get("display_running"),
|
||||
)
|
||||
progress = {
|
||||
"pending": int(active_job.get("items_pending", 0) or 0),
|
||||
"running": int(
|
||||
active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0)))
|
||||
or 0
|
||||
),
|
||||
"running": display_running,
|
||||
"completed": int(active_job.get("items_completed", 0) or 0),
|
||||
"failed": int(active_job.get("items_failed", 0) or 0),
|
||||
"blacklisted": int(active_job.get("items_blacklisted", 0) or 0),
|
||||
@@ -989,8 +1769,20 @@ def get_detect_status() -> dict:
|
||||
active_thread_snapshot["active"] = local_runtime_load
|
||||
if active_thread_snapshot["max"] <= 0:
|
||||
active_thread_snapshot["max"] = local_runtime_max_threads or effective_thread_count
|
||||
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
if aggregate_detect_view:
|
||||
raw_distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
distributed_node_stats = _filter_live_aggregate_runtime_nodes(raw_distributed_node_stats)
|
||||
raw_participant_count = sum(
|
||||
1
|
||||
for item in raw_distributed_node_stats
|
||||
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
|
||||
)
|
||||
live_participant_count = sum(
|
||||
1
|
||||
for item in distributed_node_stats
|
||||
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
|
||||
)
|
||||
dropped_aggregate_node_count = max(0, raw_participant_count - live_participant_count)
|
||||
aggregated_active_threads = 0
|
||||
aggregated_max_threads = 0
|
||||
for item in distributed_node_stats:
|
||||
@@ -1005,8 +1797,38 @@ def get_detect_status() -> dict:
|
||||
aggregated_max_threads += int(item.get("max_threads", 0) or 0)
|
||||
if aggregated_active_threads > 0:
|
||||
active_thread_snapshot["active"] = aggregated_active_threads
|
||||
elif dropped_aggregate_node_count <= 0 and _max_runtime_metric(
|
||||
active_job.get("display_active_threads"),
|
||||
active_job.get("display_items_running"),
|
||||
progress.get("running"),
|
||||
) > 0:
|
||||
active_thread_snapshot["active"] = _max_runtime_metric(
|
||||
active_job.get("display_active_threads"),
|
||||
active_job.get("display_items_running"),
|
||||
progress.get("running"),
|
||||
)
|
||||
if aggregated_max_threads > 0:
|
||||
active_thread_snapshot["max"] = aggregated_max_threads
|
||||
elif dropped_aggregate_node_count <= 0 and _max_runtime_metric(
|
||||
active_job.get("display_max_threads"),
|
||||
aggregate_capacity.get("max_threads"),
|
||||
) > 0:
|
||||
active_thread_snapshot["max"] = _max_runtime_metric(
|
||||
active_job.get("display_max_threads"),
|
||||
aggregate_capacity.get("max_threads"),
|
||||
)
|
||||
configured_max_threads = int(aggregate_capacity.get("max_threads", 0) or 0)
|
||||
if configured_max_threads > 0:
|
||||
active_thread_snapshot["max"] = max(active_thread_snapshot["max"], configured_max_threads)
|
||||
available_proxy_count, proxy_runtime = _build_aggregate_proxy_runtime(
|
||||
active_job=active_job,
|
||||
settings_payload=settings_payload,
|
||||
fallback_available_proxy_count=available_proxy_count,
|
||||
fallback_proxy_runtime=proxy_runtime,
|
||||
)
|
||||
display_worker_process_count = int(runtime.get("process_count", 0) or 0)
|
||||
if aggregate_detect_view and int(aggregate_capacity.get("process_count", 0) or 0) > 0:
|
||||
display_worker_process_count = int(aggregate_capacity.get("process_count", 0) or 0)
|
||||
runtime_snapshot = {
|
||||
**runtime,
|
||||
"detecting": inferred_detecting,
|
||||
@@ -1029,21 +1851,22 @@ def get_detect_status() -> dict:
|
||||
)
|
||||
remote_log_lines = list(remote_log_snapshot.get("lines") or [])
|
||||
dependency_alerts = _extract_dependency_alerts(recent_lines)
|
||||
append_detect_result_projection_if_changed(
|
||||
detect={
|
||||
"active_job": active_job,
|
||||
"progress": progress,
|
||||
"phase_label": runtime_state.get("phase", ""),
|
||||
"phase_detail": runtime_state.get("detail", ""),
|
||||
}
|
||||
)
|
||||
if _local_worker_expected_on_this_node():
|
||||
append_detect_result_projection_if_changed(
|
||||
detect={
|
||||
"active_job": active_job,
|
||||
"progress": progress,
|
||||
"phase_label": runtime_state.get("phase", ""),
|
||||
"phase_detail": runtime_state.get("detail", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"worker_online": worker_online,
|
||||
"worker_mode": runtime.get("mode", "windows-local"),
|
||||
"worker_service_name": runtime_settings.get("worker_service_name", ""),
|
||||
"api_service_name": runtime_settings.get("api_service_name", ""),
|
||||
"worker_process_count": runtime.get("process_count", 0),
|
||||
"worker_process_count": display_worker_process_count,
|
||||
"worker_latest_start_time": runtime.get("latest_start_time", ""),
|
||||
"worker_runtime_message": runtime.get("message", ""),
|
||||
"runtime_state": runtime_state,
|
||||
@@ -1057,6 +1880,11 @@ def get_detect_status() -> dict:
|
||||
"thread_count_node_code": str(thread_count_resolution["node_code"]),
|
||||
"active_thread_count": active_thread_snapshot["active"],
|
||||
"max_thread_count": active_thread_snapshot["max"] or effective_thread_count,
|
||||
"aggregate_process_count": int(aggregate_capacity.get("process_count", 0) or 0),
|
||||
"aggregate_participating_node_count": int(aggregate_capacity.get("participant_node_count", 0) or 0),
|
||||
"aggregate_participating_node_codes": list(aggregate_capacity.get("participant_node_codes") or []),
|
||||
"aggregate_max_thread_count": int(aggregate_capacity.get("max_threads", 0) or 0),
|
||||
"aggregate_thread_count_per_process": int(aggregate_capacity.get("per_process_thread_count", 0) or 0),
|
||||
"proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False),
|
||||
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
|
||||
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
|
||||
@@ -1080,7 +1908,7 @@ def get_detect_status() -> dict:
|
||||
"progress_percent": progress_percent,
|
||||
"recent_event": runtime_state.get("detail") or _recent_event(recent_lines),
|
||||
"recent_warning": recent_proxy_warning,
|
||||
"aggregate_detect_view": bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job),
|
||||
"aggregate_detect_view": aggregate_detect_view,
|
||||
"log_lines": recent_lines,
|
||||
"remote_log_lines": remote_log_lines,
|
||||
"remote_log_line_count": int(remote_log_snapshot.get("line_count", 0) or 0),
|
||||
@@ -1094,3 +1922,7 @@ def get_detect_status() -> dict:
|
||||
"worker_log_sync_enabled": worker_log_sync_enabled,
|
||||
"worker_log_sync_mode": worker_log_sync_mode,
|
||||
}
|
||||
with _DETECT_STATUS_CACHE_LOCK:
|
||||
_DETECT_STATUS_CACHE_VALUE = _clone_detect_status_payload(result)
|
||||
_DETECT_STATUS_CACHE_EXPIRES_AT = time.monotonic() + _DETECT_STATUS_CACHE_TTL_SECONDS
|
||||
return result
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -113,6 +114,8 @@ def _build_step_details(row: tuple) -> list[dict]:
|
||||
_normalize_step_detail("时光机", row[21]),
|
||||
_normalize_step_detail("站长之家", row[22]),
|
||||
_normalize_step_detail("爱站网", row[23]),
|
||||
_normalize_step_detail("桔子SEO", row[24]),
|
||||
_normalize_step_detail("聚查", row[25]),
|
||||
]
|
||||
|
||||
|
||||
@@ -152,6 +155,28 @@ def _normalize_detection_update(value) -> bool | None:
|
||||
raise ValueError("检测结果字段仅支持“是”或“否”")
|
||||
|
||||
|
||||
def _now_text() -> str:
|
||||
return datetime.now().isoformat(sep=" ", timespec="seconds")
|
||||
|
||||
|
||||
def _merge_detection_step_update(field: str, value: bool, existing_payload) -> dict | bool:
|
||||
if field == "is_chinese_title":
|
||||
return bool(value)
|
||||
|
||||
existing = dict(existing_payload or {}) if isinstance(existing_payload, dict) else {}
|
||||
previous_status = existing.get("status") if isinstance(existing.get("status"), bool) else None
|
||||
merged = dict(existing)
|
||||
merged["status"] = bool(value)
|
||||
merged["state"] = "passed" if bool(value) else "failed"
|
||||
merged["step"] = str(merged.get("step") or field)
|
||||
if previous_status != bool(value) or not str(merged.get("message") or "").strip():
|
||||
merged["message"] = "人工批量更新"
|
||||
if previous_status != bool(value) or not str(merged.get("checked_at") or "").strip():
|
||||
merged["checked_at"] = _now_text()
|
||||
merged["manual_override"] = True
|
||||
return merged
|
||||
|
||||
|
||||
def _build_domain_query_parts(filters: dict | None = None) -> tuple[str, str, list[object]]:
|
||||
filters = filters or {}
|
||||
conditions: list[str] = []
|
||||
@@ -190,8 +215,9 @@ def _build_domain_query_parts(filters: dict | None = None) -> tuple[str, str, li
|
||||
if filters.get("source_type") is not None:
|
||||
conditions.append("d.source_type = %s")
|
||||
params.append(int(filters["source_type"]))
|
||||
if filters.get("backlink_gt_10"):
|
||||
conditions.append("coalesce(dd.backlink_count_gt_10, false) = true")
|
||||
if filters.get("backlink_gt_10") is not None:
|
||||
conditions.append("coalesce(dd.backlink_count_gt_10, false) = %s")
|
||||
params.append(bool(filters["backlink_gt_10"]))
|
||||
|
||||
from_clause = """
|
||||
from domains d
|
||||
@@ -264,7 +290,9 @@ def fetch_domains(
|
||||
dd.google_site,
|
||||
dd.wayback_info,
|
||||
dd.chinaz_info,
|
||||
dd.aizhan_info
|
||||
dd.aizhan_info,
|
||||
dd.juziseo_info,
|
||||
dd.jucha_info
|
||||
{from_clause}
|
||||
{where_clause}
|
||||
order by d.id desc
|
||||
@@ -360,26 +388,7 @@ def fetch_domain_detail(domain_id: int) -> dict | None:
|
||||
if not row:
|
||||
return None
|
||||
|
||||
step_details = [
|
||||
_normalize_step_detail("百度历史收录", row[16]),
|
||||
_normalize_step_detail("百度Site收录", row[17]),
|
||||
{
|
||||
"label": "标题为中文",
|
||||
"state": "passed" if bool(row[18]) else "",
|
||||
"status": bool(row[18]),
|
||||
"message": "标题含中文" if bool(row[18]) else "",
|
||||
"checked_at": "",
|
||||
"step": "中文标题",
|
||||
"raw": row[18],
|
||||
},
|
||||
_normalize_step_detail("360 Site收录", row[19]),
|
||||
_normalize_step_detail("Google Site收录", row[20]),
|
||||
_normalize_step_detail("时光机", row[21]),
|
||||
_normalize_step_detail("站长之家", row[22]),
|
||||
_normalize_step_detail("爱站网", row[23]),
|
||||
_normalize_step_detail("桔子SEO", row[24]),
|
||||
_normalize_step_detail("聚查", row[25]),
|
||||
]
|
||||
step_details = _build_step_details(row)
|
||||
step_summary = _summarize_step_details(step_details)
|
||||
return {
|
||||
"id": row[0],
|
||||
@@ -523,6 +532,34 @@ def batch_update_domains(domain_ids: list[int], updates: dict) -> dict:
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
existing_detection_payloads: dict[str, object] = {}
|
||||
existing_detection_row = None
|
||||
if "backlink_count" in payload or detection_fields.intersection(payload.keys()):
|
||||
cur.execute(
|
||||
"""
|
||||
select
|
||||
id,
|
||||
baidu_history,
|
||||
baidu_site,
|
||||
is_chinese_title,
|
||||
qihu360_site,
|
||||
google_site,
|
||||
backlink_count_gt_10
|
||||
from domain_detections
|
||||
where domain_id = %s
|
||||
""",
|
||||
(domain_id,),
|
||||
)
|
||||
existing_detection_row = cur.fetchone()
|
||||
if existing_detection_row:
|
||||
existing_detection_payloads = {
|
||||
"baidu_history": existing_detection_row[1],
|
||||
"baidu_site": existing_detection_row[2],
|
||||
"is_chinese_title": existing_detection_row[3],
|
||||
"qihu360_site": existing_detection_row[4],
|
||||
"google_site": existing_detection_row[5],
|
||||
}
|
||||
|
||||
detection_payload: dict[str, object] = {}
|
||||
for field in detection_fields:
|
||||
if field not in payload:
|
||||
@@ -530,15 +567,15 @@ def batch_update_domains(domain_ids: list[int], updates: dict) -> dict:
|
||||
normalized = _normalize_detection_update(payload[field])
|
||||
if normalized is None:
|
||||
continue
|
||||
if field == "is_chinese_title":
|
||||
detection_payload[field] = normalized
|
||||
else:
|
||||
detection_payload[field] = {"status": normalized}
|
||||
detection_payload[field] = _merge_detection_step_update(
|
||||
field,
|
||||
normalized,
|
||||
existing_detection_payloads.get(field),
|
||||
)
|
||||
|
||||
if "backlink_count" in payload:
|
||||
backlink_gt_10 = int(payload["backlink_count"]) > 10
|
||||
cur.execute("select id from domain_detections where domain_id = %s", (domain_id,))
|
||||
if cur.fetchone():
|
||||
if existing_detection_row:
|
||||
cur.execute(
|
||||
"update domain_detections set backlink_count_gt_10 = %s, update_time = now() where domain_id = %s",
|
||||
(backlink_gt_10, domain_id),
|
||||
@@ -553,9 +590,7 @@ def batch_update_domains(domain_ids: list[int], updates: dict) -> dict:
|
||||
)
|
||||
|
||||
if detection_payload:
|
||||
cur.execute("select id from domain_detections where domain_id = %s", (domain_id,))
|
||||
existing_detection = cur.fetchone()
|
||||
if existing_detection:
|
||||
if existing_detection_row:
|
||||
detection_set_parts: list[str] = []
|
||||
detection_params: list[object] = []
|
||||
for field, value in detection_payload.items():
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.services.import_worker_service import import_domains_from_path
|
||||
|
||||
|
||||
_IMPORT_TASK_LOCK = threading.Lock()
|
||||
_IMPORT_EXECUTION_LOCK = threading.Lock()
|
||||
_SOURCE_TYPE_LABELS = {
|
||||
6: "手工录入",
|
||||
7: "TXT 导入",
|
||||
@@ -77,63 +78,64 @@ def _source_label(source_type: int) -> str:
|
||||
|
||||
|
||||
def _run_import_task(task_id: str, file_path: str, source_type: int = 7) -> None:
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"导入任务开始执行,来源类型:{_source_label(source_type)}",
|
||||
status="running",
|
||||
started_at=_now(),
|
||||
message=f"导入任务开始执行,来源类型:{_source_label(source_type)}",
|
||||
phase="reading",
|
||||
phase_label=_phase_label("reading"),
|
||||
)
|
||||
try:
|
||||
path = Path(file_path)
|
||||
with _IMPORT_EXECUTION_LOCK:
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"开始读取文件:{path.name}",
|
||||
f"导入任务开始执行,来源类型:{_source_label(source_type)}",
|
||||
status="running",
|
||||
started_at=_now(),
|
||||
message=f"导入任务开始执行,来源类型:{_source_label(source_type)}",
|
||||
phase="reading",
|
||||
phase_label=_phase_label("reading"),
|
||||
)
|
||||
raw_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
total_lines = len(raw_lines)
|
||||
non_empty = sum(1 for line in raw_lines if line.strip())
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"文件读取完成,共 {total_lines} 行,非空 {non_empty} 行",
|
||||
phase="normalizing",
|
||||
phase_label=_phase_label("normalizing"),
|
||||
message=f"文件读取完成,准备清洗 {non_empty} 条域名",
|
||||
)
|
||||
try:
|
||||
path = Path(file_path)
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"开始读取文件:{path.name}",
|
||||
phase="reading",
|
||||
phase_label=_phase_label("reading"),
|
||||
)
|
||||
raw_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
total_lines = len(raw_lines)
|
||||
non_empty = sum(1 for line in raw_lines if line.strip())
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"文件读取完成,共 {total_lines} 行,非空 {non_empty} 行",
|
||||
phase="normalizing",
|
||||
phase_label=_phase_label("normalizing"),
|
||||
message=f"文件读取完成,准备清洗 {non_empty} 条域名",
|
||||
)
|
||||
|
||||
result = import_domains_from_path(path, source_type=source_type)
|
||||
stats = result.get("stats", {})
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
(
|
||||
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)},"
|
||||
f"来源类型 {result.get('source_label') or _source_label(source_type)}"
|
||||
),
|
||||
status="completed",
|
||||
completed_at=_now(),
|
||||
result=result,
|
||||
message=(
|
||||
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)}"
|
||||
),
|
||||
phase="completed",
|
||||
phase_label=_phase_label("completed"),
|
||||
)
|
||||
except Exception as exc:
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"导入失败:{exc}",
|
||||
status="failed",
|
||||
completed_at=_now(),
|
||||
message=f"导入失败:{exc}",
|
||||
phase="failed",
|
||||
phase_label=_phase_label("failed"),
|
||||
)
|
||||
result = import_domains_from_path(path, source_type=source_type)
|
||||
stats = result.get("stats", {})
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
(
|
||||
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)},"
|
||||
f"来源类型 {result.get('source_label') or _source_label(source_type)}"
|
||||
),
|
||||
status="completed",
|
||||
completed_at=_now(),
|
||||
result=result,
|
||||
message=(
|
||||
f"导入完成:总数 {stats.get('total', 0)},有效 {stats.get('valid', 0)},"
|
||||
f"新增 {stats.get('added', 0)},已存在 {stats.get('exists', 0)},无效 {stats.get('invalid', 0)}"
|
||||
),
|
||||
phase="completed",
|
||||
phase_label=_phase_label("completed"),
|
||||
)
|
||||
except Exception as exc:
|
||||
_update_task_with_log(
|
||||
task_id,
|
||||
f"导入失败:{exc}",
|
||||
status="failed",
|
||||
completed_at=_now(),
|
||||
message=f"导入失败:{exc}",
|
||||
phase="failed",
|
||||
phase_label=_phase_label("failed"),
|
||||
)
|
||||
|
||||
|
||||
def create_import_task(content: bytes, filename: str, source_type: int = 7) -> dict:
|
||||
|
||||
@@ -47,6 +47,8 @@ def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||
domains = [row[0] for row in normalized_rows]
|
||||
existing_set: set[str] = set()
|
||||
inserted = 0
|
||||
exists = 0
|
||||
seen_in_batch: set[str] = set()
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -55,7 +57,12 @@ def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||
existing_set = {row[0] for row in cur.fetchall()}
|
||||
|
||||
for domain, tld in normalized_rows:
|
||||
if domain in seen_in_batch:
|
||||
exists += 1
|
||||
continue
|
||||
seen_in_batch.add(domain)
|
||||
if domain in existing_set:
|
||||
exists += 1
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -70,11 +77,17 @@ def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||
null, now(), now(), 0, null,
|
||||
0, 0, 0
|
||||
)
|
||||
on conflict (domain) do nothing
|
||||
returning id
|
||||
""",
|
||||
(domain, tld, source_type),
|
||||
)
|
||||
domain_id = cur.fetchone()[0]
|
||||
inserted_row = cur.fetchone()
|
||||
if not inserted_row:
|
||||
existing_set.add(domain)
|
||||
exists += 1
|
||||
continue
|
||||
domain_id = inserted_row[0]
|
||||
cur.execute(
|
||||
"""
|
||||
insert into detect_tasks (domain_id, task_type, status, priority, retry_count, create_time, update_time)
|
||||
@@ -82,10 +95,10 @@ def import_domains_from_path(file_path: Path, source_type: int = 7) -> dict:
|
||||
""",
|
||||
(domain_id,),
|
||||
)
|
||||
existing_set.add(domain)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
|
||||
exists = len(existing_set)
|
||||
valid = len(normalized_rows)
|
||||
stats = {
|
||||
"total": total,
|
||||
|
||||
@@ -6,10 +6,10 @@ from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import psycopg2
|
||||
import redis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.files import runtime_root as api_runtime_root
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
STRUCTURED_ACTIONS = {
|
||||
@@ -272,24 +272,10 @@ def _truncate_detect_runtime_tables(*, include_domains: bool) -> dict:
|
||||
|
||||
|
||||
def _flush_runtime_redis() -> dict:
|
||||
client = 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,
|
||||
)
|
||||
try:
|
||||
size_before = int(client.dbsize() or 0)
|
||||
client.flushdb()
|
||||
size_after = int(client.dbsize() or 0)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
client = get_redis()
|
||||
size_before = int(client.dbsize() or 0)
|
||||
client.flushdb()
|
||||
size_after = int(client.dbsize() or 0)
|
||||
return {"db": int(settings.redis_db), "size_before": size_before, "size_after": size_after}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import shlex
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.ops_command_service import build_bash_command
|
||||
@@ -80,6 +82,32 @@ ALTER TABLE ops_jobs ADD COLUMN IF NOT EXISTS last_agent_complete_request_id VAR
|
||||
_OPS_AGENT_SCHEMA_LOCK = threading.Lock()
|
||||
_OPS_AGENT_SCHEMA_READY = False
|
||||
_OPS_AGENT_SCHEMA_ADVISORY_LOCK_KEY = 90421802
|
||||
_OPS_AGENT_REQUIRED_TABLES = ("ops_node_tokens", "ops_job_events")
|
||||
_OPS_AGENT_REQUIRED_COLUMNS = {
|
||||
"ops_node_tokens": (
|
||||
"node_code",
|
||||
"purpose",
|
||||
"issued_by",
|
||||
"is_enabled",
|
||||
"expires_at",
|
||||
"last_used_at",
|
||||
"metadata_json",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
),
|
||||
"ops_job_events": (
|
||||
"job_id",
|
||||
"step_id",
|
||||
"node_code",
|
||||
"client_event_id",
|
||||
"event_type",
|
||||
"level",
|
||||
"message",
|
||||
"payload_json",
|
||||
"created_at",
|
||||
),
|
||||
"ops_jobs": ("last_agent_complete_request_id",),
|
||||
}
|
||||
|
||||
|
||||
def ensure_ops_agent_schema() -> None:
|
||||
@@ -91,14 +119,52 @@ def ensure_ops_agent_schema() -> None:
|
||||
if _OPS_AGENT_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_OPS_AGENT_SCHEMA_ADVISORY_LOCK_KEY,))
|
||||
cur.execute(_AGENT_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
if _ops_agent_schema_basics_present(cur):
|
||||
_OPS_AGENT_SCHEMA_READY = True
|
||||
return
|
||||
conn.autocommit = False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_OPS_AGENT_SCHEMA_ADVISORY_LOCK_KEY,))
|
||||
cur.execute(_AGENT_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
recoverable = isinstance(exc, (errors.DeadlockDetected, errors.LockNotAvailable))
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if not recoverable:
|
||||
raise
|
||||
with conn.cursor() as cur:
|
||||
if not _ops_agent_schema_basics_present(cur):
|
||||
raise
|
||||
_OPS_AGENT_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _ops_agent_schema_basics_present(cur) -> bool:
|
||||
for table_name in _OPS_AGENT_REQUIRED_TABLES:
|
||||
cur.execute("SELECT to_regclass(%s)", (f"public.{table_name}",))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return False
|
||||
|
||||
for table_name, required_columns in _OPS_AGENT_REQUIRED_COLUMNS.items():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
existing_columns = {str(row[0] or "").strip() for row in list(cur.fetchall() or [])}
|
||||
if not set(required_columns).issubset(existing_columns):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -152,12 +218,21 @@ def _merge_detect_runtime_snapshot(cluster_metadata: dict, metadata: dict, curre
|
||||
"phase_label": str(cluster_metadata.get("phase_label", metadata.get("phase_label", "")) or "").strip(),
|
||||
"phase_detail": str(cluster_metadata.get("phase_detail", metadata.get("phase_detail", "")) or "").strip(),
|
||||
"recent_warning": str(cluster_metadata.get("recent_warning", metadata.get("recent_warning", "")) or "").strip(),
|
||||
"available_proxy_count": int(
|
||||
cluster_metadata.get("available_proxy_count", metadata.get("available_proxy_count", 0)) or 0
|
||||
),
|
||||
"proxy_runtime_label": str(
|
||||
cluster_metadata.get("proxy_runtime_label", metadata.get("proxy_runtime_label", "")) or ""
|
||||
).strip(),
|
||||
"proxy_runtime_reason": str(
|
||||
cluster_metadata.get("proxy_runtime_reason", metadata.get("proxy_runtime_reason", "")) or ""
|
||||
).strip(),
|
||||
"proxy_last_refresh_status": str(
|
||||
cluster_metadata.get("proxy_last_refresh_status", metadata.get("proxy_last_refresh_status", "")) or ""
|
||||
).strip(),
|
||||
"proxy_last_refresh_time": str(
|
||||
cluster_metadata.get("proxy_last_refresh_time", metadata.get("proxy_last_refresh_time", "")) or ""
|
||||
).strip(),
|
||||
"updated_at": str(cluster_metadata.get("updated_at", metadata.get("updated_at", "")) or "").strip(),
|
||||
}
|
||||
|
||||
@@ -2714,6 +2789,15 @@ def _upsert_agent_detect_runtime(node_code: str, payload: dict) -> None:
|
||||
"phase_label": phase_label,
|
||||
"phase_detail": phase_detail,
|
||||
"recent_warning": recent_warning,
|
||||
"available_proxy_count": max(0, int(detect_runtime.get("available_proxy_count") or 0)),
|
||||
"proxy_runtime_label": str(detect_runtime.get("proxy_runtime_label") or "").strip(),
|
||||
"proxy_runtime_reason": str(detect_runtime.get("proxy_runtime_reason") or "").strip(),
|
||||
"proxy_last_refresh_status": str(detect_runtime.get("proxy_last_refresh_status") or "").strip(),
|
||||
"proxy_last_refresh_time": str(detect_runtime.get("proxy_last_refresh_time") or "").strip(),
|
||||
"proxy_last_refresh_source_count": max(0, int(detect_runtime.get("proxy_last_refresh_source_count") or 0)),
|
||||
"proxy_last_refresh_total_items": max(0, int(detect_runtime.get("proxy_last_refresh_total_items") or 0)),
|
||||
"proxy_last_validated_count": max(0, int(detect_runtime.get("proxy_last_validated_count") or 0)),
|
||||
"proxy_last_available_count": max(0, int(detect_runtime.get("proxy_last_available_count") or 0)),
|
||||
"updated_at": str(detect_runtime.get("updated_at") or "").strip(),
|
||||
"agent_heartbeat_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
@@ -2778,8 +2862,10 @@ def _build_agent_runtime_config_bundle(node_code: str) -> dict:
|
||||
"node_code": str(node_code or "").strip(),
|
||||
"detect_options": dict(settings_payload.get("detect_options") or {}),
|
||||
"proxy_config": dict(settings_payload.get("proxy_config") or {}),
|
||||
"thread_count": int(settings_payload.get("thread_count", 2) or 2),
|
||||
"thread_count": int(settings_payload.get("thread_count", 1000) or 1000),
|
||||
"node_thread_counts": dict(settings_payload.get("node_thread_counts") or {}),
|
||||
"process_count": int(settings_payload.get("process_count", 80) or 80),
|
||||
"node_process_counts": dict(settings_payload.get("node_process_counts") or {}),
|
||||
"runtime_settings": dict(runtime_settings or {}),
|
||||
"sensitive_words": {
|
||||
"text": str(sensitive_words_payload.get("text") or ""),
|
||||
|
||||
@@ -19,6 +19,7 @@ _SSH_ACTIONS = set(STRUCTURED_ACTIONS) | {"deploy.release"}
|
||||
_REMOTE_AGENT_ACTIONS = set(STRUCTURED_ACTIONS) | _REMOTE_AGENT_ONLY_ACTIONS | {"deploy.release"}
|
||||
_CONTROL_PLANE_ACTIONS = {
|
||||
"node.bootstrap",
|
||||
"migration.execute",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import threading
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.ops_execution_capability_service import (
|
||||
@@ -116,6 +118,28 @@ _LOCAL_RUNTIME_ACTIONS = {
|
||||
_OPS_SCHEMA_LOCK = threading.Lock()
|
||||
_OPS_SCHEMA_READY = False
|
||||
_OPS_SCHEMA_ADVISORY_LOCK_KEY = 90421801
|
||||
_OPS_REQUIRED_TABLES = (
|
||||
"ops_managed_nodes",
|
||||
"ops_managed_node_secrets",
|
||||
"ops_jobs",
|
||||
"ops_job_steps",
|
||||
)
|
||||
_OPS_REQUIRED_COLUMNS = {
|
||||
"ops_jobs": (
|
||||
"risk_level",
|
||||
"approval_required",
|
||||
"approval_status",
|
||||
"approved_by",
|
||||
"approved_at",
|
||||
"blocked_reason",
|
||||
"cancellation_reason",
|
||||
"dispatched_at",
|
||||
"target_selector_json",
|
||||
"policy_json",
|
||||
"rollout_id",
|
||||
),
|
||||
"ops_job_steps": ("stdout_text", "stderr_text", "result_json"),
|
||||
}
|
||||
|
||||
|
||||
def ensure_ops_schema() -> None:
|
||||
@@ -126,14 +150,52 @@ def ensure_ops_schema() -> None:
|
||||
if _OPS_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_OPS_SCHEMA_ADVISORY_LOCK_KEY,))
|
||||
cur.execute(_OPS_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
if _ops_schema_basics_present(cur):
|
||||
_OPS_SCHEMA_READY = True
|
||||
return
|
||||
conn.autocommit = False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_OPS_SCHEMA_ADVISORY_LOCK_KEY,))
|
||||
cur.execute(_OPS_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
recoverable = isinstance(exc, (errors.DeadlockDetected, errors.LockNotAvailable))
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if not recoverable:
|
||||
raise
|
||||
with conn.cursor() as cur:
|
||||
if not _ops_schema_basics_present(cur):
|
||||
raise
|
||||
_OPS_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _ops_schema_basics_present(cur) -> bool:
|
||||
for table_name in _OPS_REQUIRED_TABLES:
|
||||
cur.execute("SELECT to_regclass(%s)", (f"public.{table_name}",))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return False
|
||||
|
||||
for table_name, required_columns in _OPS_REQUIRED_COLUMNS.items():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
existing_columns = {str(row[0] or "").strip() for row in list(cur.fetchall() or [])}
|
||||
if not set(required_columns).issubset(existing_columns):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _decode_json(value: object) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
@@ -819,6 +881,7 @@ def _resolve_bootstrap_target_defaults(target_node_code: str) -> dict:
|
||||
def _execute_control_plane_job(
|
||||
action: str,
|
||||
*,
|
||||
job_id: int = 0,
|
||||
target_node_code: str,
|
||||
payload: dict | None = None,
|
||||
requested_by: str = "api",
|
||||
@@ -864,6 +927,17 @@ def _execute_control_plane_job(
|
||||
}
|
||||
return False, message, data
|
||||
|
||||
if action == "migration.execute":
|
||||
from app.services.ops_migration_service import execute_ops_migration_job
|
||||
|
||||
return execute_ops_migration_job(
|
||||
job_id=int(job_id or 0),
|
||||
target_node_code=str(target_node_code or "").strip(),
|
||||
payload=dict(normalized_payload or {}),
|
||||
requested_by=str(requested_by or "api").strip() or "api",
|
||||
metadata=dict(normalized_metadata or {}),
|
||||
)
|
||||
|
||||
return False, f"当前未实现控制面执行动作: {action}", {}
|
||||
|
||||
|
||||
@@ -1006,6 +1080,7 @@ def _execute_control_plane_job_record(job_id: int) -> tuple[bool, str, dict]:
|
||||
|
||||
ok, message, result = _execute_control_plane_job(
|
||||
str(job.get("action") or ""),
|
||||
job_id=int(job_id),
|
||||
target_node_code=str(job.get("target_node_code") or ""),
|
||||
payload=dict(job.get("payload") or {}),
|
||||
requested_by=str(job.get("requested_by") or "api"),
|
||||
@@ -1339,6 +1414,7 @@ def create_ops_job(payload: dict) -> tuple[bool, str, dict]:
|
||||
if execution_mode == "control-plane":
|
||||
ok, message, result = _execute_control_plane_job(
|
||||
action,
|
||||
job_id=job_id,
|
||||
target_node_code=target_node_code,
|
||||
payload=dict(input_payload or {}),
|
||||
requested_by=requested_by,
|
||||
|
||||
1559
domain-api/app/services/ops_migration_service.py
Normal file
1559
domain-api/app/services/ops_migration_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ _HIGH_RISK_ACTIONS = {
|
||||
_CRITICAL_RISK_ACTIONS = {
|
||||
"deploy.rollback",
|
||||
"node.bootstrap",
|
||||
"migration.execute",
|
||||
"cluster.reconfigure",
|
||||
"runtime.reset_lab_state",
|
||||
}
|
||||
@@ -114,6 +115,13 @@ def _preview_action_payload_guardrails(
|
||||
if target_nodes_total > 1:
|
||||
recommendations.append("接管动作建议按单节点节奏推进,先确认首台节点接入成功后再继续放量。")
|
||||
|
||||
if action == "migration.execute":
|
||||
if execution_mode != "control-plane":
|
||||
blocking_reasons.append("migration.execute 仅支持 control-plane 执行方式。")
|
||||
if bool(payload.get("overwrite_database", False)):
|
||||
approval_reasons.append("迁移任务包含数据库覆盖,正式环境必须显式确认后再执行。")
|
||||
recommendations.append("迁移属于长任务,建议通过后台任务窗口持续观察日志与健康检查结果。")
|
||||
|
||||
if action in _CRITICAL_RISK_ACTIONS:
|
||||
approval_reasons.append("该动作属于 critical 风险动作,正式环境必须审批。")
|
||||
elif action in _HIGH_RISK_ACTIONS:
|
||||
|
||||
@@ -11,6 +11,7 @@ import tarfile
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,9 @@ _SYSTEMD_TEMPLATE_SPECS = {
|
||||
"domaincheck-worker": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-worker.service"),
|
||||
},
|
||||
"domaincheck-worker@": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-worker@.service"),
|
||||
},
|
||||
"domaincheck-sync-agent": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-sync-agent.service"),
|
||||
},
|
||||
@@ -71,6 +75,78 @@ def normalize_release_health_check_services(
|
||||
return normalize_text_list(health_check_service_source)
|
||||
|
||||
|
||||
def normalize_release_health_check_urls(raw_value: object) -> list[str]:
|
||||
normalized_urls: list[str] = []
|
||||
for raw_item in normalize_text_list(raw_value):
|
||||
normalized_item = str(raw_item or "").strip()
|
||||
if not normalized_item:
|
||||
continue
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(normalized_item)
|
||||
except Exception:
|
||||
normalized_urls.append(normalized_item)
|
||||
continue
|
||||
normalized_path = str(parsed.path or "").rstrip("/")
|
||||
if normalized_path in {"/api/v1/runtime/status", "/runtime/status"}:
|
||||
parsed = parsed._replace(path="/health", query="", fragment="")
|
||||
normalized_item = urllib.parse.urlunsplit(parsed)
|
||||
normalized_urls.append(normalized_item)
|
||||
return normalized_urls
|
||||
|
||||
|
||||
def _dedupe_service_names(service_names: list[str]) -> list[str]:
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in service_names:
|
||||
normalized = str(item or "").strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped.append(normalized)
|
||||
return deduped
|
||||
|
||||
|
||||
def _list_worker_instance_units(run_command, base_service_name: str) -> list[str]:
|
||||
normalized_base = str(base_service_name or "").strip()
|
||||
if normalized_base != "domaincheck-worker":
|
||||
return []
|
||||
code, stdout, stderr = run_command(
|
||||
[
|
||||
"systemctl",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"domaincheck-worker@*",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
],
|
||||
timeout=30,
|
||||
)
|
||||
raw_output = stdout if stdout.strip() else stderr
|
||||
if int(code or 0) != 0 and not str(raw_output or "").strip():
|
||||
return []
|
||||
units: list[str] = []
|
||||
for line in str(raw_output or "").splitlines():
|
||||
parts = line.strip().split()
|
||||
if not parts:
|
||||
continue
|
||||
unit_name = str(parts[0] or "").strip()
|
||||
if unit_name:
|
||||
units.append(unit_name)
|
||||
return _dedupe_service_names(units)
|
||||
|
||||
|
||||
def expand_release_service_units(run_command, service_names: list[str]) -> list[str]:
|
||||
expanded: list[str] = []
|
||||
for item in list(service_names or []):
|
||||
normalized = str(item or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
expanded.append(normalized)
|
||||
expanded.extend(_list_worker_instance_units(run_command, normalized))
|
||||
return _dedupe_service_names(expanded)
|
||||
|
||||
|
||||
def collect_service_state(run_command, service_name: str) -> dict:
|
||||
code, stdout, stderr = run_command(["systemctl", "is-active", service_name], timeout=15)
|
||||
state = stdout or stderr
|
||||
@@ -295,7 +371,7 @@ def _systemd_dropin_content(service_name: str, install_root: str) -> str:
|
||||
"[Service]",
|
||||
f"WorkingDirectory={normalized_install_root}/current/domain-api",
|
||||
"ExecStart=",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100 --timeout-graceful-shutdown 15",
|
||||
]
|
||||
)
|
||||
if normalized_service_name == "domaincheck-worker":
|
||||
@@ -411,7 +487,7 @@ def execute_release_action(
|
||||
install_root = Path(str(normalized_payload.get("install_root") or "/opt/domaincheck")).resolve()
|
||||
switch_current = coerce_bool(normalized_payload.get("switch_current", True), default=True)
|
||||
restart_services = normalize_text_list(normalized_payload.get("restart_services"))
|
||||
health_check_urls = normalize_text_list(normalized_payload.get("health_check_urls"))
|
||||
health_check_urls = normalize_release_health_check_urls(normalized_payload.get("health_check_urls"))
|
||||
health_check_services = normalize_release_health_check_services(
|
||||
normalized_payload,
|
||||
restart_services,
|
||||
@@ -457,14 +533,17 @@ def execute_release_action(
|
||||
"prepared_dirs": prepared_dirs,
|
||||
}
|
||||
|
||||
expanded_restart_services = expand_release_service_units(run_command, restart_services)
|
||||
expanded_health_check_services = expand_release_service_units(run_command, health_check_services)
|
||||
|
||||
service_execstarts = [
|
||||
collect_service_execstart(run_command, service_name)
|
||||
for service_name in restart_services
|
||||
for service_name in expanded_restart_services
|
||||
if str(service_name or "").strip()
|
||||
]
|
||||
service_identities = [
|
||||
collect_service_identity(run_command, service_name)
|
||||
for service_name in restart_services
|
||||
for service_name in expanded_restart_services
|
||||
if str(service_name or "").strip()
|
||||
]
|
||||
current_link_text = str(current_link)
|
||||
@@ -645,7 +724,7 @@ def execute_release_action(
|
||||
)
|
||||
|
||||
restarted: list[dict] = []
|
||||
for service_name in restart_services:
|
||||
for service_name in expanded_restart_services:
|
||||
normalized_service_name = str(service_name or "").strip()
|
||||
if not normalized_service_name:
|
||||
continue
|
||||
@@ -681,7 +760,7 @@ def execute_release_action(
|
||||
|
||||
health_ok, health_result = run_release_health_checks(
|
||||
urls=health_check_urls,
|
||||
services=health_check_services,
|
||||
services=expanded_health_check_services,
|
||||
timeout=health_check_timeout_seconds,
|
||||
retries=health_check_retries,
|
||||
interval_seconds=health_check_interval_seconds,
|
||||
@@ -723,7 +802,7 @@ def execute_release_action(
|
||||
)
|
||||
rollback_result["post_rollback_health"] = run_release_health_checks(
|
||||
urls=health_check_urls,
|
||||
services=health_check_services,
|
||||
services=expanded_health_check_services,
|
||||
timeout=health_check_timeout_seconds,
|
||||
retries=0,
|
||||
interval_seconds=0,
|
||||
@@ -766,6 +845,8 @@ def execute_release_action(
|
||||
"current_link": str(current_link),
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"expanded_restart_services": expanded_restart_services,
|
||||
"expanded_health_check_services": expanded_health_check_services,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"systemd_sync": systemd_sync_result,
|
||||
"daemon_reload": daemon_reload_result,
|
||||
@@ -784,6 +865,7 @@ def build_remote_release_action_script(
|
||||
normalize_text_list,
|
||||
coerce_bool,
|
||||
normalize_release_health_check_services,
|
||||
normalize_release_health_check_urls,
|
||||
collect_service_state,
|
||||
check_health_url,
|
||||
run_release_health_checks,
|
||||
@@ -793,6 +875,9 @@ def build_remote_release_action_script(
|
||||
_pick_release_owner_group,
|
||||
apply_release_permissions,
|
||||
collect_service_execstart,
|
||||
_dedupe_service_names,
|
||||
_list_worker_instance_units,
|
||||
expand_release_service_units,
|
||||
_write_text_file,
|
||||
_systemd_dropin_content,
|
||||
_sync_release_systemd_units,
|
||||
@@ -816,8 +901,11 @@ def build_remote_release_action_script(
|
||||
)
|
||||
return f"""from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
@@ -12,6 +12,8 @@ from threading import Lock
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.build_info_service import get_runtime_build_info
|
||||
from app.services.ops_command_service import build_bash_command
|
||||
@@ -74,6 +76,19 @@ _ROLLOUT_INSPECTION_ACTION_KEYS = ("health.snapshot", "logs.collect", "diagnosti
|
||||
_SAFE_RELEASE_PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
_RELEASE_SCHEMA_LOCK = Lock()
|
||||
_RELEASE_SCHEMA_READY = False
|
||||
_RELEASE_SCHEMA_ADVISORY_LOCK_KEY = 90421803
|
||||
_RELEASE_REQUIRED_TABLES = ("ops_releases", "ops_release_rollouts")
|
||||
_RELEASE_REQUIRED_COLUMNS = {
|
||||
"ops_release_rollouts": (
|
||||
"rollout_code",
|
||||
"target_nodes_json",
|
||||
"batch_cursor",
|
||||
"batches_total",
|
||||
"jobs_total",
|
||||
"jobs_created",
|
||||
"result_summary_json",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def ensure_ops_release_schema() -> None:
|
||||
@@ -86,13 +101,52 @@ def ensure_ops_release_schema() -> None:
|
||||
if _RELEASE_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_RELEASE_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
if _ops_release_schema_basics_present(cur):
|
||||
_RELEASE_SCHEMA_READY = True
|
||||
return
|
||||
conn.autocommit = False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_RELEASE_SCHEMA_ADVISORY_LOCK_KEY,))
|
||||
cur.execute(_RELEASE_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
recoverable = isinstance(exc, (errors.DeadlockDetected, errors.LockNotAvailable))
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if not recoverable:
|
||||
raise
|
||||
with conn.cursor() as cur:
|
||||
if not _ops_release_schema_basics_present(cur):
|
||||
raise
|
||||
_RELEASE_SCHEMA_READY = True
|
||||
|
||||
|
||||
def _ops_release_schema_basics_present(cur) -> bool:
|
||||
for table_name in _RELEASE_REQUIRED_TABLES:
|
||||
cur.execute("SELECT to_regclass(%s)", (f"public.{table_name}",))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
return False
|
||||
|
||||
for table_name, required_columns in _RELEASE_REQUIRED_COLUMNS.items():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
existing_columns = {str(row[0] or "").strip() for row in list(cur.fetchall() or [])}
|
||||
if not set(required_columns).issubset(existing_columns):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _decode_json(value: object) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
@@ -1091,7 +1145,11 @@ def build_rollout_target_operational_readiness(
|
||||
desired_release: dict | None = None,
|
||||
) -> dict:
|
||||
from app.services.cluster_runtime_service import get_cluster_snapshot
|
||||
from app.services.ops_agent_service import ensure_ops_agent_schema, get_managed_node_onboarding
|
||||
from app.services.ops_agent_service import (
|
||||
ensure_ops_agent_schema,
|
||||
get_managed_node_onboarding,
|
||||
list_managed_nodes_with_agent_state,
|
||||
)
|
||||
from app.services.ops_job_service import list_managed_nodes
|
||||
|
||||
ensure_ops_agent_schema()
|
||||
@@ -1132,6 +1190,7 @@ def build_rollout_target_operational_readiness(
|
||||
for item in list(cluster_snapshot.get("nodes") or [])
|
||||
if str(item.get("node_code") or "").strip()
|
||||
}
|
||||
managed_nodes_payload = list_managed_nodes_with_agent_state()
|
||||
managed_nodes = list_managed_nodes()
|
||||
managed_map = {
|
||||
str(item.get("node_code") or "").strip(): item
|
||||
@@ -1239,7 +1298,11 @@ def build_rollout_target_operational_readiness(
|
||||
last_seen_at = str(managed.get("last_seen_at") or "").strip() or str(metadata.get("last_seen_at") or "").strip()
|
||||
cluster_status = str(cluster_node.get("status") or target.get("status") or "").strip()
|
||||
current_load = int(cluster_node.get("current_load", target.get("current_load", 0)) or 0)
|
||||
onboarding = get_managed_node_onboarding(node_code) if node_code else {}
|
||||
onboarding = (
|
||||
get_managed_node_onboarding(node_code, nodes_payload=managed_nodes_payload)
|
||||
if node_code
|
||||
else {}
|
||||
)
|
||||
onboarding_stage = dict(onboarding.get("onboarding_stage") or {})
|
||||
recovery_decision = dict(onboarding.get("recovery_decision") or {})
|
||||
onboarding_stage_code = str(onboarding_stage.get("code") or "").strip()
|
||||
|
||||
@@ -4180,6 +4180,8 @@ def get_ops_activity_stream(
|
||||
status: str = "",
|
||||
execution_mode: str = "",
|
||||
query: str = "",
|
||||
runtime_status: dict | None = None,
|
||||
managed_nodes_payload: dict | None = None,
|
||||
) -> dict:
|
||||
safe_limit = min(max(int(limit or _OPS_ACTIVITY_FETCH_LIMIT), 1), 100)
|
||||
safe_scan_limit = min(max(int(scan_limit or (safe_limit * 4)), safe_limit), 400)
|
||||
@@ -4187,12 +4189,18 @@ def get_ops_activity_stream(
|
||||
normalized_status = str(status or "").strip()
|
||||
normalized_execution_mode = str(execution_mode or "").strip()
|
||||
normalized_query = str(query or "").strip()
|
||||
runtime_status = get_runtime_status()
|
||||
execution_scene = _build_ops_execution_scene(runtime_status.get("detect") or {})
|
||||
managed_nodes_payload = list_managed_nodes_with_agent_state()
|
||||
resolved_runtime_status = dict(runtime_status or {})
|
||||
if not resolved_runtime_status:
|
||||
resolved_runtime_status = get_runtime_status()
|
||||
execution_scene = _build_ops_execution_scene(resolved_runtime_status.get("detect") or {})
|
||||
resolved_managed_nodes_payload = dict(managed_nodes_payload or {})
|
||||
if not resolved_managed_nodes_payload:
|
||||
resolved_managed_nodes_payload = list_managed_nodes_with_agent_state(
|
||||
participation_payload=resolved_runtime_status.get("detect") or {}
|
||||
)
|
||||
managed_node_map = {
|
||||
str(item.get("node_code") or "").strip(): dict(item or {})
|
||||
for item in list(managed_nodes_payload.get("nodes") or [])
|
||||
for item in list(resolved_managed_nodes_payload.get("nodes") or [])
|
||||
if str(item.get("node_code") or "").strip()
|
||||
}
|
||||
|
||||
@@ -4219,7 +4227,10 @@ def get_ops_activity_stream(
|
||||
rollouts = list_release_rollouts(limit=safe_scan_limit)
|
||||
rollout_items = [_build_rollout_activity(rollout) for rollout in rollouts if int(rollout.get("id") or 0) > 0]
|
||||
|
||||
runbook = get_ops_runbook()
|
||||
runbook = get_ops_runbook(
|
||||
runtime_status=resolved_runtime_status,
|
||||
managed_nodes_payload=resolved_managed_nodes_payload,
|
||||
)
|
||||
runbook_items = [
|
||||
_build_runbook_sequence_activity(sequence)
|
||||
for sequence in list(runbook.get("control_sequences") or [])
|
||||
@@ -8099,7 +8110,11 @@ def get_ops_overview() -> dict:
|
||||
managed_nodes = list(managed_nodes_payload.get("nodes") or [])
|
||||
managed_nodes_summary = managed_nodes_payload.get("summary") or {}
|
||||
inspection_overview = get_ops_inspection_overview(managed_nodes=managed_nodes)
|
||||
activity_stream = get_ops_activity_stream(limit=8)
|
||||
activity_stream = get_ops_activity_stream(
|
||||
limit=8,
|
||||
runtime_status=runtime,
|
||||
managed_nodes_payload=managed_nodes_payload,
|
||||
)
|
||||
release_summary = get_release_summary()
|
||||
preferred_release = _preferred_release_for_ops()
|
||||
release_launchpad = get_release_launchpad()
|
||||
@@ -11555,17 +11570,30 @@ def get_ops_blueprint() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_ops_runbook() -> dict:
|
||||
runtime = get_runtime_status()
|
||||
def get_ops_runbook(
|
||||
*,
|
||||
runtime_status: dict | None = None,
|
||||
managed_nodes_payload: dict | None = None,
|
||||
release_launchpad: dict | None = None,
|
||||
) -> dict:
|
||||
runtime = dict(runtime_status or {})
|
||||
if not runtime:
|
||||
runtime = get_runtime_status()
|
||||
readiness = runtime.get("readiness") or {}
|
||||
worker_runtime = runtime.get("worker") or {}
|
||||
sync_agent_runtime = runtime.get("sync_agent") or {}
|
||||
managed_nodes_payload = list_managed_nodes_with_agent_state(participation_payload=runtime.get("detect") or {})
|
||||
release_launchpad = get_release_launchpad()
|
||||
resolved_managed_nodes_payload = dict(managed_nodes_payload or {})
|
||||
if not resolved_managed_nodes_payload:
|
||||
resolved_managed_nodes_payload = list_managed_nodes_with_agent_state(
|
||||
participation_payload=runtime.get("detect") or {}
|
||||
)
|
||||
resolved_release_launchpad = dict(release_launchpad or {})
|
||||
if not resolved_release_launchpad:
|
||||
resolved_release_launchpad = get_release_launchpad()
|
||||
control_sequences = _attach_ops_runbook_sequence_resolutions(
|
||||
_build_ops_runbook_control_sequences(
|
||||
managed_nodes_payload=managed_nodes_payload,
|
||||
release_launchpad=release_launchpad,
|
||||
managed_nodes_payload=resolved_managed_nodes_payload,
|
||||
release_launchpad=resolved_release_launchpad,
|
||||
),
|
||||
requested_by="api/runbook",
|
||||
)
|
||||
@@ -11606,6 +11634,6 @@ def get_ops_runbook() -> dict:
|
||||
"status": str(readiness.get("status") or ""),
|
||||
"summary": str(readiness.get("summary") or ""),
|
||||
},
|
||||
"release_launchpad": release_launchpad,
|
||||
"release_launchpad": resolved_release_launchpad,
|
||||
"control_sequences": control_sequences,
|
||||
}
|
||||
|
||||
@@ -211,14 +211,21 @@ def runtime_action(action: str, payload: dict | None = None) -> tuple[bool, str,
|
||||
_emit_runtime_action_event(normalized_action, stage="finished", ok=command_ok, message=command_message, data=result)
|
||||
return command_ok, command_message, result
|
||||
if normalized_action == "stop_detection":
|
||||
command_ok, command_message = send_worker_command("stop_detection")
|
||||
command_ok, command_message = send_worker_command(
|
||||
"stop_detection",
|
||||
payload={
|
||||
key: value
|
||||
for key, value in normalized_payload.items()
|
||||
if value not in (None, "")
|
||||
},
|
||||
)
|
||||
result = _build_runtime_action_result(
|
||||
action=normalized_action,
|
||||
poll_after_seconds=2,
|
||||
refresh_runtime=True,
|
||||
ok=command_ok,
|
||||
message=command_message,
|
||||
data={},
|
||||
data={"payload": normalized_payload},
|
||||
)
|
||||
_emit_runtime_action_event(normalized_action, stage="finished", ok=command_ok, message=command_message, data=result)
|
||||
return command_ok, command_message, result
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.files import read_runtime_json, write_runtime_json
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
DEFAULT_RUNTIME_SETTINGS = {
|
||||
@@ -11,13 +14,50 @@ DEFAULT_RUNTIME_SETTINGS = {
|
||||
"sync_agent_service_name": settings.sync_agent_service_name,
|
||||
"worker_log_sync_enabled": False,
|
||||
"worker_log_sync_mode": "key",
|
||||
"control_node_autoresume_enabled": False,
|
||||
"claim_recent_jobs_first": False,
|
||||
"claim_recent_jobs_limit": 0,
|
||||
"claim_recent_jobs_window_hours": 0,
|
||||
"claim_batch_floor": 0,
|
||||
"claim_batch_ceil": 0,
|
||||
"submit_backlog_floor": 0,
|
||||
"submit_backlog_ceil": 0,
|
||||
"dispatch_cap_multiplier": 1,
|
||||
"pending_buffer_cap_multiplier": 1,
|
||||
}
|
||||
|
||||
RUNTIME_SETTINGS_REDIS_KEY = "domain_tool:runtime_settings"
|
||||
CONFIG_UPDATE_CHANNEL = "domain_tool:config_update"
|
||||
|
||||
|
||||
def _normalize_worker_log_sync_mode(value: object) -> str:
|
||||
return "full" if str(value or "").strip().lower() == "full" else "key"
|
||||
|
||||
|
||||
def _normalize_bool(value: object, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value or "").strip().lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _normalize_non_negative_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError):
|
||||
normalized = int(default)
|
||||
return max(0, normalized)
|
||||
|
||||
|
||||
def _normalize_positive_int(value: object, default: int = 1) -> int:
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError):
|
||||
normalized = int(default)
|
||||
return max(1, normalized)
|
||||
|
||||
|
||||
def normalize_runtime_settings(payload: dict | None) -> dict:
|
||||
merged = dict(DEFAULT_RUNTIME_SETTINGS)
|
||||
if isinstance(payload, dict):
|
||||
@@ -33,8 +73,28 @@ def normalize_runtime_settings(payload: dict | None) -> dict:
|
||||
value = str(merged.get(key) or "").strip()
|
||||
merged[key] = value or DEFAULT_RUNTIME_SETTINGS[key]
|
||||
|
||||
merged["worker_log_sync_enabled"] = bool(merged.get("worker_log_sync_enabled", False))
|
||||
merged["worker_log_sync_enabled"] = _normalize_bool(merged.get("worker_log_sync_enabled", False), default=False)
|
||||
merged["worker_log_sync_mode"] = _normalize_worker_log_sync_mode(merged.get("worker_log_sync_mode"))
|
||||
merged["control_node_autoresume_enabled"] = _normalize_bool(
|
||||
merged.get("control_node_autoresume_enabled", False),
|
||||
default=False,
|
||||
)
|
||||
merged["claim_recent_jobs_first"] = _normalize_bool(
|
||||
merged.get("claim_recent_jobs_first", False),
|
||||
default=False,
|
||||
)
|
||||
for key in ("claim_batch_floor", "claim_batch_ceil", "submit_backlog_floor", "submit_backlog_ceil"):
|
||||
merged[key] = _normalize_non_negative_int(merged.get(key), DEFAULT_RUNTIME_SETTINGS[key])
|
||||
for key in (
|
||||
"claim_recent_jobs_limit",
|
||||
"claim_recent_jobs_window_hours",
|
||||
"dispatch_cap_multiplier",
|
||||
"pending_buffer_cap_multiplier",
|
||||
):
|
||||
if key in {"claim_recent_jobs_limit", "claim_recent_jobs_window_hours"}:
|
||||
merged[key] = _normalize_non_negative_int(merged.get(key), DEFAULT_RUNTIME_SETTINGS[key])
|
||||
continue
|
||||
merged[key] = _normalize_positive_int(merged.get(key), DEFAULT_RUNTIME_SETTINGS[key])
|
||||
return merged
|
||||
|
||||
|
||||
@@ -43,7 +103,17 @@ def get_runtime_settings() -> dict:
|
||||
return normalize_runtime_settings(stored)
|
||||
|
||||
|
||||
def _sync_runtime_settings_update(runtime_settings: dict) -> None:
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
redis_client.set(RUNTIME_SETTINGS_REDIS_KEY, json.dumps(runtime_settings, ensure_ascii=False))
|
||||
redis_client.publish(CONFIG_UPDATE_CHANNEL, "runtime_settings")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def update_runtime_settings(payload: dict) -> dict:
|
||||
merged = normalize_runtime_settings({**get_runtime_settings(), **(payload or {})})
|
||||
write_runtime_json("runtime_settings.json", merged)
|
||||
_sync_runtime_settings_update(merged)
|
||||
return merged
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ REDIS_KEYS = {
|
||||
"proxy_config": "domain_tool:proxy_config",
|
||||
"thread_count": "domain_tool:thread_count",
|
||||
"node_thread_counts": "domain_tool:node_thread_counts",
|
||||
"process_count": "domain_tool:process_count",
|
||||
"node_process_counts": "domain_tool:node_process_counts",
|
||||
"credentials": "domain_tool:credentials",
|
||||
"runtime_settings": "domain_tool:runtime_settings",
|
||||
}
|
||||
@@ -40,6 +42,16 @@ def _normalize_thread_count(value: object, *, field_name: str = "thread_count")
|
||||
return thread_count
|
||||
|
||||
|
||||
def _normalize_process_count(value: object, *, field_name: str = "process_count") -> int:
|
||||
try:
|
||||
process_count = int(value)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"{field_name} must be an integer") from exc
|
||||
if process_count < 1:
|
||||
raise ValueError(f"{field_name} must be >= 1")
|
||||
return process_count
|
||||
|
||||
|
||||
def _normalize_node_thread_counts(payload: object) -> dict[str, int]:
|
||||
if payload in (None, ""):
|
||||
return {}
|
||||
@@ -55,14 +67,32 @@ def _normalize_node_thread_counts(payload: object) -> dict[str, int]:
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_node_process_counts(payload: object) -> dict[str, int]:
|
||||
if payload in (None, ""):
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("node_process_counts must be an object")
|
||||
|
||||
normalized: dict[str, int] = {}
|
||||
for raw_node_code, raw_process_count in payload.items():
|
||||
node_code = str(raw_node_code or "").strip()
|
||||
if not node_code:
|
||||
raise ValueError("node_process_counts contains empty node code")
|
||||
normalized[node_code] = _normalize_process_count(
|
||||
raw_process_count,
|
||||
field_name=f"node_process_counts.{node_code}",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_thread_count_config() -> tuple[int, dict[str, int]]:
|
||||
thread_count_payload = read_json("thread_count.json", default={"thread_count": "2"})
|
||||
thread_count_payload = read_json("thread_count.json", default={"thread_count": "1000"})
|
||||
node_thread_counts_payload = read_json("node_thread_counts.json", default={})
|
||||
|
||||
try:
|
||||
default_thread_count = _normalize_thread_count(thread_count_payload.get("thread_count", 2))
|
||||
default_thread_count = _normalize_thread_count(thread_count_payload.get("thread_count", 1000))
|
||||
except ValueError:
|
||||
default_thread_count = 2
|
||||
default_thread_count = 1000
|
||||
try:
|
||||
node_thread_counts = _normalize_node_thread_counts(node_thread_counts_payload)
|
||||
except ValueError:
|
||||
@@ -86,9 +116,40 @@ def _load_thread_count_config() -> tuple[int, dict[str, int]]:
|
||||
return default_thread_count, node_thread_counts
|
||||
|
||||
|
||||
def _load_process_count_config() -> tuple[int, dict[str, int]]:
|
||||
process_count_payload = read_json("process_count.json", default={"process_count": "80"})
|
||||
node_process_counts_payload = read_json("node_process_counts.json", default={})
|
||||
|
||||
try:
|
||||
default_process_count = _normalize_process_count(process_count_payload.get("process_count", 80))
|
||||
except ValueError:
|
||||
default_process_count = 80
|
||||
try:
|
||||
node_process_counts = _normalize_node_process_counts(node_process_counts_payload)
|
||||
except ValueError:
|
||||
node_process_counts = {}
|
||||
|
||||
redis_client = get_redis()
|
||||
try:
|
||||
if redis_process_count := redis_client.get(REDIS_KEYS["process_count"]):
|
||||
try:
|
||||
default_process_count = _normalize_process_count(redis_process_count)
|
||||
except ValueError:
|
||||
pass
|
||||
if redis_node_process_counts := redis_client.get(REDIS_KEYS["node_process_counts"]):
|
||||
try:
|
||||
node_process_counts = _normalize_node_process_counts(json.loads(redis_node_process_counts))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return default_process_count, node_process_counts
|
||||
|
||||
|
||||
def resolve_thread_count(node_code: str | None = None, settings_payload: dict | None = None) -> dict:
|
||||
payload = settings_payload or get_settings_payload()
|
||||
default_thread_count = int(payload.get("thread_count", 2))
|
||||
default_thread_count = int(payload.get("thread_count", 1000))
|
||||
node_thread_counts = _normalize_node_thread_counts(payload.get("node_thread_counts", {}))
|
||||
normalized_node_code = str(node_code or app_settings.node_code or "").strip()
|
||||
|
||||
@@ -110,10 +171,35 @@ def resolve_thread_count(node_code: str | None = None, settings_payload: dict |
|
||||
}
|
||||
|
||||
|
||||
def resolve_process_count(node_code: str | None = None, settings_payload: dict | None = None) -> dict:
|
||||
payload = settings_payload or get_settings_payload()
|
||||
default_process_count = int(payload.get("process_count", 80))
|
||||
node_process_counts = _normalize_node_process_counts(payload.get("node_process_counts", {}))
|
||||
normalized_node_code = str(node_code or app_settings.node_code or "").strip()
|
||||
|
||||
override_process_count = None
|
||||
source = "default"
|
||||
effective_process_count = default_process_count
|
||||
if normalized_node_code and normalized_node_code in node_process_counts:
|
||||
override_process_count = node_process_counts[normalized_node_code]
|
||||
effective_process_count = override_process_count
|
||||
source = "node_override"
|
||||
|
||||
return {
|
||||
"node_code": normalized_node_code,
|
||||
"default_process_count": default_process_count,
|
||||
"effective_process_count": effective_process_count,
|
||||
"override_process_count": override_process_count,
|
||||
"source": source,
|
||||
"node_process_counts": node_process_counts,
|
||||
}
|
||||
|
||||
|
||||
def get_settings_payload() -> dict:
|
||||
detect_options = read_json("detect_options.json", default={})
|
||||
proxy_config = read_json("proxy_config.json", default={})
|
||||
thread_count, node_thread_counts = _load_thread_count_config()
|
||||
process_count, node_process_counts = _load_process_count_config()
|
||||
|
||||
redis_client = get_redis()
|
||||
try:
|
||||
@@ -129,6 +215,8 @@ def get_settings_payload() -> dict:
|
||||
"proxy_config": proxy_config,
|
||||
"thread_count": thread_count,
|
||||
"node_thread_counts": node_thread_counts,
|
||||
"process_count": process_count,
|
||||
"node_process_counts": node_process_counts,
|
||||
"current_node_code": app_settings.node_code,
|
||||
"runtime_settings": get_runtime_settings(),
|
||||
}
|
||||
@@ -193,12 +281,18 @@ def update_settings_payload(payload: dict) -> dict:
|
||||
proxy_config = payload.get("proxy_config", current["proxy_config"])
|
||||
thread_count = _normalize_thread_count(payload.get("thread_count", current["thread_count"]))
|
||||
node_thread_counts = _normalize_node_thread_counts(payload.get("node_thread_counts", current.get("node_thread_counts", {})))
|
||||
process_count = _normalize_process_count(payload.get("process_count", current.get("process_count", 80)))
|
||||
node_process_counts = _normalize_node_process_counts(
|
||||
payload.get("node_process_counts", current.get("node_process_counts", {}))
|
||||
)
|
||||
runtime_settings = update_runtime_settings(payload.get("runtime_settings", current["runtime_settings"]))
|
||||
|
||||
write_json("detect_options.json", detect_options)
|
||||
write_json("proxy_config.json", proxy_config)
|
||||
write_json("thread_count.json", {"thread_count": str(thread_count)})
|
||||
write_json("node_thread_counts.json", node_thread_counts)
|
||||
write_json("process_count.json", {"process_count": str(process_count)})
|
||||
write_json("node_process_counts.json", node_process_counts)
|
||||
redis_client = get_redis()
|
||||
try:
|
||||
redis_client.set(REDIS_KEYS["detect_options"], json.dumps(detect_options, ensure_ascii=False))
|
||||
@@ -209,12 +303,16 @@ def update_settings_payload(payload: dict) -> dict:
|
||||
redis_client.publish("domain_tool:thread_count:update", str(thread_count))
|
||||
redis_client.set(REDIS_KEYS["node_thread_counts"], json.dumps(node_thread_counts, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:node_thread_counts:update", json.dumps(node_thread_counts, ensure_ascii=False))
|
||||
redis_client.set(REDIS_KEYS["runtime_settings"], json.dumps(runtime_settings, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:config_update", "runtime_settings")
|
||||
redis_client.set(REDIS_KEYS["process_count"], process_count)
|
||||
redis_client.publish("domain_tool:process_count:update", str(process_count))
|
||||
redis_client.set(REDIS_KEYS["node_process_counts"], json.dumps(node_process_counts, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:node_process_counts:update", json.dumps(node_process_counts, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:config_update", "node_thread_counts")
|
||||
redis_client.publish("domain_tool:config_update", "node_process_counts")
|
||||
redis_client.publish("domain_tool:config_update", "detect_options")
|
||||
redis_client.publish("domain_tool:config_update", "proxy_config")
|
||||
redis_client.publish("domain_tool:config_update", "thread_count")
|
||||
redis_client.publish("domain_tool:config_update", "process_count")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -223,6 +321,8 @@ def update_settings_payload(payload: dict) -> dict:
|
||||
"proxy_config": proxy_config,
|
||||
"thread_count": thread_count,
|
||||
"node_thread_counts": node_thread_counts,
|
||||
"process_count": process_count,
|
||||
"node_process_counts": node_process_counts,
|
||||
"current_node_code": app_settings.node_code,
|
||||
"runtime_settings": runtime_settings,
|
||||
}
|
||||
@@ -254,6 +354,12 @@ def validate_settings_payload(payload: dict) -> None:
|
||||
if "node_thread_counts" in payload:
|
||||
_normalize_node_thread_counts(payload["node_thread_counts"])
|
||||
|
||||
if "process_count" in payload:
|
||||
_normalize_process_count(payload["process_count"])
|
||||
|
||||
if "node_process_counts" in payload:
|
||||
_normalize_node_process_counts(payload["node_process_counts"])
|
||||
|
||||
if "detect_options" in payload:
|
||||
detect_options = payload["detect_options"]
|
||||
if not isinstance(detect_options, dict):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -11,17 +12,30 @@ from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.redis_client import get_redis
|
||||
from app.services.cluster_runtime_service import (
|
||||
cleanup_imported_runtime_nodes,
|
||||
cleanup_imported_runtime_nodes_many,
|
||||
get_cluster_snapshot,
|
||||
register_node_heartbeat,
|
||||
)
|
||||
from app.services.detect_job_service import (
|
||||
_load_domain_pipeline_snapshot,
|
||||
get_active_detect_job_summary,
|
||||
resolve_initial_domain_pipeline_item,
|
||||
)
|
||||
from app.services.settings_service import get_settings_payload
|
||||
from app.services.sync_record_service import _decode_json, _normalize_region
|
||||
from app.services.settings_service import (
|
||||
get_settings_payload,
|
||||
resolve_process_count,
|
||||
resolve_thread_count,
|
||||
)
|
||||
from app.services.sync_record_service import (
|
||||
_RUNTIME_PROJECTION_FUTURE_SKEW_GRACE,
|
||||
_decode_json,
|
||||
_normalize_region,
|
||||
_pick_latest_projection_row,
|
||||
append_runtime_projection_if_changed,
|
||||
)
|
||||
|
||||
|
||||
_DETECT_RESULT_EVENT_TYPES = {
|
||||
@@ -30,6 +44,222 @@ _DETECT_RESULT_EVENT_TYPES = {
|
||||
"domain_failed",
|
||||
"domain_blacklisted",
|
||||
}
|
||||
_LOCAL_BACKLOG_PENDING_FRESHNESS_HOURS = 6
|
||||
_LOCAL_BACKLOG_MAX_JOBS = 4
|
||||
_SYNC_PULL_WORKER_WAKE_TTL_SECONDS = 20
|
||||
_SYNC_PULL_WORKER_WAKE_KEY_PREFIX = "domain_tool:sync_pull_worker_wake"
|
||||
|
||||
|
||||
def _flag_enabled(raw_value: object, *, default: bool = False) -> bool:
|
||||
if raw_value is None:
|
||||
return bool(default)
|
||||
if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
return str(raw_value or "").strip().lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _fast_runtime_projection_enabled() -> bool:
|
||||
return _flag_enabled(
|
||||
os.getenv("DOMAINCHECK_SYNC_RUNTIME_FAST_PROJECTION"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
def _local_projection_node_code(node_code: str) -> bool:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
local_node_code = str(settings.node_code or "").strip()
|
||||
if not normalized_node_code or not local_node_code:
|
||||
return False
|
||||
return normalized_node_code == local_node_code or normalized_node_code.startswith(f"{local_node_code}-")
|
||||
|
||||
|
||||
def _append_fast_runtime_projection_snapshot() -> int | None:
|
||||
local_node_code = str(settings.node_code or "").strip()
|
||||
if not local_node_code:
|
||||
return None
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, region, role, status, current_load, metadata_json
|
||||
FROM detect_worker_nodes
|
||||
WHERE node_code = %s OR node_code LIKE %s
|
||||
ORDER BY node_code ASC
|
||||
""",
|
||||
(local_node_code, f"{local_node_code}-%"),
|
||||
)
|
||||
raw_rows = list(cur.fetchall() or [])
|
||||
|
||||
if not raw_rows:
|
||||
return None
|
||||
|
||||
cluster_nodes: list[dict] = []
|
||||
queue_nodes: list[dict] = []
|
||||
busy_nodes: list[str] = []
|
||||
stale_nodes: list[str] = []
|
||||
offline_nodes: list[str] = []
|
||||
online_worker_nodes = 0
|
||||
dedicated_online_worker_nodes = 0
|
||||
online_control_nodes = 0
|
||||
display_running = 0
|
||||
display_max_threads = 0
|
||||
controller_metadata: dict = {}
|
||||
|
||||
for node_code, region, role, status, current_load, metadata_json in raw_rows:
|
||||
metadata = dict(metadata_json or {})
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
normalized_role = str(role or metadata.get("source_role") or "").strip() or "worker"
|
||||
normalized_status = str(status or metadata.get("source_status") or "").strip() or "unknown"
|
||||
normalized_region = _normalize_region(region, settings.node_region)
|
||||
normalized_current_load = int(current_load or 0)
|
||||
active_threads = int(metadata.get("active_threads", 0) or 0)
|
||||
max_threads = int(metadata.get("max_threads", 0) or 0)
|
||||
detect_participating = bool(metadata.get("detect_participating", False) or normalized_current_load > 0 or active_threads > 0)
|
||||
runtime_running = max(active_threads, normalized_current_load)
|
||||
|
||||
if normalized_status == "busy":
|
||||
busy_nodes.append(normalized_node_code)
|
||||
elif normalized_status == "stale":
|
||||
stale_nodes.append(normalized_node_code)
|
||||
elif normalized_status == "offline":
|
||||
offline_nodes.append(normalized_node_code)
|
||||
|
||||
if normalized_status not in {"stale", "offline"}:
|
||||
if normalized_role == "worker":
|
||||
online_worker_nodes += 1
|
||||
if normalized_node_code != local_node_code:
|
||||
dedicated_online_worker_nodes += 1
|
||||
elif normalized_role == "control":
|
||||
online_control_nodes += 1
|
||||
|
||||
cluster_nodes.append(
|
||||
{
|
||||
"node_code": normalized_node_code,
|
||||
"role": normalized_role,
|
||||
"status": normalized_status,
|
||||
"current_load": normalized_current_load,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"detect_participating": detect_participating,
|
||||
}
|
||||
)
|
||||
|
||||
queue_nodes.append(
|
||||
{
|
||||
"node_code": normalized_node_code,
|
||||
"items_total": 0,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 0,
|
||||
"items_running": runtime_running,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"display_running": runtime_running,
|
||||
"display_claimed": 0,
|
||||
"current_load": normalized_current_load,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"role": normalized_role,
|
||||
"status": normalized_status,
|
||||
"detect_participating": detect_participating,
|
||||
}
|
||||
)
|
||||
|
||||
display_running += runtime_running
|
||||
display_max_threads += max_threads
|
||||
|
||||
if normalized_node_code == local_node_code:
|
||||
controller_metadata = metadata
|
||||
|
||||
controller_job_code = str(controller_metadata.get("active_job_code") or "").strip()
|
||||
controller_job_status = str(controller_metadata.get("active_job_status") or "").strip()
|
||||
detect_payload = {
|
||||
"worker_online": True,
|
||||
"worker_mode": str(controller_metadata.get("worker_mode") or "linux-systemd").strip() or "linux-systemd",
|
||||
"phase_label": str(controller_metadata.get("phase_label") or "集群执行中").strip() or "集群执行中",
|
||||
"phase_detail": str(controller_metadata.get("phase_detail") or "").strip(),
|
||||
"proxy_runtime_label": str(controller_metadata.get("proxy_runtime_label") or "").strip(),
|
||||
"proxy_runtime_reason": str(controller_metadata.get("proxy_runtime_reason") or "").strip(),
|
||||
"detect_participating": bool(display_running > 0),
|
||||
"progress": {
|
||||
"pending": 0,
|
||||
"running": display_running,
|
||||
"completed": 0,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 0,
|
||||
"pending": 0,
|
||||
"claimed": 0,
|
||||
"running": display_running,
|
||||
"completed": 0,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"terminal": 0,
|
||||
"display_claimed": 0,
|
||||
"display_running": display_running,
|
||||
"display_max_threads": display_max_threads,
|
||||
},
|
||||
"nodes": list(queue_nodes),
|
||||
},
|
||||
"active_job": {
|
||||
"job_id": None,
|
||||
"job_code": controller_job_code,
|
||||
"status": controller_job_status,
|
||||
"progress_percent": 0,
|
||||
"items_total": 0,
|
||||
"items_terminal": 0,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 0,
|
||||
"items_running": display_running,
|
||||
"items_failed": 0,
|
||||
"items_completed": 0,
|
||||
"display_items_claimed": 0,
|
||||
"display_items_running": display_running,
|
||||
"display_active_threads": display_running,
|
||||
"display_max_threads": display_max_threads,
|
||||
"node_stats": list(queue_nodes),
|
||||
"distributed_node_stats": list(queue_nodes),
|
||||
},
|
||||
"backlog": {},
|
||||
"dependency_alerts": [],
|
||||
}
|
||||
cluster_payload = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": item["node_code"],
|
||||
"role": item["role"],
|
||||
"status": item["status"],
|
||||
"current_load": item["current_load"],
|
||||
"metadata": {
|
||||
"active_threads": item["active_threads"],
|
||||
"max_threads": item["max_threads"],
|
||||
"detect_participating": item["detect_participating"],
|
||||
"source_role": item["role"],
|
||||
"source_status": item["status"],
|
||||
},
|
||||
}
|
||||
for item in cluster_nodes
|
||||
],
|
||||
"nodes_total": len(cluster_nodes),
|
||||
"summary": {
|
||||
"busy_nodes": busy_nodes,
|
||||
"stale_nodes": stale_nodes,
|
||||
"offline_nodes": offline_nodes,
|
||||
"online_worker_nodes": online_worker_nodes,
|
||||
"dedicated_online_worker_nodes": dedicated_online_worker_nodes,
|
||||
"online_control_nodes": online_control_nodes,
|
||||
},
|
||||
}
|
||||
return append_runtime_projection_if_changed(
|
||||
detect=detect_payload,
|
||||
cluster=cluster_payload,
|
||||
source_region=_normalize_region(settings.sync_source_region, settings.node_region),
|
||||
target_region=_normalize_region(settings.sync_target_region, "overseas"),
|
||||
)
|
||||
|
||||
|
||||
def _format_time(value: datetime | None) -> str:
|
||||
@@ -69,6 +299,42 @@ def _task_ack_url(base_url: str) -> str:
|
||||
return f"{text}/api/v1/runtime/task-ack"
|
||||
|
||||
|
||||
def _build_sync_pull_worker_wake_key(
|
||||
*,
|
||||
projection_job_code: str = "",
|
||||
projection_cycle_token: str = "",
|
||||
target_job_code: str = "",
|
||||
source_record_id: int = 0,
|
||||
) -> str:
|
||||
scope = (
|
||||
str(projection_cycle_token or "").strip()
|
||||
or str(projection_job_code or "").strip()
|
||||
or str(target_job_code or "").strip()
|
||||
or f"record-{int(source_record_id or 0)}"
|
||||
)
|
||||
return f"{_SYNC_PULL_WORKER_WAKE_KEY_PREFIX}:{scope}"
|
||||
|
||||
|
||||
def _acquire_sync_pull_worker_wake_guard(key: str, ttl_seconds: int = _SYNC_PULL_WORKER_WAKE_TTL_SECONDS) -> bool:
|
||||
normalized_key = str(key or "").strip()
|
||||
if not normalized_key:
|
||||
return True
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
return bool(
|
||||
redis_client.set(
|
||||
normalized_key,
|
||||
datetime.now().isoformat(timespec="seconds"),
|
||||
ex=max(1, int(ttl_seconds or 1)),
|
||||
nx=True,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Wake dedupe is a throughput optimization; fall back to legacy behavior
|
||||
# if Redis is temporarily unavailable.
|
||||
return True
|
||||
|
||||
|
||||
def _projection_ingest_type(sync_type: str) -> str:
|
||||
if sync_type == "runtime_projection":
|
||||
return "runtime_ingest"
|
||||
@@ -126,21 +392,82 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
cleanup_imported_runtime_nodes(region=region, role=role, keep_node_code=node_code)
|
||||
|
||||
active_job = projection.get("active_job") or {}
|
||||
worker_node_codes: list[str] = []
|
||||
worker_rows_by_code: dict[str, dict] = {}
|
||||
for cluster_node in list(projection.get("cluster_nodes") or []):
|
||||
if not isinstance(cluster_node, dict):
|
||||
continue
|
||||
worker_node_code = str(cluster_node.get("node_code") or "").strip()
|
||||
if not worker_node_code or worker_node_code == node_code:
|
||||
continue
|
||||
worker_rows_by_code[worker_node_code] = {
|
||||
"node_code": worker_node_code,
|
||||
"role": str(cluster_node.get("role") or "worker").strip() or "worker",
|
||||
"status": str(cluster_node.get("status") or "").strip(),
|
||||
"current_load": int(cluster_node.get("current_load", 0) or 0),
|
||||
"active_threads": int(cluster_node.get("active_threads", 0) or 0),
|
||||
"max_threads": int(cluster_node.get("max_threads", 0) or 0),
|
||||
"detect_participating": bool(cluster_node.get("detect_participating", False)),
|
||||
"items_total": 0,
|
||||
"items_running": 0,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"metrics_source": "runtime",
|
||||
}
|
||||
|
||||
for node_stat in list(active_job.get("node_stats") or []):
|
||||
worker_node_code = str(node_stat.get("node_code") or "").strip()
|
||||
if not worker_node_code or worker_node_code == "unassigned":
|
||||
if not worker_node_code or worker_node_code == "unassigned" or worker_node_code == node_code:
|
||||
continue
|
||||
if worker_node_code == node_code:
|
||||
continue
|
||||
items_running = int(node_stat.get("items_running", 0) or 0)
|
||||
items_claimed = int(node_stat.get("items_claimed", 0) or 0)
|
||||
items_total = int(node_stat.get("items_total", 0) or 0)
|
||||
worker_runtime_load = int(node_stat.get("current_load", 0) or 0)
|
||||
worker_active_threads = int(node_stat.get("active_threads", worker_runtime_load) or 0)
|
||||
worker_max_threads = int(node_stat.get("max_threads", 0) or 0)
|
||||
worker_load = max(worker_active_threads, items_running, 0)
|
||||
worker_status = "busy" if worker_load > 0 else "online"
|
||||
worker_row = worker_rows_by_code.setdefault(
|
||||
worker_node_code,
|
||||
{
|
||||
"node_code": worker_node_code,
|
||||
"role": str(node_stat.get("role") or "worker").strip() or "worker",
|
||||
"status": str(node_stat.get("status") or "").strip(),
|
||||
"current_load": int(node_stat.get("current_load", 0) or 0),
|
||||
"active_threads": int(node_stat.get("active_threads", 0) or 0),
|
||||
"max_threads": int(node_stat.get("max_threads", 0) or 0),
|
||||
"detect_participating": False,
|
||||
"items_total": 0,
|
||||
"items_running": 0,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
)
|
||||
worker_row["role"] = str(node_stat.get("role") or worker_row.get("role") or "worker").strip() or "worker"
|
||||
worker_row["status"] = str(node_stat.get("status") or worker_row.get("status") or "").strip()
|
||||
worker_row["current_load"] = max(int(worker_row.get("current_load", 0) or 0), int(node_stat.get("current_load", 0) or 0))
|
||||
worker_row["active_threads"] = max(int(worker_row.get("active_threads", 0) or 0), int(node_stat.get("active_threads", 0) or 0))
|
||||
worker_row["max_threads"] = max(int(worker_row.get("max_threads", 0) or 0), int(node_stat.get("max_threads", 0) or 0))
|
||||
worker_row["detect_participating"] = bool(
|
||||
worker_row.get("detect_participating", False)
|
||||
or int(node_stat.get("items_running", 0) or 0) > 0
|
||||
or int(node_stat.get("items_claimed", 0) or 0) > 0
|
||||
or int(node_stat.get("active_threads", 0) or 0) > 0
|
||||
)
|
||||
worker_row["items_total"] = int(node_stat.get("items_total", worker_row.get("items_total", 0)) or 0)
|
||||
worker_row["items_running"] = int(node_stat.get("items_running", worker_row.get("items_running", 0)) or 0)
|
||||
worker_row["items_claimed"] = int(node_stat.get("items_claimed", worker_row.get("items_claimed", 0)) or 0)
|
||||
worker_row["items_completed"] = int(node_stat.get("items_completed", worker_row.get("items_completed", 0)) or 0)
|
||||
worker_row["items_failed"] = int(node_stat.get("items_failed", worker_row.get("items_failed", 0)) or 0)
|
||||
worker_row["items_blacklisted"] = int(node_stat.get("items_blacklisted", worker_row.get("items_blacklisted", 0)) or 0)
|
||||
worker_row["metrics_source"] = str(node_stat.get("metrics_source") or worker_row.get("metrics_source") or "runtime").strip() or "runtime"
|
||||
|
||||
worker_node_codes: list[str] = []
|
||||
for worker_node_code, worker_row in sorted(worker_rows_by_code.items()):
|
||||
items_running = int(worker_row.get("items_running", 0) or 0)
|
||||
items_claimed = int(worker_row.get("items_claimed", 0) or 0)
|
||||
items_total = int(worker_row.get("items_total", 0) or 0)
|
||||
worker_runtime_load = int(worker_row.get("current_load", 0) or 0)
|
||||
worker_active_threads = int(worker_row.get("active_threads", worker_runtime_load) or 0)
|
||||
worker_max_threads = int(worker_row.get("max_threads", 0) or 0)
|
||||
worker_load = max(worker_active_threads, items_running, worker_runtime_load, 0)
|
||||
worker_status = str(worker_row.get("status") or "").strip() or ("busy" if worker_load > 0 else "online")
|
||||
worker_metadata = {
|
||||
"service": "runtime-ingest",
|
||||
"projection_source_region": source_region,
|
||||
@@ -155,12 +482,13 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
"job_items_total": items_total,
|
||||
"job_items_running": items_running,
|
||||
"job_items_claimed": items_claimed,
|
||||
"job_items_completed": int(node_stat.get("items_completed", 0) or 0),
|
||||
"job_items_failed": int(node_stat.get("items_failed", 0) or 0),
|
||||
"job_items_blacklisted": int(node_stat.get("items_blacklisted", 0) or 0),
|
||||
"metrics_source": str(node_stat.get("metrics_source") or "runtime").strip() or "runtime",
|
||||
"source_status": str(node_stat.get("status") or "").strip(),
|
||||
"source_role": str(node_stat.get("role") or "worker").strip() or "worker",
|
||||
"job_items_completed": int(worker_row.get("items_completed", 0) or 0),
|
||||
"job_items_failed": int(worker_row.get("items_failed", 0) or 0),
|
||||
"job_items_blacklisted": int(worker_row.get("items_blacklisted", 0) or 0),
|
||||
"metrics_source": str(worker_row.get("metrics_source") or "runtime").strip() or "runtime",
|
||||
"source_status": str(worker_row.get("status") or "").strip(),
|
||||
"source_role": str(worker_row.get("role") or "worker").strip() or "worker",
|
||||
"detect_participating": bool(worker_row.get("detect_participating", False)),
|
||||
"derived_from": node_code,
|
||||
}
|
||||
register_node_heartbeat(
|
||||
@@ -181,6 +509,7 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
def _load_latest_projection(sync_type: str) -> dict | None:
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
future_cutoff = datetime.now() + _RUNTIME_PROJECTION_FUTURE_SKEW_GRACE
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -190,12 +519,15 @@ def _load_latest_projection(sync_type: str) -> dict | None:
|
||||
WHERE sync_type = %s
|
||||
AND source_region = %s
|
||||
AND target_region = %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
CASE WHEN created_at <= %s THEN 0 ELSE 1 END ASC,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(sync_type, source_region, target_region),
|
||||
(sync_type, source_region, target_region, future_cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
row = _pick_latest_projection_row(list(cur.fetchall() or []), created_at_index=5)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
@@ -222,7 +554,7 @@ def _load_pushable_projections(sync_type: str, limit: int) -> list[dict]:
|
||||
WHERE sync_type = %s
|
||||
AND source_region = %s
|
||||
AND target_region = %s
|
||||
ORDER BY created_at ASC, id ASC
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(sync_type, source_region, target_region, safe_limit * 5),
|
||||
@@ -259,20 +591,158 @@ def _load_pushable_projections(sync_type: str, limit: int) -> list[dict]:
|
||||
|
||||
def _estimate_total_worker_threads(settings_payload: dict | None = None) -> int:
|
||||
payload = settings_payload if isinstance(settings_payload, dict) else get_settings_payload()
|
||||
default_threads = max(1, int(payload.get("thread_count", 100) or 100))
|
||||
node_thread_counts = payload.get("node_thread_counts") if isinstance(payload.get("node_thread_counts"), dict) else {}
|
||||
total_threads = 0
|
||||
for raw_value in node_thread_counts.values():
|
||||
thread_info = resolve_thread_count(settings.node_code, settings_payload=payload)
|
||||
process_info = resolve_process_count(settings.node_code, settings_payload=payload)
|
||||
effective_threads = max(
|
||||
1,
|
||||
int(thread_info.get("effective_thread_count", payload.get("thread_count", 100)) or 100),
|
||||
)
|
||||
effective_process_count = max(
|
||||
1,
|
||||
int(process_info.get("effective_process_count", payload.get("process_count", 1)) or 1),
|
||||
)
|
||||
return effective_threads * effective_process_count
|
||||
|
||||
|
||||
def _sync_task_projection_limit_cap() -> int:
|
||||
configured_cap = int(os.getenv("DOMAINCHECK_SYNC_TASK_LIMIT_CAP", "200000") or 200000)
|
||||
return max(10000, configured_cap)
|
||||
|
||||
|
||||
def _resolve_task_pull_request_limit(limit: int | None, settings_payload: dict | None = None) -> int:
|
||||
configured = max(5000, int(settings.sync_batch_size or 200))
|
||||
estimated_total_threads = _estimate_total_worker_threads(settings_payload)
|
||||
cap = _sync_task_projection_limit_cap()
|
||||
default_limit = max(
|
||||
configured,
|
||||
min(cap, max(10000, estimated_total_threads * 2)),
|
||||
)
|
||||
requested = int(limit or default_limit)
|
||||
return max(1, min(requested, cap))
|
||||
|
||||
|
||||
def _select_relevant_backlog_job_ids_from_rows(
|
||||
job_rows: list[tuple[object, object, object]] | tuple[tuple[object, object, object], ...],
|
||||
*,
|
||||
freshness_hours: int = _LOCAL_BACKLOG_PENDING_FRESHNESS_HOURS,
|
||||
limit: int = _LOCAL_BACKLOG_MAX_JOBS,
|
||||
) -> list[int]:
|
||||
safe_limit = max(1, min(int(limit or _LOCAL_BACKLOG_MAX_JOBS), 16))
|
||||
safe_freshness_hours = max(1, min(int(freshness_hours or _LOCAL_BACKLOG_PENDING_FRESHNESS_HOURS), 168))
|
||||
|
||||
selected: list[int] = []
|
||||
fallback_job_id = 0
|
||||
for raw_job_id, raw_status, raw_activity_at in list(job_rows or []):
|
||||
try:
|
||||
total_threads += max(0, int(raw_value or 0))
|
||||
job_id = int(raw_job_id or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return max(total_threads, default_threads)
|
||||
if job_id <= 0:
|
||||
continue
|
||||
if fallback_job_id <= 0:
|
||||
fallback_job_id = job_id
|
||||
if job_id in selected:
|
||||
continue
|
||||
status = str(raw_status or "").strip().lower()
|
||||
keep = status == "running"
|
||||
if not keep and raw_activity_at is not None:
|
||||
now = datetime.now(raw_activity_at.tzinfo) if getattr(raw_activity_at, "tzinfo", None) else datetime.now()
|
||||
keep = now - raw_activity_at <= timedelta(hours=safe_freshness_hours)
|
||||
if not keep:
|
||||
continue
|
||||
selected.append(job_id)
|
||||
if len(selected) >= safe_limit:
|
||||
break
|
||||
if not selected and fallback_job_id > 0:
|
||||
selected.append(fallback_job_id)
|
||||
return selected
|
||||
|
||||
|
||||
def _build_backlog_snapshot_from_active_job(active_job: dict | None) -> dict:
|
||||
normalized_job = dict(active_job or {})
|
||||
if not normalized_job:
|
||||
return {}
|
||||
|
||||
pending_total = max(0, int(normalized_job.get("items_pending", 0) or 0))
|
||||
claimed_total = max(
|
||||
max(
|
||||
int(normalized_job.get("items_claimed", 0) or 0),
|
||||
int(normalized_job.get("display_items_claimed", 0) or 0),
|
||||
),
|
||||
0,
|
||||
)
|
||||
running_total = max(
|
||||
max(
|
||||
int(normalized_job.get("items_running", 0) or 0),
|
||||
int(normalized_job.get("display_items_running", 0) or 0),
|
||||
),
|
||||
int(normalized_job.get("display_active_threads", 0) or 0),
|
||||
0,
|
||||
)
|
||||
|
||||
register_pending = 0
|
||||
downstream_pending = 0
|
||||
for raw_step in list(normalized_job.get("step_stats") or normalized_job.get("raw_step_stats") or []):
|
||||
if not isinstance(raw_step, dict):
|
||||
continue
|
||||
step_code = str(raw_step.get("step_code") or raw_step.get("code") or "").strip()
|
||||
step_pending = max(
|
||||
int(raw_step.get("items_pending", raw_step.get("pending", 0)) or 0),
|
||||
0,
|
||||
)
|
||||
if step_pending <= 0:
|
||||
continue
|
||||
if step_code == "detect_register":
|
||||
register_pending += step_pending
|
||||
else:
|
||||
downstream_pending += step_pending
|
||||
|
||||
if register_pending <= 0 and downstream_pending <= 0 and pending_total > 0:
|
||||
downstream_pending = pending_total
|
||||
|
||||
if pending_total <= 0 and claimed_total <= 0 and running_total <= 0:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"pending_total": pending_total,
|
||||
"claimed_total": claimed_total,
|
||||
"running_total": running_total,
|
||||
"register_pending": register_pending,
|
||||
"downstream_pending": downstream_pending,
|
||||
}
|
||||
|
||||
|
||||
def _load_local_detect_backlog_snapshot() -> dict:
|
||||
active_job_snapshot = _build_backlog_snapshot_from_active_job(
|
||||
get_active_detect_job_summary(event_limit=1)
|
||||
)
|
||||
if active_job_snapshot:
|
||||
return active_job_snapshot
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status, COALESCE(started_at, created_at) AS activity_at
|
||||
FROM detect_jobs
|
||||
WHERE status IN ('pending', 'running')
|
||||
ORDER BY
|
||||
CASE WHEN status = 'running' THEN 0 ELSE 1 END,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(_LOCAL_BACKLOG_MAX_JOBS * 8,),
|
||||
)
|
||||
selected_job_ids = _select_relevant_backlog_job_ids_from_rows(cur.fetchall())
|
||||
if not selected_job_ids:
|
||||
return {
|
||||
"pending_total": 0,
|
||||
"claimed_total": 0,
|
||||
"running_total": 0,
|
||||
"register_pending": 0,
|
||||
"downstream_pending": 0,
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -282,9 +752,9 @@ def _load_local_detect_backlog_snapshot() -> dict:
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code = 'detect_register') AS register_pending,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code <> 'detect_register') AS downstream_pending
|
||||
FROM detect_job_items item
|
||||
JOIN detect_jobs job ON job.id = item.job_id
|
||||
WHERE job.status IN ('pending', 'running')
|
||||
"""
|
||||
WHERE item.job_id = ANY(%s)
|
||||
""",
|
||||
(selected_job_ids,),
|
||||
)
|
||||
row = cur.fetchone() or (0, 0, 0, 0, 0)
|
||||
return {
|
||||
@@ -422,9 +892,9 @@ def _task_selection_sql() -> str:
|
||||
|
||||
|
||||
def _task_projection_limit(limit: int | None) -> int:
|
||||
requested = max(1, int(limit or 5000))
|
||||
requested = max(1, int(limit or max(5000, int(settings.sync_batch_size or 200))))
|
||||
configured = max(5000, int(settings.sync_batch_size or 200))
|
||||
cap = max(10000, configured, 5000)
|
||||
cap = max(configured, _sync_task_projection_limit_cap())
|
||||
return max(1, min(requested, cap))
|
||||
|
||||
|
||||
@@ -1422,15 +1892,21 @@ def ingest_runtime_projection(payload: dict, *, shared_token: str | None = None)
|
||||
|
||||
def _push_projection_now(sync_type: str, ingest_url: str) -> tuple[bool, str, dict]:
|
||||
if sync_type == "runtime_projection":
|
||||
# Regenerate the runtime snapshot before every push so the sync agent
|
||||
# does not keep replaying a stale projection record while the worker
|
||||
# thread count / phase is still changing.
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
if _fast_runtime_projection_enabled():
|
||||
try:
|
||||
_append_fast_runtime_projection_snapshot()
|
||||
except Exception as exc:
|
||||
return False, f"快速刷新 runtime_projection 失败: {exc}", {"action": "push_sync", "sync_type": sync_type}
|
||||
else:
|
||||
# Refresh only the lightweight runtime projection snapshot before every
|
||||
# push so the sync agent does not keep replaying a stale record while
|
||||
# avoiding the full runtime/status assembly cost.
|
||||
from app.services.runtime_status_service import refresh_runtime_projection_snapshot
|
||||
|
||||
try:
|
||||
get_runtime_status()
|
||||
except Exception as exc:
|
||||
return False, f"刷新 runtime_projection 失败: {exc}", {"action": "push_sync", "sync_type": sync_type}
|
||||
try:
|
||||
refresh_runtime_projection_snapshot(window_minutes=15)
|
||||
except Exception as exc:
|
||||
return False, f"刷新 runtime_projection 失败: {exc}", {"action": "push_sync", "sync_type": sync_type}
|
||||
source_record = _load_latest_projection(sync_type)
|
||||
if not source_record:
|
||||
return False, f"当前没有可推送的{sync_type}", {"action": "push_sync", "sync_type": sync_type}
|
||||
@@ -1667,12 +2143,10 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
if not export_url or not ack_url:
|
||||
return False, "未配置任务拉取目标地址", {"action": "pull_tasks", "pull_state": "misconfigured", "ui_level": "warning", "poll_schedule_seconds": []}
|
||||
|
||||
configured_limit = max(5000, int(settings.sync_batch_size or 200))
|
||||
requested_limit = int(limit or configured_limit)
|
||||
safe_limit = max(1, min(requested_limit, max(10000, configured_limit)))
|
||||
settings_payload = get_settings_payload()
|
||||
safe_limit = _resolve_task_pull_request_limit(limit, settings_payload=settings_payload)
|
||||
backlog_snapshot = _load_local_detect_backlog_snapshot()
|
||||
backlog_limits = _build_task_pull_backlog_limits(configured_limit, settings_payload=settings_payload)
|
||||
backlog_limits = _build_task_pull_backlog_limits(safe_limit, settings_payload=settings_payload)
|
||||
should_throttle, throttle_reason = _should_throttle_task_pull(backlog_snapshot, backlog_limits)
|
||||
if should_throttle:
|
||||
return True, "本地待处理积压较高,暂停拉取新批次", {
|
||||
@@ -1804,17 +2278,48 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
try:
|
||||
from app.services.worker_control_service import send_worker_command
|
||||
|
||||
start_ok, start_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload={
|
||||
"source": "sync-pull",
|
||||
"source_record_id": source_record_id,
|
||||
"target_job_id": int(result.get("target_job_id", 0) or 0),
|
||||
"target_job_code": str(result.get("target_job_code") or "").strip(),
|
||||
},
|
||||
projection_active_job = dict(projection.get("active_job") or {})
|
||||
projection_job_id = int(projection_active_job.get("job_id", 0) or 0)
|
||||
projection_job_code = str(projection_active_job.get("job_code") or "").strip()
|
||||
projection_cycle_token = str(
|
||||
projection_active_job.get("current_cycle_token")
|
||||
or projection_active_job.get("cycle_token")
|
||||
or ""
|
||||
).strip()
|
||||
start_payload = {
|
||||
"source": "sync-pull",
|
||||
"source_record_id": source_record_id,
|
||||
"target_job_id": int(result.get("target_job_id", 0) or 0),
|
||||
"target_job_code": str(result.get("target_job_code") or "").strip(),
|
||||
}
|
||||
# Mainland ingest creates a local target_job_* for queue ownership, but
|
||||
# worker runtime/log identity should still follow the upstream active
|
||||
# detect job so cluster aggregation keeps controller activity attached
|
||||
# to the real pipeline job instead of the local sync-pull surrogate.
|
||||
if projection_job_id > 0:
|
||||
start_payload["job_id"] = projection_job_id
|
||||
if projection_job_code:
|
||||
start_payload["job_code"] = projection_job_code
|
||||
if projection_cycle_token:
|
||||
start_payload["cycle_token"] = projection_cycle_token
|
||||
wake_guard_key = _build_sync_pull_worker_wake_key(
|
||||
projection_job_code=projection_job_code,
|
||||
projection_cycle_token=projection_cycle_token,
|
||||
target_job_code=str(result.get("target_job_code") or "").strip(),
|
||||
source_record_id=source_record_id,
|
||||
)
|
||||
result["worker_start_ok"] = bool(start_ok)
|
||||
result["worker_start_message"] = str(start_message or "").strip()
|
||||
if _acquire_sync_pull_worker_wake_guard(wake_guard_key):
|
||||
start_ok, start_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload=start_payload,
|
||||
)
|
||||
result["worker_start_ok"] = bool(start_ok)
|
||||
result["worker_start_message"] = str(start_message or "").strip()
|
||||
result["worker_start_skipped"] = False
|
||||
else:
|
||||
result["worker_start_ok"] = True
|
||||
result["worker_start_skipped"] = True
|
||||
result["worker_start_message"] = "已跳过同任务短窗内重复 Worker 唤起"
|
||||
except Exception as exc:
|
||||
result["worker_start_ok"] = False
|
||||
result["worker_start_message"] = f"同步入库后自动唤起 Worker 失败: {exc}"
|
||||
|
||||
@@ -51,6 +51,80 @@ _TERMINAL_DETECT_RESULT_EVENT_TYPES = {
|
||||
"domain_blacklisted",
|
||||
}
|
||||
|
||||
_RUNTIME_PROJECTION_HEARTBEAT_INTERVAL = timedelta(seconds=45)
|
||||
_RUNTIME_PROJECTION_FUTURE_SKEW_GRACE = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _runtime_projection_activity_signature(projection: dict) -> dict:
|
||||
normalized_projection = dict(projection or {})
|
||||
active_job = dict(normalized_projection.get("active_job") or {})
|
||||
normalized_nodes: list[tuple] = []
|
||||
for raw_item in list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or []):
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
continue
|
||||
normalized_nodes.append(
|
||||
(
|
||||
node_code,
|
||||
int(raw_item.get("display_running", raw_item.get("current_load", 0)) or 0),
|
||||
int(raw_item.get("active_threads", 0) or 0),
|
||||
int(raw_item.get("max_threads", 0) or 0),
|
||||
int(raw_item.get("items_claimed", 0) or 0),
|
||||
int(raw_item.get("items_running", 0) or 0),
|
||||
int(raw_item.get("items_total", 0) or 0),
|
||||
str(raw_item.get("status") or "").strip(),
|
||||
)
|
||||
)
|
||||
normalized_cluster_nodes: list[tuple] = []
|
||||
for raw_item in list(normalized_projection.get("cluster_nodes") or []):
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
continue
|
||||
normalized_cluster_nodes.append(
|
||||
(
|
||||
node_code,
|
||||
str(raw_item.get("role") or "").strip(),
|
||||
str(raw_item.get("status") or "").strip(),
|
||||
int(raw_item.get("current_load", 0) or 0),
|
||||
int(raw_item.get("active_threads", 0) or 0),
|
||||
int(raw_item.get("max_threads", 0) or 0),
|
||||
bool(raw_item.get("detect_participating", False)),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"active_thread_count": int(normalized_projection.get("active_thread_count", 0) or 0),
|
||||
"max_thread_count": int(normalized_projection.get("max_thread_count", 0) or 0),
|
||||
"job_display_running": int(active_job.get("display_items_running", 0) or 0),
|
||||
"job_display_claimed": int(active_job.get("display_items_claimed", 0) or 0),
|
||||
"job_display_max_threads": int(active_job.get("display_max_threads", 0) or 0),
|
||||
"job_items_total": int(active_job.get("items_total", 0) or 0),
|
||||
"job_items_running": int(active_job.get("items_running", 0) or 0),
|
||||
"job_items_claimed": int(active_job.get("items_claimed", 0) or 0),
|
||||
"node_stats": normalized_nodes,
|
||||
"cluster_nodes": normalized_cluster_nodes,
|
||||
}
|
||||
|
||||
|
||||
def _pick_latest_projection_row(rows: list[tuple], *, created_at_index: int) -> tuple | None:
|
||||
candidates = list(rows or [])
|
||||
if not candidates:
|
||||
return None
|
||||
fallback = candidates[0]
|
||||
for row in candidates:
|
||||
if len(row) <= int(created_at_index):
|
||||
return row
|
||||
created_at = row[created_at_index]
|
||||
if not isinstance(created_at, datetime):
|
||||
return row
|
||||
now = datetime.now(created_at.tzinfo) if created_at.tzinfo else datetime.now()
|
||||
if created_at <= now + _RUNTIME_PROJECTION_FUTURE_SKEW_GRACE:
|
||||
return row
|
||||
return fallback
|
||||
|
||||
|
||||
def _collect_recent_domain_events(active_job: dict, limit: int = 30) -> list[dict]:
|
||||
safe_limit = max(1, int(limit or 30))
|
||||
@@ -214,10 +288,15 @@ def _should_append_runtime_projection(previous_payload: dict, current_projection
|
||||
if previous_alerts != current_alerts:
|
||||
return True
|
||||
|
||||
if _runtime_projection_activity_signature(previous_projection) != _runtime_projection_activity_signature(current_projection):
|
||||
return True
|
||||
|
||||
if not previous_created_at:
|
||||
return True
|
||||
now = datetime.now(previous_created_at.tzinfo) if previous_created_at.tzinfo else datetime.now()
|
||||
return now - previous_created_at >= timedelta(seconds=45)
|
||||
if previous_created_at > now + _RUNTIME_PROJECTION_FUTURE_SKEW_GRACE:
|
||||
return True
|
||||
return now - previous_created_at >= _RUNTIME_PROJECTION_HEARTBEAT_INTERVAL
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
@@ -293,6 +372,25 @@ def get_detect_result_sync_batches(limit: int = 5) -> dict:
|
||||
safe_limit = max(1, min(int(limit or 5), 20))
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
local_worker_expected = _local_node_expected_to_execute_worker()
|
||||
if not local_worker_expected:
|
||||
return {
|
||||
"applicable": False,
|
||||
"local_worker_expected": False,
|
||||
"reason": "当前节点不承载本地检测执行,结果批次推送概览不适用。",
|
||||
"source_region": source_region,
|
||||
"target_region": target_region,
|
||||
"jobs_total": 0,
|
||||
"state_counts": {
|
||||
"synced": 0,
|
||||
"delivered": 0,
|
||||
"pushing": 0,
|
||||
"projected": 0,
|
||||
"failed": 0,
|
||||
"unsynced": 0,
|
||||
},
|
||||
"batches": [],
|
||||
}
|
||||
batches: list[dict] = []
|
||||
|
||||
with get_db() as conn:
|
||||
@@ -424,6 +522,9 @@ def get_detect_result_sync_batches(limit: int = 5) -> dict:
|
||||
state_counts[state] = state_counts.get(state, 0) + 1
|
||||
|
||||
return {
|
||||
"applicable": True,
|
||||
"local_worker_expected": local_worker_expected,
|
||||
"reason": "",
|
||||
"source_region": source_region,
|
||||
"target_region": target_region,
|
||||
"jobs_total": len(batches),
|
||||
@@ -436,6 +537,11 @@ def get_detect_result_sync_batches(limit: int = 5) -> dict:
|
||||
def get_sync_summary(record_limit: int = 10) -> dict:
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
local_worker_expected = _local_node_expected_to_execute_worker()
|
||||
push_expected_on_this_node = bool(
|
||||
str(settings.node_region or "").strip() == "mainland"
|
||||
and str(settings.node_role or "").strip() == "control"
|
||||
)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -486,6 +592,8 @@ def get_sync_summary(record_limit: int = 10) -> dict:
|
||||
|
||||
return {
|
||||
"enabled": bool(settings.sync_push_enabled),
|
||||
"push_expected_on_this_node": push_expected_on_this_node,
|
||||
"local_worker_expected_on_this_node": local_worker_expected,
|
||||
"source_region": source_region,
|
||||
"target_region": target_region,
|
||||
"target_api_base_url": settings.sync_target_api_base_url,
|
||||
@@ -540,6 +648,107 @@ def _local_node_expected_to_execute_worker() -> bool:
|
||||
return node_role == "worker" or (node_region == "mainland" and node_role == "control")
|
||||
|
||||
|
||||
def _projection_node_rows(*, detect: dict, active_job: dict) -> list[dict]:
|
||||
queue_health = dict(detect.get("queue_health") or {})
|
||||
queue_nodes = [
|
||||
dict(item)
|
||||
for item in list(queue_health.get("nodes") or [])
|
||||
if isinstance(item, dict) and str(item.get("node_code") or "").strip()
|
||||
]
|
||||
if queue_nodes:
|
||||
return queue_nodes
|
||||
return [
|
||||
dict(item)
|
||||
for item in list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
if isinstance(item, dict) and str(item.get("node_code") or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def _projection_cluster_node_rows(*, cluster: dict) -> list[dict]:
|
||||
normalized_rows: list[dict] = []
|
||||
for raw_item in list(cluster.get("nodes") or []):
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
node_code = str(raw_item.get("node_code") or "").strip()
|
||||
if not _is_local_projection_node(node_code):
|
||||
continue
|
||||
metadata = dict(raw_item.get("metadata") or {})
|
||||
normalized_rows.append(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"role": str(raw_item.get("role") or metadata.get("source_role") or "").strip(),
|
||||
"status": str(raw_item.get("status") or metadata.get("source_status") or "").strip(),
|
||||
"current_load": int(raw_item.get("current_load", 0) or 0),
|
||||
"active_threads": int(metadata.get("active_threads", 0) or 0),
|
||||
"max_threads": int(metadata.get("max_threads", raw_item.get("max_threads", 0)) or 0),
|
||||
"detect_participating": bool(
|
||||
raw_item.get("detect_participating", metadata.get("detect_participating", False))
|
||||
),
|
||||
}
|
||||
)
|
||||
normalized_rows.sort(key=lambda item: str(item.get("node_code") or ""))
|
||||
return normalized_rows
|
||||
|
||||
|
||||
def _projection_display_summary(*, detect: dict, active_job: dict, node_rows: list[dict]) -> dict:
|
||||
queue_payload = dict((detect.get("queue_health") or {}).get("queue") or {})
|
||||
display_running = 0
|
||||
display_max_threads = 0
|
||||
display_claimed = 0
|
||||
running_items = 0
|
||||
|
||||
for raw_item in list(node_rows or []):
|
||||
item = dict(raw_item or {})
|
||||
running_items += int(item.get("items_running", 0) or 0)
|
||||
display_running += max(
|
||||
int(item.get("display_running", 0) or 0),
|
||||
int(item.get("current_load", 0) or 0),
|
||||
int(item.get("active_threads", 0) or 0),
|
||||
int(item.get("items_running", 0) or 0),
|
||||
)
|
||||
display_max_threads += max(0, int(item.get("max_threads", 0) or 0))
|
||||
display_claimed += max(
|
||||
int(item.get("items_claimed", 0) or 0),
|
||||
int(item.get("display_claimed", 0) or 0),
|
||||
)
|
||||
|
||||
display_running = max(
|
||||
display_running,
|
||||
int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
int(active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0))) or 0),
|
||||
)
|
||||
running_items = max(
|
||||
running_items,
|
||||
int(queue_payload.get("running", 0) or 0),
|
||||
int(active_job.get("items_running", 0) or 0),
|
||||
)
|
||||
display_claimed = max(
|
||||
display_claimed,
|
||||
int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0),
|
||||
int(active_job.get("display_items_claimed", active_job.get("items_claimed", 0)) or 0),
|
||||
)
|
||||
display_max_threads = max(
|
||||
display_max_threads,
|
||||
int(active_job.get("display_max_threads", 0) or 0),
|
||||
int(detect.get("aggregate_max_thread_count", 0) or 0),
|
||||
int(detect.get("max_thread_count", 0) or 0),
|
||||
)
|
||||
return {
|
||||
"items_running": running_items,
|
||||
"display_running": display_running,
|
||||
"display_claimed": display_claimed,
|
||||
"display_max_threads": display_max_threads,
|
||||
}
|
||||
|
||||
|
||||
def _is_local_projection_node(node_code: str) -> bool:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
local_node_code = str(settings.node_code or "").strip()
|
||||
if not normalized_node_code or not local_node_code:
|
||||
return False
|
||||
return normalized_node_code == local_node_code or normalized_node_code.startswith(f"{local_node_code}-")
|
||||
|
||||
|
||||
def _build_runtime_projection_payload(
|
||||
*,
|
||||
detect: dict,
|
||||
@@ -549,6 +758,19 @@ def _build_runtime_projection_payload(
|
||||
) -> dict:
|
||||
active_job = detect.get("active_job") or {}
|
||||
local_worker_expected = _local_node_expected_to_execute_worker()
|
||||
projection_node_rows = _projection_node_rows(detect=detect, active_job=active_job) if local_worker_expected else []
|
||||
projection_cluster_rows = _projection_cluster_node_rows(cluster=cluster) if local_worker_expected else []
|
||||
display_summary = (
|
||||
_projection_display_summary(detect=detect, active_job=active_job, node_rows=projection_node_rows)
|
||||
if local_worker_expected
|
||||
else {
|
||||
"items_running": 0,
|
||||
"display_running": 0,
|
||||
"display_claimed": 0,
|
||||
"display_max_threads": 0,
|
||||
}
|
||||
)
|
||||
queue_payload = dict((detect.get("queue_health") or {}).get("queue") or {}) if local_worker_expected else {}
|
||||
local_participating = False
|
||||
for node in list(cluster.get("nodes") or []):
|
||||
if str(node.get("node_code") or "").strip() != settings.node_code:
|
||||
@@ -558,15 +780,22 @@ def _build_runtime_projection_payload(
|
||||
break
|
||||
local_job_bucket = {}
|
||||
if local_worker_expected:
|
||||
for item in list(active_job.get("node_stats") or []):
|
||||
if str(item.get("node_code") or "").strip() != settings.node_code:
|
||||
for item in projection_node_rows:
|
||||
if not _is_local_projection_node(str(item.get("node_code") or "").strip()):
|
||||
continue
|
||||
local_job_bucket = item
|
||||
local_participating = local_participating or bool(
|
||||
int(item.get("display_running", 0) or 0) > 0
|
||||
or int(item.get("active_threads", 0) or 0) > 0
|
||||
or int(item.get("items_running", 0) or 0) > 0
|
||||
or int(item.get("items_claimed", 0) or 0) > 0
|
||||
)
|
||||
break
|
||||
if local_worker_expected and not local_participating:
|
||||
local_participating = bool(
|
||||
int(local_job_bucket.get("items_running", 0) or 0) > 0
|
||||
or int(local_job_bucket.get("items_claimed", 0) or 0) > 0
|
||||
or int(display_summary.get("display_running", 0) or 0) > 0
|
||||
)
|
||||
|
||||
projection_active_job = (
|
||||
@@ -575,12 +804,18 @@ def _build_runtime_projection_payload(
|
||||
"job_code": active_job.get("job_code", ""),
|
||||
"status": active_job.get("status", ""),
|
||||
"progress_percent": active_job.get("progress_percent", 0),
|
||||
"items_total": active_job.get("items_total", 0),
|
||||
"items_terminal": active_job.get("items_terminal", 0),
|
||||
"items_pending": active_job.get("items_pending", 0),
|
||||
"items_running": active_job.get("items_running", 0),
|
||||
"items_failed": active_job.get("items_failed", 0),
|
||||
"node_stats": list(active_job.get("node_stats") or []),
|
||||
"items_total": int(queue_payload.get("items_total", active_job.get("items_total", 0)) or 0),
|
||||
"items_terminal": int(queue_payload.get("terminal", active_job.get("items_terminal", 0)) or 0),
|
||||
"items_pending": int(queue_payload.get("pending", active_job.get("items_pending", 0)) or 0),
|
||||
"items_claimed": int(queue_payload.get("claimed", active_job.get("items_claimed", 0)) or 0),
|
||||
"items_running": int(display_summary.get("items_running", 0) or 0),
|
||||
"items_failed": int(queue_payload.get("failed", active_job.get("items_failed", 0)) or 0),
|
||||
"display_items_claimed": int(display_summary.get("display_claimed", 0) or 0),
|
||||
"display_items_running": int(display_summary.get("display_running", 0) or 0),
|
||||
"display_active_threads": int(display_summary.get("display_running", 0) or 0),
|
||||
"display_max_threads": int(display_summary.get("display_max_threads", 0) or 0),
|
||||
"node_stats": list(projection_node_rows),
|
||||
"distributed_node_stats": list(projection_node_rows),
|
||||
}
|
||||
if local_worker_expected
|
||||
else {
|
||||
@@ -591,18 +826,24 @@ def _build_runtime_projection_payload(
|
||||
"items_total": 0,
|
||||
"items_terminal": 0,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_failed": 0,
|
||||
"display_items_claimed": 0,
|
||||
"display_items_running": 0,
|
||||
"display_active_threads": 0,
|
||||
"display_max_threads": 0,
|
||||
"node_stats": [],
|
||||
"distributed_node_stats": [],
|
||||
}
|
||||
)
|
||||
progress_payload = (
|
||||
{
|
||||
"pending": int((detect.get("progress") or {}).get("pending", 0) or 0),
|
||||
"running": int((detect.get("progress") or {}).get("running", 0) or 0),
|
||||
"completed": int((detect.get("progress") or {}).get("completed", 0) or 0),
|
||||
"blacklisted": int((detect.get("progress") or {}).get("blacklisted", 0) or 0),
|
||||
"failed": int((detect.get("progress") or {}).get("failed", 0) or 0),
|
||||
"pending": int(queue_payload.get("pending", (detect.get("progress") or {}).get("pending", 0)) or 0),
|
||||
"running": int(display_summary.get("display_running", 0) or 0),
|
||||
"completed": int(queue_payload.get("completed", (detect.get("progress") or {}).get("completed", 0)) or 0),
|
||||
"blacklisted": int(queue_payload.get("blacklisted", (detect.get("progress") or {}).get("blacklisted", 0)) or 0),
|
||||
"failed": int(queue_payload.get("failed", (detect.get("progress") or {}).get("failed", 0)) or 0),
|
||||
}
|
||||
if local_worker_expected
|
||||
else {
|
||||
@@ -624,8 +865,8 @@ def _build_runtime_projection_payload(
|
||||
"worker_online": bool(detect.get("worker_online", False)) if local_worker_expected else False,
|
||||
"detect_participating": local_participating if local_worker_expected else False,
|
||||
"worker_mode": detect.get("worker_mode", ""),
|
||||
"active_thread_count": int(detect.get("active_thread_count", 0) or 0) if local_worker_expected else 0,
|
||||
"max_thread_count": int(detect.get("max_thread_count", 0) or 0) if local_worker_expected else 0,
|
||||
"active_thread_count": int(display_summary.get("display_running", 0) or 0) if local_worker_expected else 0,
|
||||
"max_thread_count": int(display_summary.get("display_max_threads", 0) or 0) if local_worker_expected else 0,
|
||||
"phase_label": detect.get("phase_label", ""),
|
||||
"phase_detail": detect.get("phase_detail", ""),
|
||||
"proxy_runtime_label": detect.get("proxy_runtime_label", ""),
|
||||
@@ -633,6 +874,7 @@ def _build_runtime_projection_payload(
|
||||
"progress": progress_payload,
|
||||
"backlog": dict(detect.get("backlog") or {}) if local_worker_expected else {},
|
||||
"active_job": projection_active_job,
|
||||
"cluster_nodes": list(projection_cluster_rows),
|
||||
"cluster_summary": {
|
||||
"nodes_total": int(cluster.get("nodes_total", 0) or 0),
|
||||
"online_worker_nodes": int((cluster.get("summary") or {}).get("online_worker_nodes", 0) or 0),
|
||||
@@ -670,6 +912,7 @@ def append_runtime_projection_if_changed(
|
||||
) -> int | None:
|
||||
normalized_source_region = _normalize_region(source_region, _normalize_region(settings.sync_source_region, settings.node_region))
|
||||
normalized_target_region = _normalize_region(target_region, _normalize_region(settings.sync_target_region, "overseas"))
|
||||
future_cutoff = datetime.now() + _RUNTIME_PROJECTION_FUTURE_SKEW_GRACE
|
||||
payload = _build_runtime_projection_payload(
|
||||
detect=detect,
|
||||
cluster=cluster,
|
||||
@@ -686,16 +929,17 @@ def append_runtime_projection_if_changed(
|
||||
WHERE sync_type = 'runtime_projection'
|
||||
AND source_region = %s
|
||||
AND target_region = %s
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
CASE WHEN created_at <= %s THEN 0 ELSE 1 END ASC,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(normalized_source_region, normalized_target_region),
|
||||
(normalized_source_region, normalized_target_region, future_cutoff),
|
||||
)
|
||||
latest = cur.fetchone()
|
||||
latest = _pick_latest_projection_row(list(cur.fetchall() or []), created_at_index=1)
|
||||
latest_payload = _decode_json(latest[0]) if latest else {}
|
||||
latest_created_at = latest[1] if latest else None
|
||||
if latest_payload.get("projection_hash") == payload["projection_hash"]:
|
||||
return None
|
||||
if not _should_append_runtime_projection(latest_payload, payload["projection"], latest_created_at):
|
||||
return None
|
||||
cur.execute(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user