Files
atc-agents/api/streaming_ops.py
T
mo d066def8b4 feat(cdc): add Re-sync sources button + auto-heal for Debezium connectors
- streaming_ops: add connector status discovery, manual resync endpoint
  (POST /api/pipeline/streaming/resync), connectors status endpoint, and a
  background connector_autoheal_loop that restarts FAILED tasks automatically
- main: wire connector_autoheal_loop into app lifespan
- api.ts: add resyncSources() helper
- ChangesView: add "Re-sync sources" header button with live status
2026-06-29 13:31:11 +00:00

559 lines
23 KiB
Python

"""Live Spark + Kafka visibility and manual job control for the Command Center."""
from __future__ import annotations
import asyncio
import os
import time
from typing import Any
import httpx
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse, Response
SPARK_UI_URL = os.getenv("SPARK_UI_URL", "http://10.0.21.50:8080").rstrip("/")
KAFKA_UI_URL = os.getenv("KAFKA_UI_URL", "http://10.0.21.36:9000").rstrip("/")
KAFKA_CONNECT_URL = os.getenv("KAFKA_CONNECT_URL", "http://10.0.21.50:8083").rstrip("/")
AIRFLOW_URL = os.getenv("AIRFLOW_URL", "http://10.0.21.55:8080").rstrip("/")
AIRFLOW_USER = os.getenv("AIRFLOW_USER", "admin")
AIRFLOW_PASSWORD = os.getenv("AIRFLOW_PASSWORD", "")
from hdfs_kafka import export_hdfs_to_kafka, hdfs_export_snapshot
router = APIRouter(prefix="/api/pipeline/streaming", tags=["streaming"])
# Manual Spark / lakehouse jobs (Airflow DAGs on the lab)
SPARK_JOBS: dict[str, dict[str, Any]] = {
"spark_to_curated": {
"label": "Spark → Iceberg curated (mask PII)",
"dag_id": "mask_to_curated",
"agent": "lakehouse-ops",
"description": "Runs the mask_to_curated DAG — Spark/SQL transform into iceberg.curated_masked",
"default_conf": {},
"editable_fields": ["batch_size", "sources"],
},
"hadoop_to_trino": {
"label": "HDFS → Iceberg (historical)",
"dag_id": "hadoop_to_trino",
"agent": "hadoop-ranger",
"description": "Load historical_sales from HDFS into Iceberg via Trino/Spark pipeline",
"default_conf": {"mode": "refresh"},
"editable_fields": ["mode"],
},
"spark_to_s3": {
"label": "Spark → S3 curated layer",
"dag_id": "mask_to_curated",
"agent": "lakehouse-ops",
"description": "Spark transform into Iceberg curated tables mirrored to S3",
"default_conf": {"target": "s3"},
"editable_fields": ["target", "batch_size"],
},
"generate_all": {
"label": "Seed all source DBs",
"dag_id": "generate_data_all_databases",
"agent": "data-custodian",
"description": "Airflow DAG — generates rows into PostgreSQL, MySQL, MongoDB, Cassandra, Neo4j",
"default_conf": {"rows": 3000},
"editable_fields": ["rows"],
},
}
# Master pipeline pulse switch: running | paused | stopped
_flow_state: dict[str, Any] = {"mode": "running", "since": time.time()}
def flow_mode() -> str:
return _flow_state.get("mode", "running")
def flow_snapshot() -> dict[str, Any]:
return {"mode": _flow_state.get("mode", "running"), "since": _flow_state.get("since")}
_cache: dict[str, Any] = {"ts": 0.0, "data": None}
_TTL = 8.0
_token_cache: dict[str, Any] = {"token": None, "exp": 0.0}
def _feed(agent_id: str, message: str, level: str = "info") -> None:
try:
from main import add_feed
add_feed(agent_id, message, level)
except Exception:
pass
async def _airflow_token(client: httpx.AsyncClient) -> str:
now = time.time()
if _token_cache["token"] and _token_cache["exp"] > now + 30:
return _token_cache["token"]
r = await client.post(
f"{AIRFLOW_URL}/auth/token",
json={"username": AIRFLOW_USER, "password": AIRFLOW_PASSWORD},
timeout=10,
)
r.raise_for_status()
tok = r.json()["access_token"]
_token_cache["token"] = tok
_token_cache["exp"] = now + 20 * 60
return tok
async def collect_spark() -> dict[str, Any]:
out: dict[str, Any] = {
"ui_url": SPARK_UI_URL,
"ui_ok": False,
"status": "UNKNOWN",
"workers": [],
"alive_workers": 0,
"cores": 0,
"cores_used": 0,
"memory_mb": 0,
"memory_used_mb": 0,
"active_apps": [],
"completed_apps": [],
}
try:
async with httpx.AsyncClient(timeout=6.0) as client:
r = await client.get(f"{SPARK_UI_URL}/json/")
if r.status_code >= 400:
return out
d = r.json()
out["ui_ok"] = True
out["status"] = d.get("status") or "ALIVE"
out["alive_workers"] = int(d.get("aliveworkers") or 0)
out["cores"] = int(d.get("cores") or 0)
out["cores_used"] = int(d.get("coresused") or 0)
out["memory_mb"] = int(d.get("memory") or 0)
out["memory_used_mb"] = int(d.get("memoryused") or 0)
workers = d.get("workers") or []
out["workers"] = [
{
"id": w.get("id"),
"host": w.get("host"),
"cores": w.get("cores"),
"cores_used": w.get("coresused"),
"memory_mb": w.get("memory"),
"state": w.get("state"),
"webui": w.get("webuiaddress"),
}
for w in workers
]
for app in (d.get("activeapps") or [])[:20]:
out["active_apps"].append({
"id": app.get("id"),
"name": app.get("name"),
"cores": app.get("cores"),
"memory_mb": app.get("memory"),
"submitdate": app.get("submitdate"),
"duration_ms": app.get("duration"),
"user": app.get("user"),
})
for app in (d.get("completedapps") or [])[:10]:
out["completed_apps"].append({
"id": app.get("id"),
"name": app.get("name"),
"duration_ms": app.get("duration"),
})
except Exception as exc:
out["error"] = str(exc)[:200]
return out
async def collect_kafka() -> dict[str, Any]:
out: dict[str, Any] = {
"ui_url": KAFKA_UI_URL,
"ui_ok": False,
"connect_url": KAFKA_CONNECT_URL,
"connect_ok": False,
"cluster": {},
"topics": [],
"connectors": [],
}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
cr = await client.get(f"{KAFKA_UI_URL}/api/clusters")
if cr.status_code < 400:
clusters = cr.json()
out["ui_ok"] = True
if clusters:
name = clusters[0].get("name", "local")
out["cluster"] = {
"name": name,
"status": clusters[0].get("status"),
"broker_count": clusters[0].get("brokerCount"),
"topic_count": clusters[0].get("topicCount"),
"online_partitions": clusters[0].get("onlinePartitionCount"),
}
tr = await client.get(
f"{KAFKA_UI_URL}/api/clusters/{name}/topics",
params={"page": 1, "perPage": 50, "showInternal": False},
)
if tr.status_code < 400:
topics = tr.json().get("topics") or []
out["topics"] = [
{
"name": t.get("name"),
"partitions": t.get("partitionCount"),
"replicas": t.get("replicationFactor"),
"messages": t.get("messagesCount"),
}
for t in topics[:50]
]
lr = await client.get(f"{KAFKA_CONNECT_URL}/connectors")
if lr.status_code < 400:
out["connect_ok"] = True
names_raw = lr.json()
names = names_raw if isinstance(names_raw, list) else []
for cn in names[:20]:
try:
sr = await client.get(f"{KAFKA_CONNECT_URL}/connectors/{cn}/status")
st = sr.json() if sr.status_code < 400 else {}
conn = st.get("connector") or {}
tasks = st.get("tasks") or []
out["connectors"].append({
"name": cn,
"state": conn.get("state"),
"worker": conn.get("worker_id"),
"tasks": [{"id": t.get("id"), "state": t.get("state")} for t in tasks],
"type": st.get("type"),
})
except Exception:
out["connectors"].append({"name": cn, "state": "UNKNOWN"})
except Exception as exc:
out["error"] = str(exc)[:200]
return out
async def build_streaming_status() -> dict[str, Any]:
spark, kafka = await asyncio.gather(collect_spark(), collect_kafka())
cdc_active = False
try:
from cdc_consumer import snapshot as cdc_snapshot
cdc = cdc_snapshot(15)
cdc_active = bool(cdc.get("connected")) and int(cdc.get("window_total") or 0) > 0
except Exception:
pass
connectors_running = sum(
1 for c in kafka.get("connectors", [])
if (c.get("state") or "").upper() == "RUNNING"
)
spark_alive = spark.get("ui_ok") and (spark.get("status") or "").upper() == "ALIVE"
apps_running = len(spark.get("active_apps") or [])
hdfs_recent = hdfs_export_snapshot().get("recent")
edges = {
"hdfs→kafka": bool(hdfs_recent),
"kafka→spark": cdc_active and spark_alive or hdfs_recent,
"spark→iceberg": apps_running > 0 or spark_alive,
"spark→s3": apps_running > 0 or spark_alive,
"sources→kafka": cdc_active,
"connectors": connectors_running > 0,
}
mode = flow_mode()
if mode != "running":
edges = {k: False for k in edges}
return {
"ok": True,
"flow": mode,
"spark": spark,
"kafka": kafka,
"edges": edges,
"jobs": [
{**{"id": k}, **{kk: vv for kk, vv in v.items() if kk != "editable_fields"}}
for k, v in SPARK_JOBS.items()
],
"ts": time.time(),
}
@router.get("/status")
async def streaming_status(refresh: bool = False) -> JSONResponse:
now = time.time()
if not refresh and _cache["data"] and now - _cache["ts"] < _TTL:
return JSONResponse(_cache["data"])
data = await build_streaming_status()
_cache["data"] = data
_cache["ts"] = now
return JSONResponse(data)
@router.get("/jobs")
async def list_spark_jobs() -> JSONResponse:
return JSONResponse({"ok": True, "jobs": SPARK_JOBS})
@router.post("/flow/{action}")
async def set_flow(action: str) -> JSONResponse:
mapping = {
"pause": "paused",
"stop": "stopped",
"resume": "running",
"start": "running",
"run": "running",
}
if action not in mapping:
return JSONResponse({"ok": False, "error": f"unknown action {action}"}, status_code=400)
_flow_state["mode"] = mapping[action]
_flow_state["since"] = time.time()
_cache["ts"] = 0
label = {"running": "resumed", "paused": "paused", "stopped": "stopped"}[mapping[action]]
_feed("lakehouse-ops", f"[pipeline] data flow {label}", "info" if mapping[action] == "running" else "warn")
return JSONResponse({"ok": True, "flow": flow_snapshot()})
@router.get("/flow")
async def get_flow() -> JSONResponse:
return JSONResponse({"ok": True, "flow": flow_snapshot()})
@router.post("/jobs/{job_id}/trigger")
async def trigger_spark_job(job_id: str, body: dict[str, Any] = Body(default={})) -> JSONResponse:
job = SPARK_JOBS.get(job_id)
if not job:
return JSONResponse({"ok": False, "error": f"unknown job {job_id}"}, status_code=400)
conf = {**(job.get("default_conf") or {}), **(body.get("conf") or {})}
agent = job.get("agent", "lakehouse-ops")
dag_id = job["dag_id"]
_feed(agent, f"[spark] Triggering {job['label']} conf={conf}", "info")
try:
async with httpx.AsyncClient() as client:
tok = await _airflow_token(client)
h = {"Authorization": f"Bearer {tok}"}
r = await client.post(
f"{AIRFLOW_URL}/api/v2/dags/{dag_id}/dagRuns",
headers=h,
json={"logical_date": None, "conf": conf},
timeout=20,
)
if r.status_code >= 400:
_feed(agent, f"[spark] {job['label']}: Airflow {r.status_code}", "err")
return JSONResponse({"ok": False, "error": f"airflow {r.status_code}: {r.text[:300]}"}, status_code=502)
run = r.json()
run_id = run.get("dag_run_id")
_feed(agent, f"[spark] {job['label']}: started run {run_id}", "info")
return JSONResponse({"ok": True, "job_id": job_id, "dag_id": dag_id, "run_id": run_id, "conf": conf})
except Exception as exc:
_feed(agent, f"[spark] {job['label']}: {str(exc)[:120]}", "err")
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@router.post("/hdfs/to-kafka")
async def hdfs_to_kafka(body: dict[str, Any] = Body(default={})) -> JSONResponse:
path = body.get("path", "/data/historical/sales_orders")
topic = body.get("topic", "hdfs.historical.sales")
limit = int(body.get("limit", 2000))
try:
result = await export_hdfs_to_kafka(path=path, topic=topic, limit=limit, feed=_feed)
if not result.get("ok"):
return JSONResponse(result, status_code=502)
_cache["ts"] = 0
return JSONResponse(result)
except Exception as exc:
_feed("hadoop-ranger", f"[hdfs→kafka] failed: {str(exc)[:120]}", "err")
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@router.post("/pipeline/{pipeline_id}")
async def run_streaming_pipeline(pipeline_id: str) -> JSONResponse:
if pipeline_id != "hadoop-lake":
return JSONResponse({"ok": False, "error": f"unknown pipeline {pipeline_id}"}, status_code=400)
steps: list[str] = []
try:
exp = await export_hdfs_to_kafka(feed=_feed)
if not exp.get("ok"):
return JSONResponse({"ok": False, "error": exp.get("error"), "steps": steps}, status_code=502)
steps.append(f"hdfs→kafka ({exp.get('rows_sent')} rows)")
async with httpx.AsyncClient() as client:
tok = await _airflow_token(client)
h = {"Authorization": f"Bearer {tok}"}
for job_id, dag_id, agent in [
("hadoop_to_trino", "hadoop_to_trino", "hadoop-ranger"),
("spark_to_curated", "mask_to_curated", "lakehouse-ops"),
]:
r = await client.post(
f"{AIRFLOW_URL}/api/v2/dags/{dag_id}/dagRuns",
headers=h,
json={"logical_date": None, "conf": {}},
timeout=20,
)
if r.status_code >= 400:
_feed(agent, f"[pipeline] {dag_id} failed {r.status_code}", "err")
return JSONResponse({"ok": False, "error": f"{dag_id}: {r.text[:200]}", "steps": steps}, status_code=502)
steps.append(dag_id)
_feed(agent, f"[pipeline] started {dag_id}", "info")
_cache["ts"] = 0
return JSONResponse({"ok": True, "pipeline": pipeline_id, "steps": steps, "export": exp})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc), "steps": steps}, status_code=500)
@router.post("/kafka/connectors/{name}/restart")
async def restart_kafka_connector(name: str) -> JSONResponse:
_feed("etl-guardian", f"[kafka] Restarting connector {name}", "info")
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart")
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
_feed("etl-guardian", f"[kafka] Connector {name} restart requested", "info")
_cache["ts"] = 0
return JSONResponse({"ok": True, "connector": name, "action": "restart"})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@router.post("/kafka/connectors/{name}/pause")
async def pause_kafka_connector(name: str) -> JSONResponse:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.put(f"{KAFKA_CONNECT_URL}/connectors/{name}/pause")
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
_cache["ts"] = 0
return JSONResponse({"ok": True, "connector": name, "action": "pause"})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
@router.post("/kafka/connectors/{name}/resume")
async def resume_kafka_connector(name: str) -> JSONResponse:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.put(f"{KAFKA_CONNECT_URL}/connectors/{name}/resume")
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
_cache["ts"] = 0
return JSONResponse({"ok": True, "connector": name, "action": "resume"})
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
# ── Source CDC re-sync + autonomous self-heal ───────────────────────────────
# When a source database briefly drops, the Debezium tasks land in FAILED (or
# stall while still reporting RUNNING) and never recover on their own. The
# re-sync restarts the connectors so CDC catches up; the auto-heal loop does the
# same automatically for FAILED tasks.
SOURCE_CONNECTORS = [c for c in os.getenv(
"CDC_SOURCE_CONNECTORS",
"postgres-sales-connector,mysql-hr-connector,mongodb-supplychain-connector",
).split(",") if c.strip()]
async def _term(agent_id: str, text: str, level: str = "info", phase: str = "resync") -> None:
try:
from agent_terminal import terminal_log
await terminal_log(agent_id, text, level=level, phase=phase)
except Exception:
pass
async def _connector_status(client: httpx.AsyncClient, name: str) -> dict[str, Any]:
try:
r = await client.get(f"{KAFKA_CONNECT_URL}/connectors/{name}/status")
if r.status_code >= 400:
return {"name": name, "state": "MISSING", "tasks": [], "failed": []}
st = r.json()
tasks = st.get("tasks") or []
return {
"name": name,
"state": (st.get("connector") or {}).get("state"),
"tasks": [{"id": t.get("id"), "state": t.get("state")} for t in tasks],
"failed": [t.get("id") for t in tasks if t.get("state") == "FAILED"],
}
except Exception as exc:
return {"name": name, "state": "ERROR", "error": str(exc)[:120], "tasks": [], "failed": []}
async def _discover_connectors(client: httpx.AsyncClient) -> list[str]:
try:
r = await client.get(f"{KAFKA_CONNECT_URL}/connectors")
if r.status_code < 400 and isinstance(r.json(), list) and r.json():
return r.json()
except Exception:
pass
return list(SOURCE_CONNECTORS)
async def resync_connectors(force: bool = True, names: list[str] | None = None) -> dict[str, Any]:
"""force=True → restart every connector incl. all tasks (full re-sync, also
recovers stalled-but-RUNNING tasks). force=False → only restart connectors
that have a FAILED connector or task (self-heal)."""
result: dict[str, Any] = {"ok": True, "restarted": [], "skipped": [], "before": []}
async with httpx.AsyncClient(timeout=20.0) as client:
targets = names or await _discover_connectors(client)
for name in targets:
before = await _connector_status(client, name)
result["before"].append(before)
unhealthy = before.get("state") in ("FAILED", "ERROR", "MISSING") or before.get("failed")
if not force and not unhealthy:
result["skipped"].append(name)
continue
try:
qs = "includeTasks=true" if force else "includeTasks=true&onlyFailed=true"
rr = await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart?{qs}")
if rr.status_code < 400:
result["restarted"].append(name)
await _term("etl-guardian", f"$ kafka-connect restart {name} ({'full re-sync' if force else 'failed tasks'})", level="cmd")
else:
result["ok"] = False
await _term("etl-guardian", f" ✗ {name}: HTTP {rr.status_code}", level="err")
except Exception as exc:
result["ok"] = False
await _term("etl-guardian", f" ✗ {name}: {str(exc)[:100]}", level="err")
_cache["ts"] = 0
return result
@router.get("/connectors")
async def connectors_status() -> JSONResponse:
async with httpx.AsyncClient(timeout=12.0) as client:
names = await _discover_connectors(client)
items = [await _connector_status(client, n) for n in names]
healthy = sum(1 for c in items if c.get("state") == "RUNNING" and not c.get("failed"))
return JSONResponse({"ok": True, "connect_url": KAFKA_CONNECT_URL,
"healthy": healthy, "total": len(items), "connectors": items})
@router.post("/resync")
async def resync_sources(body: dict[str, Any] = Body(default={})) -> JSONResponse:
force = bool(body.get("force", True))
_feed("etl-guardian", f"[kafka] Source CDC re-sync requested ({'full' if force else 'failed only'})", "warn")
await _term("etl-guardian", "═══ Source CDC re-sync — restarting Debezium connectors ═══", level="info")
res = await resync_connectors(force=force)
await asyncio.sleep(3) # let tasks transition
async with httpx.AsyncClient(timeout=15.0) as client:
after = [await _connector_status(client, c["name"]) for c in res["before"]]
res["after"] = after
healthy = sum(1 for c in after if c.get("state") == "RUNNING" and not c.get("failed"))
res["healthy"] = healthy
res["total"] = len(after)
await _term("etl-guardian", f" ← re-sync requested for {len(res['restarted'])} connectors · {healthy}/{len(after)} healthy", level="ok")
return JSONResponse(res)
async def connector_autoheal_loop() -> None:
"""Autonomously restart FAILED Debezium tasks so CDC recovers after a source
DB outage without operator action."""
await asyncio.sleep(40)
interval = max(30.0, float(os.getenv("CONNECTOR_AUTOHEAL_SECONDS", "60")))
while True:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
for name in await _discover_connectors(client):
st = await _connector_status(client, name)
if st.get("state") in ("FAILED", "ERROR") or st.get("failed"):
await client.post(f"{KAFKA_CONNECT_URL}/connectors/{name}/restart?includeTasks=true&onlyFailed=true")
_feed("etl-guardian", f"[kafka] auto-heal restarted {name}", "warn")
await _term("etl-guardian", f" ⟳ auto-heal: restarted FAILED task(s) on {name}", level="warn", phase="autoheal")
_cache["ts"] = 0
except Exception:
pass
await asyncio.sleep(interval)