d
This commit is contained in:
@@ -11,7 +11,16 @@ from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.cluster_runtime_service import cleanup_imported_runtime_nodes, register_node_heartbeat
|
||||
from app.services.cluster_runtime_service import (
|
||||
cleanup_imported_runtime_nodes,
|
||||
cleanup_imported_runtime_nodes_many,
|
||||
register_node_heartbeat,
|
||||
)
|
||||
from app.services.detect_job_service import (
|
||||
_load_domain_pipeline_snapshot,
|
||||
resolve_initial_domain_pipeline_item,
|
||||
)
|
||||
from app.services.settings_service import get_settings_payload
|
||||
from app.services.sync_record_service import _decode_json, _normalize_region
|
||||
|
||||
|
||||
@@ -80,10 +89,16 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
if not node_code:
|
||||
node_code = f"{region}-{role}-imported"
|
||||
|
||||
controller_current_load = max(
|
||||
int(projection.get("active_thread_count", 0) or 0),
|
||||
int(((projection.get("active_job") or {}).get("items_running", 0) or 0)),
|
||||
)
|
||||
metadata = {
|
||||
"service": "runtime-ingest",
|
||||
"projection_source_region": source_region,
|
||||
"worker_mode": projection.get("worker_mode", ""),
|
||||
"active_threads": int(projection.get("active_thread_count", 0) or 0),
|
||||
"max_threads": int(projection.get("max_thread_count", 0) or 0),
|
||||
"phase_label": projection.get("phase_label", ""),
|
||||
"phase_detail": projection.get("phase_detail", ""),
|
||||
"proxy_runtime_label": projection.get("proxy_runtime_label", ""),
|
||||
@@ -102,8 +117,8 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
node_code=node_code,
|
||||
region=region,
|
||||
role=role,
|
||||
status="online",
|
||||
current_load=int(((projection.get("progress") or {}).get("running", 0) or 0)),
|
||||
status="busy" if controller_current_load > 0 else "online",
|
||||
current_load=controller_current_load,
|
||||
metadata=metadata,
|
||||
hostname_override=hostname,
|
||||
ip_override=ip,
|
||||
@@ -111,15 +126,21 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
cleanup_imported_runtime_nodes(region=region, role=role, keep_node_code=node_code)
|
||||
|
||||
active_job = projection.get("active_job") or {}
|
||||
worker_node_codes: list[str] = []
|
||||
for node_stat in list(active_job.get("node_stats") or []):
|
||||
worker_node_code = str(node_stat.get("node_code") or "").strip()
|
||||
if not worker_node_code or worker_node_code == "unassigned":
|
||||
continue
|
||||
if worker_node_code == node_code:
|
||||
continue
|
||||
items_running = int(node_stat.get("items_running", 0) or 0)
|
||||
items_claimed = int(node_stat.get("items_claimed", 0) or 0)
|
||||
items_total = int(node_stat.get("items_total", 0) or 0)
|
||||
worker_status = "busy" if (items_running > 0 or items_claimed > 0) else "online"
|
||||
worker_load = max(items_running, items_claimed, 0)
|
||||
worker_runtime_load = int(node_stat.get("current_load", 0) or 0)
|
||||
worker_active_threads = int(node_stat.get("active_threads", worker_runtime_load) or 0)
|
||||
worker_max_threads = int(node_stat.get("max_threads", 0) or 0)
|
||||
worker_load = max(worker_active_threads, items_running, 0)
|
||||
worker_status = "busy" if worker_load > 0 else "online"
|
||||
worker_metadata = {
|
||||
"service": "runtime-ingest",
|
||||
"projection_source_region": source_region,
|
||||
@@ -128,10 +149,18 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
"phase_detail": projection.get("phase_detail", ""),
|
||||
"proxy_runtime_label": projection.get("proxy_runtime_label", ""),
|
||||
"proxy_runtime_reason": projection.get("proxy_runtime_reason", ""),
|
||||
"active_threads": worker_active_threads,
|
||||
"max_threads": worker_max_threads,
|
||||
"updated_at": _format_time(received_at or datetime.now()),
|
||||
"job_items_total": items_total,
|
||||
"job_items_running": items_running,
|
||||
"job_items_claimed": items_claimed,
|
||||
"job_items_completed": int(node_stat.get("items_completed", 0) or 0),
|
||||
"job_items_failed": int(node_stat.get("items_failed", 0) or 0),
|
||||
"job_items_blacklisted": int(node_stat.get("items_blacklisted", 0) or 0),
|
||||
"metrics_source": str(node_stat.get("metrics_source") or "runtime").strip() or "runtime",
|
||||
"source_status": str(node_stat.get("status") or "").strip(),
|
||||
"source_role": str(node_stat.get("role") or "worker").strip() or "worker",
|
||||
"derived_from": node_code,
|
||||
}
|
||||
register_node_heartbeat(
|
||||
@@ -144,7 +173,9 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
hostname_override=hostname,
|
||||
ip_override=ip,
|
||||
)
|
||||
cleanup_imported_runtime_nodes(region=region, role="worker", keep_node_code=worker_node_code)
|
||||
worker_node_codes.append(worker_node_code)
|
||||
if worker_node_codes:
|
||||
cleanup_imported_runtime_nodes_many(region=region, role="worker", keep_node_codes=worker_node_codes)
|
||||
|
||||
|
||||
def _load_latest_projection(sync_type: str) -> dict | None:
|
||||
@@ -226,6 +257,88 @@ def _load_pushable_projections(sync_type: str, limit: int) -> list[dict]:
|
||||
return selected
|
||||
|
||||
|
||||
def _estimate_total_worker_threads(settings_payload: dict | None = None) -> int:
|
||||
payload = settings_payload if isinstance(settings_payload, dict) else get_settings_payload()
|
||||
default_threads = max(1, int(payload.get("thread_count", 100) or 100))
|
||||
node_thread_counts = payload.get("node_thread_counts") if isinstance(payload.get("node_thread_counts"), dict) else {}
|
||||
total_threads = 0
|
||||
for raw_value in node_thread_counts.values():
|
||||
try:
|
||||
total_threads += max(0, int(raw_value or 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return max(total_threads, default_threads)
|
||||
|
||||
|
||||
def _load_local_detect_backlog_snapshot() -> dict:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending') AS pending_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'claimed') AS claimed_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'running') AS running_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code = 'detect_register') AS register_pending,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code <> 'detect_register') AS downstream_pending
|
||||
FROM detect_job_items item
|
||||
JOIN detect_jobs job ON job.id = item.job_id
|
||||
WHERE job.status IN ('pending', 'running')
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone() or (0, 0, 0, 0, 0)
|
||||
return {
|
||||
"pending_total": int(row[0] or 0),
|
||||
"claimed_total": int(row[1] or 0),
|
||||
"running_total": int(row[2] or 0),
|
||||
"register_pending": int(row[3] or 0),
|
||||
"downstream_pending": int(row[4] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _build_task_pull_backlog_limits(configured_limit: int, settings_payload: dict | None = None) -> dict:
|
||||
estimated_total_threads = _estimate_total_worker_threads(settings_payload)
|
||||
max_pending_total = int(settings.sync_pull_max_pending_items or 0)
|
||||
if max_pending_total <= 0:
|
||||
max_pending_total = max(int(configured_limit or 0), estimated_total_threads * 2)
|
||||
|
||||
max_register_pending = int(settings.sync_pull_max_register_pending_items or 0)
|
||||
if max_register_pending <= 0:
|
||||
max_register_pending = max(max(500, int(configured_limit or 0) // 2), estimated_total_threads)
|
||||
|
||||
max_downstream_pending = int(settings.sync_pull_max_downstream_pending_items or 0)
|
||||
if max_downstream_pending <= 0:
|
||||
max_downstream_pending = max(250, estimated_total_threads // 4)
|
||||
|
||||
return {
|
||||
"estimated_total_threads": estimated_total_threads,
|
||||
"max_pending_total": max_pending_total,
|
||||
"max_register_pending": max_register_pending,
|
||||
"max_downstream_pending": max_downstream_pending,
|
||||
}
|
||||
|
||||
|
||||
def _should_throttle_task_pull(backlog_snapshot: dict, backlog_limits: dict) -> tuple[bool, str]:
|
||||
pending_total = int(backlog_snapshot.get("pending_total", 0) or 0)
|
||||
register_pending = int(backlog_snapshot.get("register_pending", 0) or 0)
|
||||
downstream_pending = int(backlog_snapshot.get("downstream_pending", 0) or 0)
|
||||
max_pending_total = int(backlog_limits.get("max_pending_total", 0) or 0)
|
||||
max_register_pending = int(backlog_limits.get("max_register_pending", 0) or 0)
|
||||
max_downstream_pending = int(backlog_limits.get("max_downstream_pending", 0) or 0)
|
||||
|
||||
if max_pending_total > 0 and pending_total >= max_pending_total:
|
||||
return True, "pending_total"
|
||||
if (
|
||||
downstream_pending > 0
|
||||
and max_register_pending > 0
|
||||
and register_pending >= max_register_pending
|
||||
):
|
||||
return True, "register_pending"
|
||||
if max_downstream_pending > 0 and downstream_pending >= max_downstream_pending:
|
||||
return True, "downstream_pending"
|
||||
return False, ""
|
||||
|
||||
|
||||
def _latest_push_attempt(source_record_id: int, target_region: str, sync_type: str) -> dict | None:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -308,10 +421,51 @@ def _task_selection_sql() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _task_projection_limit(limit: int | None) -> int:
|
||||
requested = max(1, int(limit or 5000))
|
||||
configured = max(5000, int(settings.sync_batch_size or 200))
|
||||
cap = max(10000, configured, 5000)
|
||||
return max(1, min(requested, cap))
|
||||
|
||||
|
||||
def _task_projection_items_total(projection: dict) -> int:
|
||||
payload = projection.get("payload") or {}
|
||||
projection_payload = payload.get("projection") or {}
|
||||
try:
|
||||
return int(projection_payload.get("items_total", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _task_projection_selection_limit(projection: dict) -> int:
|
||||
payload = projection.get("payload") or {}
|
||||
projection_payload = payload.get("projection") or {}
|
||||
try:
|
||||
return int(projection_payload.get("selection_limit", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _mark_task_projection_superseded(record_id: int, *, reason: str) -> None:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_sync_records
|
||||
SET status = 'superseded',
|
||||
error_message = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(str(reason or "").strip()[:500], int(record_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _load_pending_task_projection(limit: int) -> dict | None:
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
safe_limit = max(1, min(int(limit or 1000), max(1, int(settings.sync_batch_size or 200))))
|
||||
safe_limit = _task_projection_limit(limit)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -340,6 +494,22 @@ def _load_pending_task_projection(limit: int) -> dict | None:
|
||||
latest_ingest = _latest_ingest_attempt(projection["id"], projection["target_region"], "detect_task_projection")
|
||||
if latest_ingest and latest_ingest["status"] == "received":
|
||||
continue
|
||||
items_total = _task_projection_items_total(projection)
|
||||
selection_limit = _task_projection_selection_limit(projection)
|
||||
created_at = projection.get("created_at")
|
||||
if (
|
||||
safe_limit >= 1000
|
||||
and max(items_total, selection_limit) > 0
|
||||
and max(items_total, selection_limit) < safe_limit
|
||||
and isinstance(created_at, datetime)
|
||||
):
|
||||
now = datetime.now(created_at.tzinfo) if created_at.tzinfo else datetime.now()
|
||||
if now - created_at >= timedelta(minutes=10):
|
||||
_mark_task_projection_superseded(
|
||||
projection["id"],
|
||||
reason=f"stale small task projection skipped: items_total={items_total}, selection_limit={selection_limit}, requested_limit={safe_limit}",
|
||||
)
|
||||
continue
|
||||
return projection
|
||||
return None
|
||||
|
||||
@@ -361,7 +531,7 @@ def export_detect_task_projection(limit: int = 1000, *, shared_token: str | None
|
||||
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
safe_limit = max(1, min(int(limit or 1000), max(1, int(settings.sync_batch_size or 200))))
|
||||
safe_limit = _task_projection_limit(limit)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_task_selection_sql(), (safe_limit,))
|
||||
@@ -528,6 +698,7 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
|
||||
inserted_count = 0
|
||||
updated_count = 0
|
||||
domain_ids: list[int] = []
|
||||
for item in items:
|
||||
domain = str(item.get("domain") or "").strip().lower()
|
||||
if not domain:
|
||||
@@ -562,7 +733,7 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
ELSE EXCLUDED.detect_status
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
RETURNING id, (xmax = 0) AS inserted
|
||||
""",
|
||||
(
|
||||
domain,
|
||||
@@ -576,12 +747,120 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
int(item.get("juziseo_status") or 0),
|
||||
),
|
||||
)
|
||||
inserted = bool((cur.fetchone() or [False])[0])
|
||||
row = cur.fetchone() or [0, False]
|
||||
domain_id = int(row[0] or 0)
|
||||
inserted = bool(row[1])
|
||||
if domain_id > 0:
|
||||
domain_ids.append(domain_id)
|
||||
if inserted:
|
||||
inserted_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
target_job_code = f"sync-{source_region}-{source_record_id}"
|
||||
target_job_remark = (
|
||||
f"同步拉取待检测批次 {str(projection.get('batch_code') or '').strip() or source_record_id},"
|
||||
f"共 {len(domain_ids)} 个域名"
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_jobs (job_code, source, plan_hash, task_mode, step_code, status, remark, created_by)
|
||||
VALUES (%s, %s, %s, 'domain_pipeline', '', 'pending', %s, %s)
|
||||
ON CONFLICT (job_code) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
plan_hash = EXCLUDED.plan_hash,
|
||||
task_mode = EXCLUDED.task_mode,
|
||||
step_code = EXCLUDED.step_code,
|
||||
remark = EXCLUDED.remark,
|
||||
created_by = EXCLUDED.created_by,
|
||||
status = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN 'pending'
|
||||
ELSE detect_jobs.status
|
||||
END,
|
||||
started_at = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN NULL
|
||||
ELSE detect_jobs.started_at
|
||||
END,
|
||||
finished_at = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN NULL
|
||||
ELSE detect_jobs.finished_at
|
||||
END
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
target_job_code,
|
||||
"sync-pull",
|
||||
projection_hash,
|
||||
target_job_remark,
|
||||
"sync-agent",
|
||||
),
|
||||
)
|
||||
target_job_id = int((cur.fetchone() or [0])[0] or 0)
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
queued_count = 0
|
||||
deduplicated_job_items = 0
|
||||
skipped_job_items = 0
|
||||
if target_job_id > 0:
|
||||
for domain_id in domain_ids:
|
||||
domain_snapshot = _load_domain_pipeline_snapshot(cur, int(domain_id))
|
||||
if not domain_snapshot:
|
||||
skipped_job_items += 1
|
||||
continue
|
||||
item_step_code, step_payload = resolve_initial_domain_pipeline_item(
|
||||
domain_snapshot,
|
||||
settings_payload=settings_payload,
|
||||
)
|
||||
if not item_step_code or not step_payload:
|
||||
skipped_job_items += 1
|
||||
continue
|
||||
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
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
domain_id,
|
||||
item_step_code,
|
||||
json.dumps(step_payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
inserted_job_item = cur.fetchone()
|
||||
if inserted_job_item:
|
||||
queued_count += 1
|
||||
else:
|
||||
deduplicated_job_items += 1
|
||||
|
||||
if queued_count > 0:
|
||||
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)
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
settings.node_code,
|
||||
"job_created",
|
||||
"info",
|
||||
f"同步拉取待检测批次 {target_job_code},共 {queued_count} 个任务项",
|
||||
json.dumps(
|
||||
{
|
||||
"source_region": source_region,
|
||||
"source_record_id": source_record_id,
|
||||
"projection_hash": projection_hash,
|
||||
"batch_code": str(projection.get("batch_code") or "").strip(),
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_sync_records (
|
||||
@@ -602,6 +881,11 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
"items_total": len(items),
|
||||
"inserted_count": inserted_count,
|
||||
"updated_count": updated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"target_job_code": target_job_code,
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
"received_at": _format_time(received_at),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -617,6 +901,11 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
"items_total": len(items),
|
||||
"inserted_count": inserted_count,
|
||||
"updated_count": updated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"target_job_code": target_job_code,
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
"deduplicated": False,
|
||||
}
|
||||
|
||||
@@ -742,7 +1031,27 @@ def _extract_detect_result_projection_events(
|
||||
return events
|
||||
|
||||
|
||||
def _resolve_detect_result_target_job_id() -> int:
|
||||
def _resolve_detect_result_target_job_id(*, projection: dict) -> int:
|
||||
source_job = projection.get("job") or {}
|
||||
source_job_code = str(source_job.get("job_code") or "").strip()
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if source_job_code:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM detect_jobs
|
||||
WHERE job_code = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_job_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row[0] or 0)
|
||||
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=1) or {}
|
||||
@@ -759,6 +1068,163 @@ def _parse_event_created_at(value: str) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_event_domain(payload: dict, message: str) -> str:
|
||||
domain = str(payload.get("domain") or "").strip().lower()
|
||||
if domain:
|
||||
return domain
|
||||
text = str(message or "").strip()
|
||||
if ":" in text:
|
||||
candidate = text.rsplit(":", 1)[-1].strip().lower()
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_detect_result_event_to_domain(cur, event: dict) -> None:
|
||||
payload = _decode_json(event.get("payload"))
|
||||
domain = _extract_event_domain(payload, str(event.get("message") or ""))
|
||||
if not domain:
|
||||
return
|
||||
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
created_at = _parse_event_created_at(str(event.get("created_at") or ""))
|
||||
effective_time = created_at or datetime.now()
|
||||
|
||||
if event_type == "domain_started":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = CASE
|
||||
WHEN detect_status IN (1, 3) THEN detect_status
|
||||
ELSE 2
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_completed":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = 1,
|
||||
detect_time = COALESCE(detect_time, %s),
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(effective_time, domain),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_blacklisted":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = 3,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_failed":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = CASE
|
||||
WHEN detect_status IN (1, 3) THEN detect_status
|
||||
ELSE 4
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def _apply_detect_result_event_to_job_item(cur, *, target_job_id: int, event: dict) -> int:
|
||||
if int(target_job_id or 0) <= 0:
|
||||
return 0
|
||||
|
||||
payload = _decode_json(event.get("payload"))
|
||||
domain = _extract_event_domain(payload, str(event.get("message") or ""))
|
||||
if not domain:
|
||||
return 0
|
||||
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
node_code = str(event.get("node_code") or "").strip()
|
||||
message = str(event.get("message") or "").strip()
|
||||
|
||||
if event_type == "domain_started":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_job_items AS item
|
||||
SET status = CASE
|
||||
WHEN item.status IN ('completed', 'blacklisted', 'failed') THEN item.status
|
||||
ELSE 'running'
|
||||
END,
|
||||
claimed_by = CASE
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE item.claimed_by
|
||||
END,
|
||||
started_at = COALESCE(item.started_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM domains AS d
|
||||
WHERE item.job_id = %s
|
||||
AND item.domain_id = d.id
|
||||
AND d.domain = %s
|
||||
AND item.status IN ('pending', 'claimed', 'running')
|
||||
""",
|
||||
(node_code, node_code, int(target_job_id), domain),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
if event_type not in {"domain_completed", "domain_blacklisted", "domain_failed"}:
|
||||
return 0
|
||||
|
||||
final_status = {
|
||||
"domain_completed": "completed",
|
||||
"domain_blacklisted": "blacklisted",
|
||||
"domain_failed": "failed",
|
||||
}[event_type]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_job_items AS item
|
||||
SET status = %s,
|
||||
claimed_by = CASE
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE item.claimed_by
|
||||
END,
|
||||
finished_at = COALESCE(item.finished_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
lease_expires_at = NULL,
|
||||
last_error = CASE
|
||||
WHEN %s = 'failed' THEN LEFT(%s, 1000)
|
||||
ELSE item.last_error
|
||||
END
|
||||
FROM domains AS d
|
||||
WHERE item.job_id = %s
|
||||
AND item.domain_id = d.id
|
||||
AND d.domain = %s
|
||||
AND item.status IN ('pending', 'claimed', 'running')
|
||||
""",
|
||||
(
|
||||
final_status,
|
||||
node_code,
|
||||
node_code,
|
||||
final_status,
|
||||
message,
|
||||
int(target_job_id),
|
||||
domain,
|
||||
),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def _import_detect_result_projection_events(
|
||||
*,
|
||||
source_region: str,
|
||||
@@ -773,12 +1239,11 @@ def _import_detect_result_projection_events(
|
||||
if not events:
|
||||
return {"imported_count": 0, "deduplicated_count": 0, "target_job_id": 0}
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id()
|
||||
if target_job_id <= 0:
|
||||
return {"imported_count": 0, "deduplicated_count": 0, "target_job_id": 0}
|
||||
target_job_id = _resolve_detect_result_target_job_id(projection=projection)
|
||||
|
||||
imported_count = 0
|
||||
deduplicated_count = 0
|
||||
updated_job_items = 0
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for event in events:
|
||||
@@ -831,12 +1296,27 @@ def _import_detect_result_projection_events(
|
||||
json.dumps(event["payload"], ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
_apply_detect_result_event_to_domain(cur, event)
|
||||
if target_job_id > 0:
|
||||
updated_job_items += _apply_detect_result_event_to_job_item(
|
||||
cur,
|
||||
target_job_id=target_job_id,
|
||||
event=event,
|
||||
)
|
||||
imported_count += 1
|
||||
conn.commit()
|
||||
if target_job_id > 0:
|
||||
from app.services.detect_job_service import refresh_detect_job_status
|
||||
|
||||
try:
|
||||
refresh_detect_job_status(target_job_id)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"imported_count": imported_count,
|
||||
"deduplicated_count": deduplicated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"updated_job_items": updated_job_items,
|
||||
}
|
||||
|
||||
|
||||
@@ -941,6 +1421,16 @@ def ingest_runtime_projection(payload: dict, *, shared_token: str | None = None)
|
||||
|
||||
|
||||
def _push_projection_now(sync_type: str, ingest_url: str) -> tuple[bool, str, dict]:
|
||||
if sync_type == "runtime_projection":
|
||||
# Regenerate the runtime snapshot before every push so the sync agent
|
||||
# does not keep replaying a stale projection record while the worker
|
||||
# thread count / phase is still changing.
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
|
||||
try:
|
||||
get_runtime_status()
|
||||
except Exception as exc:
|
||||
return False, f"刷新 runtime_projection 失败: {exc}", {"action": "push_sync", "sync_type": sync_type}
|
||||
source_record = _load_latest_projection(sync_type)
|
||||
if not source_record:
|
||||
return False, f"当前没有可推送的{sync_type}", {"action": "push_sync", "sync_type": sync_type}
|
||||
@@ -1177,15 +1667,33 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
if not export_url or not ack_url:
|
||||
return False, "未配置任务拉取目标地址", {"action": "pull_tasks", "pull_state": "misconfigured", "ui_level": "warning", "poll_schedule_seconds": []}
|
||||
|
||||
safe_limit = max(1, min(int(limit or settings.sync_batch_size or 200), max(1, int(settings.sync_batch_size or 200))))
|
||||
configured_limit = max(5000, int(settings.sync_batch_size or 200))
|
||||
requested_limit = int(limit or configured_limit)
|
||||
safe_limit = max(1, min(requested_limit, max(10000, configured_limit)))
|
||||
settings_payload = get_settings_payload()
|
||||
backlog_snapshot = _load_local_detect_backlog_snapshot()
|
||||
backlog_limits = _build_task_pull_backlog_limits(configured_limit, settings_payload=settings_payload)
|
||||
should_throttle, throttle_reason = _should_throttle_task_pull(backlog_snapshot, backlog_limits)
|
||||
if should_throttle:
|
||||
return True, "本地待处理积压较高,暂停拉取新批次", {
|
||||
"action": "pull_tasks",
|
||||
"pull_state": "throttled",
|
||||
"ui_level": "info",
|
||||
"poll_schedule_seconds": [1, 3],
|
||||
"reason": throttle_reason,
|
||||
**backlog_snapshot,
|
||||
**backlog_limits,
|
||||
}
|
||||
|
||||
request_url = f"{export_url}?limit={safe_limit}"
|
||||
export_timeout = max(20, min(90, 15 + safe_limit // 40))
|
||||
request = urllib.request.Request(
|
||||
request_url,
|
||||
headers={**({"X-Domaincheck-Sync-Token": settings.sync_shared_token} if settings.sync_shared_token else {})},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
with urllib.request.urlopen(request, timeout=export_timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -1280,7 +1788,7 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
**(ingest_data or {}),
|
||||
}
|
||||
|
||||
return True, "待检测任务批次拉取并入库成功", {
|
||||
result = {
|
||||
"action": "pull_tasks",
|
||||
"pull_state": "success",
|
||||
"ui_level": "success",
|
||||
@@ -1291,3 +1799,27 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
**(ingest_data or {}),
|
||||
"ack": ack_data,
|
||||
}
|
||||
queued_count = int(result.get("queued_count", 0) or 0)
|
||||
if queued_count > 0:
|
||||
try:
|
||||
from app.services.worker_control_service import send_worker_command
|
||||
|
||||
start_ok, start_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload={
|
||||
"source": "sync-pull",
|
||||
"source_record_id": source_record_id,
|
||||
"target_job_id": int(result.get("target_job_id", 0) or 0),
|
||||
"target_job_code": str(result.get("target_job_code") or "").strip(),
|
||||
},
|
||||
)
|
||||
result["worker_start_ok"] = bool(start_ok)
|
||||
result["worker_start_message"] = str(start_message or "").strip()
|
||||
except Exception as exc:
|
||||
result["worker_start_ok"] = False
|
||||
result["worker_start_message"] = f"同步入库后自动唤起 Worker 失败: {exc}"
|
||||
result["ui_level"] = "warning"
|
||||
result["pull_state"] = "worker_start_warning"
|
||||
return True, "待检测任务批次拉取并入库成功;但自动唤起 Worker 失败", result
|
||||
|
||||
return True, "待检测任务批次拉取并入库成功", result
|
||||
|
||||
Reference in New Issue
Block a user