from __future__ import annotations import hashlib import math import json import threading import time from datetime import datetime, timedelta from uuid import uuid4 from psycopg2 import errors 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 _PREFERRED_RUNTIME_SNAPSHOT_GRACE = timedelta(minutes=10) _LIVE_RUNTIME_NODE_STALE_AFTER = timedelta(seconds=90) _DEFAULT_TASK_MODE = "domain_pipeline" _DETECT_JOBS_LEGACY_SELECT_MODE: bool | None = None _DETECT_JOB_ITEM_RECYCLE_LOCK = threading.Lock() _DETECT_JOB_ITEM_RECYCLE_LAST_RUN_TS = 0.0 _DETECT_JOB_ITEM_RECYCLE_INTERVAL_SECONDS = 15.0 _DETECT_JOB_ITEM_RECYCLE_ADVISORY_LOCK_KEY = 0 _DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE = 2000 _DETECT_JOB_ITEM_RECYCLE_MAX_BATCHES = 6 _ACTIVE_JOB_SUMMARY_CACHE_LOCK = threading.Lock() _ACTIVE_JOB_SUMMARY_CACHE_TTL_SECONDS = 3.0 _ACTIVE_JOB_SUMMARY_CACHE: dict[tuple[int], tuple[float, dict | None]] = {} _DETECT_QUEUE_HEALTH_CACHE_LOCK = threading.Lock() _DETECT_QUEUE_HEALTH_CACHE_TTL_SECONDS = 3.0 _DETECT_QUEUE_HEALTH_CACHE: dict[tuple[int], tuple[float, dict]] = {} _DISABLED_MANAGED_NODE_CACHE: set[str] = set() _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = 0.0 _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 _build_pg_advisory_lock_key(scope: str) -> int: normalized_scope = str(scope or "").strip() or "domaincheck-default" digest = hashlib.sha1(normalized_scope.encode("utf-8")).digest() raw_value = int.from_bytes(digest[:8], "big", signed=False) return raw_value - (1 << 64) if raw_value >= (1 << 63) else raw_value _DETECT_JOB_ITEM_RECYCLE_ADVISORY_LOCK_KEY = _build_pg_advisory_lock_key("detect-job-items-recycle-expired") def _clone_cacheable_payload(value): try: return json.loads(json.dumps(value, ensure_ascii=False)) except Exception: if isinstance(value, dict): return dict(value) if isinstance(value, list): return list(value) return value 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 _refresh_detect_job_status_with_cursor(cur, job_id: int) -> None: 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 """, (int(job_id),), ) refresh_row = cur.fetchone() if not refresh_row: return 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 AND ( status <> 'running' OR started_at IS NULL OR finished_at IS NOT NULL ) """, (int(job_id),), ) return if pending_count > 0: cur.execute( """ UPDATE detect_jobs SET status = 'pending', finished_at = NULL WHERE id = %s """, (int(job_id),), ) return 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, int(job_id)), ) def _recycle_expired_detect_job_items_once() -> int: conn = None advisory_locked = False try: with get_db() as conn: conn.autocommit = False with conn.cursor() as cur: cur.execute( "SELECT pg_try_advisory_lock(%s)", (_DETECT_JOB_ITEM_RECYCLE_ADVISORY_LOCK_KEY,), ) lock_row = cur.fetchone() advisory_locked = bool((lock_row or [False])[0]) if not advisory_locked: conn.rollback() return 0 recycled_count = 0 touched_job_ids: set[int] = set() for _ in range(_DETECT_JOB_ITEM_RECYCLE_MAX_BATCHES): cur.execute( """ WITH expired_candidates AS ( SELECT id, job_id, status FROM detect_job_items WHERE status IN ('claimed', 'running') AND lease_expires_at IS NOT NULL AND lease_expires_at < CURRENT_TIMESTAMP ORDER BY lease_expires_at ASC, id ASC FOR UPDATE SKIP LOCKED LIMIT %s ), recycled AS ( UPDATE detect_job_items AS item SET status = 'pending', claimed_by = '', claim_token = '', lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP, last_error = CASE WHEN expired_candidates.status = 'running' THEN 'lease expired while running' WHEN expired_candidates.status = 'claimed' THEN 'lease expired before running' ELSE item.last_error END FROM expired_candidates WHERE item.id = expired_candidates.id RETURNING expired_candidates.job_id ) SELECT count(*), array_remove(array_agg(DISTINCT job_id), NULL) FROM recycled """, (_DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE,), ) row = cur.fetchone() batch_count = int((row or [0])[0] or 0) recycled_count += batch_count touched_job_ids.update( int(item) for item in list((row or [0, []])[1] or []) if item is not None ) if batch_count < _DETECT_JOB_ITEM_RECYCLE_BATCH_SIZE: break for job_id in sorted(touched_job_ids): _refresh_detect_job_status_with_cursor(cur, job_id) conn.commit() if recycled_count > 0: try: push_debug_event( service="detect-job", event_type="expired_item_recycled", level="info", message=f"控制面回收过期检测任务项 {recycled_count} 个", payload={ "recycled_count": recycled_count, "job_ids": touched_job_ids, }, ) except Exception: pass return recycled_count except Exception as exc: try: if conn is not None: conn.rollback() except Exception: pass try: push_debug_event( service="detect-job", event_type="expired_item_recycle_failed", level="warning", message="控制面回收过期检测任务项失败", payload={"error": str(exc or "")[:500]}, ) except Exception: pass return 0 finally: if conn is not None and advisory_locked: try: with conn.cursor() as cur: cur.execute( "SELECT pg_advisory_unlock(%s)", (_DETECT_JOB_ITEM_RECYCLE_ADVISORY_LOCK_KEY,), ) conn.commit() except Exception: try: conn.rollback() except Exception: pass def _maybe_recycle_expired_detect_job_items() -> int: global _DETECT_JOB_ITEM_RECYCLE_LAST_RUN_TS now_ts = time.monotonic() with _DETECT_JOB_ITEM_RECYCLE_LOCK: if now_ts - _DETECT_JOB_ITEM_RECYCLE_LAST_RUN_TS < _DETECT_JOB_ITEM_RECYCLE_INTERVAL_SECONDS: return 0 _DETECT_JOB_ITEM_RECYCLE_LAST_RUN_TS = now_ts return _recycle_expired_detect_job_items_once() def _format_time(value: datetime | None) -> str: return value.isoformat(sep=" ", timespec="seconds") if value else "" def _load_disabled_managed_node_codes(node_codes: list[str] | None = None) -> set[str]: global _DISABLED_MANAGED_NODE_CACHE, _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT normalized_codes = [ str(item or "").strip() for item in list(node_codes or []) if str(item or "").strip() ] now_ts = time.time() if not normalized_codes and now_ts < _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT: return set(_DISABLED_MANAGED_NODE_CACHE) try: with get_db() as conn: with conn.cursor() as cur: if normalized_codes: cur.execute( """ SELECT node_code FROM ops_managed_nodes WHERE is_enabled = FALSE AND node_code = ANY(%s) """, (normalized_codes,), ) else: cur.execute( """ SELECT node_code FROM ops_managed_nodes WHERE is_enabled = FALSE """ ) rows = list(cur.fetchall() or []) except Exception: if normalized_codes: return {code for code in normalized_codes if code in _DISABLED_MANAGED_NODE_CACHE} return set(_DISABLED_MANAGED_NODE_CACHE) disabled_codes = { str(row[0] or "").strip() for row in rows if str(row[0] or "").strip() } if normalized_codes: return disabled_codes _DISABLED_MANAGED_NODE_CACHE = disabled_codes _DISABLED_MANAGED_NODE_CACHE_EXPIRES_AT = now_ts + 5.0 return set(disabled_codes) def _detect_jobs_select_columns(*, alias: str = "", legacy_mode: bool = False) -> str: prefix = f"{str(alias).strip()}." if str(alias or "").strip() else "" task_mode_expr = f"'{_DEFAULT_TASK_MODE}' AS task_mode" if legacy_mode else f"{prefix}task_mode" step_code_expr = "'' AS step_code" if legacy_mode else f"{prefix}step_code" return ( f"{prefix}id, {prefix}job_code, {prefix}source, " f"{task_mode_expr}, {step_code_expr}, " f"{prefix}status, {prefix}created_by, {prefix}created_at, {prefix}started_at, {prefix}finished_at" ) def _execute_detect_jobs_select( cur, *, from_clause: str, where_clause: str = "", order_clause: str = "", limit_clause: str = "", params: tuple | list | None = None, alias: str = "", ) -> None: global _DETECT_JOBS_LEGACY_SELECT_MODE safe_params = tuple(params or ()) candidate_modes = ( [_DETECT_JOBS_LEGACY_SELECT_MODE] if _DETECT_JOBS_LEGACY_SELECT_MODE is not None else [False, True] ) last_error: Exception | None = None for legacy_mode in candidate_modes: parts = [ f"SELECT {_detect_jobs_select_columns(alias=alias, legacy_mode=bool(legacy_mode))}", from_clause, ] if where_clause: parts.append(f"WHERE {where_clause}") if order_clause: parts.append(f"ORDER BY {order_clause}") if limit_clause: normalized_limit = str(limit_clause or "").strip() if normalized_limit: parts.append( normalized_limit if normalized_limit.upper().startswith("LIMIT") else f"LIMIT {normalized_limit}" ) sql = "\n".join(parts) try: cur.execute(sql, safe_params) _DETECT_JOBS_LEGACY_SELECT_MODE = bool(legacy_mode) return except errors.UndefinedColumn as exc: last_error = exc _DETECT_JOBS_LEGACY_SELECT_MODE = True if bool(legacy_mode): raise if last_error is not None: raise last_error 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 order_step_buckets( step_items: list[dict] | None, *, settings_payload: dict | None = None, limit: int | None = None, ) -> list[dict]: normalized_items = [dict(item) for item in list(step_items or []) if isinstance(item, dict)] if not normalized_items: return [] pipeline_order = _load_pipeline_order(settings_payload=settings_payload) order_index = {step_code: index for index, step_code in enumerate(pipeline_order)} ordered = sorted( normalized_items, key=lambda item: ( int(order_index.get(str(item.get("step_code") or "").strip(), 10_000)), str(item.get("step_code") or ""), ), ) if limit is not None and int(limit or 0) > 0: return ordered[: int(limit)] return ordered 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 _max_runtime_metric(*values: object) -> int: return max((_int_value(value) for value in values), default=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]: disabled_node_codes = _load_disabled_managed_node_codes() 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 [ row for row in list(cur.fetchall() or []) if str((row or [""])[0] or "").strip() not in disabled_node_codes ] 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() snapshot_job_codes = _load_recent_runtime_snapshot_job_codes(safe_window_minutes) job_activity: dict[str, dict] = {} overall_totals = { "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, } overall_node_stats: dict[str, dict] = {} overall_step_activity: dict[str, dict] = {} per_job_totals: dict[str, dict] = {} per_job_node_stats: dict[str, dict[str, dict]] = {} per_job_step_activity: dict[str, 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"): overall_totals["processed_recent"] += 1 step_bucket = overall_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 = overall_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 if job_code: job_totals = per_job_totals.setdefault( job_code, { "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) job_totals["processed_recent"] += 1 job_step_bucket = per_job_step_activity.setdefault(job_code, {}).setdefault( step_code, { "step_code": step_code, "started_recent": 0, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) job_step_bucket["processed_recent"] += 1 job_node_bucket = per_job_node_stats.setdefault(job_code, {}).setdefault( node_code, { "node_code": node_code, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) job_node_bucket["processed_recent"] += 1 terminal_status = str(classified.get("terminal_status") or "").strip() if terminal_status == "completed": overall_totals["completed_recent"] += 1 node_bucket["completed_recent"] += 1 step_bucket["completed_recent"] += 1 if job_code: job_totals["completed_recent"] += 1 job_node_bucket["completed_recent"] += 1 job_step_bucket["completed_recent"] += 1 elif terminal_status == "failed": overall_totals["failed_recent"] += 1 node_bucket["failed_recent"] += 1 step_bucket["failed_recent"] += 1 if job_code: job_totals["failed_recent"] += 1 job_node_bucket["failed_recent"] += 1 job_step_bucket["failed_recent"] += 1 elif terminal_status == "blacklisted": overall_totals["blacklisted_recent"] += 1 node_bucket["blacklisted_recent"] += 1 step_bucket["blacklisted_recent"] += 1 if job_code: job_totals["blacklisted_recent"] += 1 job_node_bucket["blacklisted_recent"] += 1 job_step_bucket["blacklisted_recent"] += 1 if classified.get("started"): step_bucket = overall_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 job_code: job_step_bucket = per_job_step_activity.setdefault(job_code, {}).setdefault( step_code, { "step_code": step_code, "started_recent": 0, "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, }, ) job_step_bucket["started_recent"] += 1 ordered_jobs = [] 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 ""), ), ) ordered_activity_codes = [ str(item.get("job_code") or "").strip() for item in ordered_jobs if str(item.get("job_code") or "").strip() ] merged_job_codes: list[str] = [] seen_job_codes: set[str] = set() for job_code in [*snapshot_job_codes, *ordered_activity_codes]: normalized_job_code = str(job_code or "").strip() if not normalized_job_code or normalized_job_code in seen_job_codes: continue merged_job_codes.append(normalized_job_code) seen_job_codes.add(normalized_job_code) focus_job_code = merged_job_codes[0] if merged_job_codes else "" if focus_job_code: snapshot["focus_job_code"] = focus_job_code snapshot["job_codes"] = merged_job_codes[:5] elif ordered_activity_codes: snapshot["focus_job_code"] = ordered_activity_codes[0] snapshot["job_codes"] = ordered_activity_codes[:5] selected_totals = overall_totals selected_step_activity = overall_step_activity selected_node_stats = overall_node_stats if focus_job_code: if focus_job_code in per_job_totals: selected_totals = dict(per_job_totals.get(focus_job_code) or {}) if focus_job_code in per_job_step_activity: selected_step_activity = dict(per_job_step_activity.get(focus_job_code) or {}) if focus_job_code in per_job_node_stats: selected_node_stats = dict(per_job_node_stats.get(focus_job_code) or {}) snapshot["processed_recent"] = int(selected_totals.get("processed_recent", 0) or 0) snapshot["completed_recent"] = int(selected_totals.get("completed_recent", 0) or 0) snapshot["failed_recent"] = int(selected_totals.get("failed_recent", 0) or 0) snapshot["blacklisted_recent"] = int(selected_totals.get("blacklisted_recent", 0) or 0) if selected_step_activity: ordered_steps = sorted( selected_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() } snapshot["nodes"] = selected_node_stats return snapshot def _load_recent_runtime_snapshot_job_codes(window_minutes: int, *, limit: int = 5) -> list[str]: safe_window_minutes = max(5, min(int(window_minutes or 15), 12 * 60)) safe_limit = max(1, min(int(limit or 5), 10)) if not (settings.node_region == "overseas" and settings.node_role == "control"): return [] entries: list[tuple[datetime, str]] = [] with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT payload_json, created_at FROM detect_debug_events WHERE source_region = 'mainland' 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 12 """, (safe_window_minutes,), ) for payload_json, created_at in list(cur.fetchall() or []): payload = _decode_payload(payload_json) job_code = str((payload.get("job") or {}).get("job_code") or "").strip() if isinstance(payload, dict) else "" if job_code and created_at: entries.append((created_at, job_code)) cur.execute( """ SELECT payload_json, COALESCE(updated_at, created_at) FROM detect_sync_records WHERE sync_type = 'runtime_ingest' AND source_region = 'mainland' AND target_region = 'overseas' AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ORDER BY COALESCE(updated_at, created_at) DESC, id DESC LIMIT 12 """, (safe_window_minutes,), ) for payload_json, created_at in list(cur.fetchall() or []): payload = _decode_payload(payload_json) projection = payload.get("projection") if isinstance(payload, dict) else {} job_code = str((projection.get("active_job") or {}).get("job_code") or "").strip() if isinstance(projection, dict) else "" if job_code and created_at: entries.append((created_at, job_code)) codes: list[str] = [] seen_codes: set[str] = set() for _created_at, job_code in sorted(entries, key=lambda item: item[0], reverse=True): if job_code in seen_codes: continue codes.append(job_code) seen_codes.add(job_code) if len(codes) >= safe_limit: break return codes def _load_latest_runtime_debug_active_job_snapshot( window_minutes: int, *, preferred_job_codes: list[str] | None = None, ) -> 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 {} preferred = { str(item or "").strip() for item in list(preferred_job_codes or []) if str(item or "").strip() } 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 12 """, (source_region, safe_window_minutes), ) rows = list(cur.fetchall() or []) fallback_snapshot: dict = {} for row in rows: payload = _decode_payload(row[0]) if not isinstance(payload, dict): continue snapshot = dict(payload) snapshot["_created_at"] = _format_time(row[1]) snapshot["_snapshot_source"] = "active_job_snapshot" if not fallback_snapshot: fallback_snapshot = snapshot job_code = str((snapshot.get("job") or {}).get("job_code") or "").strip() if preferred and job_code in preferred: return snapshot return fallback_snapshot def _normalize_runtime_projection_nodes( *, active_job: dict, progress: dict, projection: dict, ) -> list[dict]: node_info = dict(projection.get("node") or {}) fallback_node_code = str(node_info.get("node_code") or "").strip() fallback_region = str(node_info.get("region") or "").strip() fallback_role = str(node_info.get("role") or "").strip() active_thread_count = _int_value(projection.get("active_thread_count")) max_thread_count = _int_value(projection.get("max_thread_count")) nodes: list[dict] = [] for raw_item in list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or []): if not isinstance(raw_item, dict): continue node_code = str(raw_item.get("node_code") or "").strip() if not node_code: continue nodes.append( _normalize_node_bucket( { **raw_item, "node_code": node_code, "metrics_source": str(raw_item.get("metrics_source") or "runtime_ingest").strip() or "runtime_ingest", "region": str(raw_item.get("region") or (fallback_region if node_code == fallback_node_code else "")).strip(), "role": str(raw_item.get("role") or (fallback_role if node_code == fallback_node_code else "")).strip(), "status": str(raw_item.get("status") or "").strip(), } ) ) if fallback_node_code: matched = False for item in nodes: if str(item.get("node_code") or "").strip() != fallback_node_code: continue matched = True item["region"] = str(item.get("region") or fallback_region).strip() item["role"] = str(item.get("role") or fallback_role).strip() item["active_threads"] = max(_int_value(item.get("active_threads")), active_thread_count) item["max_threads"] = max(_int_value(item.get("max_threads")), max_thread_count) item["current_load"] = _effective_runtime_load( items_running=item.get("items_running"), active_threads=item.get("active_threads"), ) item["display_running"] = item["current_load"] if not str(item.get("status") or "").strip(): item["status"] = "busy" if item["current_load"] > 0 else "online" break has_child_instances = any( str(item.get("node_code") or "").strip().startswith(f"{fallback_node_code}-") for item in nodes ) if not matched and not has_child_instances and ( int(active_job.get("items_total", 0) or 0) > 0 or active_thread_count > 0 or max_thread_count > 0 ): nodes.append( _normalize_node_bucket( { "node_code": fallback_node_code, "items_total": int(active_job.get("items_total", 0) or 0), "items_pending": int(progress.get("pending", 0) or 0), "items_claimed": max( 0, int(active_job.get("items_total", 0) or 0) - int(progress.get("pending", 0) or 0) - int(progress.get("running", 0) or 0) - int(active_job.get("items_terminal", 0) or 0), ), "items_running": int(progress.get("running", 0) or 0), "items_completed": int(progress.get("completed", 0) or 0), "items_blacklisted": int(progress.get("blacklisted", 0) or 0), "items_failed": max( int(progress.get("failed", 0) or 0), int(active_job.get("items_failed", 0) or 0), ), "active_threads": active_thread_count, "max_threads": max_thread_count, "metrics_source": "runtime_ingest", "region": fallback_region, "role": fallback_role, "status": "busy" if max(active_thread_count, int(progress.get("running", 0) or 0)) > 0 else "online", } ) ) items_total = max(0, int(active_job.get("items_total", 0) or 0)) pending = max(0, int(progress.get("pending", 0) or 0)) running = max(0, int(progress.get("running", 0) or 0)) completed = max(0, int(progress.get("completed", 0) or 0)) blacklisted = max(0, int(progress.get("blacklisted", 0) or 0)) failed = max( max(0, int(progress.get("failed", 0) or 0)), max(0, int(active_job.get("items_failed", 0) or 0)), ) terminal = max( max(0, int(active_job.get("items_terminal", 0) or 0)), completed + blacklisted + failed, ) claimed = max(0, items_total - pending - running - terminal) assigned_totals = { "items_total": sum(_int_value(item.get("items_total")) for item in nodes), "items_pending": sum(_int_value(item.get("items_pending")) for item in nodes), "items_claimed": sum(_int_value(item.get("items_claimed")) for item in nodes), "items_running": sum(_int_value(item.get("items_running")) for item in nodes), "items_completed": sum(_int_value(item.get("items_completed")) for item in nodes), "items_blacklisted": sum(_int_value(item.get("items_blacklisted")) for item in nodes), "items_failed": sum(_int_value(item.get("items_failed")) for item in nodes), } unassigned_bucket = { "node_code": "unassigned", "items_total": max(0, items_total - assigned_totals["items_total"]), "items_pending": max(0, pending - assigned_totals["items_pending"]), "items_claimed": max(0, claimed - assigned_totals["items_claimed"]), "items_running": max(0, running - assigned_totals["items_running"]), "items_completed": max(0, completed - assigned_totals["items_completed"]), "items_blacklisted": max(0, blacklisted - assigned_totals["items_blacklisted"]), "items_failed": max(0, failed - assigned_totals["items_failed"]), "metrics_source": "runtime_ingest", "status": "online", } if any(int(unassigned_bucket.get(key, 0) or 0) > 0 for key in ( "items_total", "items_pending", "items_claimed", "items_running", "items_completed", "items_blacklisted", "items_failed", )): nodes.append(_normalize_node_bucket(unassigned_bucket)) return nodes def _build_runtime_snapshot_from_projection( projection: dict, *, created_at: datetime | None = None, window_minutes: int = 15, ) -> dict: normalized_projection = dict(projection or {}) active_job = dict(normalized_projection.get("active_job") or {}) progress = dict(normalized_projection.get("progress") or {}) backlog = dict(normalized_projection.get("backlog") or {}) nodes = _normalize_runtime_projection_nodes( active_job=active_job, progress=progress, projection=normalized_projection, ) items_total = max(0, int(active_job.get("items_total", 0) or 0)) pending = max(0, int(progress.get("pending", active_job.get("items_pending", 0)) or 0)) running = max(0, int(progress.get("running", active_job.get("items_running", 0)) or 0)) completed = max(0, int(progress.get("completed", 0) or 0)) blacklisted = max(0, int(progress.get("blacklisted", 0) or 0)) failed = max( max(0, int(progress.get("failed", 0) or 0)), max(0, int(active_job.get("items_failed", 0) or 0)), ) terminal = max( max(0, int(active_job.get("items_terminal", 0) or 0)), completed + blacklisted + failed, ) if items_total <= 0: items_total = pending + running + terminal claimed = max(0, items_total - pending - running - terminal) display_running = _max_runtime_metric( running, normalized_projection.get("active_thread_count"), active_job.get("display_active_threads"), active_job.get("display_items_running"), ) display_claimed = _max_runtime_metric(claimed, active_job.get("display_items_claimed")) progress_percent = float(active_job.get("progress_percent", 0) or 0) if progress_percent <= 0 and items_total > 0 and terminal > 0: progress_percent = round((terminal / items_total) * 100, 2) queue_health = { "window_minutes": max(5, min(int(window_minutes or 15), 120)), "has_active_job": bool(str(active_job.get("job_code") or "").strip() or items_total > 0), "job": { "job_id": int(active_job.get("job_id", 0) or 0), "job_code": str(active_job.get("job_code") or "").strip(), "runtime_job_code": str(active_job.get("job_code") or "").strip(), "status": str(active_job.get("status") or "").strip(), "progress_percent": progress_percent, }, "queue": { "items_total": items_total, "pending": pending, "claimed": claimed, "running": running, "display_claimed": display_claimed, "display_running": display_running, "completed": completed, "blacklisted": blacklisted, "failed": failed, "terminal": terminal, "terminal_percent": round((terminal / items_total) * 100, 2) if items_total else 0.0, "oldest_pending_at": "", "oldest_pending_age_minutes": 0, "nearest_lease_expiry_at": "", "overdue_leases": 0, "expiring_soon_leases": 0, }, "throughput": { "processed_recent": 0, "processed_per_minute": 0.0, "completed_recent": 0, "blacklisted_recent": 0, "failed_recent": 0, }, "nodes": nodes, "steps": [], "runtime_activity": {}, } snapshot = { "job": { "job_id": int(active_job.get("job_id", 0) or 0), "job_code": str(active_job.get("job_code") or "").strip(), "status": str(active_job.get("status") or "").strip(), "items_total": items_total, "items_pending": pending, "items_claimed": claimed, "items_running": running, "items_completed": completed, "items_blacklisted": blacklisted, "items_failed": failed, "items_terminal": terminal, "progress_percent": progress_percent, "node_stats": nodes, }, "queue_health": queue_health, "backlog": backlog if isinstance(backlog, dict) else {}, "recent_events": [], "_snapshot_source": "runtime_ingest", } if created_at: snapshot["_created_at"] = _format_time(created_at) return snapshot def _snapshot_created_at(snapshot: dict) -> datetime | None: return _parse_runtime_timestamp(snapshot.get("_created_at")) def _parse_runtime_timestamp(value: object) -> datetime | None: raw_value = str(value or "").strip() if not raw_value: return None normalized_value = raw_value.replace("T", " ") if normalized_value.endswith("Z"): normalized_value = f"{normalized_value[:-1]}+00:00" try: return datetime.fromisoformat(normalized_value) except ValueError: for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): try: return datetime.strptime(normalized_value, fmt) except ValueError: continue return None def _runtime_timestamp_is_fresh(value: datetime | None, *, max_age: timedelta) -> bool: if value is None: return False now = datetime.now(value.tzinfo) if value.tzinfo else datetime.now() return now - value <= max_age def _runtime_node_display_running(item: dict) -> int: return _max_runtime_metric( item.get("display_running"), item.get("current_load"), item.get("active_threads"), item.get("items_running"), ) def _load_runtime_node_overlay_map(node_codes: list[str]) -> dict[str, dict]: normalized_codes = [ str(item or "").strip() for item in list(node_codes or []) if str(item or "").strip() and str(item or "").strip() != "unassigned" ] if not normalized_codes: return {} if not (settings.node_region == "overseas" and settings.node_role == "control"): return {} disabled_node_codes = _load_disabled_managed_node_codes(normalized_codes) try: with get_db() as conn: with conn.cursor() as cur: cur.execute( """ SELECT node_code, region, role, status, current_load, metadata_json, last_heartbeat_at FROM detect_worker_nodes WHERE node_code = ANY(%s) """, (normalized_codes,), ) rows = list(cur.fetchall() or []) except Exception: return {} overlays: dict[str, dict] = {} for row in rows: node_code = str((row or [""])[0] or "").strip() if not node_code or node_code in disabled_node_codes: continue metadata = _decode_payload((row or ["", "", "", "", 0, {}])[5]) overlays[node_code] = { "region": str((row or ["", "", ""])[1] or "").strip(), "role": str((row or ["", "", "", ""])[2] or "").strip(), "status": str((row or ["", "", "", "", ""])[3] or "").strip(), "current_load": _int_value((row or ["", "", "", "", 0])[4]), "active_threads": _int_value(metadata.get("active_threads")), "max_threads": _int_value(metadata.get("max_threads")), "last_heartbeat_at": _format_time((row or ["", "", "", "", 0, {}, None])[6]) if len(row or []) > 6 and row[6] else "", "metrics_source": str(metadata.get("service") or "runtime").strip() or "runtime", } return overlays def _merge_runtime_node_overlay(item: dict, overlay: dict | None) -> dict: merged = dict(item or {}) normalized_overlay = dict(overlay or {}) if not normalized_overlay: return merged for key in ("region", "role", "status", "last_heartbeat_at", "metrics_source"): if not str(merged.get(key) or "").strip() and str(normalized_overlay.get(key) or "").strip(): merged[key] = normalized_overlay.get(key) for key in ("current_load", "active_threads", "max_threads"): merged[key] = max(_int_value(merged.get(key)), _int_value(normalized_overlay.get(key))) return merged def _filter_live_runtime_nodes(nodes: list[dict], *, snapshot_created_at: datetime | None) -> list[dict]: filtered: list[dict] = [] for raw_item in list(nodes or []): if not isinstance(raw_item, dict): continue item = dict(raw_item) node_code = str(item.get("node_code") or "").strip() if not node_code: continue if node_code == "unassigned": filtered.append(item) continue status = str(item.get("status") or "").strip().lower() if status in {"stale", "offline"}: continue reference_at = _parse_runtime_timestamp(item.get("last_heartbeat_at")) if reference_at is None: filtered.append(item) continue if not _runtime_timestamp_is_fresh(reference_at, max_age=_LIVE_RUNTIME_NODE_STALE_AFTER): continue filtered.append(item) return filtered def _build_live_runtime_snapshot(snapshot: dict | None) -> dict: normalized_snapshot = dict(snapshot or {}) if not normalized_snapshot: return {} snapshot_created_at = _snapshot_created_at(normalized_snapshot) job = dict(normalized_snapshot.get("job") or {}) queue_health = dict(normalized_snapshot.get("queue_health") or {}) queue = dict(queue_health.get("queue") or {}) queue_nodes = [dict(item) for item in list(queue_health.get("nodes") or []) if isinstance(item, dict)] job_nodes = [dict(item) for item in list(job.get("node_stats") or []) if isinstance(item, dict)] overlay_map = {} if str(normalized_snapshot.get("_snapshot_source") or "").strip() == "runtime_ingest": overlay_map = _load_runtime_node_overlay_map( [ *(item.get("node_code") for item in queue_nodes), *(item.get("node_code") for item in job_nodes), ] ) queue_nodes = [ _merge_runtime_node_overlay(item, overlay_map.get(str(item.get("node_code") or "").strip())) for item in queue_nodes ] job_nodes = [ _merge_runtime_node_overlay(item, overlay_map.get(str(item.get("node_code") or "").strip())) for item in job_nodes ] live_queue_nodes = _filter_live_runtime_nodes(queue_nodes, snapshot_created_at=snapshot_created_at) live_job_nodes = _filter_live_runtime_nodes(job_nodes, snapshot_created_at=snapshot_created_at) has_runtime_node_rows = bool(queue_nodes or job_nodes) raw_node_count = len([item for item in queue_nodes or job_nodes if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"]) live_nodes_for_display = live_queue_nodes if queue_nodes else live_job_nodes live_participant_nodes = [ item for item in live_nodes_for_display if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ] dropped_node_count = max(0, raw_node_count - len(live_participant_nodes)) display_running = sum(_runtime_node_display_running(item) for item in live_participant_nodes) display_claimed = sum( _max_runtime_metric(item.get("display_claimed"), item.get("items_claimed")) for item in live_participant_nodes ) display_max_threads = sum(max(0, _int_value(item.get("max_threads"))) for item in live_participant_nodes) allow_raw_display_fallback = dropped_node_count <= 0 and ( not has_runtime_node_rows or bool(live_participant_nodes) ) if allow_raw_display_fallback: display_running = _max_runtime_metric( display_running, queue.get("display_running"), job.get("display_items_running"), job.get("display_active_threads"), normalized_snapshot.get("active_thread_count"), job.get("items_running"), queue.get("running"), ) display_claimed = _max_runtime_metric( display_claimed, queue.get("display_claimed"), job.get("display_items_claimed"), queue.get("claimed"), job.get("items_claimed"), ) display_max_threads = _max_runtime_metric( display_max_threads, job.get("display_max_threads"), normalized_snapshot.get("max_thread_count"), ) if queue_nodes or job_nodes: queue_health["nodes"] = list(live_queue_nodes or live_job_nodes) queue["display_running"] = display_running queue["display_claimed"] = display_claimed queue_health["queue"] = queue job["node_stats"] = list(live_job_nodes or live_queue_nodes) job["display_items_running"] = display_running job["display_active_threads"] = display_running job["display_max_threads"] = display_max_threads normalized_snapshot["job"] = job normalized_snapshot["queue_health"] = queue_health normalized_snapshot["_raw_runtime_node_count"] = raw_node_count normalized_snapshot["_live_runtime_node_count"] = len(live_participant_nodes) normalized_snapshot["_dropped_runtime_node_count"] = dropped_node_count normalized_snapshot["_has_runtime_node_rows"] = has_runtime_node_rows return normalized_snapshot def _snapshot_display_running(snapshot: dict) -> int: queue = dict((snapshot.get("queue_health") or {}).get("queue") or {}) job = dict(snapshot.get("job") or {}) return max( _int_value(queue.get("display_running")), _int_value(job.get("display_items_running")), _int_value(job.get("display_active_threads")), _int_value(snapshot.get("active_thread_count")), ) def _runtime_job_matches_active_job( active_job: dict | None, *, runtime_job_code: object = "", runtime_job_id: object = 0, ) -> bool: normalized_active_job = dict(active_job or {}) active_job_code = str(normalized_active_job.get("job_code") or "").strip() active_job_id = _int_value(normalized_active_job.get("job_id")) normalized_runtime_job_code = str(runtime_job_code or "").strip() normalized_runtime_job_id = _int_value(runtime_job_id) if active_job_id > 0 and normalized_runtime_job_id > 0 and active_job_id == normalized_runtime_job_id: return True if active_job_code and normalized_runtime_job_code and active_job_code == normalized_runtime_job_code: return True return False def _snapshot_node_count(snapshot: dict) -> int: queue_nodes = list((snapshot.get("queue_health") or {}).get("nodes") or []) if queue_nodes: return len( [ item for item in queue_nodes if isinstance(item, dict) and str(item.get("node_code") or "").strip() != "unassigned" ] ) job_nodes = list((snapshot.get("job") or {}).get("node_stats") or []) return len( [ item for item in job_nodes if isinstance(item, dict) and str(item.get("node_code") or "").strip() != "unassigned" ] ) def _snapshot_is_stale_for_live_display(snapshot: dict) -> bool: normalized_snapshot = dict(snapshot or {}) if str(normalized_snapshot.get("_snapshot_source") or "").strip() != "runtime_ingest": return False created_at = _snapshot_created_at(normalized_snapshot) if created_at is None: return False return not _runtime_timestamp_is_fresh(created_at, max_age=_PREFERRED_RUNTIME_SNAPSHOT_GRACE) def _should_prefer_fallback_runtime_snapshot(*, fallback_snapshot: dict, matched_snapshot: dict) -> bool: fallback_nodes = _snapshot_node_count(fallback_snapshot) matched_nodes = _snapshot_node_count(matched_snapshot) fallback_display_running = _snapshot_display_running(fallback_snapshot) matched_display_running = _snapshot_display_running(matched_snapshot) if fallback_nodes <= matched_nodes and fallback_display_running <= matched_display_running: return False fallback_created_at = _snapshot_created_at(fallback_snapshot) matched_created_at = _snapshot_created_at(matched_snapshot) if fallback_created_at and matched_created_at: return fallback_created_at - matched_created_at > _PREFERRED_RUNTIME_SNAPSHOT_GRACE return matched_nodes <= 0 and matched_display_running <= 0 def _load_latest_runtime_ingest_active_job_snapshot( window_minutes: int, *, preferred_job_codes: list[str] | None = None, ) -> 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 {} preferred = { str(item or "").strip() for item in list(preferred_job_codes or []) if str(item or "").strip() } query_sql = """ SELECT payload_json, COALESCE(updated_at, created_at) FROM detect_sync_records WHERE sync_type = 'runtime_ingest' AND source_region = 'mainland' AND target_region = 'overseas' AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval ORDER BY COALESCE(updated_at, created_at) DESC, id DESC LIMIT 20 """ def _select_rows(lookback_minutes: int) -> list[tuple]: with get_db() as conn: with conn.cursor() as cur: cur.execute(query_sql, (int(lookback_minutes),)) return list(cur.fetchall() or []) def _pick_snapshot(rows: list[tuple]) -> tuple[dict, dict]: fallback_snapshot: dict = {} matched_snapshot: dict = {} for row in rows: payload = _decode_payload(row[0]) projection = payload.get("projection") if isinstance(payload, dict) else {} if not isinstance(projection, dict): continue snapshot = _build_runtime_snapshot_from_projection( projection, created_at=row[1], window_minutes=safe_window_minutes, ) if not fallback_snapshot: fallback_snapshot = snapshot job_code = str((snapshot.get("job") or {}).get("job_code") or "").strip() if preferred and job_code in preferred: matched_snapshot = snapshot break return fallback_snapshot, matched_snapshot recent_rows = _select_rows(safe_window_minutes) fallback_snapshot, matched_snapshot = _pick_snapshot(recent_rows) if matched_snapshot: if ( fallback_snapshot and fallback_snapshot is not matched_snapshot and _should_prefer_fallback_runtime_snapshot( fallback_snapshot=fallback_snapshot, matched_snapshot=matched_snapshot, ) ): return fallback_snapshot return matched_snapshot if preferred: extended_rows = _select_rows(max(safe_window_minutes, 12 * 60)) extended_fallback, matched_snapshot = _pick_snapshot(extended_rows) if matched_snapshot: if ( extended_fallback and extended_fallback is not matched_snapshot and _should_prefer_fallback_runtime_snapshot( fallback_snapshot=extended_fallback, matched_snapshot=matched_snapshot, ) ): return extended_fallback return matched_snapshot if not fallback_snapshot: fallback_snapshot = extended_fallback return fallback_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 {} runtime_activity = _load_runtime_activity_snapshot(safe_window_minutes) focus_job_code = str(runtime_activity.get("focus_job_code") or "").strip() preferred_job_codes = [focus_job_code] + [ str(item or "").strip() for item in list(runtime_activity.get("job_codes") or []) if str(item or "").strip() ] debug_snapshot = _load_latest_runtime_debug_active_job_snapshot( safe_window_minutes, preferred_job_codes=preferred_job_codes, ) ingest_snapshot = _load_latest_runtime_ingest_active_job_snapshot( safe_window_minutes, preferred_job_codes=preferred_job_codes, ) debug_has_payload = bool(debug_snapshot.get("job")) or bool((debug_snapshot.get("queue_health") or {}).get("queue")) ingest_has_payload = bool(ingest_snapshot.get("job")) or bool((ingest_snapshot.get("queue_health") or {}).get("queue")) if ingest_has_payload and not debug_has_payload: return _build_live_runtime_snapshot(ingest_snapshot) if debug_has_payload and not ingest_has_payload: return _build_live_runtime_snapshot(debug_snapshot) if not debug_has_payload and not ingest_has_payload: return {} if focus_job_code: debug_job_code = str((debug_snapshot.get("job") or {}).get("job_code") or "").strip() ingest_job_code = str((ingest_snapshot.get("job") or {}).get("job_code") or "").strip() debug_matches_focus = bool(debug_job_code and debug_job_code == focus_job_code) ingest_matches_focus = bool(ingest_job_code and ingest_job_code == focus_job_code) if ingest_matches_focus and not debug_matches_focus: return _build_live_runtime_snapshot(ingest_snapshot) if debug_matches_focus and not ingest_matches_focus: return _build_live_runtime_snapshot(debug_snapshot) debug_queue = dict((debug_snapshot.get("queue_health") or {}).get("queue") or {}) ingest_queue = dict((ingest_snapshot.get("queue_health") or {}).get("queue") or {}) debug_nodes = list((debug_snapshot.get("queue_health") or {}).get("nodes") or (debug_snapshot.get("job") or {}).get("node_stats") or []) ingest_nodes = list((ingest_snapshot.get("queue_health") or {}).get("nodes") or (ingest_snapshot.get("job") or {}).get("node_stats") or []) debug_display_running = max( _int_value(debug_queue.get("display_running")), _int_value(((debug_snapshot.get("job") or {}).get("display_items_running"))), _int_value(((debug_snapshot.get("job") or {}).get("display_active_threads"))), ) ingest_display_running = max( _int_value(ingest_queue.get("display_running")), _int_value(((ingest_snapshot.get("job") or {}).get("display_items_running"))), _int_value(((ingest_snapshot.get("job") or {}).get("display_active_threads"))), ) if len(ingest_nodes) > len(debug_nodes) or ingest_display_running > debug_display_running: return _build_live_runtime_snapshot(ingest_snapshot) if str(ingest_snapshot.get("_created_at") or "") > str(debug_snapshot.get("_created_at") or ""): return _build_live_runtime_snapshot(ingest_snapshot) return _build_live_runtime_snapshot(debug_snapshot) 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 _build_active_job_summary_from_runtime_snapshot(runtime_snapshot: dict, *, event_limit: int = 20) -> dict | None: normalized_snapshot = _build_live_runtime_snapshot(runtime_snapshot) runtime_job = dict(normalized_snapshot.get("job") or {}) runtime_queue = dict((normalized_snapshot.get("queue_health") or {}).get("queue") or {}) if not runtime_job and not runtime_queue: return None runtime_nodes = list((normalized_snapshot.get("queue_health") or {}).get("nodes") or runtime_job.get("node_stats") or []) runtime_steps = list((normalized_snapshot.get("queue_health") or {}).get("steps") or []) runtime_throughput = dict((normalized_snapshot.get("queue_health") or {}).get("throughput") or {}) normalized_nodes = [ _normalize_node_bucket(item) for item in runtime_nodes if isinstance(item, dict) and str(item.get("node_code") or "").strip() ] display_summary = _build_display_summary(normalized_nodes) runtime_allow_raw_display_fallback = ( int(normalized_snapshot.get("_dropped_runtime_node_count", 0) or 0) <= 0 and ( not bool(normalized_snapshot.get("_has_runtime_node_rows")) or bool(display_summary.get("active_nodes") or []) or any(str(item.get("node_code") or "").strip() != "unassigned" for item in normalized_nodes) ) ) display_running_candidates = [ runtime_queue.get("display_running"), display_summary.get("display_running"), runtime_job.get("display_items_running"), runtime_job.get("display_active_threads"), ] if runtime_allow_raw_display_fallback: display_running_candidates.extend((runtime_job.get("items_running"), runtime_queue.get("running"))) display_items_running = _max_runtime_metric( *display_running_candidates, ) display_active_thread_candidates = [ display_summary.get("active_threads"), runtime_job.get("display_active_threads"), runtime_job.get("display_items_running"), runtime_queue.get("display_running"), ] if runtime_allow_raw_display_fallback: display_active_thread_candidates.extend((runtime_job.get("items_running"), runtime_queue.get("running"))) display_active_threads = _max_runtime_metric( *display_active_thread_candidates, ) display_max_threads = _max_runtime_metric( display_summary.get("max_threads"), runtime_job.get("display_max_threads"), ) items_total = _int_value(runtime_queue.get("items_total", runtime_job.get("items_total", 0))) items_pending = _int_value(runtime_queue.get("pending", runtime_job.get("items_pending", 0))) items_claimed = _int_value(runtime_queue.get("claimed", runtime_job.get("items_claimed", 0))) items_running = _int_value(runtime_queue.get("running", runtime_job.get("items_running", 0))) items_completed = _int_value(runtime_queue.get("completed", runtime_job.get("items_completed", 0))) items_blacklisted = _int_value(runtime_queue.get("blacklisted", runtime_job.get("items_blacklisted", 0))) items_failed = _int_value(runtime_queue.get("failed", runtime_job.get("items_failed", 0))) items_terminal = _int_value( runtime_queue.get( "terminal", runtime_job.get("items_terminal", items_completed + items_blacklisted + items_failed), ) ) display_current_load = _max_runtime_metric( display_summary.get("current_load"), display_items_running, display_active_threads, items_running, ) recent_events = _select_runtime_snapshot_events( normalized_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, ) cycle_token, current_cycle_events = _extract_current_cycle_events(recent_events) return { "job_id": int(runtime_job.get("job_id", 0) or 0), "job_code": str(runtime_job.get("job_code") or "").strip(), "source": str(normalized_snapshot.get("_snapshot_source") or "runtime").strip() or "runtime", "task_mode": _DEFAULT_TASK_MODE, "step_code": "", "status": str(runtime_job.get("status") or "").strip() or "running", "created_by": str(normalized_snapshot.get("_snapshot_source") or "runtime").strip() or "runtime", "created_at": str(normalized_snapshot.get("_created_at") or "").strip(), "started_at": "", "finished_at": "", "items_total": items_total, "items_pending": items_pending, "items_claimed": items_claimed, "items_running": items_running, "items_completed": items_completed, "items_blacklisted": items_blacklisted, "items_failed": items_failed, "items_terminal": items_terminal, "progress_percent": float(runtime_job.get("progress_percent", 0) or 0), "raw_items_total": items_total, "raw_items_pending": items_pending, "raw_items_claimed": items_claimed, "raw_items_running": items_running, "raw_items_completed": items_completed, "raw_items_blacklisted": items_blacklisted, "raw_items_failed": items_failed, "raw_items_terminal": items_terminal, "raw_node_stats": list(normalized_nodes), "raw_step_stats": list(runtime_steps), "node_stats": list(normalized_nodes), "distributed_node_stats": list(normalized_nodes), "step_stats": list(runtime_steps), "display_items_claimed": _max_runtime_metric( runtime_queue.get("display_claimed"), display_summary.get("items_claimed"), items_claimed, ), "display_items_running": display_items_running, "display_current_load": display_current_load, "display_active_threads": display_active_threads, "display_max_threads": display_max_threads, "display_items_completed": int(display_summary.get("items_completed", items_completed) or 0), "display_items_failed": int(display_summary.get("items_failed", items_failed) or 0), "display_active_node_codes": list(display_summary.get("active_nodes") or []), "processed_recent": int(runtime_throughput.get("processed_recent", 0) or 0), "processed_per_minute": float(runtime_throughput.get("processed_per_minute", 0) or 0), "completed_recent": int(runtime_throughput.get("completed_recent", 0) or 0), "blacklisted_recent": int(runtime_throughput.get("blacklisted_recent", 0) or 0), "failed_recent": int(runtime_throughput.get("failed_recent", 0) or 0), "recent_events": recent_events, "latest_event": recent_events[0] if recent_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 (recent_events[0] if recent_events else None), "recent_domain_events": [], } 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 = _build_live_runtime_snapshot(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), ) runtime_allow_raw_display_fallback = True runtime_missing_participant_attribution = False 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 []) runtime_snapshot_source = str(runtime_snapshot.get("_snapshot_source") or "").strip() runtime_snapshot_stale_for_live_display = _snapshot_is_stale_for_live_display(runtime_snapshot) if runtime_snapshot_stale_for_live_display and str(enriched.get("created_by") or "").strip() == runtime_snapshot_source: runtime_snapshot_stale_for_live_display = False has_runtime_participant_nodes = any( str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" for item in [*runtime_nodes, *runtime_job_nodes] if isinstance(item, dict) ) runtime_missing_participant_attribution = bool(runtime_snapshot.get("_has_runtime_node_rows")) and not has_runtime_participant_nodes runtime_allow_raw_display_fallback = ( int(runtime_snapshot.get("_dropped_runtime_node_count", 0) or 0) <= 0 and ( not bool(runtime_snapshot.get("_has_runtime_node_rows")) or has_runtime_participant_nodes ) ) if runtime_snapshot_stale_for_live_display: runtime_missing_participant_attribution = True runtime_allow_raw_display_fallback = False if runtime_job and not runtime_snapshot_stale_for_live_display: 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 and not runtime_snapshot_stale_for_live_display: 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"] = _max_runtime_metric( enriched.get("display_items_claimed"), runtime_queue.get("display_claimed"), enriched.get("items_claimed"), ) display_running_candidates = [ enriched.get("display_items_running"), runtime_queue.get("display_running"), enriched.get("display_active_threads"), ] if runtime_allow_raw_display_fallback: display_running_candidates.append(enriched.get("items_running")) enriched["display_items_running"] = _max_runtime_metric(*display_running_candidates) if runtime_job_nodes and not runtime_snapshot_stale_for_live_display: 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"] = _max_runtime_metric( enriched.get("display_items_claimed"), runtime_display_summary.get("items_claimed"), enriched.get("items_claimed"), ) enriched["display_items_running"] = _max_runtime_metric( enriched.get("display_items_running"), runtime_display_summary.get("display_running"), enriched.get("display_active_threads"), *([] if not runtime_allow_raw_display_fallback else [enriched.get("items_running")]), ) enriched["display_current_load"] = _max_runtime_metric( enriched.get("display_current_load"), runtime_display_summary.get("current_load"), enriched.get("display_items_running"), ) enriched["display_active_threads"] = _max_runtime_metric( enriched.get("display_active_threads"), runtime_display_summary.get("active_threads"), enriched.get("display_items_running"), *([] if not runtime_allow_raw_display_fallback else [enriched.get("items_running")]), ) enriched["display_max_threads"] = _max_runtime_metric( enriched.get("display_max_threads"), runtime_display_summary.get("max_threads"), ) 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 and not runtime_snapshot_stale_for_live_display: enriched["node_stats"] = list(runtime_nodes) enriched["distributed_node_stats"] = list(runtime_nodes) if runtime_steps and not runtime_snapshot_stale_for_live_display: enriched["step_stats"] = list(runtime_steps) if not runtime_snapshot_stale_for_live_display: 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] enriched["display_items_running"] = _max_runtime_metric( enriched.get("display_items_running"), enriched.get("display_active_threads"), *([] if not runtime_allow_raw_display_fallback else [enriched.get("items_running")]), ) enriched["display_current_load"] = _max_runtime_metric( enriched.get("display_current_load"), enriched.get("display_items_running"), enriched.get("display_active_threads"), *([] if not runtime_allow_raw_display_fallback else [enriched.get("items_running")]), ) enriched["display_active_threads"] = _max_runtime_metric( enriched.get("display_active_threads"), enriched.get("display_items_running"), *([] if not runtime_allow_raw_display_fallback else [enriched.get("items_running")]), ) if runtime_missing_participant_attribution: enriched["display_items_running"] = 0 enriched["display_current_load"] = 0 enriched["display_active_threads"] = 0 enriched["display_active_node_codes"] = [] enriched["display_max_threads"] = 0 enriched["display_max_threads"] = _max_runtime_metric( enriched.get("display_max_threads"), sum( _int_value(item.get("max_threads")) for item in list(enriched.get("distributed_node_stats") or []) if isinstance(item, dict) ), ) 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: _execute_detect_jobs_select( cur, from_clause="FROM detect_jobs", where_clause="id = %s", limit_clause="1", params=(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) def _select_runtime_aligned_active_job_row( job_rows: list[tuple], runtime_activity: dict | None = None, runtime_snapshot: dict | None = None, ): rows = list(job_rows or []) if not rows: return None activity = dict(runtime_activity or {}) snapshot = dict(runtime_snapshot or {}) snapshot_job = dict(snapshot.get("job") or {}) preferred_job_codes = [ str(snapshot_job.get("job_code") or "").strip(), str(activity.get("focus_job_code") or "").strip(), *[ str(item or "").strip() for item in list(activity.get("job_codes") or []) if str(item or "").strip() ], ] normalized_codes: list[str] = [] seen_codes: set[str] = set() for item in preferred_job_codes: if not item or item in seen_codes: continue normalized_codes.append(item) seen_codes.add(item) if not normalized_codes: return rows[0] for preferred_code in normalized_codes: for row in rows: if str(row[1] or "").strip() == preferred_code: return row return rows[0] @db_read_retry() def get_active_detect_job_summary(event_limit: int = 20) -> dict | None: safe_event_limit = max(1, min(int(event_limit or 20), 100)) cache_key = (safe_event_limit,) now_ts = time.monotonic() with _ACTIVE_JOB_SUMMARY_CACHE_LOCK: cached = _ACTIVE_JOB_SUMMARY_CACHE.get(cache_key) if cached and now_ts < float(cached[0] or 0.0): return _clone_cacheable_payload(cached[1]) _maybe_recycle_expired_detect_job_items() result: dict | None = None with get_db() as conn: with conn.cursor() as cur: runtime_snapshot = _load_latest_runtime_active_job_snapshot(15) runtime_activity = _load_runtime_activity_snapshot(15) _execute_detect_jobs_select( cur, from_clause="FROM detect_jobs", where_clause="status IN ('pending', 'running')", order_clause=""" CASE WHEN status = 'running' THEN 0 ELSE 1 END ASC, COALESCE(started_at, created_at) DESC, id DESC """, limit_clause="20", ) rows = list(cur.fetchall() or []) if not rows: runtime_summary = _build_active_job_summary_from_runtime_snapshot( runtime_snapshot, event_limit=safe_event_limit, ) if not runtime_summary: result = None else: result = _enrich_active_job_summary_with_runtime( runtime_summary, event_limit=safe_event_limit, runtime_activity=runtime_activity, runtime_snapshot=runtime_snapshot, ) else: row = _select_runtime_aligned_active_job_row( rows, runtime_activity=runtime_activity, runtime_snapshot=runtime_snapshot, ) result = _enrich_active_job_summary_with_runtime( _fetch_job_summary(cur, row, event_limit=safe_event_limit), event_limit=safe_event_limit, runtime_activity=runtime_activity, runtime_snapshot=runtime_snapshot, ) with _ACTIVE_JOB_SUMMARY_CACHE_LOCK: _ACTIVE_JOB_SUMMARY_CACHE[cache_key] = ( time.monotonic() + _ACTIVE_JOB_SUMMARY_CACHE_TTL_SECONDS, _clone_cacheable_payload(result), ) return _clone_cacheable_payload(result) @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: _execute_detect_jobs_select( cur, from_clause="FROM detect_jobs", where_clause=" AND ".join(where_clauses), order_clause=""" 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_clause="1", params=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: _execute_detect_jobs_select( cur, from_clause="FROM detect_jobs AS job", where_clause=" AND ".join(where_clauses), order_clause=""" COALESCE(job.finished_at, job.started_at, job.created_at) DESC, job.id DESC """, limit_clause="1", params=tuple(params), alias="job", ) 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: _execute_detect_jobs_select( cur, from_clause="FROM detect_jobs", order_clause="created_at DESC, id DESC", limit_clause="%s", params=(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)) cache_key = (window_minutes,) now_ts = time.monotonic() with _DETECT_QUEUE_HEALTH_CACHE_LOCK: cached = _DETECT_QUEUE_HEALTH_CACHE.get(cache_key) if cached and now_ts < float(cached[0] or 0.0): return _clone_cacheable_payload(cached[1]) 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) active_job_runtime_code = str( (active_job or {}).get("runtime_snapshot_job_code") or (active_job or {}).get("runtime_job_code") or (active_job or {}).get("job_code") or "" ).strip() if active_job_runtime_code: merged_runtime_codes = [active_job_runtime_code] + [ str(item or "").strip() for item in list(runtime_activity.get("job_codes") or []) if str(item or "").strip() and str(item or "").strip() != active_job_runtime_code ] runtime_activity = { **dict(runtime_activity or {}), "focus_job_code": active_job_runtime_code, "job_codes": merged_runtime_codes[:5], } runtime_snapshot_job = dict((runtime_snapshot or {}).get("job") or {}) runtime_snapshot_matches_active_job = _runtime_job_matches_active_job( active_job, runtime_job_code=runtime_snapshot_job.get("job_code"), runtime_job_id=runtime_snapshot_job.get("job_id"), ) runtime_activity_matches_active_job = _runtime_job_matches_active_job( active_job, runtime_job_code=(runtime_activity or {}).get("focus_job_code"), ) if runtime_snapshot and not runtime_snapshot_matches_active_job: runtime_snapshot = {} if runtime_activity and not runtime_activity_matches_active_job and not runtime_snapshot_matches_active_job: runtime_activity = { **dict(runtime_activity or {}), "focus_job_code": "", "job_codes": [], "processed_recent": 0, "completed_recent": 0, "failed_recent": 0, "blacklisted_recent": 0, "step_code": "", "step_stats": {}, "nodes": {}, } if not active_job: result = { "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, } with _DETECT_QUEUE_HEALTH_CACHE_LOCK: _DETECT_QUEUE_HEALTH_CACHE[cache_key] = ( time.monotonic() + _DETECT_QUEUE_HEALTH_CACHE_TTL_SECONDS, _clone_cacheable_payload(result), ) return _clone_cacheable_payload(result) 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, "display_running": _int_value(item.get("display_running")), "current_load": _int_value(item.get("current_load")), "active_threads": _int_value(item.get("active_threads")), "max_threads": _int_value(item.get("max_threads")), "region": str(item.get("region") or "").strip(), "role": str(item.get("role") or "").strip(), "status": str(item.get("status") or "").strip(), "last_heartbeat_at": str(item.get("last_heartbeat_at") or "").strip(), } 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(), "display_running": _int_value(item.get("display_running")), "current_load": _int_value(item.get("current_load")), "active_threads": _int_value(item.get("active_threads")), "max_threads": _int_value(item.get("max_threads")), "region": str(item.get("region") or "").strip(), "role": str(item.get("role") or "").strip(), "status": str(item.get("status") or "").strip(), "last_heartbeat_at": str(item.get("last_heartbeat_at") 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", 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", display_running) 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(), "display_running": _int_value(item.get("display_running")), "current_load": _int_value(item.get("current_load")), "active_threads": _int_value(item.get("active_threads")), "max_threads": _int_value(item.get("max_threads")), "region": str(item.get("region") or "").strip(), "role": str(item.get("role") or "").strip(), "status": str(item.get("status") or "").strip(), "last_heartbeat_at": str(item.get("last_heartbeat_at") 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) snapshot_created_at = _snapshot_created_at(runtime_snapshot) raw_nodes = list(node_map.values()) live_nodes = _filter_live_runtime_nodes(raw_nodes, snapshot_created_at=snapshot_created_at) raw_participant_count = sum( 1 for item in raw_nodes if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) live_participant_count = sum( 1 for item in live_nodes if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned" ) dropped_runtime_node_count = max(0, raw_participant_count - live_participant_count) if raw_nodes: node_map = { str(item.get("node_code") or "unknown"): dict(item) for item in live_nodes } 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 or dropped_runtime_node_count > 0: running_items = normalized_running_items if normalized_display_running > 0 or dropped_runtime_node_count > 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 = order_step_buckets(list(step_map.values())) result = { "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 {}), } with _DETECT_QUEUE_HEALTH_CACHE_LOCK: _DETECT_QUEUE_HEALTH_CACHE[cache_key] = ( time.monotonic() + _DETECT_QUEUE_HEALTH_CACHE_TTL_SECONDS, _clone_cacheable_payload(result), ) return _clone_cacheable_payload(result) 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: _refresh_detect_job_status_with_cursor(cur, 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)