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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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=["*"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user