Files
atc-agents/api/lake_meta.py
T
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

148 lines
6.9 KiB
Python

"""Shared lakehouse metadata + Trino helpers.
Single source of truth for the business datasets the governance / data-quality /
lineage / observability features operate on, plus a small synchronous Trino
client. Imported by dq_monitor.py, observability.py, lineage.py and
catalog_governance.py so every feature reasons about the exact same tables that
the rest of the Command Center (pii_catalog, etl_offload, trino_federated)
already exposes.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo")
# Canonical business datasets, aligned with pii_catalog.DATASETS / etl_offload.
# fqtn = fully-qualified Trino table name (catalog.schema.table)
# om_fqn = OpenMetadata FQN best-effort (governance falls back to native store)
DATASETS: list[dict[str, Any]] = [
{"key": "orders", "label": "Sales orders", "engine": "PostgreSQL", "color": "#fbbf24",
"catalog": "postgres_sales", "schema": "public", "table": "sales_orders",
"fqtn": "postgres_sales.public.sales_orders", "pii_key": "postgres",
"om_fqn": "atc_trino.postgres_sales.public.sales_orders",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"native_count": ("postgres", "public.sales_orders"),
"domain": "Sales", "topic": "atc.public.sales_orders"},
{"key": "hr", "label": "HR events", "engine": "MySQL", "color": "#60a5fa",
"catalog": "mysql_hr", "schema": "hr", "table": "employee_events",
"fqtn": "mysql_hr.hr.employee_events", "pii_key": "mysql",
"om_fqn": "atc_trino.mysql_hr.hr.employee_events",
"key_col": "event_id", "ts_col": "event_ts", "amount_col": "salary_change",
"native_count": ("mysql", "hr.employee_events"),
"domain": "People", "topic": "atc.hr.employee_events"},
{"key": "supply", "label": "Supply chain events", "engine": "MongoDB", "color": "#a78bfa",
"catalog": "mongodb_supplychain", "schema": "supplychain", "table": "events",
"fqtn": "mongodb_supplychain.supplychain.events", "pii_key": "mongodb",
"om_fqn": "atc_trino.mongodb_supplychain.supplychain.events",
"key_col": "event_id", "ts_col": "ts", "amount_col": "amount",
"native_count": ("mongodb", "supplychain.events"),
"domain": "Supply Chain", "topic": "atc.supplychain.events"},
{"key": "telemetry", "label": "Device telemetry", "engine": "Cassandra", "color": "#22d3ee",
"catalog": "cassandra_telemetry", "schema": "telemetry", "table": "device_metrics",
"fqtn": "cassandra_telemetry.telemetry.device_metrics", "pii_key": "cassandra",
"om_fqn": "atc_trino.cassandra_telemetry.telemetry.device_metrics",
"key_col": "device_id", "ts_col": "metric_ts", "amount_col": "metric_value",
"unique_key": False,
"domain": "IoT", "topic": "atc.telemetry.device_metrics"},
{"key": "curated", "label": "Curated masked (Iceberg)", "engine": "Iceberg / Trino", "color": "#34d399",
"catalog": "iceberg", "schema": "curated_masked", "table": "sales_orders_masked",
"fqtn": "iceberg.curated_masked.sales_orders_masked", "pii_key": "curated",
"om_fqn": "atc_trino.iceberg.curated_masked.sales_orders_masked",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
{"key": "hadoop", "label": "Historical sales (HDFS)", "engine": "Iceberg / HDFS", "color": "#f472b6",
"catalog": "iceberg", "schema": "hadoop", "table": "historical_sales_hdfs",
"fqtn": "iceberg.hadoop.historical_sales_hdfs", "pii_key": "hadoop",
"om_fqn": "atc_trino.iceberg.hadoop.historical_sales_hdfs",
"key_col": "order_id", "ts_col": "order_ts", "amount_col": "amount",
"domain": "Sales", "curated": True, "topic": None},
]
DATASET_BY_KEY = {d["key"]: d for d in DATASETS}
# Heuristics for picking the freshness / key column when discovering schema.
_TS_HINTS = ("_ts", "ts", "updated_at", "created_at", "event_time", "modified", "time")
_KEY_HINTS = ("_id", "id", "uuid", "key", "pk")
def trino(sql: str, timeout: float = 25.0) -> tuple[list[str], list[list[Any]]]:
"""Run a Trino statement, following nextUri pages. Returns (columns, rows).
`timeout` is both the per-request timeout AND an overall wall-clock deadline,
so a long full-scan (e.g. count(*) on a huge Cassandra table) is aborted and
cancelled instead of looping over nextUri pages for minutes and blocking the
caller's thread.
"""
import time as _t
cols: list[str] = []
rows: list[list[Any]] = []
deadline = _t.monotonic() + timeout
with httpx.Client(timeout=min(timeout, 15.0)) as client:
d = client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(),
headers={"X-Trino-User": TRINO_USER}).json()
while True:
if d.get("error"):
raise RuntimeError(d["error"].get("message", "trino error"))
c = d.get("columns")
if c and not cols:
cols = [x["name"] for x in c]
rows += d.get("data") or []
nxt = d.get("nextUri")
if not nxt:
break
if _t.monotonic() > deadline:
try:
client.delete(nxt) # cancel the running query server-side
except Exception:
pass
raise TimeoutError(f"trino query exceeded {timeout}s deadline")
d = client.get(nxt).json()
return cols, rows
def trino_scalar(sql: str, timeout: float = 25.0) -> Any:
_c, rows = trino(sql, timeout=timeout)
if rows and rows[0]:
return rows[0][0]
return None
def discover_columns(ds: dict[str, Any], timeout: float = 12.0) -> list[dict[str, str]]:
"""[{name, type}] for a dataset's table via information_schema."""
sql = (f"SELECT column_name, data_type FROM {ds['catalog']}.information_schema.columns "
f"WHERE table_name = '{ds['table']}'")
if ds.get("schema"):
sql += f" AND table_schema = '{ds['schema']}'"
try:
_c, rows = trino(sql, timeout=timeout)
return [{"name": r[0], "type": r[1]} for r in rows]
except Exception:
return []
def pick_ts_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
for c in columns:
if "timestamp" in (c.get("type") or "").lower() or "date" in (c.get("type") or "").lower():
return c["name"]
for n in names:
if any(h in n.lower() for h in _TS_HINTS):
return n
return default if default in names else None
def pick_key_col(columns: list[dict[str, str]], default: str | None = None) -> str | None:
names = [c["name"] for c in columns]
if default in names:
return default
for n in names:
if any(n.lower() == h or n.lower().endswith(h) for h in _KEY_HINTS):
return n
return names[0] if names else None