Files
mo f36c8906bc feat(governance): close the 6 data-disease gaps — DQ monitoring, ownership, access posture, lineage & observability
Adds native Command Center features (no new containers) integrated as sub-tabs
in the existing Data Explorer and Data Quality views:

- Continuous Data Quality (dq_monitor.py): live completeness/uniqueness/validity/
  freshness scorecards via Trino with rolling trends → DataQuality "Live Monitoring".
- Ownership & stewardship (catalog_governance.py): owner/steward/tier matrix,
  orphan detection, business glossary; local store best-effort synced to
  OpenMetadata (owner PATCH) → Data Explorer "Ownership".
- Access & policy posture: per-dataset compliance combining PII masking, ownership,
  live DQ and observability alerts vs data contracts → Data Explorer "Access & Policies".
- Lineage (lineage.py): staged source→CDC→Spark→S3→Iceberg→Trino→serving graph with
  live row counts and column-level PII/masking tracing → Data Explorer "Lineage".
- Observability (observability.py): volume/freshness/schema-drift monitoring with
  alerts → Data Explorer "Observability".
- Shared lake_meta.py dataset registry + bounded Trino client; fast native row-count
  and PK-indexed freshness so monitors stay cheap on 25-54M-row tables.
- LLM context (lab_context.py) enriched with DQ scores, ownership and active alerts.
2026-06-29 17:43:37 +00:00

