Files
getDomain/domain-api/app/services/detect_job_service.py
Your Name 7cbde2aa78 d
2026-04-22 14:13:21 +08:00

2972 lines
126 KiB
Python
Raw Blame History

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