Files
atc-agents/api/cdc_consumer.py
T
mo ee9357aa31 feat(dataflow): live Data Flow graph API + PII catalog
api/dataflow.py: node-link landscape (generators->sources->CDC/Kafka->sinks,
HDFS->Iceberg, sources->curated_masked) with live overlays (movement run state,
CDC volume, Trino counts) and PII overlay. api/pii_catalog.py: column PII
classification via Trino information_schema (OM-ready). Endpoints /api/dataflow,
/api/dataflow/{id}/run, /api/pii.
2026-06-27 02:04:46 +02:00

264 lines
8.7 KiB
Python

"""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
def snapshot(minutes: int = 15) -> dict[str, Any]:
"""Lightweight CDC snapshot for other modules (Data Flow graph)."""
cutoff = datetime.now(timezone.utc).timestamp() - minutes * 60
by_source: dict[str, int] = {}
total = 0
for c in _ring:
try:
if datetime.fromisoformat(c["ts"]).timestamp() < cutoff:
continue
except Exception:
continue
total += 1
by_source[c["source"]] = by_source.get(c["source"], 0) + 1
return {"connected": _state["connected"], "consumed": _state["consumed"],
"buffered": len(_ring), "window_total": total, "by_source": by_source}
# ── 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})