346 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Continuous data-quality monitoring (Disease #2 — Dirty Data).
Where dq-api runs Great-Expectations/Soda on *uploaded* files, this agent runs
quality checks continuously against the *live* business tables through Trino and
produces a per-dataset scorecard (0-100) across five dimensions:
completeness non-null ratio across columns
uniqueness distinct/key ratio (duplicate detection)
validity domain rules (non-negative amounts, present timestamps)
freshness age of the newest record vs a threshold
volume row count + delta vs the previous cycle
A rolling score history powers trend sparklines. The loop streams the exact
SQL it runs into the Data Custodian terminal so operators can see the checks.
Endpoints:
GET /api/dq/scorecards -> all datasets, dimensions, score, trend
GET /api/dq/scorecard/{key} -> one dataset detail
POST /api/dq/run -> run one cycle now
POST /api/dq/config -> {enabled, interval_s, sample}
"""
from __future__ import annotations
import threading
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from lake_meta import DATASETS, DATASET_BY_KEY, trino, trino_scalar, discover_columns
router = APIRouter(prefix="/api/dq", tags=["data-quality"])
FRESHNESS_THRESHOLD_MIN = 60.0 # newest row older than this => freshness degraded
MAX_COLS = 24
_lock = threading.Lock()
_state: dict[str, Any] = {
"enabled": True,
"interval_s": 180.0,
"sample": 20000,
"running": False,
"cycles": 0,
"last_cycle_ts": 0.0,
"next_run_ts": 0.0,
"started_at": None,
"cards": {}, # key -> latest scorecard
"history": {d["key"]: deque(maxlen=60) for d in DATASETS},
"cols_cache": {}, # key -> [{name,type}]
"feed": deque(maxlen=40),
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _term(text: str, level: str = "info", phase: str = "dq") -> None:
try:
from agent_terminal import emit_threadsafe
emit_threadsafe("data-custodian", text, level=level, phase=phase)
except Exception:
pass
def _feed(text: str, level: str = "info") -> None:
_state["feed"].appendleft({"ts": _now().isoformat(), "text": text, "level": level})
def _score_color(score: float) -> str:
if score >= 90:
return "#34d399"
if score >= 75:
return "#fbbf24"
if score >= 50:
return "#fb923c"
return "#f87171"
def _obs_volume_freshness(key: str) -> tuple[int | None, float | None]:
"""Read row count + freshness from the observability monitor (in-memory)."""
try:
from observability import _state, _lock
with _lock:
st = _state["datasets"].get(key) or {}
return st.get("rows"), st.get("freshness_age_min")
except Exception:
return None, None
def _columns(ds: dict[str, Any]) -> list[dict[str, str]]:
key = ds["key"]
cols = _state["cols_cache"].get(key)
if not cols:
cols = discover_columns(ds)
if cols:
_state["cols_cache"][key] = cols
return cols or []
def _assess(ds: dict[str, Any], sample: int) -> dict[str, Any]:
key = ds["key"]
fq = ds["fqtn"]
cols = _columns(ds)
col_names = [c["name"] for c in cols][:MAX_COLS]
dims: dict[str, Any] = {}
issues: list[str] = []
col_profiles: list[dict[str, Any]] = []
# ── one bounded query: completeness + uniqueness + validity over a sample ──
has_unique_key = ds.get("unique_key", True)
key_col = ds.get("key_col") if ds.get("key_col") in col_names else (col_names[0] if col_names else None)
if not has_unique_key:
key_col = None # no single-column unique key (e.g. composite PK) → skip dedup
ts_col = ds.get("ts_col") if ds.get("ts_col") in col_names else None
amt_col = ds.get("amount_col") if ds.get("amount_col") in col_names else None
selects = ["count(*) AS n"]
for i, c in enumerate(col_names):
selects.append(f'count("{c}") AS c{i}')
if key_col:
selects.append(f'count(DISTINCT "{key_col}") AS dk')
if amt_col:
selects.append(f'count_if("{amt_col}" >= 0) AS amt_ok')
if ts_col:
selects.append(f'count_if("{ts_col}" IS NOT NULL) AS ts_ok')
sql = f"SELECT {', '.join(selects)} FROM (SELECT * FROM {fq} LIMIT {sample}) s"
_term(f"$ trino: profile {key} ({len(col_names)} cols · sample {sample:,})", level="cmd", phase="dq")
cols_out, rows = trino(sql, timeout=40.0)
rec = dict(zip(cols_out, rows[0])) if rows else {}
n = int(rec.get("n") or 0)
if n > 0:
# completeness
non_null_ratios = []
for i, c in enumerate(col_names):
cnt = int(rec.get(f"c{i}") or 0)
ratio = cnt / n
non_null_ratios.append(ratio)
col_profiles.append({"name": c, "completeness": round(ratio * 100, 1),
"nulls": n - cnt})
completeness = round(100.0 * sum(non_null_ratios) / max(1, len(non_null_ratios)), 1)
dims["completeness"] = completeness
worst = sorted(col_profiles, key=lambda x: x["completeness"])[:3]
for w in worst:
if w["completeness"] < 95:
issues.append(f'{w["name"]} {100 - w["completeness"]:.0f}% null')
# uniqueness / dedup
if key_col and rec.get("dk") is not None:
dk = int(rec["dk"])
uniq = round(100.0 * dk / n, 1)
dims["uniqueness"] = uniq
dups = n - dk
if dups > 0:
issues.append(f"{dups:,} duplicate {key_col} in sample")
# validity
valid_parts = []
if amt_col and rec.get("amt_ok") is not None:
valid_parts.append(int(rec["amt_ok"]) / n)
if ts_col and rec.get("ts_ok") is not None:
valid_parts.append(int(rec["ts_ok"]) / n)
if valid_parts:
validity = round(100.0 * sum(valid_parts) / len(valid_parts), 1)
dims["validity"] = validity
if validity < 99 and amt_col:
issues.append(f"negative/invalid {amt_col}")
# ── volume + freshness: reuse the observability monitor's in-memory probes
# (it already polls count(*) and max(ts)). We deliberately do NOT issue our
# own count/max here — those are expensive full scans on 25-54M-row tables —
# so a DQ cycle stays fast and the score is driven by the cheap sample. ──
volume, fresh_age_min = _obs_volume_freshness(key)
if volume is None:
volume = n # sample size until observability reports the real count
if fresh_age_min is not None:
fresh_score = 100.0 if fresh_age_min <= FRESHNESS_THRESHOLD_MIN else max(
0.0, 100.0 - (fresh_age_min - FRESHNESS_THRESHOLD_MIN) / 5.0)
dims["freshness"] = round(fresh_score, 1)
if fresh_age_min > FRESHNESS_THRESHOLD_MIN and not ds.get("curated"):
issues.append(f"stale {fresh_age_min:.0f}m")
score = round(sum(dims.values()) / len(dims), 1) if dims else 0.0
prev = _state["cards"].get(key, {})
prev_vol = prev.get("volume")
delta = (volume - prev_vol) if (volume is not None and prev_vol is not None) else None
card = {
"key": key, "label": ds["label"], "engine": ds["engine"], "color": ds["color"],
"table": fq, "domain": ds.get("domain"),
"score": score, "score_color": _score_color(score),
"dimensions": dims, "volume": volume, "volume_delta": delta,
"freshness_age_min": round(fresh_age_min, 1) if fresh_age_min is not None else None,
"issues": issues[:5], "columns": len(col_names),
"worst_columns": sorted(col_profiles, key=lambda x: x["completeness"])[:5],
"ts": _now().isoformat(),
}
lvl = "ok" if score >= 90 else ("warn" if score >= 60 else "err")
_term(f" ← {key}: score {score} · " + " · ".join(f"{k} {v}" for k, v in dims.items())
+ (f" · {len(issues)} issue(s)" if issues else ""), level=lvl, phase="dq")
return card
def run_cycle() -> dict[str, Any]:
if _state["running"]:
return {"ok": True, "skipped": "already running"}
_state["running"] = True
sample = int(_state["sample"])
n_ok = 0
try:
_term(f"═══ DQ monitor cycle {_state['cycles'] + 1} — live tables via Trino ═══",
level="info", phase="cycle")
for ds in DATASETS:
if not _state["enabled"]:
break
try:
card = _assess(ds, sample)
except Exception as exc:
card = {"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "error": str(exc)[:160],
"issues": [f"check failed: {str(exc)[:80]}"], "ts": _now().isoformat()}
_term(f" ✗ {ds['key']}: {str(exc)[:120]}", level="err", phase="dq")
with _lock:
_state["cards"][ds["key"]] = card
if card.get("score") is not None:
_state["history"][ds["key"]].append({"t": _now().strftime("%H:%M"), "score": card["score"]})
n_ok += 1
_state["cycles"] += 1
_state["last_cycle_ts"] = time.time()
_state["next_run_ts"] = time.time() + float(_state["interval_s"])
avg = _overall_score()
_feed(f"cycle {_state['cycles']}{n_ok}/{len(DATASETS)} datasets scored · platform DQ {avg}")
_term(f"═══ cycle {_state['cycles']} done — platform DQ score {avg} ═══", level="ok", phase="cycle")
finally:
_state["running"] = False
return {"ok": True, "scored": n_ok, "platform_score": _overall_score()}
def _overall_score() -> float | None:
vals = [c["score"] for c in _state["cards"].values() if c.get("score") is not None]
return round(sum(vals) / len(vals), 1) if vals else None
def _loop() -> None:
_state["started_at"] = _now().isoformat()
time.sleep(20)
while True:
try:
if _state["enabled"]:
run_cycle()
except Exception:
pass
time.sleep(max(30.0, float(_state["interval_s"])))
threading.Thread(target=_loop, daemon=True, name="dq-monitor").start()
def scorecards_view() -> dict[str, Any]:
with _lock:
cards = []
for ds in DATASETS:
c = _state["cards"].get(ds["key"])
if c:
cards.append({**c, "trend": list(_state["history"][ds["key"]])})
else:
cards.append({"key": ds["key"], "label": ds["label"], "engine": ds["engine"],
"color": ds["color"], "table": ds["fqtn"], "score": None,
"score_color": "#64748b", "dimensions": {}, "issues": [],
"trend": [], "pending": True})
# platform dimension averages
dim_avg: dict[str, list[float]] = {}
for c in cards:
for k, v in (c.get("dimensions") or {}).items():
dim_avg.setdefault(k, []).append(v)
return {
"ok": True,
"enabled": _state["enabled"],
"interval_s": _state["interval_s"],
"sample": _state["sample"],
"running": _state["running"],
"cycles": _state["cycles"],
"last_cycle_ts": _state["last_cycle_ts"],
"next_run_ts": _state["next_run_ts"],
"platform_score": _overall_score(),
"dimension_averages": {k: round(sum(v) / len(v), 1) for k, v in dim_avg.items()},
"cards": cards,
"feed": list(_state["feed"])[:20],
}
def summary_for_llm() -> dict[str, Any]:
with _lock:
return {
"platform_dq_score": _overall_score(),
"datasets": [{"key": c["key"], "score": c.get("score"),
"issues": c.get("issues", []), "freshness_age_min": c.get("freshness_age_min")}
for c in _state["cards"].values()],
}
@router.get("/scorecards")
async def get_scorecards() -> JSONResponse:
return JSONResponse(scorecards_view())
@router.get("/scorecard/{key}")
async def get_scorecard(key: str) -> JSONResponse:
with _lock:
c = _state["cards"].get(key)
if not c:
return JSONResponse({"ok": False, "error": "unknown or not yet scored"}, status_code=404)
return JSONResponse({"ok": True, "card": {**c, "trend": list(_state["history"].get(key, []))}})
@router.post("/run")
async def post_run() -> JSONResponse:
from starlette.concurrency import run_in_threadpool
res = await run_in_threadpool(run_cycle)
return JSONResponse({**res, "scorecards": scorecards_view()})
@router.post("/config")
async def post_config(body: dict = Body(default={})) -> JSONResponse:
if "enabled" in body:
_state["enabled"] = bool(body["enabled"])
if "interval_s" in body:
try:
_state["interval_s"] = max(30.0, min(1800.0, float(body["interval_s"])))
except Exception:
pass
if "sample" in body:
try:
_state["sample"] = max(1000, min(200000, int(body["sample"])))
except Exception:
pass
return JSONResponse({"ok": True, "enabled": _state["enabled"],
"interval_s": _state["interval_s"], "sample": _state["sample"]})