feat(cdc): live Debezium CDC consumer + /api/changes for the Changes dashboard

Add api/cdc_consumer.py: aiokafka background consumer subscribes to the CDC
topics (postgres_sales/mysql_hr/mongodb_supplychain + cassandra/neo4j), parses
Debezium before/after envelopes, keeps a ring buffer and publishes each change
live as type=cdc_change. Endpoints /api/changes, /api/changes/stats, /status.
This commit is contained in:
mo
2026-06-27 01:31:18 +02:00
parent 5147538b05
commit 1a454f76cf
4 changed files with 252 additions and 1 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py hive_bench_seed.json . COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py agent_ops.py cdc_consumer.py hive_bench_seed.json .
RUN mkdir -p /data RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201 EXPOSE 3201
+246
View File
@@ -0,0 +1,246 @@
"""Live CDC consumer for the Command Center "Changes" dashboard.
A background aiokafka consumer subscribes to the Debezium CDC topics on the
Kafka broker, parses each change event (insert/update/delete with before/after
images), keeps a bounded in-memory ring buffer, and fans every change out over
the WebSocket bus as a ``cdc_change`` event so the UI can render new & changed
data in real time.
Endpoints:
GET /api/changes -> recent changes from the ring buffer (filterable)
GET /api/changes/stats -> volume per source/table/op + per-minute buckets
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import uuid
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/changes", tags=["changes"])
KAFKA_BOOTSTRAP = os.getenv("KAFKA_BOOTSTRAP", "10.0.21.36:9092")
RING_SIZE = int(os.getenv("CDC_RING_SIZE", "1000"))
# Active CDC topic prefixes -> logical source. Matches Debezium topic.prefix.
PREFIX_SOURCE = {
"postgres_sales": "postgres",
"mysql_hr": "mysql",
"mongodb_supplychain": "mongodb",
"cassandra_telemetry": "cassandra",
"neo4j_graph": "neo4j",
}
TOPIC_PATTERN = re.compile(
r"^(postgres_sales|mysql_hr|mongodb_supplychain|cassandra_telemetry|neo4j_graph)\..+"
)
OP_MAP = {"c": "insert", "u": "update", "d": "delete", "r": "snapshot"}
_ring: deque[dict[str, Any]] = deque(maxlen=RING_SIZE)
_state: dict[str, Any] = {
"started": False,
"connected": False,
"consumed": 0,
"last_error": None,
"last_ts": None,
"topics": [],
}
def _source_of(topic: str) -> tuple[str, str]:
prefix = topic.split(".", 1)[0]
source = PREFIX_SOURCE.get(prefix, prefix)
table = topic.split(".")[-1]
return source, table
def _coerce(v: Any) -> Any:
"""Debezium/Mongo nests the document as a JSON string sometimes."""
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return v
return v
def _key_fields(source: str, table: str, after: Any, before: Any) -> str:
row = after or before or {}
if not isinstance(row, dict):
return ""
prefer = ["order_id", "event_id", "_id", "id", "region", "order_status",
"event_type", "type", "amount", "salary_change"]
parts = []
for k in prefer:
if k in row and row[k] is not None:
val = row[k]
if isinstance(val, (dict, list)):
continue
sval = str(val)
if len(sval) > 40:
sval = sval[:40] + ""
parts.append(f"{k}={sval}")
if len(parts) >= 4:
break
return " ".join(parts)
def _parse(topic: str, value: bytes | None) -> dict[str, Any] | None:
if value is None: # tombstone
return None
try:
payload = json.loads(value.decode("utf-8"))
except Exception:
return None
# Debezium envelope: {schema, payload:{before,after,op,source,ts_ms}}
if isinstance(payload, dict) and "payload" in payload and isinstance(payload["payload"], dict):
payload = payload["payload"]
if not isinstance(payload, dict):
return None
op = OP_MAP.get(payload.get("op"), payload.get("op") or "change")
before = _coerce(payload.get("before"))
after = _coerce(payload.get("after"))
src = payload.get("source") or {}
source, table = _source_of(topic)
if isinstance(src, dict) and src.get("table"):
table = src.get("table")
ts_ms = payload.get("ts_ms")
ts = datetime.fromtimestamp(ts_ms / 1000, timezone.utc).isoformat() if ts_ms else datetime.now(timezone.utc).isoformat()
return {
"id": uuid.uuid4().hex[:10],
"ts": ts,
"source": source,
"table": table,
"topic": topic,
"op": op,
"summary": _key_fields(source, table, after, before),
"before": before if isinstance(before, dict) else None,
"after": after if isinstance(after, dict) else None,
}
async def cdc_consumer_loop() -> None:
"""Background loop: consume CDC topics and publish each change live."""
_state["started"] = True
await asyncio.sleep(6)
try:
from aiokafka import AIOKafkaConsumer
except Exception as exc:
_state["last_error"] = f"aiokafka import failed: {exc}"
return
while True:
consumer = None
try:
consumer = AIOKafkaConsumer(
bootstrap_servers=KAFKA_BOOTSTRAP,
group_id=f"atc-cc-cdc-{uuid.uuid4().hex[:8]}",
auto_offset_reset="latest",
enable_auto_commit=False,
client_id="atc-command-center-cdc",
)
consumer.subscribe(pattern=TOPIC_PATTERN)
await consumer.start()
_state["connected"] = True
_state["last_error"] = None
from main import publish_event
async for msg in consumer:
entry = _parse(msg.topic, msg.value)
if not entry:
continue
_ring.append(entry)
_state["consumed"] += 1
_state["last_ts"] = entry["ts"]
try:
_state["topics"] = sorted(consumer.subscription() or [])
except Exception:
pass
try:
await publish_event({"type": "cdc_change", "entry": entry})
except Exception:
pass
except Exception as exc:
_state["connected"] = False
_state["last_error"] = str(exc)
await asyncio.sleep(10) # backoff then reconnect
finally:
if consumer is not None:
try:
await consumer.stop()
except Exception:
pass
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("")
async def list_changes(
limit: int = Query(100, le=500),
source: str | None = None,
op: str | None = None,
table: str | None = None,
) -> JSONResponse:
items = list(_ring)
if source:
items = [c for c in items if c["source"] == source]
if op:
items = [c for c in items if c["op"] == op]
if table:
items = [c for c in items if c["table"] == table]
items = list(reversed(items))[:limit]
return JSONResponse({
"ok": True,
"changes": items,
"buffered": len(_ring),
"connected": _state["connected"],
"consumed": _state["consumed"],
"last_error": _state["last_error"],
})
@router.get("/stats")
async def change_stats(minutes: int = Query(15, le=240)) -> JSONResponse:
now = datetime.now(timezone.utc)
cutoff = now.timestamp() - minutes * 60
by_source: dict[str, int] = {}
by_op: dict[str, int] = {}
by_table: dict[str, int] = {}
buckets: dict[str, int] = {}
total = 0
for c in _ring:
try:
t = datetime.fromisoformat(c["ts"]).timestamp()
except Exception:
continue
if t < cutoff:
continue
total += 1
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
by_op[c["op"]] = by_op.get(c["op"], 0) + 1
by_table[c["table"]] = by_table.get(c["table"], 0) + 1
bucket = datetime.fromtimestamp(t, timezone.utc).strftime("%H:%M")
buckets[bucket] = buckets.get(bucket, 0) + 1
return JSONResponse({
"ok": True,
"window_minutes": minutes,
"total": total,
"by_source": by_source,
"by_op": by_op,
"by_table": by_table,
"buckets": [{"t": k, "n": v} for k, v in sorted(buckets.items())],
"connected": _state["connected"],
"consumed": _state["consumed"],
})
@router.get("/status")
async def changes_status() -> JSONResponse:
return JSONResponse({"ok": True, "buffered": len(_ring), **_state})
+4
View File
@@ -43,6 +43,7 @@ from hadoop_analytics import router as hadoop_router
from elasticsearch_api import router as elasticsearch_router from elasticsearch_api import router as elasticsearch_router
from sql_console import router as sql_router from sql_console import router as sql_router
from agent_ops import router as agent_ops_router, agent_dml_loop from agent_ops import router as agent_ops_router, agent_dml_loop
from cdc_consumer import router as cdc_router, cdc_consumer_loop
from ssh_terminal import ssh_session from ssh_terminal import ssh_session
from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id from node_registry import NODE_IDS, NODE_AGENT, NODE_REGISTRY, is_node_id
from node_ops import build_node_detail, probe_node, run_node_probe_task from node_ops import build_node_detail, probe_node, run_node_probe_task
@@ -713,10 +714,12 @@ async def lifespan(app: FastAPI):
await terminal_log(nid, f"{meta['label']} shell ready — click node to connect", level="info", phase="boot") await terminal_log(nid, f"{meta['label']} shell ready — click node to connect", level="info", phase="boot")
task = asyncio.create_task(heartbeat_loop()) task = asyncio.create_task(heartbeat_loop())
dml_task = asyncio.create_task(agent_dml_loop()) dml_task = asyncio.create_task(agent_dml_loop())
cdc_task = asyncio.create_task(cdc_consumer_loop())
add_feed("infra-sentinel", "ATC Command Center API online", "info") add_feed("infra-sentinel", "ATC Command Center API online", "info")
yield yield
task.cancel() task.cancel()
dml_task.cancel() dml_task.cancel()
cdc_task.cancel()
if redis_client: if redis_client:
await redis_client.close() await redis_client.close()
@@ -729,6 +732,7 @@ app.include_router(hadoop_router)
app.include_router(elasticsearch_router) app.include_router(elasticsearch_router)
app.include_router(sql_router) app.include_router(sql_router)
app.include_router(agent_ops_router) app.include_router(agent_ops_router)
app.include_router(cdc_router)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
+1
View File
@@ -15,3 +15,4 @@ neo4j==5.26.0
python-pptx==1.0.2 python-pptx==1.0.2
boto3==1.35.99 boto3==1.35.99
paramiko==3.5.0 paramiko==3.5.0
aiokafka==0.12.0