from __future__ import annotations import json import time from datetime import datetime import math from uuid import uuid4 from app.core.config import settings from app.core.db import db_read_retry, get_db, is_retryable_db_error from app.services.debug_event_service import push_debug_event from app.services.settings_service import get_settings_payload ACTIVE_JOB_STATUSES = ("pending", "running") _RUNTIME_NODE_STALE_MINUTES = 10 _DEFAULT_TASK_MODE = "domain_pipeline" _PIPELINE_STEP_ORDER = [ "detect_register", "detect_baidu_site", "detect_360_site", "detect_chinaz", "detect_aizhan", "detect_wayback", "detect_jucha", "detect_juziseo", ] _PIPELINE_STEP_FIELDS = { "detect_baidu_site": "baidu_site", "detect_360_site": "qihu360_site", "detect_chinaz": "chinaz_info", "detect_aizhan": "aizhan_info", "detect_wayback": "wayback_info", "detect_jucha": "jucha_info", "detect_juziseo": "juziseo_info", } _PIPELINE_STEP_LABELS = { "detect_register": "注册状态检测", "detect_baidu_site": "百度site检测", "detect_360_site": "360 site检测", "detect_chinaz": "站长之家检测", "detect_aizhan": "爱站检测", "detect_wayback": "时光机检测", "detect_jucha": "聚查检测", "detect_juziseo": "桔子检测", } _PIPELINE_STEP_RETRY_LIMITS = { "default": 3, "detect_wayback": 2, } _SINGLE_STEP_JOB_SPECS = { "detect_baidu_site": { "step_code": "detect_baidu_site", "task_mode": "single_step", "label": "百度site检测", "source": "api-step-start", }, "detect_wayback": { "step_code": "detect_wayback", "task_mode": "single_step", "label": "时光机检测", "source": "api-step-start", }, } def _selection_sql() -> str: return """ SELECT id FROM domains WHERE detect_status IN (0, 4) OR (use_status = 0 AND detect_status = 1 AND register_status = 3 AND expire_date < CURRENT_DATE) ORDER BY id ASC LIMIT %s """ def _format_time(value: datetime | None) -> str: return value.isoformat(sep=" ", timespec="seconds") if value else "" def _normalize_sync_region(value: str | None, fallback: str) -> str: text = str(value or "").strip() if not text or text == "unknown": return str(fallback or "unknown").strip() or "unknown" return text def normalize_detect_step_code(step_code: object) -> str: normalized = str(step_code or "").strip() if not normalized: return "" return normalized if normalized in _SINGLE_STEP_JOB_SPECS else "" def resolve_detect_job_definition(step_code: object = None) -> dict: normalized_step_code = normalize_detect_step_code(step_code) if normalized_step_code: spec = dict(_SINGLE_STEP_JOB_SPECS[normalized_step_code]) spec["is_single_step"] = True return spec return { "step_code": "", "task_mode": _DEFAULT_TASK_MODE, "label": "全流程检测", "source": "api-start", "is_single_step": False, } def _step_label(step_code: str) -> str: return _PIPELINE_STEP_LABELS.get(str(step_code or "").strip(), str(step_code or "").strip() or "unknown") def _json_state(value: object) -> str: if isinstance(value, dict): return str(value.get("state") or "").strip().lower() return "" def _pipeline_retry_limit(step_code: str) -> int: return int(_PIPELINE_STEP_RETRY_LIMITS.get(str(step_code or "").strip(), _PIPELINE_STEP_RETRY_LIMITS["default"]) or 1) def _classify_pipeline_item_outcome( *, item_status: str, result_payload: dict | None, step_code: str, attempt_count: int, ) -> dict: normalized_item_status = str(item_status or "").strip().lower() payload = dict(result_payload or {}) result_state = str(payload.get("state") or "").strip().lower() retry_limit = _pipeline_retry_limit(step_code) retry_recommended = bool(payload.get("retry_recommended")) legacy_retry_candidate = normalized_item_status == "failed" and not result_state if normalized_item_status == "blacklisted" or result_state == "blacklisted": return { "action": "black_hit", "result_state": result_state or "blacklisted", "retry_limit": retry_limit, "should_retry": False, "reason_code": "blacklisted", } should_retry = ( result_state in {"degraded", "error"} or retry_recommended or legacy_retry_candidate ) if should_retry: return { "action": "retry" if int(attempt_count or 0) < retry_limit else "reject", "result_state": result_state or ("degraded" if retry_recommended else "failed"), "retry_limit": retry_limit, "should_retry": int(attempt_count or 0) < retry_limit, "reason_code": "external_retry" if int(attempt_count or 0) < retry_limit else "retry_exhausted", } if normalized_item_status == "failed" or result_state in {"failed", "rejected", "reject"}: return { "action": "reject", "result_state": result_state or "failed", "retry_limit": retry_limit, "should_retry": False, "reason_code": "business_reject", } return { "action": "pass", "result_state": result_state or "passed", "retry_limit": retry_limit, "should_retry": False, "reason_code": "passed", } def _load_pipeline_order(settings_payload: dict | None = None) -> list[str]: payload = settings_payload or get_settings_payload() detect_options = dict(payload.get("detect_options") or {}) configured_order = list(detect_options.get("detect_order") or []) normalized_order = [item for item in configured_order if item in _PIPELINE_STEP_ORDER] for item in _PIPELINE_STEP_ORDER: if item not in normalized_order: normalized_order.append(item) return [item for item in normalized_order if bool(detect_options.get(item, False))] def _load_domain_pipeline_snapshot(cur, domain_id: int) -> dict | None: cur.execute( """ SELECT d.id, d.domain, d.source_type, d.register_status, d.detect_status, d.use_status, d.expire_date, dd.baidu_site, dd.qihu360_site, dd.wayback_info, dd.chinaz_info, dd.aizhan_info, dd.jucha_info, dd.juziseo_info FROM domains AS d LEFT JOIN domain_detections AS dd ON dd.domain_id = d.id WHERE d.id = %s LIMIT 1 """, (int(domain_id),), ) row = cur.fetchone() if not row: return None return { "id": int(row[0]), "domain": row[1] or "", "source_type": int(row[2] or 0), "register_status": int(row[3] or 0), "detect_status": int(row[4] or 0), "use_status": int(row[5] or 0), "expire_date": _format_time(row[6]) if row[6] else "", "baidu_site": _decode_payload(row[7]), "qihu360_site": _decode_payload(row[8]), "wayback_info": _decode_payload(row[9]), "chinaz_info": _decode_payload(row[10]), "aizhan_info": _decode_payload(row[11]), "jucha_info": _decode_payload(row[12]), "juziseo_info": _decode_payload(row[13]), } def _is_pipeline_step_completed(domain_snapshot: dict, step_code: str) -> bool: normalized_step_code = str(step_code or "").strip() if normalized_step_code == "detect_register": return int(domain_snapshot.get("register_status", 0) or 0) not in {0, 10} field_name = _PIPELINE_STEP_FIELDS.get(normalized_step_code) if not field_name: return False state = _json_state(domain_snapshot.get(field_name)) return state in {"passed", "blacklisted"} def _should_skip_pipeline_step(domain_snapshot: dict, step_code: str) -> bool: normalized_step_code = str(step_code or "").strip() if normalized_step_code == "detect_register" and int(domain_snapshot.get("source_type", 0) or 0) == 1: return True return False def resolve_domain_pipeline_step(domain_snapshot: dict, *, settings_payload: dict | None = None, after_step_code: str | None = None) -> str: pipeline_order = _load_pipeline_order(settings_payload=settings_payload) if not pipeline_order: return "" normalized_after = str(after_step_code or "").strip() seen_after = not normalized_after for step_code in pipeline_order: if not seen_after: if step_code == normalized_after: seen_after = True continue if _should_skip_pipeline_step(domain_snapshot, step_code): continue if _is_pipeline_step_completed(domain_snapshot, step_code): continue return step_code return "" def resolve_initial_domain_pipeline_item( domain_snapshot: dict, *, settings_payload: dict | None = None, retry_count: int = 0, ) -> tuple[str, dict | None]: step_code = resolve_domain_pipeline_step(domain_snapshot, settings_payload=settings_payload) if not step_code: return "", None return step_code, _build_step_payload( step_code=step_code, domain_snapshot=domain_snapshot, settings_payload=settings_payload, retry_count=retry_count, ) def _build_step_payload(*, step_code: str, domain_snapshot: dict, settings_payload: dict | None = None, retry_count: int = 0) -> dict: pipeline_order = _load_pipeline_order(settings_payload=settings_payload) payload = { "step_code": str(step_code or "").strip(), "step_name": _step_label(step_code), "pipeline_order": pipeline_order, "retry_count": int(retry_count or 0), "domain": str(domain_snapshot.get("domain") or "").strip(), "source_type": int(domain_snapshot.get("source_type", 0) or 0), } if str(step_code or "").strip() == "detect_wayback": payload.update( { "wayback_strategy": "recent_years", "wayback_recent_years": 5, "wayback_stop_on_first_hit": True, } ) return payload def _decode_payload(value: object) -> dict: if isinstance(value, dict): return value if value in (None, ""): return {} try: return json.loads(value) except Exception: return {} def _upsert_domain_detection_field(cur, domain_id: int, field_name: str, payload: dict) -> bool: normalized_field_name = str(field_name or "").strip() if not normalized_field_name or normalized_field_name not in _PIPELINE_STEP_FIELDS.values(): return False normalized_payload = dict(payload or {}) cur.execute("SELECT id FROM domain_detections WHERE domain_id = %s LIMIT 1", (int(domain_id),)) existing = cur.fetchone() payload_json = json.dumps(normalized_payload, ensure_ascii=False) if existing: cur.execute( f""" UPDATE domain_detections SET {normalized_field_name} = %s::jsonb, update_time = NOW() WHERE domain_id = %s """, (payload_json, int(domain_id)), ) return True cur.execute( f""" INSERT INTO domain_detections (domain_id, {normalized_field_name}, create_time, update_time) VALUES (%s, %s::jsonb, NOW(), NOW()) """, (int(domain_id), payload_json), ) return True def _int_value(value: object) -> int: try: return int(value or 0) except Exception: return 0 def _effective_runtime_load(*, items_running: object, active_threads: object) -> int: return max(_int_value(items_running), _int_value(active_threads), 0) def _control_node_supports_worker(*, region: object, metadata: dict | None) -> bool: normalized_region = str(region or "").strip() runtime_metadata = dict(metadata or {}) active_threads = int(runtime_metadata.get("active_threads", 0) or 0) max_threads = int(runtime_metadata.get("max_threads", 0) or 0) if normalized_region != "mainland": return False return bool( runtime_metadata.get("worker_online", False) or runtime_metadata.get("detect_participating", False) or active_threads > 0 or max_threads > 0 ) def _build_runtime_display_bucket(row: tuple) -> dict | None: node_code = str(row[0] or "").strip() if not node_code: return None region = str(row[1] or "").strip() role = str(row[2] or "").strip() metadata = _decode_payload(row[5]) if role == "control" and not _control_node_supports_worker(region=region, metadata=metadata): return None items_total = _int_value(metadata.get("job_items_total")) items_claimed = _int_value(metadata.get("job_items_claimed")) items_running = _int_value(metadata.get("job_items_running")) items_completed = _int_value(metadata.get("job_items_completed")) items_failed = _int_value(metadata.get("job_items_failed")) raw_current_load = _int_value(row[4]) active_threads = _int_value(metadata.get("active_threads")) max_threads = _int_value(metadata.get("max_threads")) current_load = _effective_runtime_load(items_running=items_running, active_threads=active_threads) detect_participating = bool(metadata.get("detect_participating", False)) if ( settings.node_region == "overseas" and settings.node_role == "control" and node_code == str(settings.node_code or "").strip() and not detect_participating ): # Overseas control nodes may report their own control-plane load into # detect_worker_nodes; that load should never be counted as worker execution. return None if ( items_total <= 0 and items_claimed <= 0 and items_running <= 0 and items_completed <= 0 and items_failed <= 0 and current_load <= 0 and raw_current_load <= 0 and active_threads <= 0 and not detect_participating ): return None items_pending = max(items_total - items_claimed - items_running - items_completed - items_failed, 0) return { "node_code": node_code, "items_total": items_total, "items_pending": items_pending, "items_claimed": items_claimed, "items_running": items_running, "display_running": current_load, "items_completed": items_completed, "items_blacklisted": 0, "items_failed": items_failed, "metrics_source": str(metadata.get("service") or "runtime").strip() or "runtime", "region": region, "role": role, "status": str(row[3] or "").strip(), "current_load": current_load, "active_threads": active_threads, "max_threads": max_threads, "last_heartbeat_at": _format_time(row[6]) if row[6] else "", } def _merge_display_node_stats(*, local_node_stats: list[dict], runtime_node_rows: list[tuple]) -> list[dict]: merged: dict[str, dict] = {} def _ensure_bucket(node_code: str) -> dict: return merged.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "display_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "metrics_source": "", "region": "", "role": "", "status": "", "current_load": 0, "active_threads": 0, "max_threads": 0, "last_heartbeat_at": "", }, ) for item in list(local_node_stats or []): node_code = str(item.get("node_code") or "").strip() if not node_code: continue bucket = _ensure_bucket(node_code) for key in ( "items_total", "items_pending", "items_claimed", "items_running", "display_running", "items_completed", "items_blacklisted", "items_failed", ): bucket[key] = max(_int_value(bucket.get(key)), _int_value(item.get(key))) if node_code == "unassigned": bucket["metrics_source"] = "central_queue" for row in list(runtime_node_rows or []): runtime_bucket = _build_runtime_display_bucket(row) if not runtime_bucket: continue node_code = str(runtime_bucket.get("node_code") or "").strip() bucket = _ensure_bucket(node_code) for key in ( "items_total", "items_pending", "items_claimed", "items_running", "display_running", "items_completed", "items_blacklisted", "items_failed", "current_load", "active_threads", "max_threads", ): bucket[key] = max(_int_value(bucket.get(key)), _int_value(runtime_bucket.get(key))) for key in ("metrics_source", "region", "role", "status", "last_heartbeat_at"): if str(runtime_bucket.get(key) or "").strip(): bucket[key] = runtime_bucket.get(key) return sorted( merged.values(), key=lambda item: ( str(item.get("node_code") or "") == "unassigned", -_int_value(item.get("active_threads")), -_int_value(item.get("items_running")), -_int_value(item.get("items_claimed")), -_int_value(item.get("items_total")), str(item.get("node_code") or ""), ), ) def _load_runtime_display_rows(cur) -> list[tuple]: cur.execute( f""" SELECT node_code, region, role, status, current_load, metadata_json, last_heartbeat_at FROM detect_worker_nodes WHERE last_heartbeat_at >= CURRENT_TIMESTAMP - interval '{_RUNTIME_NODE_STALE_MINUTES} minutes' ORDER BY last_heartbeat_at DESC, node_code ASC """ ) return list(cur.fetchall()) def _build_display_summary(node_stats: list[dict]) -> dict: effective_nodes = [ item for item in list(node_stats or []) if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" and not ( settings.node_region == "overseas" and settings.node_role == "control" and str(item.get("node_code") or "").strip() == str(settings.node_code or "").strip() ) ] return { "items_claimed": sum(_int_value(item.get("items_claimed")) for item in effective_nodes), "items_running": sum(_int_value(item.get("items_running")) for item in effective_nodes), "display_running": sum( _effective_runtime_load( items_running=item.get("items_running"), active_threads=item.get("active_threads"), ) for item in effective_nodes ), "current_load": sum( _effective_runtime_load( items_running=item.get("items_running"), active_threads=item.get("active_threads"), ) for item in effective_nodes ), "active_threads": sum(_int_value(item.get("active_threads")) for item in effective_nodes), "max_threads": sum(_int_value(item.get("max_threads")) for item in effective_nodes), "items_completed": sum(_int_value(item.get("items_completed")) for item in effective_nodes), "items_failed": sum(_int_value(item.get("items_failed")) for item in effective_nodes), "active_nodes": [ str(item.get("node_code") or "").strip() for item in effective_nodes if _effective_runtime_load( items_running=item.get("items_running"), active_threads=item.get("active_threads"), ) > 0 ], } def _normalize_node_bucket(item: dict) -> dict: node_code = str(item.get("node_code") or "").strip() items_running = _int_value(item.get("items_running")) active_threads = _int_value(item.get("active_threads")) max_threads = _int_value(item.get("max_threads")) current_load = _effective_runtime_load(items_running=items_running, active_threads=active_threads) return { "node_code": node_code, "items_total": _int_value(item.get("items_total")), "items_pending": _int_value(item.get("items_pending")), "items_claimed": _int_value(item.get("items_claimed")), "items_running": items_running, "display_running": current_load, "items_completed": _int_value(item.get("items_completed")), "items_blacklisted": _int_value(item.get("items_blacklisted")), "items_failed": _int_value(item.get("items_failed")), "metrics_source": str(item.get("metrics_source") or "").strip(), "region": str(item.get("region") or "").strip(), "role": str(item.get("role") or "").strip(), "status": str(item.get("status") or "").strip(), "current_load": current_load, "active_threads": active_threads, "max_threads": max_threads, "last_heartbeat_at": str(item.get("last_heartbeat_at") or "").strip(), } def _build_effective_node_stats( *, distributed_node_stats: list[dict], raw_items_total: int, ) -> list[dict]: normalized_total = max(0, _int_value(raw_items_total)) assigned_buckets: list[dict] = [] assigned_total = 0 unassigned_bucket: dict | None = None for item in list(distributed_node_stats or []): bucket = _normalize_node_bucket(item) if not bucket["node_code"]: continue if bucket["node_code"] == "unassigned": unassigned_bucket = bucket continue if ( settings.node_region == "overseas" and settings.node_role == "control" and bucket["node_code"] == str(settings.node_code or "").strip() ): continue assigned_buckets.append(bucket) assigned_total += bucket["items_total"] if unassigned_bucket and unassigned_bucket["items_total"] > 0: assigned_buckets.append(unassigned_bucket) assigned_total += unassigned_bucket["items_total"] effective_total = max(normalized_total, assigned_total) remainder_total = max(effective_total - assigned_total, 0) if remainder_total > 0: if unassigned_bucket: unassigned_bucket["items_total"] += remainder_total unassigned_bucket["items_pending"] += remainder_total assigned_buckets = [ unassigned_bucket if str(item.get("node_code") or "").strip() == "unassigned" else item for item in assigned_buckets ] else: assigned_buckets.append( { "node_code": "unassigned", "items_total": remainder_total, "items_pending": remainder_total, "items_claimed": 0, "items_running": 0, "display_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "metrics_source": "central_queue", "region": "", "role": "", "status": "", "current_load": 0, "active_threads": 0, "max_threads": 0, "last_heartbeat_at": "", } ) return sorted( assigned_buckets, key=lambda item: ( str(item.get("node_code") or "") == "unassigned", -_int_value(item.get("active_threads")), -_int_value(item.get("items_running")), -_int_value(item.get("items_claimed")), -_int_value(item.get("items_total")), str(item.get("node_code") or ""), ), ) def _build_effective_summary( *, node_stats: list[dict], raw_items_total: int, raw_items_pending: int, raw_items_claimed: int, raw_items_running: int, raw_items_completed: int, raw_items_blacklisted: int, raw_items_failed: int, ) -> dict: effective_total = max(0, _int_value(raw_items_total)) effective_pending = max(0, _int_value(raw_items_pending)) effective_claimed = max(0, _int_value(raw_items_claimed)) effective_running = max(0, _int_value(raw_items_running)) effective_completed = max(0, _int_value(raw_items_completed)) effective_blacklisted = max(0, _int_value(raw_items_blacklisted)) effective_failed = max(0, _int_value(raw_items_failed)) effective_terminal = effective_completed + effective_blacklisted + effective_failed return { "items_total": effective_total, "items_pending": effective_pending, "items_claimed": effective_claimed, "items_running": effective_running, "items_completed": effective_completed, "items_blacklisted": effective_blacklisted, "items_failed": effective_failed, "items_terminal": max(effective_terminal, 0), } def _build_step_bucket(step_code: str) -> dict: normalized_step_code = str(step_code or "").strip() return { "step_code": normalized_step_code, "step_name": _step_label(normalized_step_code), "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "started_recent": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, } def _extract_current_cycle_events(events: list[dict]) -> tuple[str, list[dict]]: if not events: return "", [] latest_payload = events[0].get("payload") or {} latest_cycle_token = str(latest_payload.get("cycle_token") or "").strip() if latest_cycle_token: current_cycle = [] for event in events: payload = event.get("payload") or {} event_cycle = str(payload.get("cycle_token") or "").strip() if event_cycle == latest_cycle_token: current_cycle.append(event) return latest_cycle_token, current_cycle cycle_token = "" anchor_index = -1 for index, event in enumerate(events): payload = event.get("payload") or {} event_type = str(event.get("event_type") or "") candidate = str(payload.get("cycle_token") or "").strip() if candidate and event_type in {"job_dispatch_sent", "job_dispatch_requested", "job_dispatch_failed", "job_dispatch_rejected"}: cycle_token = candidate anchor_index = index break if not cycle_token: return "", events current_cycle = [] for index, event in enumerate(events[: anchor_index + 1]): payload = event.get("payload") or {} event_cycle = str(payload.get("cycle_token") or "").strip() if index == anchor_index or event_cycle == cycle_token: current_cycle.append(event) return cycle_token, current_cycle def _classify_runtime_debug_event(*, event_type: str, message: str, payload: dict) -> dict: normalized_event_type = str(event_type or "").strip() normalized_message = str(message or "").strip() normalized_payload = payload if isinstance(payload, dict) else {} normalized_job_code = str(normalized_payload.get("job_code") or "").strip() normalized_node_code = str(normalized_payload.get("node_code") or "").strip() result = { "job_code": normalized_job_code, "node_code": normalized_node_code, "started": False, "terminal": False, "terminal_status": "", "step_code": "", } if normalized_event_type in {"domain_started", "domain_completed", "domain_failed", "domain_blacklisted"}: result["started"] = normalized_event_type == "domain_started" result["terminal"] = normalized_event_type in {"domain_completed", "domain_failed", "domain_blacklisted"} terminal_map = { "domain_completed": "completed", "domain_failed": "failed", "domain_blacklisted": "blacklisted", } result["terminal_status"] = terminal_map.get(normalized_event_type, "") result["step_code"] = normalize_detect_step_code(normalized_payload.get("detect_key")) or "detect_register" return result if normalized_event_type != "worker_log": return result step_markers = ( ("detect_register", ("| step=注册状态检测 |", "detect_register")), ("detect_baidu_site", ("| step=百度site检测 |", "detect_baidu_site", "百度阶段", "detect.baidu")), ("detect_360_site", ("| step=360收录检测 |", "detect_360_site", "360阶段", "detect.c360")), ("detect_chinaz", ("| step=站长之家检测 |", "detect_chinaz", "站长之家", "detect.chinaz")), ("detect_aizhan", ("| step=爱站检测 |", "detect_aizhan", "爱站", "detect.aizhan")), ("detect_wayback", ("| step=时光机检测 |", "detect_wayback", "时光机检测")), ("detect_jucha", ("| step=聚查检测 |", "detect_jucha", "聚查", "detect.jucha")), ("detect_juziseo", ("| step=桔子检测 |", "detect_juziseo", "桔子", "juziseo")), ) for step_code, markers in step_markers: if any(marker in normalized_message for marker in markers): result["step_code"] = step_code break if "| stage=started |" in normalized_message or "开始检测域名:" in normalized_message: result["started"] = True return result if "| stage=single_step_finalized |" in normalized_message: result["terminal"] = True if "final_status=completed" in normalized_message: result["terminal_status"] = "completed" elif "final_status=failed" in normalized_message: result["terminal_status"] = "failed" elif "final_status=blacklisted" in normalized_message: result["terminal_status"] = "blacklisted" return result return result def _load_runtime_activity_snapshot(window_minutes: int) -> dict: safe_window_minutes = max(5, min(int(window_minutes or 15), 120)) snapshot = { "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, "focus_job_code": "", "job_codes": [], "step_code": "detect_register", "step_stats": {}, "nodes": {}, } if not (settings.node_region == "overseas" and settings.node_role == "control"): return snapshot if settings.node_region == "overseas" and settings.node_role == "control": source_region = "mainland" else: source_region = _normalize_sync_region(settings.sync_source_region, settings.node_region) with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT COALESCE(NULLIF(node_code, ''), 'unknown') AS node_code, event_type, message, payload_json, created_at FROM detect_debug_events WHERE source_region = %s AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval AND event_type IN ('worker_log', 'domain_started', 'domain_completed', 'domain_failed', 'domain_blacklisted') ORDER BY created_at DESC, id DESC LIMIT 2000 """, (source_region, safe_window_minutes), ) rows = cur.fetchall() job_activity: dict[str, dict] = {} node_stats: dict[str, dict] = {} step_activity: dict[str, dict] = {} for row in rows: node_code = str(row[0] or "unknown").strip() or "unknown" event_type = str(row[1] or "").strip() message = str(row[2] or "").strip() payload = _decode_payload(row[3]) created_at = _format_time(row[4]) classified = _classify_runtime_debug_event(event_type=event_type, message=message, payload=payload) job_code = str(classified.get("job_code") or "").strip() step_code = str(classified.get("step_code") or "").strip() or "detect_register" if job_code: bucket = job_activity.setdefault( job_code, { "job_code": job_code, "activity_count": 0, "started_count": 0, "terminal_count": 0, "latest_at": "", }, ) bucket["activity_count"] += 1 if classified.get("started"): bucket["started_count"] += 1 if classified.get("terminal"): bucket["terminal_count"] += 1 if created_at > str(bucket.get("latest_at") or ""): bucket["latest_at"] = created_at if classified.get("terminal"): snapshot["processed_recent"] += 1 step_bucket = step_activity.setdefault( step_code, { "step_code": step_code, "started_recent": 0, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) step_bucket["processed_recent"] += 1 node_bucket = node_stats.setdefault( node_code, { "node_code": node_code, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) node_bucket["processed_recent"] += 1 terminal_status = str(classified.get("terminal_status") or "").strip() if terminal_status == "completed": snapshot["completed_recent"] += 1 node_bucket["completed_recent"] += 1 step_bucket["completed_recent"] += 1 elif terminal_status == "failed": snapshot["failed_recent"] += 1 node_bucket["failed_recent"] += 1 step_bucket["failed_recent"] += 1 elif terminal_status == "blacklisted": snapshot["blacklisted_recent"] += 1 node_bucket["blacklisted_recent"] += 1 step_bucket["blacklisted_recent"] += 1 if classified.get("started"): step_bucket = step_activity.setdefault( step_code, { "step_code": step_code, "started_recent": 0, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) step_bucket["started_recent"] += 1 if step_activity: ordered_steps = sorted( step_activity.values(), key=lambda item: ( -int(item.get("processed_recent", 0) or 0), -int(item.get("started_recent", 0) or 0), str(item.get("step_code") or ""), ), ) snapshot["step_code"] = str((ordered_steps[0] or {}).get("step_code") or "detect_register") snapshot["step_stats"] = { str(item.get("step_code") or ""): { "step_code": str(item.get("step_code") or ""), "started_recent": int(item.get("started_recent", 0) or 0), "processed_recent": int(item.get("processed_recent", 0) or 0), "completed_recent": int(item.get("completed_recent", 0) or 0), "failed_recent": int(item.get("failed_recent", 0) or 0), "blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0), } for item in ordered_steps if str(item.get("step_code") or "").strip() } if job_activity: ordered_jobs = sorted( job_activity.values(), key=lambda item: ( -int(item.get("terminal_count", 0) or 0), -int(item.get("activity_count", 0) or 0), str(item.get("latest_at") or ""), str(item.get("job_code") or ""), ), ) snapshot["focus_job_code"] = str(ordered_jobs[0].get("job_code") or "") snapshot["job_codes"] = [str(item.get("job_code") or "") for item in ordered_jobs[:5] if str(item.get("job_code") or "")] snapshot["nodes"] = node_stats return snapshot @db_read_retry() def _load_latest_runtime_active_job_snapshot(window_minutes: int) -> dict: safe_window_minutes = max(5, min(int(window_minutes or 15), 120)) if not (settings.node_region == "overseas" and settings.node_role == "control"): return {} source_region = "mainland" with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT payload_json, created_at FROM detect_debug_events WHERE source_region = %s AND service = 'detect-runtime' AND event_type = 'active_job_snapshot' AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ORDER BY created_at DESC, id DESC LIMIT 1 """, (source_region, safe_window_minutes), ) row = cur.fetchone() if not row: return {} payload = _decode_payload(row[0]) if not isinstance(payload, dict): return {} payload["_created_at"] = _format_time(row[1]) return payload def _select_runtime_snapshot_events( runtime_snapshot: dict, *, job_code: str = "", job_id: int = 0, event_limit: int = 20, ) -> list[dict]: safe_limit = max(1, min(int(event_limit or 20), 100)) target_job_code = str(job_code or "").strip() target_job_id = int(job_id or 0) selected: list[dict] = [] for raw_event in list(runtime_snapshot.get("recent_events") or []): if not isinstance(raw_event, dict): continue payload = raw_event.get("payload") normalized_payload = payload if isinstance(payload, dict) else _decode_payload(payload) event_job_code = str(normalized_payload.get("job_code") or "").strip() event_job_id = _int_value(raw_event.get("job_id")) if target_job_code: if event_job_code == target_job_code or (target_job_id > 0 and event_job_id == target_job_id): selected.append( { "node_code": str(raw_event.get("node_code") or "").strip(), "event_type": str(raw_event.get("event_type") or "").strip(), "level": str(raw_event.get("level") or "info").strip() or "info", "message": str(raw_event.get("message") or "").strip(), "payload": normalized_payload, "created_at": str(raw_event.get("created_at") or "").strip(), } ) else: selected.append( { "node_code": str(raw_event.get("node_code") or "").strip(), "event_type": str(raw_event.get("event_type") or "").strip(), "level": str(raw_event.get("level") or "info").strip() or "info", "message": str(raw_event.get("message") or "").strip(), "payload": normalized_payload, "created_at": str(raw_event.get("created_at") or "").strip(), } ) if len(selected) >= safe_limit: break return selected def _filter_events_for_job(events: list[dict], *, job_code: str = "", job_id: int = 0, limit: int = 20) -> list[dict]: target_job_code = str(job_code or "").strip() target_job_id = int(job_id or 0) safe_limit = max(1, min(int(limit or 20), 100)) filtered: list[dict] = [] for raw_event in list(events or []): if not isinstance(raw_event, dict): continue payload = raw_event.get("payload") normalized_payload = payload if isinstance(payload, dict) else _decode_payload(payload) event_job_code = str(normalized_payload.get("job_code") or "").strip() event_job_id = _int_value(raw_event.get("job_id")) if target_job_code: if event_job_code != target_job_code and (target_job_id <= 0 or event_job_id != target_job_id): continue filtered.append( { "node_code": str(raw_event.get("node_code") or "").strip(), "event_type": str(raw_event.get("event_type") or "").strip(), "level": str(raw_event.get("level") or "info").strip() or "info", "message": str(raw_event.get("message") or "").strip(), "payload": normalized_payload, "created_at": str(raw_event.get("created_at") or "").strip(), } ) if len(filtered) >= safe_limit: break return filtered def _fetch_job_summary(cur, job_row, event_limit: int = 20) -> dict: job_id = job_row[0] cur.execute( """ SELECT status, count(*) FROM detect_job_items WHERE job_id = %s GROUP BY status """, (job_id,), ) counts = {status: int(count) for status, count in cur.fetchall()} cur.execute( """ SELECT COALESCE(NULLIF(claimed_by, ''), 'unassigned') AS node_code, status, count(*) FROM detect_job_items WHERE job_id = %s GROUP BY COALESCE(NULLIF(claimed_by, ''), 'unassigned'), status ORDER BY node_code ASC, status ASC """, (job_id,), ) node_buckets: dict[str, dict] = {} for node_code, status, count in cur.fetchall(): bucket = node_buckets.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, }, ) field_name = f"items_{status}" if field_name in bucket: bucket[field_name] += int(count) bucket["items_total"] += int(count) cur.execute( """ SELECT COALESCE(NULLIF(step_code, ''), 'domain_pipeline') AS step_code, status, count(*) FROM detect_job_items WHERE job_id = %s GROUP BY COALESCE(NULLIF(step_code, ''), 'domain_pipeline'), status ORDER BY step_code ASC, status ASC """, (job_id,), ) step_buckets: dict[str, dict] = {} for step_code, status, count in cur.fetchall(): bucket = step_buckets.setdefault(str(step_code or ""), _build_step_bucket(str(step_code or ""))) field_name = f"items_{status}" if field_name in bucket: bucket[field_name] += int(count) bucket["items_total"] += int(count) cur.execute( """ SELECT node_code, event_type, level, message, payload_json, created_at FROM detect_run_events WHERE job_id = %s ORDER BY created_at DESC, id DESC LIMIT %s """, (job_id, max(1, int(event_limit or 20))), ) events = [ { "node_code": item[0], "event_type": item[1], "level": item[2], "message": item[3], "payload": _decode_payload(item[4]), "created_at": _format_time(item[5]), } for item in cur.fetchall() ] cycle_token, current_cycle_events = _extract_current_cycle_events(events) distributed_node_stats = _merge_display_node_stats( local_node_stats=list(node_buckets.values()), runtime_node_rows=_load_runtime_display_rows(cur), ) raw_total = sum(counts.values()) raw_pending = int(counts.get("pending", 0)) raw_claimed = int(counts.get("claimed", 0)) raw_running = int(counts.get("running", 0)) raw_completed = int(counts.get("completed", 0)) raw_blacklisted = int(counts.get("blacklisted", 0)) raw_failed = int(counts.get("failed", 0)) raw_terminal = raw_completed + raw_blacklisted + raw_failed effective_node_stats = _build_effective_node_stats( distributed_node_stats=distributed_node_stats, raw_items_total=raw_total, ) effective_summary = _build_effective_summary( node_stats=effective_node_stats, raw_items_total=raw_total, raw_items_pending=raw_pending, raw_items_claimed=raw_claimed, raw_items_running=raw_running, raw_items_completed=raw_completed, raw_items_blacklisted=raw_blacklisted, raw_items_failed=raw_failed, ) display_summary = _build_display_summary(effective_node_stats) return { "job_id": job_id, "job_code": job_row[1], "source": job_row[2], "task_mode": job_row[3] or _DEFAULT_TASK_MODE, "step_code": job_row[4] or "", "status": job_row[5], "created_by": job_row[6], "created_at": _format_time(job_row[7]), "started_at": _format_time(job_row[8]), "finished_at": _format_time(job_row[9]), "items_total": int(effective_summary.get("items_total", 0) or 0), "items_pending": int(effective_summary.get("items_pending", 0) or 0), "items_claimed": int(effective_summary.get("items_claimed", 0) or 0), "items_running": int(effective_summary.get("items_running", 0) or 0), "items_completed": int(effective_summary.get("items_completed", 0) or 0), "items_blacklisted": int(effective_summary.get("items_blacklisted", 0) or 0), "items_failed": int(effective_summary.get("items_failed", 0) or 0), "items_terminal": int(effective_summary.get("items_terminal", 0) or 0), "progress_percent": round( (int(effective_summary.get("items_terminal", 0) or 0) / int(effective_summary.get("items_total", 0) or 0)) * 100, 2, ) if int(effective_summary.get("items_total", 0) or 0) else 0, "raw_items_total": raw_total, "raw_items_pending": raw_pending, "raw_items_claimed": raw_claimed, "raw_items_running": raw_running, "raw_items_completed": raw_completed, "raw_items_blacklisted": raw_blacklisted, "raw_items_failed": raw_failed, "raw_items_terminal": raw_terminal, "raw_node_stats": list(node_buckets.values()), "raw_step_stats": list(step_buckets.values()), "node_stats": effective_node_stats, "distributed_node_stats": effective_node_stats, "step_stats": sorted( step_buckets.values(), key=lambda item: ( -_int_value(item.get("items_running")), -_int_value(item.get("items_pending")), str(item.get("step_code") or ""), ), ), "display_items_claimed": int(display_summary.get("items_claimed", 0) or 0), "display_items_running": int(display_summary.get("display_running", display_summary.get("items_running", 0)) or 0), "display_current_load": int(display_summary.get("current_load", 0) or 0), "display_active_threads": int(display_summary.get("active_threads", 0) or 0), "display_max_threads": int(display_summary.get("max_threads", 0) or 0), "display_items_completed": int(display_summary.get("items_completed", 0) or 0), "display_items_failed": int(display_summary.get("items_failed", 0) or 0), "display_active_node_codes": list(display_summary.get("active_nodes") or []), "recent_events": events, "latest_event": events[0] if events else None, "current_cycle_token": cycle_token, "current_cycle_events": current_cycle_events, "latest_cycle_event": current_cycle_events[0] if current_cycle_events else None, } def _enrich_active_job_summary_with_runtime( summary: dict | None, *, event_limit: int = 20, window_minutes: int = 15, runtime_activity: dict | None = None, runtime_snapshot: dict | None = None, recent_domain_events: list[dict] | None = None, ) -> dict | None: if not summary: return summary enriched = dict(summary) runtime_activity = dict(runtime_activity or _load_runtime_activity_snapshot(window_minutes)) runtime_snapshot = dict(runtime_snapshot or _load_latest_runtime_active_job_snapshot(window_minutes)) runtime_job_code = str(runtime_activity.get("focus_job_code") or "").strip() runtime_job_codes = [ str(item or "").strip() for item in list(runtime_activity.get("job_codes") or []) if str(item or "").strip() ] if runtime_job_code: enriched["runtime_job_code"] = runtime_job_code if runtime_job_codes: enriched["runtime_job_codes"] = runtime_job_codes enriched["processed_recent"] = max( int(enriched.get("processed_recent", 0) or 0), int(runtime_activity.get("processed_recent", 0) or 0), ) enriched["processed_per_minute"] = max( float(enriched.get("processed_per_minute", 0) or 0), round(int(runtime_activity.get("processed_recent", 0) or 0) / max(1, int(window_minutes or 15)), 2), ) enriched["completed_recent"] = max( int(enriched.get("completed_recent", 0) or 0), int(runtime_activity.get("completed_recent", 0) or 0), ) enriched["failed_recent"] = max( int(enriched.get("failed_recent", 0) or 0), int(runtime_activity.get("failed_recent", 0) or 0), ) enriched["blacklisted_recent"] = max( int(enriched.get("blacklisted_recent", 0) or 0), int(runtime_activity.get("blacklisted_recent", 0) or 0), ) if recent_domain_events is not None: enriched["recent_domain_events"] = list(recent_domain_events) elif not enriched.get("recent_domain_events"): try: from app.services.sync_record_service import _collect_recent_domain_events enriched["recent_domain_events"] = _collect_recent_domain_events( enriched, limit=max(10, min(int(event_limit or 20), 60)), ) except Exception: enriched["recent_domain_events"] = [] if runtime_snapshot: runtime_job = dict(runtime_snapshot.get("job") or {}) runtime_queue = dict((runtime_snapshot.get("queue_health") or {}).get("queue") or {}) runtime_nodes = list((runtime_snapshot.get("queue_health") or {}).get("nodes") or []) runtime_steps = list((runtime_snapshot.get("queue_health") or {}).get("steps") or []) runtime_job_nodes = list(runtime_job.get("node_stats") or []) if runtime_job: runtime_snapshot_job_id = int(runtime_job.get("job_id", 0) or 0) enriched["runtime_snapshot_job_id"] = runtime_snapshot_job_id runtime_snapshot_job_code = str(runtime_job.get("job_code") or "").strip() enriched["runtime_snapshot_job_code"] = runtime_snapshot_job_code if runtime_snapshot_job_code: enriched["job_code"] = runtime_snapshot_job_code enriched["runtime_job_code"] = runtime_snapshot_job_code merged_runtime_codes = [runtime_snapshot_job_code] + [ item for item in list(enriched.get("runtime_job_codes") or []) if str(item or "").strip() != runtime_snapshot_job_code ] enriched["runtime_job_codes"] = merged_runtime_codes[:5] if runtime_snapshot_job_id > 0: enriched["job_id"] = runtime_snapshot_job_id runtime_status = str(runtime_job.get("status") or "").strip() if runtime_status: enriched["status"] = runtime_status if float(runtime_job.get("progress_percent", 0) or 0) > float(enriched.get("progress_percent", 0) or 0): enriched["progress_percent"] = float(runtime_job.get("progress_percent", 0) or 0) if runtime_queue: enriched["runtime_snapshot_queue"] = runtime_queue runtime_items_total = int(runtime_queue.get("items_total", 0) or 0) if runtime_items_total > 0: enriched["items_total"] = runtime_items_total enriched["items_pending"] = int(runtime_queue.get("pending", 0) or 0) enriched["items_claimed"] = int(runtime_queue.get("claimed", 0) or 0) enriched["items_running"] = int(runtime_queue.get("running", 0) or 0) enriched["items_completed"] = int(runtime_queue.get("completed", 0) or 0) enriched["items_blacklisted"] = int(runtime_queue.get("blacklisted", 0) or 0) enriched["items_failed"] = int(runtime_queue.get("failed", 0) or 0) enriched["items_terminal"] = int(runtime_queue.get("terminal", 0) or 0) enriched["display_items_claimed"] = int(runtime_queue.get("display_claimed", enriched.get("items_claimed", 0)) or 0) enriched["display_items_running"] = int(runtime_queue.get("display_running", enriched.get("items_running", 0)) or 0) if runtime_job_nodes: enriched["node_stats"] = list(runtime_job_nodes) enriched["distributed_node_stats"] = list(runtime_job_nodes) runtime_display_summary = _build_display_summary(list(runtime_job_nodes)) enriched["display_items_claimed"] = int(runtime_display_summary.get("items_claimed", enriched.get("display_items_claimed", 0)) or 0) enriched["display_items_running"] = int(runtime_display_summary.get("display_running", enriched.get("display_items_running", 0)) or 0) enriched["display_current_load"] = int(runtime_display_summary.get("current_load", enriched.get("display_current_load", 0)) or 0) enriched["display_active_threads"] = int(runtime_display_summary.get("active_threads", enriched.get("display_active_threads", 0)) or 0) enriched["display_max_threads"] = int(runtime_display_summary.get("max_threads", enriched.get("display_max_threads", 0)) or 0) enriched["display_items_completed"] = int(runtime_display_summary.get("items_completed", enriched.get("display_items_completed", 0)) or 0) enriched["display_items_failed"] = int(runtime_display_summary.get("items_failed", enriched.get("display_items_failed", 0)) or 0) enriched["display_active_node_codes"] = list(runtime_display_summary.get("active_nodes") or []) elif runtime_nodes: enriched["node_stats"] = list(runtime_nodes) enriched["distributed_node_stats"] = list(runtime_nodes) if runtime_steps: enriched["step_stats"] = list(runtime_steps) runtime_recent_events = _select_runtime_snapshot_events( runtime_snapshot, job_code=str(runtime_job.get("job_code") or "").strip(), job_id=int(runtime_job.get("job_id", 0) or 0), event_limit=event_limit, ) if runtime_recent_events: enriched["recent_events"] = runtime_recent_events current_cycle_token, current_cycle_events = _extract_current_cycle_events(runtime_recent_events) enriched["current_cycle_token"] = current_cycle_token enriched["current_cycle_events"] = current_cycle_events enriched["latest_event"] = runtime_recent_events[0] enriched["latest_cycle_event"] = current_cycle_events[0] if current_cycle_events else runtime_recent_events[0] aligned_job_code = str(enriched.get("job_code") or enriched.get("runtime_job_code") or "").strip() aligned_job_id = int(enriched.get("job_id", 0) or 0) aligned_recent_events = _filter_events_for_job( list(enriched.get("recent_events") or []), job_code=aligned_job_code, job_id=aligned_job_id, limit=event_limit, ) if list(enriched.get("recent_events") or []) and not aligned_recent_events: enriched["recent_events"] = [] enriched["current_cycle_token"] = "" enriched["current_cycle_events"] = [] enriched["latest_event"] = None enriched["latest_cycle_event"] = None elif aligned_recent_events: enriched["recent_events"] = aligned_recent_events current_cycle_token, current_cycle_events = _extract_current_cycle_events(aligned_recent_events) enriched["current_cycle_token"] = current_cycle_token enriched["current_cycle_events"] = current_cycle_events enriched["latest_event"] = aligned_recent_events[0] enriched["latest_cycle_event"] = current_cycle_events[0] if current_cycle_events else aligned_recent_events[0] return enriched @db_read_retry() def get_detect_job_summary(job_id: int, event_limit: int = 20) -> dict | None: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, job_code, source, task_mode, step_code, status, created_by, created_at, started_at, finished_at FROM detect_jobs WHERE id = %s LIMIT 1 """, (int(job_id),), ) row = cur.fetchone() if not row: return None return _enrich_active_job_summary_with_runtime(_fetch_job_summary(cur, row, event_limit=event_limit), event_limit=event_limit) @db_read_retry() def get_active_detect_job_summary(event_limit: int = 20) -> dict | None: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, job_code, source, task_mode, step_code, status, created_by, created_at, started_at, finished_at FROM detect_jobs WHERE status IN ('pending', 'running') ORDER BY CASE WHEN status = 'running' THEN 0 ELSE 1 END ASC, COALESCE(started_at, created_at) DESC, id DESC LIMIT 1 """ ) row = cur.fetchone() if not row: return None return _enrich_active_job_summary_with_runtime(_fetch_job_summary(cur, row, event_limit=event_limit), event_limit=event_limit) @db_read_retry() def get_latest_detect_job_summary( *, event_limit: int = 20, statuses: tuple[str, ...] | list[str] | None = None, recent_minutes: int | None = None, ) -> dict | None: normalized_statuses = [ str(item or "").strip() for item in list(statuses or []) if str(item or "").strip() ] if not normalized_statuses: normalized_statuses = ["pending", "running", "completed", "partial_failed", "failed"] where_clauses = ["status = ANY(%s)"] params: list[object] = [normalized_statuses] if recent_minutes is not None: safe_recent_minutes = max(1, min(int(recent_minutes or 1), 24 * 60)) where_clauses.append( """ COALESCE(finished_at, started_at, created_at) >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval """ ) params.append(safe_recent_minutes) with get_db() as conn: with conn.cursor() as cur: cur.execute( f""" SELECT id, job_code, source, task_mode, step_code, status, created_by, created_at, started_at, finished_at FROM detect_jobs WHERE {' AND '.join(where_clauses)} ORDER BY CASE WHEN status = 'running' THEN 0 WHEN status = 'pending' THEN 1 WHEN status = 'completed' THEN 2 WHEN status = 'partial_failed' THEN 3 WHEN status = 'failed' THEN 4 ELSE 5 END ASC, COALESCE(finished_at, started_at, created_at) DESC, id DESC LIMIT 1 """, tuple(params), ) row = cur.fetchone() if not row: return None return _enrich_active_job_summary_with_runtime(_fetch_job_summary(cur, row, event_limit=event_limit), event_limit=event_limit) @db_read_retry() def get_latest_unprojected_detect_job_summary( *, event_limit: int = 20, statuses: tuple[str, ...] | list[str] | None = None, recent_minutes: int | None = None, source_region: str | None = None, target_region: str | None = None, ) -> dict | None: normalized_statuses = [ str(item or "").strip() for item in list(statuses or []) if str(item or "").strip() ] if not normalized_statuses: normalized_statuses = ["completed", "partial_failed", "failed"] normalized_source_region = _normalize_sync_region( source_region, _normalize_sync_region(settings.sync_source_region, settings.node_region), ) normalized_target_region = _normalize_sync_region( target_region, _normalize_sync_region(settings.sync_target_region, "overseas"), ) where_clauses = [ "job.status = ANY(%s)", """ NOT EXISTS ( SELECT 1 FROM detect_sync_records AS sync_record WHERE sync_record.sync_type = 'detect_result_projection' AND sync_record.source_region = %s AND sync_record.target_region = %s AND (sync_record.payload_json->'projection'->'job'->>'job_id') = job.id::text ) """, ] params: list[object] = [normalized_statuses, normalized_source_region, normalized_target_region] if recent_minutes is not None: safe_recent_minutes = max(1, min(int(recent_minutes or 1), 24 * 60)) where_clauses.append( """ COALESCE(job.finished_at, job.started_at, job.created_at) >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval """ ) params.append(safe_recent_minutes) with get_db() as conn: with conn.cursor() as cur: cur.execute( f""" SELECT job.id, job.job_code, job.source, job.task_mode, job.step_code, job.status, job.created_by, job.created_at, job.started_at, job.finished_at FROM detect_jobs AS job WHERE {' AND '.join(where_clauses)} ORDER BY COALESCE(job.finished_at, job.started_at, job.created_at) DESC, job.id DESC LIMIT 1 """, tuple(params), ) row = cur.fetchone() if not row: return None return _enrich_active_job_summary_with_runtime(_fetch_job_summary(cur, row, event_limit=event_limit), event_limit=event_limit) def list_detect_jobs(limit: int = 20) -> list[dict]: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, job_code, source, task_mode, step_code, status, created_by, created_at, started_at, finished_at FROM detect_jobs ORDER BY created_at DESC, id DESC LIMIT %s """, (max(1, min(int(limit or 20), 100)),), ) rows = cur.fetchall() return [_fetch_job_summary(cur, row, event_limit=10) for row in rows] def list_recent_detect_run_events(limit: int = 20) -> list[dict]: safe_limit = max(1, min(int(limit or 20), 200)) with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT job_id, node_code, event_type, level, message, payload_json, created_at FROM detect_run_events ORDER BY created_at DESC, id DESC LIMIT %s """, (safe_limit,), ) rows = cur.fetchall() return [ { "job_id": int(row[0]) if row[0] is not None else None, "node_code": row[1] or "", "event_type": row[2] or "", "level": row[3] or "info", "message": row[4] or "", "payload": _decode_payload(row[5]), "created_at": _format_time(row[6]), } for row in rows ] @db_read_retry() def get_detect_queue_health(window_minutes: int = 15) -> dict: window_minutes = max(5, min(int(window_minutes or 15), 120)) active_job = get_active_detect_job_summary(event_limit=10) runtime_activity = _load_runtime_activity_snapshot(window_minutes) runtime_snapshot = _load_latest_runtime_active_job_snapshot(window_minutes) if not active_job: return { "window_minutes": window_minutes, "has_active_job": False, "job": None, "queue": { "items_total": 0, "pending": 0, "claimed": 0, "running": 0, "completed": 0, "blacklisted": 0, "failed": 0, "terminal": 0, "terminal_percent": 0, "oldest_pending_at": "", "oldest_pending_age_minutes": 0, "nearest_lease_expiry_at": "", "overdue_leases": 0, "expiring_soon_leases": 0, }, "throughput": { "processed_recent": int(runtime_activity.get("processed_recent", 0) or 0), "processed_per_minute": round(int(runtime_activity.get("processed_recent", 0) or 0) / window_minutes, 2), "completed_recent": int(runtime_activity.get("completed_recent", 0) or 0), "blacklisted_recent": int(runtime_activity.get("blacklisted_recent", 0) or 0), "failed_recent": int(runtime_activity.get("failed_recent", 0) or 0), }, "nodes": [], "steps": [], "runtime_activity": runtime_activity, } job_id = int(active_job["job_id"]) with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT MIN(create_time) FILTER (WHERE status = 'pending') AS oldest_pending_at, MIN(lease_expires_at) FILTER (WHERE status IN ('claimed', 'running') AND lease_expires_at IS NOT NULL) AS nearest_lease_expiry_at, COUNT(*) FILTER (WHERE status IN ('claimed', 'running') AND lease_expires_at IS NOT NULL AND lease_expires_at < CURRENT_TIMESTAMP) AS overdue_leases, COUNT(*) FILTER ( WHERE status IN ('claimed', 'running') AND lease_expires_at IS NOT NULL AND lease_expires_at >= CURRENT_TIMESTAMP AND lease_expires_at < CURRENT_TIMESTAMP + interval '5 minutes' ) AS expiring_soon_leases FROM detect_job_items WHERE job_id = %s """, (job_id,), ) lease_row = cur.fetchone() cur.execute( """ SELECT COALESCE(NULLIF(node_code, ''), 'unknown') AS node_code, COUNT(*) AS processed_recent, COUNT(*) FILTER (WHERE event_type = 'domain_completed') AS completed_recent, COUNT(*) FILTER (WHERE event_type = 'domain_blacklisted') AS blacklisted_recent, COUNT(*) FILTER (WHERE event_type = 'domain_failed') AS failed_recent FROM detect_run_events WHERE job_id = %s AND event_type IN ('domain_completed', 'domain_blacklisted', 'domain_failed') AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval GROUP BY COALESCE(NULLIF(node_code, ''), 'unknown') ORDER BY processed_recent DESC, node_code ASC """, (job_id, window_minutes), ) throughput_rows = cur.fetchall() cur.execute( """ SELECT COALESCE(NULLIF(step_code, ''), 'domain_pipeline') AS step_code, COUNT(*) FILTER ( WHERE status IN ('completed', 'blacklisted', 'failed') AND finished_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ) AS processed_recent, COUNT(*) FILTER ( WHERE status = 'completed' AND finished_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ) AS completed_recent, COUNT(*) FILTER ( WHERE status = 'blacklisted' AND finished_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ) AS blacklisted_recent, COUNT(*) FILTER ( WHERE status = 'failed' AND finished_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ) AS failed_recent FROM detect_job_items WHERE job_id = %s GROUP BY COALESCE(NULLIF(step_code, ''), 'domain_pipeline') ORDER BY step_code ASC """, (window_minutes, window_minutes, window_minutes, window_minutes, job_id), ) step_throughput_rows = cur.fetchall() runtime_display_rows = _load_runtime_display_rows(cur) oldest_pending_at = _format_time(lease_row[0]) if lease_row and lease_row[0] else "" nearest_lease_expiry_at = _format_time(lease_row[1]) if lease_row and lease_row[1] else "" oldest_pending_age_minutes = 0 if lease_row and lease_row[0]: oldest_pending_age_minutes = max(0, int((datetime.now() - lease_row[0]).total_seconds() // 60)) node_map = { str(item.get("node_code") or "unknown"): { "node_code": str(item.get("node_code") or "unknown"), "items_total": int(item.get("items_total", 0) or 0), "items_pending": int(item.get("items_pending", 0) or 0), "items_claimed": int(item.get("items_claimed", 0) or 0), "items_running": int(item.get("items_running", 0) or 0), "items_completed": int(item.get("items_completed", 0) or 0), "items_blacklisted": int(item.get("items_blacklisted", 0) or 0), "items_failed": int(item.get("items_failed", 0) or 0), "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, } for item in active_job.get("node_stats") or [] } distributed_nodes = list(active_job.get("distributed_node_stats") or []) if distributed_nodes: node_map = { str(item.get("node_code") or "unknown"): { "node_code": str(item.get("node_code") or "unknown"), "items_total": _int_value(item.get("items_total")), "items_pending": _int_value(item.get("items_pending")), "items_claimed": _int_value(item.get("items_claimed")), "items_running": _int_value(item.get("items_running")), "items_completed": _int_value(item.get("items_completed")), "items_blacklisted": _int_value(item.get("items_blacklisted")), "items_failed": _int_value(item.get("items_failed")), "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, "metrics_source": str(item.get("metrics_source") or "").strip(), } for item in distributed_nodes } total_processed_recent = 0 total_completed_recent = 0 total_blacklisted_recent = 0 total_failed_recent = 0 step_map = { str(item.get("step_code") or "domain_pipeline"): { **_build_step_bucket(str(item.get("step_code") or "domain_pipeline")), "items_total": _int_value(item.get("items_total")), "items_pending": _int_value(item.get("items_pending")), "items_claimed": _int_value(item.get("items_claimed")), "items_running": _int_value(item.get("items_running")), "items_completed": _int_value(item.get("items_completed")), "items_blacklisted": _int_value(item.get("items_blacklisted")), "items_failed": _int_value(item.get("items_failed")), } for item in active_job.get("step_stats") or [] } for row in throughput_rows: node_code = str(row[0] or "unknown") bucket = node_map.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, }, ) processed_recent = int(row[1] or 0) completed_recent = int(row[2] or 0) blacklisted_recent = int(row[3] or 0) failed_recent = int(row[4] or 0) bucket["processed_recent"] = processed_recent bucket["processed_per_minute"] = round(processed_recent / window_minutes, 2) bucket["completed_recent"] = completed_recent bucket["blacklisted_recent"] = blacklisted_recent bucket["failed_recent"] = failed_recent total_processed_recent += processed_recent total_completed_recent += completed_recent total_blacklisted_recent += blacklisted_recent total_failed_recent += failed_recent for row in list(runtime_display_rows or []): runtime_bucket = _build_runtime_display_bucket(row) if not runtime_bucket: continue node_code = str(runtime_bucket.get("node_code") or "unknown") bucket = node_map.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, "metrics_source": "runtime", "display_running": 0, "current_load": 0, "active_threads": 0, "max_threads": 0, "region": "", "role": "", "status": "", "last_heartbeat_at": "", }, ) runtime_display_running = _effective_runtime_load( items_running=runtime_bucket.get("items_running"), active_threads=runtime_bucket.get("active_threads"), ) for key in ( "items_total", "items_pending", "items_claimed", "items_completed", "items_blacklisted", "items_failed", "current_load", "active_threads", "max_threads", ): bucket[key] = max(_int_value(bucket.get(key)), _int_value(runtime_bucket.get(key))) bucket["display_running"] = max( _int_value(bucket.get("display_running")), runtime_display_running, ) bucket["items_running"] = max( _int_value(bucket.get("items_running")), _int_value(runtime_bucket.get("items_running")), ) for key in ("metrics_source", "region", "role", "status", "last_heartbeat_at"): if str(runtime_bucket.get(key) or "").strip(): bucket[key] = runtime_bucket.get(key) for node_code, runtime_bucket in dict(runtime_activity.get("nodes") or {}).items(): bucket = node_map.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, "metrics_source": "runtime", "display_running": 0, "current_load": 0, "active_threads": 0, "max_threads": 0, "region": "", "role": "", "status": "", "last_heartbeat_at": "", }, ) runtime_processed_recent = int(runtime_bucket.get("processed_recent", 0) or 0) if runtime_processed_recent > int(bucket.get("processed_recent", 0) or 0): bucket["processed_recent"] = runtime_processed_recent bucket["processed_per_minute"] = round(runtime_processed_recent / window_minutes, 2) bucket["completed_recent"] = int(runtime_bucket.get("completed_recent", 0) or 0) bucket["blacklisted_recent"] = int(runtime_bucket.get("blacklisted_recent", 0) or 0) bucket["failed_recent"] = int(runtime_bucket.get("failed_recent", 0) or 0) for row in step_throughput_rows: step_code = str(row[0] or "domain_pipeline") bucket = step_map.setdefault(step_code, _build_step_bucket(step_code)) processed_recent = int(row[1] or 0) completed_recent = int(row[2] or 0) blacklisted_recent = int(row[3] or 0) failed_recent = int(row[4] or 0) bucket["processed_recent"] = processed_recent bucket["processed_per_minute"] = round(processed_recent / window_minutes, 2) bucket["completed_recent"] = completed_recent bucket["blacklisted_recent"] = blacklisted_recent bucket["failed_recent"] = failed_recent runtime_step_stats = dict(runtime_activity.get("step_stats") or {}) for runtime_step_code, runtime_step_bucket in runtime_step_stats.items(): normalized_step_code = str(runtime_step_code or "").strip() if not normalized_step_code: continue bucket = step_map.setdefault(normalized_step_code, _build_step_bucket(normalized_step_code)) runtime_processed_recent = int(runtime_step_bucket.get("processed_recent", 0) or 0) runtime_started_recent = int(runtime_step_bucket.get("started_recent", 0) or 0) bucket["started_recent"] = max(int(bucket.get("started_recent", 0) or 0), runtime_started_recent) if runtime_processed_recent > int(bucket.get("processed_recent", 0) or 0): bucket["processed_recent"] = runtime_processed_recent bucket["processed_per_minute"] = round(runtime_processed_recent / window_minutes, 2) bucket["completed_recent"] = int(runtime_step_bucket.get("completed_recent", 0) or 0) bucket["blacklisted_recent"] = int(runtime_step_bucket.get("blacklisted_recent", 0) or 0) bucket["failed_recent"] = int(runtime_step_bucket.get("failed_recent", 0) or 0) elif int(bucket.get("processed_recent", 0) or 0) <= 0 and runtime_started_recent > 0: bucket["processed_recent"] = runtime_started_recent bucket["processed_per_minute"] = round(runtime_started_recent / window_minutes, 2) runtime_step_code = str(runtime_activity.get("step_code") or "").strip() runtime_processed_recent = int(runtime_activity.get("processed_recent", 0) or 0) if runtime_step_code and runtime_processed_recent > 0 and runtime_step_code not in runtime_step_stats: runtime_bucket = step_map.setdefault(runtime_step_code, _build_step_bucket(runtime_step_code)) if runtime_processed_recent > int(runtime_bucket.get("processed_recent", 0) or 0): runtime_bucket["processed_recent"] = runtime_processed_recent runtime_bucket["processed_per_minute"] = round(runtime_processed_recent / window_minutes, 2) runtime_bucket["completed_recent"] = int(runtime_activity.get("completed_recent", 0) or 0) runtime_bucket["blacklisted_recent"] = int(runtime_activity.get("blacklisted_recent", 0) or 0) runtime_bucket["failed_recent"] = int(runtime_activity.get("failed_recent", 0) or 0) total_processed_recent = max(total_processed_recent, int(runtime_activity.get("processed_recent", 0) or 0)) total_completed_recent = max(total_completed_recent, int(runtime_activity.get("completed_recent", 0) or 0)) total_blacklisted_recent = max(total_blacklisted_recent, int(runtime_activity.get("blacklisted_recent", 0) or 0)) total_failed_recent = max(total_failed_recent, int(runtime_activity.get("failed_recent", 0) or 0)) runtime_snapshot_job = dict(runtime_snapshot.get("job") or {}) runtime_snapshot_queue = dict((runtime_snapshot.get("queue_health") or {}).get("queue") or {}) runtime_snapshot_nodes = list((runtime_snapshot.get("queue_health") or {}).get("nodes") or []) runtime_snapshot_steps = list((runtime_snapshot.get("queue_health") or {}).get("steps") or []) runtime_snapshot_job_code = str(runtime_snapshot_job.get("job_code") or "").strip() items_total = int(active_job.get("items_total", 0) or 0) pending_items = int(active_job.get("items_pending", 0) or 0) claimed_items = int(active_job.get("items_claimed", 0) or 0) running_items = int(active_job.get("items_running", 0) or 0) completed_items = int(active_job.get("items_completed", 0) or 0) blacklisted_items = int(active_job.get("items_blacklisted", 0) or 0) failed_items = int(active_job.get("items_failed", 0) or 0) display_claimed = int(active_job.get("display_items_claimed", active_job.get("items_claimed", 0)) or 0) display_running = int( active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0))) or 0 ) progress_percent = float(active_job.get("progress_percent", 0) or 0) if runtime_snapshot_queue: runtime_items_total = int(runtime_snapshot_queue.get("items_total", 0) or 0) if runtime_items_total > 0: items_total = runtime_items_total pending_items = int(runtime_snapshot_queue.get("pending", 0) or 0) claimed_items = int(runtime_snapshot_queue.get("claimed", 0) or 0) running_items = int(runtime_snapshot_queue.get("running", 0) or 0) completed_items = int(runtime_snapshot_queue.get("completed", 0) or 0) blacklisted_items = int(runtime_snapshot_queue.get("blacklisted", 0) or 0) failed_items = int(runtime_snapshot_queue.get("failed", 0) or 0) display_claimed = int(runtime_snapshot_queue.get("display_claimed", claimed_items) or 0) display_running = int(runtime_snapshot_queue.get("display_running", running_items) or 0) progress_percent = float(runtime_snapshot_job.get("progress_percent", progress_percent) or 0) if runtime_snapshot_nodes: node_map = { str(item.get("node_code") or "unknown"): { "node_code": str(item.get("node_code") or "unknown"), "items_total": _int_value(item.get("items_total")), "items_pending": _int_value(item.get("items_pending")), "items_claimed": _int_value(item.get("items_claimed")), "items_running": _int_value(item.get("items_running")), "items_completed": _int_value(item.get("items_completed")), "items_blacklisted": _int_value(item.get("items_blacklisted")), "items_failed": _int_value(item.get("items_failed")), "processed_recent": _int_value(item.get("processed_recent")), "processed_per_minute": float(item.get("processed_per_minute", 0) or 0), "completed_recent": _int_value(item.get("completed_recent")), "blacklisted_recent": _int_value(item.get("blacklisted_recent")), "failed_recent": _int_value(item.get("failed_recent")), "metrics_source": str(item.get("metrics_source") or "").strip(), } for item in runtime_snapshot_nodes } if runtime_snapshot_steps: step_map = { str(item.get("step_code") or "domain_pipeline"): { **_build_step_bucket(str(item.get("step_code") or "domain_pipeline")), "items_total": _int_value(item.get("items_total")), "items_pending": _int_value(item.get("items_pending")), "items_claimed": _int_value(item.get("items_claimed")), "items_running": _int_value(item.get("items_running")), "items_completed": _int_value(item.get("items_completed")), "items_blacklisted": _int_value(item.get("items_blacklisted")), "items_failed": _int_value(item.get("items_failed")), "processed_recent": _int_value(item.get("processed_recent")), "processed_per_minute": float(item.get("processed_per_minute", 0) or 0), "completed_recent": _int_value(item.get("completed_recent")), "blacklisted_recent": _int_value(item.get("blacklisted_recent")), "failed_recent": _int_value(item.get("failed_recent")), } for item in runtime_snapshot_steps } for row in list(runtime_display_rows or []): runtime_bucket = _build_runtime_display_bucket(row) if not runtime_bucket: continue node_code = str(runtime_bucket.get("node_code") or "unknown") bucket = node_map.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, "metrics_source": "runtime", "display_running": 0, "current_load": 0, "active_threads": 0, "max_threads": 0, "region": "", "role": "", "status": "", "last_heartbeat_at": "", }, ) runtime_display_running = _effective_runtime_load( items_running=runtime_bucket.get("items_running"), active_threads=runtime_bucket.get("active_threads"), ) for key in ( "items_total", "items_pending", "items_claimed", "items_completed", "items_blacklisted", "items_failed", "current_load", "active_threads", "max_threads", ): bucket[key] = max(_int_value(bucket.get(key)), _int_value(runtime_bucket.get(key))) bucket["display_running"] = max( _int_value(bucket.get("display_running")), runtime_display_running, ) bucket["items_running"] = max( _int_value(bucket.get("items_running")), _int_value(runtime_bucket.get("items_running")), ) for key in ("metrics_source", "region", "role", "status", "last_heartbeat_at"): if str(runtime_bucket.get(key) or "").strip(): bucket[key] = runtime_bucket.get(key) for node_code, runtime_bucket in dict(runtime_activity.get("nodes") or {}).items(): bucket = node_map.setdefault( node_code, { "node_code": node_code, "items_total": 0, "items_pending": 0, "items_claimed": 0, "items_running": 0, "items_completed": 0, "items_blacklisted": 0, "items_failed": 0, "processed_recent": 0, "processed_per_minute": 0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, "metrics_source": "runtime", }, ) runtime_processed_recent = int(runtime_bucket.get("processed_recent", 0) or 0) if runtime_processed_recent > int(bucket.get("processed_recent", 0) or 0): bucket["processed_recent"] = runtime_processed_recent bucket["processed_per_minute"] = round(runtime_processed_recent / window_minutes, 2) bucket["completed_recent"] = int(runtime_bucket.get("completed_recent", 0) or 0) bucket["blacklisted_recent"] = int(runtime_bucket.get("blacklisted_recent", 0) or 0) bucket["failed_recent"] = int(runtime_bucket.get("failed_recent", 0) or 0) if not str(bucket.get("metrics_source") or "").strip(): bucket["metrics_source"] = "runtime" for bucket in node_map.values(): active_threads = int(bucket.get("active_threads", 0) or 0) current_load = int(bucket.get("current_load", 0) or 0) display_running_value = max( int(bucket.get("display_running", 0) or 0), active_threads, current_load, ) bucket["display_running"] = display_running_value if active_threads > 0: bucket["items_running"] = min( max(int(bucket.get("items_running", 0) or 0), active_threads), active_threads, ) bucket["current_load"] = max(current_load, active_threads) for bucket in step_map.values(): runtime_bucket = dict(runtime_activity.get("step_stats") or {}).get(str(bucket.get("step_code") or "").strip()) or {} if runtime_bucket: bucket["started_recent"] = max( int(bucket.get("started_recent", 0) or 0), int(runtime_bucket.get("started_recent", 0) or 0), ) runtime_processed_recent = int(runtime_bucket.get("processed_recent", 0) or 0) if runtime_processed_recent > int(bucket.get("processed_recent", 0) or 0): bucket["processed_recent"] = runtime_processed_recent bucket["processed_per_minute"] = round(runtime_processed_recent / window_minutes, 2) bucket["completed_recent"] = int(runtime_bucket.get("completed_recent", 0) or 0) bucket["blacklisted_recent"] = int(runtime_bucket.get("blacklisted_recent", 0) or 0) bucket["failed_recent"] = int(runtime_bucket.get("failed_recent", 0) or 0) normalized_running_items = sum( int(item.get("items_running", 0) or 0) for item in node_map.values() if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) normalized_display_running = sum( int(item.get("display_running", 0) or 0) for item in node_map.values() if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) # Prefer normalized per-node sums once runtime heartbeats are present. # Stale snapshot queue rows can temporarily over-report thread load from an # older cycle, which is exactly what misleads the ops dashboard. if normalized_running_items > 0: running_items = normalized_running_items if normalized_display_running > 0: display_running = normalized_display_running nodes = sorted( node_map.values(), key=lambda item: ( -int(item.get("processed_recent", 0) or 0), -int(item.get("display_running", item.get("items_running", 0)) or 0), -int(item.get("items_running", 0) or 0), str(item.get("node_code") or ""), ), ) terminal = completed_items + blacklisted_items + failed_items steps = sorted( step_map.values(), key=lambda item: ( -int(item.get("processed_recent", 0) or 0), -int(item.get("items_running", 0) or 0), -int(item.get("items_pending", 0) or 0), str(item.get("step_code") or ""), ), ) return { "window_minutes": window_minutes, "has_active_job": True, "job": { "job_id": job_id, "job_code": active_job.get("job_code", ""), "runtime_job_code": runtime_snapshot_job_code or str(runtime_activity.get("focus_job_code") or "").strip(), "runtime_job_codes": list(runtime_activity.get("job_codes") or []), "status": active_job.get("status", ""), "progress_percent": progress_percent, }, "queue": { "items_total": items_total, "pending": pending_items, "claimed": claimed_items, "running": running_items, "display_claimed": display_claimed, "display_running": display_running, "completed": completed_items, "blacklisted": blacklisted_items, "failed": failed_items, "terminal": terminal, "terminal_percent": round((terminal / items_total) * 100, 2) if items_total else 0, "oldest_pending_at": oldest_pending_at, "oldest_pending_age_minutes": oldest_pending_age_minutes, "nearest_lease_expiry_at": nearest_lease_expiry_at, "overdue_leases": int(lease_row[2] or 0) if lease_row else 0, "expiring_soon_leases": int(lease_row[3] or 0) if lease_row else 0, }, "throughput": { "processed_recent": total_processed_recent, "processed_per_minute": round(total_processed_recent / window_minutes, 2), "completed_recent": total_completed_recent, "blacklisted_recent": total_blacklisted_recent, "failed_recent": total_failed_recent, }, "nodes": nodes, "steps": steps, "runtime_activity": runtime_activity, "runtime_snapshot_backlog": dict(runtime_snapshot.get("backlog") or {}), } def get_detect_capacity_plan(*, queue_health: dict | None = None, online_worker_nodes: int = 0, target_finish_hours: int = 6) -> dict: queue_health = queue_health or get_detect_queue_health(window_minutes=15) target_finish_hours = max(1, min(int(target_finish_hours or 6), 72)) online_worker_nodes = max(0, int(online_worker_nodes or 0)) if not queue_health.get("has_active_job"): return { "has_active_job": False, "online_worker_nodes": online_worker_nodes, "target_finish_hours": target_finish_hours, "estimated_hours_remaining": 0, "recommended_total_workers": max(1, online_worker_nodes), "recommended_additional_workers": 0, "current_processed_per_hour": 0, "pending_items": 0, "terminal_items": 0, "summary": "当前没有活跃任务,无需扩容建议。", } queue = queue_health.get("queue") or {} throughput = queue_health.get("throughput") or {} pending_items = int(queue.get("pending", 0) or 0) claimed_items = int(queue.get("claimed", 0) or 0) running_items = int(queue.get("running", 0) or 0) remaining_items = pending_items + claimed_items + running_items current_processed_per_hour = round(float(throughput.get("processed_per_minute", 0) or 0) * 60, 2) estimated_hours_remaining = round((remaining_items / current_processed_per_hour), 2) if current_processed_per_hour > 0 else 0 recommended_total_workers = max(1, online_worker_nodes or 1) recommended_additional_workers = 0 if remaining_items > 0 and target_finish_hours > 0: required_per_hour = remaining_items / target_finish_hours if current_processed_per_hour > 0 and max(1, online_worker_nodes) > 0: per_worker_per_hour = current_processed_per_hour / max(1, online_worker_nodes) recommended_total_workers = max(1, int(math.ceil(required_per_hour / per_worker_per_hour))) recommended_additional_workers = max(0, recommended_total_workers - online_worker_nodes) elif remaining_items > 0: recommended_total_workers = max(1, online_worker_nodes or 1) recommended_additional_workers = 0 summary = ( f"当前在线 Worker {online_worker_nodes} 台,近窗吞吐约 {current_processed_per_hour} 项/小时," f"剩余待处理约 {remaining_items} 项,预计还需 {estimated_hours_remaining} 小时。" ) if recommended_additional_workers > 0: summary = ( f"{summary} 若希望在 {target_finish_hours} 小时内收敛,建议总 Worker 数达到 " f"{recommended_total_workers} 台,至少再加 {recommended_additional_workers} 台。" ) else: summary = f"{summary} 按当前目标 {target_finish_hours} 小时看,现有 Worker 数量暂时够用。" return { "has_active_job": True, "online_worker_nodes": online_worker_nodes, "target_finish_hours": target_finish_hours, "estimated_hours_remaining": estimated_hours_remaining, "recommended_total_workers": recommended_total_workers, "recommended_additional_workers": recommended_additional_workers, "current_processed_per_hour": current_processed_per_hour, "pending_items": pending_items, "remaining_items": remaining_items, "terminal_items": int(queue.get("terminal", 0) or 0), "summary": summary, } def process_detect_pipeline(limit: int = 5000, job_id: int | None = None) -> dict: safe_limit = max(1, min(int(limit or 5000), 5000)) normalized_job_id = int(job_id or 0) if job_id not in (None, "", 0, "0") else 0 summary = { "processed_items": 0, "advanced_items": 0, "retried_items": 0, "terminal_items": 0, "skipped_items": 0, "created_next_items": 0, "retry_reset_items": 0, "legacy_items_upgraded": 0, "legacy_items_completed": 0, "job_ids": [], } with get_db() as conn: conn.autocommit = False with conn.cursor() as cur: settings_payload = get_settings_payload() touched_job_ids: set[int] = set() cur.execute( """ SELECT item.id, item.job_id, item.domain_id FROM detect_job_items AS item JOIN detect_jobs AS job ON job.id = item.job_id WHERE job.task_mode = 'domain_pipeline' AND (%s = 0 OR item.job_id = %s) AND job.status IN ('pending', 'running', 'completed', 'failed', 'partial_failed') AND COALESCE(item.step_code, '') = '' AND item.status = 'pending' ORDER BY item.id ASC LIMIT %s FOR UPDATE SKIP LOCKED """, (normalized_job_id, normalized_job_id, safe_limit), ) legacy_rows = cur.fetchall() for row in legacy_rows: item_id = int(row[0]) legacy_job_id = int(row[1]) domain_id = int(row[2]) domain_snapshot = _load_domain_pipeline_snapshot(cur, domain_id) if not domain_snapshot: continue step_code, step_payload = resolve_initial_domain_pipeline_item( domain_snapshot, settings_payload=settings_payload, ) if step_code and step_payload: cur.execute( """ UPDATE detect_job_items SET step_code = %s, step_payload_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (step_code, json.dumps(step_payload, ensure_ascii=False), item_id), ) touched_job_ids.add(legacy_job_id) summary["processed_items"] += 1 summary["legacy_items_upgraded"] += 1 continue processed_payload = { "controller_processed": True, "controller_action": "pipeline_already_complete", "controller_processed_at": datetime.now().isoformat(timespec="seconds"), } cur.execute( """ UPDATE detect_job_items SET status = 'completed', finished_at = COALESCE(finished_at, CURRENT_TIMESTAMP), result_payload_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (json.dumps(processed_payload, ensure_ascii=False), item_id), ) touched_job_ids.add(legacy_job_id) summary["processed_items"] += 1 summary["legacy_items_completed"] += 1 cur.execute( """ SELECT item.id, item.job_id, item.domain_id, item.step_code, item.status, item.attempt_count, item.result_payload_json, job.task_mode FROM detect_job_items AS item JOIN detect_jobs AS job ON job.id = item.job_id WHERE job.task_mode IN ('domain_pipeline', 'single_step') AND (%s = 0 OR item.job_id = %s) AND job.status IN ('pending', 'running', 'completed', 'failed', 'partial_failed') AND COALESCE(item.step_code, '') <> '' AND item.status IN ('completed', 'blacklisted', 'failed') AND COALESCE(item.result_payload_json->>'controller_processed', 'false') <> 'true' ORDER BY COALESCE(item.finished_at, item.updated_at) ASC, item.id ASC LIMIT %s FOR UPDATE SKIP LOCKED """, (normalized_job_id, normalized_job_id, safe_limit), ) rows = cur.fetchall() for row in rows: item_id = int(row[0]) job_id = int(row[1]) domain_id = int(row[2]) current_step_code = str(row[3] or "").strip() item_status = str(row[4] or "").strip() attempt_count = int(row[5] or 0) result_payload = _decode_payload(row[6]) task_mode = str(row[7] or "").strip() domain_snapshot = _load_domain_pipeline_snapshot(cur, domain_id) if not domain_snapshot: cur.execute( """ UPDATE detect_job_items SET result_payload_json = jsonb_set(COALESCE(result_payload_json, '{}'::jsonb), '{controller_processed}', 'true'::jsonb, true), updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (item_id,), ) summary["processed_items"] += 1 summary["skipped_items"] += 1 continue if task_mode == "single_step": result_field_name = str( result_payload.get("field_name") or _PIPELINE_STEP_FIELDS.get(current_step_code) or "" ).strip() persisted_payload = { key: value for key, value in dict(result_payload or {}).items() if key not in {"controller_processed", "controller_action", "controller_processed_at"} } action = "single_step_ignored" if result_field_name: persisted_payload.setdefault("step_code", current_step_code) persisted_payload.setdefault("step_name", _step_label(current_step_code)) persisted_payload.setdefault("field_name", result_field_name) persisted_payload.setdefault("task_mode", "single_step") persisted_payload.setdefault("domain_id", domain_id) _upsert_domain_detection_field(cur, domain_id, result_field_name, persisted_payload) action = "single_step_persisted" processed_payload = { **dict(result_payload or {}), "controller_processed": True, "controller_action": action, "controller_processed_at": datetime.now().isoformat(timespec="seconds"), } cur.execute( """ UPDATE detect_job_items SET result_payload_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (json.dumps(processed_payload, ensure_ascii=False), item_id), ) touched_job_ids.add(job_id) summary["processed_items"] += 1 if action == "single_step_persisted": summary["advanced_items"] += 1 else: summary["skipped_items"] += 1 continue result_message = str(result_payload.get("message") or "").strip() outcome = _classify_pipeline_item_outcome( item_status=item_status, result_payload=result_payload, step_code=current_step_code, attempt_count=attempt_count, ) action = str(outcome.get("action") or "pass").strip() result_state = str(outcome.get("result_state") or "").strip().lower() retry_limit = int(outcome.get("retry_limit", 0) or 0) if action == "retry": retry_payload = { **result_payload, "controller_action": "retry", "controller_processed": False, "controller_retry_count": int(attempt_count), "controller_reason_code": str(outcome.get("reason_code") or "external_retry"), } cur.execute( """ UPDATE detect_job_items SET status = 'pending', claimed_by = '', claim_token = '', lease_expires_at = NULL, finished_at = NULL, last_error = %s, result_payload_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, ( result_message or f"{_step_label(current_step_code)} 执行失败,等待重试", json.dumps(retry_payload, ensure_ascii=False), item_id, ), ) _append_detect_job_event_with_cursor( cur, job_id, event_type="step_retry_scheduled", message=f"{domain_snapshot['domain']} 的 {_step_label(current_step_code)} 已重投", payload={ "domain_id": domain_id, "domain": domain_snapshot["domain"], "step_code": current_step_code, "attempt_count": attempt_count, "retry_limit": retry_limit, "result_state": result_state, }, ) touched_job_ids.add(job_id) summary["processed_items"] += 1 summary["retried_items"] += 1 summary["retry_reset_items"] += 1 continue if action == "pass": next_step_code = resolve_domain_pipeline_step( domain_snapshot, settings_payload=settings_payload, after_step_code=current_step_code, ) if next_step_code: next_payload = _build_step_payload( step_code=next_step_code, domain_snapshot=domain_snapshot, settings_payload=settings_payload, ) cur.execute( """ INSERT INTO detect_job_items (job_id, domain_id, step_code, status, step_payload_json) VALUES (%s, %s, %s, 'pending', %s::jsonb) ON CONFLICT (job_id, domain_id, step_code) DO NOTHING RETURNING id """, ( job_id, domain_id, next_step_code, json.dumps(next_payload, ensure_ascii=False), ), ) created_next = bool(cur.fetchone()) action = "pass" summary["advanced_items"] += 1 if created_next: summary["created_next_items"] += 1 _append_detect_job_event_with_cursor( cur, job_id, event_type="pipeline_step_advanced", message=f"{domain_snapshot['domain']} 进入下一步骤: {_step_label(next_step_code)}", payload={ "domain_id": domain_id, "domain": domain_snapshot["domain"], "current_step_code": current_step_code, "next_step_code": next_step_code, }, ) else: action = "pipeline_completed" _append_detect_job_event_with_cursor( cur, job_id, event_type="pipeline_completed", message=f"{domain_snapshot['domain']} 本次 pipeline 已完成", payload={ "domain_id": domain_id, "domain": domain_snapshot["domain"], "current_step_code": current_step_code, }, ) elif action == "black_hit": _append_detect_job_event_with_cursor( cur, job_id, event_type="pipeline_black_hit", message=f"{domain_snapshot['domain']} 在 {_step_label(current_step_code)} 命中黑名单并终止后续步骤", level="warning", payload={ "domain_id": domain_id, "domain": domain_snapshot["domain"], "current_step_code": current_step_code, "result_state": result_state or "blacklisted", }, ) elif action == "reject": _append_detect_job_event_with_cursor( cur, job_id, event_type="pipeline_rejected", message=f"{domain_snapshot['domain']} 在 {_step_label(current_step_code)} 被判定终止", level="warning", payload={ "domain_id": domain_id, "domain": domain_snapshot["domain"], "current_step_code": current_step_code, "result_state": result_state or "failed", "attempt_count": attempt_count, "retry_limit": retry_limit, "reason_code": str(outcome.get("reason_code") or ""), }, ) processed_payload = { **result_payload, "controller_processed": True, "controller_action": action, "controller_reason_code": str(outcome.get("reason_code") or ""), "controller_processed_at": datetime.now().isoformat(timespec="seconds"), } if action == "pass": processed_payload["controller_result_state"] = result_state or "passed" cur.execute( """ UPDATE detect_job_items SET result_payload_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (json.dumps(processed_payload, ensure_ascii=False), item_id), ) touched_job_ids.add(job_id) summary["processed_items"] += 1 if action in {"black_hit", "reject", "pipeline_completed"}: summary["terminal_items"] += 1 for job_id in touched_job_ids: cur.execute( """ SELECT job.task_mode, count(*) FILTER (WHERE item.status = 'pending') AS pending_count, count(*) FILTER (WHERE item.status IN ('claimed', 'running')) AS dispatch_active_count, count(*) FILTER (WHERE item.status = 'failed') AS failed_count, count(*) FILTER (WHERE item.status IN ('completed', 'blacklisted')) AS done_count, count(*) FILTER ( WHERE item.status IN ('completed', 'blacklisted', 'failed') AND COALESCE(item.step_code, '') <> '' AND COALESCE(item.result_payload_json->>'controller_processed', 'false') <> 'true' ) AS unprocessed_terminal_count FROM detect_job_items AS item JOIN detect_jobs AS job ON job.id = item.job_id WHERE item.job_id = %s GROUP BY job.task_mode """, (job_id,), ) refresh_row = cur.fetchone() task_mode = str((refresh_row or [""])[0] or "").strip() pending_count = int((refresh_row or ["", 0])[1] or 0) dispatch_active_count = int((refresh_row or ["", 0, 0])[2] or 0) failed_count = int((refresh_row or ["", 0, 0, 0])[3] or 0) done_count = int((refresh_row or ["", 0, 0, 0, 0])[4] or 0) unprocessed_terminal_count = int((refresh_row or ["", 0, 0, 0, 0, 0])[5] or 0) if dispatch_active_count > 0 or (task_mode == "domain_pipeline" and unprocessed_terminal_count > 0): cur.execute( """ UPDATE detect_jobs SET status = 'running', started_at = COALESCE(started_at, CURRENT_TIMESTAMP), finished_at = NULL WHERE id = %s """, (job_id,), ) elif pending_count > 0: cur.execute( """ UPDATE detect_jobs SET status = 'pending', finished_at = NULL WHERE id = %s """, (job_id,), ) else: final_status = "completed" if failed_count > 0 and done_count > 0: final_status = "partial_failed" elif failed_count > 0: final_status = "failed" cur.execute( """ UPDATE detect_jobs SET status = %s, finished_at = CURRENT_TIMESTAMP, started_at = COALESCE(started_at, CURRENT_TIMESTAMP) WHERE id = %s """, (final_status, job_id), ) summary["job_ids"].append(job_id) conn.commit() summary["job_ids"] = sorted(set(int(item) for item in summary["job_ids"])) return summary def process_detect_pipeline_now(limit: int = 2000, job_id: int | None = None) -> tuple[bool, str, dict]: attempts = 3 delay_seconds = 0.05 for attempt in range(1, attempts + 1): try: data = process_detect_pipeline(limit=limit, job_id=job_id) processed = int(data.get("processed_items", 0) or 0) advanced = int(data.get("advanced_items", 0) or 0) retried = int(data.get("retried_items", 0) or 0) message = f"pipeline 处理完成:processed={processed}, advanced={advanced}, retried={retried}" if attempt > 1: message = f"{message}(deadlock 自动重试 {attempt - 1} 次后成功)" return True, message, {"action": "process_pipeline", "retry_attempts": attempt - 1, **data} except Exception as exc: if not is_retryable_db_error(exc) or attempt >= attempts: raise time.sleep(delay_seconds) delay_seconds *= 2 data = process_detect_pipeline(limit=limit, job_id=job_id) processed = int(data.get("processed_items", 0) or 0) advanced = int(data.get("advanced_items", 0) or 0) retried = int(data.get("retried_items", 0) or 0) message = f"pipeline 处理完成:processed={processed}, advanced={advanced}, retried={retried}" return True, message, {"action": "process_pipeline", **data} def _append_detect_job_event_with_cursor( cur, job_id: int, *, event_type: str, message: str, level: str = "info", payload: dict | None = None, node_code: str | None = None, ) -> bool: cur.execute( "SELECT 1 FROM detect_jobs WHERE id = %s LIMIT 1", (int(job_id),), ) if cur.fetchone() is None: return False cur.execute( """ INSERT INTO detect_run_events (job_id, node_code, event_type, level, message, payload_json) VALUES (%s, %s, %s, %s, %s, %s::jsonb) """, ( int(job_id), node_code or settings.node_code, str(event_type or "").strip() or "info", str(level or "info").strip() or "info", str(message or "").strip(), json.dumps(payload or {}, ensure_ascii=False), ), ) return True def append_detect_job_event( job_id: int, *, event_type: str, message: str, level: str = "info", payload: dict | None = None, node_code: str | None = None, ) -> None: inserted = False with get_db() as conn: with conn.cursor() as cur: inserted = _append_detect_job_event_with_cursor( cur, job_id, event_type=event_type, message=message, level=level, payload=payload, node_code=node_code, ) if inserted: conn.commit() try: push_debug_event( service="detect-job", event_type=str(event_type or "").strip() or "info", level=str(level or "info").strip() or "info", message=str(message or "").strip(), payload={ "job_id": int(job_id), "node_code": node_code or settings.node_code, **(payload or {}), }, ) except Exception: pass def create_detect_job_if_needed(limit: int = 1000, created_by: str = "system", step_code: str | None = None) -> dict | None: job_definition = resolve_detect_job_definition(step_code) existing = get_active_detect_job_summary() if existing: existing_task_mode = str(existing.get("task_mode") or "").strip() existing_step_code = normalize_detect_step_code(existing.get("step_code")) if ( existing_task_mode == str(job_definition["task_mode"]).strip() and existing_step_code == normalize_detect_step_code(job_definition["step_code"]) ): return existing settings_payload = get_settings_payload() with get_db() as conn: conn.autocommit = False with conn.cursor() as cur: cur.execute(_selection_sql(), (max(1, int(limit or 1000)),)) domain_ids = [row[0] for row in cur.fetchall()] if not domain_ids: conn.rollback() if not domain_ids: try: from app.services.sync_push_service import pull_detect_task_batch_now pull_detect_task_batch_now(limit=max(1, int(limit or 1000))) except Exception: pass with get_db() as conn: conn.autocommit = False with conn.cursor() as cur: cur.execute(_selection_sql(), (max(1, int(limit or 1000)),)) domain_ids = [row[0] for row in cur.fetchall()] if not domain_ids: conn.rollback() return None with get_db() as conn: conn.autocommit = False with conn.cursor() as cur: domain_snapshots: list[dict] = [] for domain_id in domain_ids: snapshot = _load_domain_pipeline_snapshot(cur, int(domain_id)) if snapshot: domain_snapshots.append(snapshot) if not domain_snapshots: conn.rollback() return None job_prefix = "step" if job_definition["is_single_step"] else "detect" job_code = f"{job_prefix}-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" plan_hash = uuid4().hex cur.execute( """ INSERT INTO detect_jobs (job_code, source, plan_hash, task_mode, step_code, status, remark, created_by) VALUES (%s, %s, %s, %s, %s, 'pending', %s, %s) RETURNING id """, ( job_code, job_definition["source"], plan_hash, job_definition["task_mode"], job_definition["step_code"], f"API 创建{job_definition['label']}任务,待检测域名 {len(domain_ids)} 个", created_by, ), ) job_id = cur.fetchone()[0] queued_count = 0 skipped_count = 0 for snapshot in domain_snapshots: item_step_code = ( job_definition["step_code"] if job_definition["is_single_step"] else resolve_initial_domain_pipeline_item(snapshot, settings_payload=settings_payload)[0] ) if not item_step_code: skipped_count += 1 continue _, step_payload = resolve_initial_domain_pipeline_item(snapshot, settings_payload=settings_payload) cur.execute( """ INSERT INTO detect_job_items (job_id, domain_id, step_code, status, step_payload_json) VALUES (%s, %s, %s, 'pending', %s::jsonb) ON CONFLICT (job_id, domain_id, step_code) DO NOTHING RETURNING id """, ( job_id, int(snapshot["id"]), item_step_code, json.dumps(step_payload, ensure_ascii=False), ), ) if cur.fetchone(): queued_count += 1 else: skipped_count += 1 if queued_count <= 0: conn.rollback() return None cur.execute( """ INSERT INTO detect_run_events (job_id, node_code, event_type, level, message, payload_json) VALUES (%s, %s, %s, %s, %s, %s::jsonb) """, ( job_id, settings.node_code, "job_created", "info", f"创建检测任务 {job_code},已入队 {queued_count} 个任务项", json.dumps( { "count": len(domain_ids), "queued_count": queued_count, "skipped_count": skipped_count, "task_mode": job_definition["task_mode"], "step_code": job_definition["step_code"], }, ensure_ascii=False, ), ), ) conn.commit() return get_detect_job_summary(job_id)