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
This commit is contained in:
mo
2026-06-29 13:31:11 +00:00
parent 1e2cfe80f2
commit d066def8b4
4 changed files with 173 additions and 2 deletions
+123
View File
@@ -433,3 +433,126 @@ async def resume_kafka_connector(name: str) -> JSONResponse:
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)