feat: stabilize multi-region runtime sync and worker orchestration

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

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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():

View File

@@ -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:

View File

@@ -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,

View File

@@ -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}

View File

@@ -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 ""),

View File

@@ -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",
}

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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}"

View File

@@ -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(

View File

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