Files
atc-agents/api/agent_ops.py
T
mo a724615a9a feat(etl): Hadoop->Trino movement DAG + movements registry + autonomous ETL-agents
- DAG hadoop_to_trino (deployed to Airflow) + worker hadoop_to_trino.py (on the
  Hadoop master) move HDFS historical_sales -> iceberg.hadoop.historical_sales_hdfs.
- api/movements.py: movement registry, Airflow trigger+watch, run tracking
  (state/duration/rows), endpoints /api/movements, /{id}/run, /runs.
- agent_ops.py: ETL-agent loop autonomously triggers movements on an interval
  and logs each run; /api/agent-ops/etl/toggle + etl status.
2026-06-27 01:59:35 +02:00

374 lines
16 KiB
Python

"""Autonomous agent DML operations against the source databases.
Agents continuously perform small, guard-railed INSERT/UPDATE/DELETE operations
on the operational source databases (PostgreSQL, MySQL, MongoDB) so that the
Debezium CDC connectors capture a steady stream of changes that flow through
Kafka and downstream.
Safety guardrails:
- Only a fixed whitelist of tables/collections is touched (the exact ones the
Debezium connectors capture: public.sales_orders, hr.employee_events,
supplychain.events).
- Every agent-created row is tagged (notes/payload marker, or an `atc_agent`
field for Mongo). UPDATE and DELETE operate ONLY on rows the agents created
themselves, never on seed/real data.
- A hard per-tick row limit and an env kill-switch bound the activity.
The work runs in a background loop started from the app lifespan. DB drivers
are synchronous, so each operation is executed via ``asyncio.to_thread``.
"""
from __future__ import annotations
import asyncio
import os
import random
import uuid
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/agent-ops", tags=["agent-ops"])
# ── Config (env-overridable) ────────────────────────────────────────────────
DB_HOST = os.getenv("SRC_DB_HOST", "10.0.21.51")
DB_USER = os.getenv("SRC_DB_USER", "mo")
DB_PASSWORD = os.getenv("SRC_DB_PASSWORD", "Dell2026!")
PG_DB = os.getenv("SRC_PG_DB", "postgres")
PG_PORT = int(os.getenv("SRC_PG_PORT", "5432"))
MYSQL_DB = os.getenv("SRC_MYSQL_DB", "hr")
MYSQL_PORT = int(os.getenv("SRC_MYSQL_PORT", "3306"))
MONGO_URI = os.getenv("SRC_MONGO_URI", f"mongodb://{DB_HOST}:27017/?replicaSet=rs0")
MONGO_DB = os.getenv("SRC_MONGO_DB", "supplychain")
AGENT_TAG = "ATC-AGENT" # marker stored on agent-created rows
DML_AGENT = "data-custodian"
_INTERVAL = float(os.getenv("AGENT_DML_INTERVAL_SECONDS", "45"))
_MAX_ROWS = int(os.getenv("AGENT_DML_MAX_ROWS", "5"))
# ETL-agent: autonomously trigger data movements on an interval.
_ETL_INTERVAL = float(os.getenv("ETL_AGENT_INTERVAL_SECONDS", "300"))
# Movements the ETL-agent cycles through autonomously (must exist in movements.py).
_ETL_ROTATION = [m for m in os.getenv("ETL_AGENT_MOVEMENTS", "hadoop_to_trino,gen_postgres,gen_mysql,gen_mongodb").split(",") if m]
# How many agent-created rows we keep around per source before favouring DELETE.
_POOL_CAP = int(os.getenv("AGENT_DML_POOL_CAP", "400"))
_state: dict[str, Any] = {
"enabled": os.getenv("AGENT_DML_ENABLED", "1") not in ("0", "false", "False", ""),
"interval": _INTERVAL,
"max_rows": _MAX_ROWS,
"started": False,
"ops_total": 0,
"last_op": None,
"last_error": None,
"by_source": {"postgres": 0, "mysql": 0, "mongodb": 0},
"by_op": {"insert": 0, "update": 0, "delete": 0},
}
_etl_state: dict[str, Any] = {
"enabled": os.getenv("ETL_AGENT_ENABLED", "1") not in ("0", "false", "False", ""),
"interval": _ETL_INTERVAL,
"rotation": _ETL_ROTATION,
"idx": 0,
"runs_total": 0,
"last": None,
"started": False,
}
# ── Realistic value domains ─────────────────────────────────────────────────
REGIONS = ["NA", "EU", "EMEA", "APAC", "LATAM"]
CHANNELS = ["online", "retail", "partner", "wholesale", "direct"]
CURRENCIES = ["USD", "EUR", "GBP"]
ORDER_STATUS = ["NEW", "PROCESSING", "SHIPPED", "DELIVERED", "RETURNED", "CANCELLED"]
DEPARTMENTS = ["Engineering", "Sales", "HR", "Finance", "Operations", "Marketing"]
ROLES = ["Analyst", "Manager", "Engineer", "Director", "Specialist"]
EVENT_TYPES = ["HIRE", "PROMOTION", "SALARY_CHANGE", "TRANSFER", "TERMINATION"]
MONGO_TYPES = ["ORDER", "SHIPMENT", "RETURN", "RESTOCK", "UPDATE"]
MONGO_SOURCES = ["CRM", "ERP", "WMS", "POS"]
# ── Feed / terminal helpers (lazy import to avoid circular import) ───────────
async def _emit(message: str, level: str = "info") -> None:
try:
from main import add_feed, publish_event
from agent_terminal import terminal_log
entry = add_feed(DML_AGENT, message, level)
await publish_event({"type": "feed", "entry": entry})
await terminal_log(DML_AGENT, message, level=level, phase="dml")
except Exception:
pass
# ── Synchronous DB operations (run in a thread) ──────────────────────────────
def _pg_conn():
import psycopg2
return psycopg2.connect(
host=DB_HOST, dbname=PG_DB, user=DB_USER, password=DB_PASSWORD, port=PG_PORT, connect_timeout=8
)
def _mysql_conn():
import pymysql
return pymysql.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=MYSQL_DB,
port=MYSQL_PORT, connect_timeout=8, autocommit=True,
)
def _mongo_coll():
import pymongo
client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=8000)
return client, client[MONGO_DB]["events"]
def _tag(run_id: str) -> str:
return f"{AGENT_TAG} {run_id}"
# In-memory pools of agent-created primary keys per source. All UPDATE/DELETE
# operate by PK on these (instant), so we never scan the large unindexed
# `notes` column. Bounded by _POOL_CAP; orphaned tagged rows after a restart
# are harmless (clearly marked) and simply not re-tracked.
_pools: dict[str, deque] = {
"postgres": deque(maxlen=_POOL_CAP),
"mysql": deque(maxlen=_POOL_CAP),
"mongodb": deque(maxlen=_POOL_CAP),
}
def _resolve_op(op: str, pool: deque) -> str:
"""UPDATE/DELETE require an existing agent row; otherwise fall back to INSERT."""
if op in ("update", "delete") and not pool:
return "insert"
if op == "insert" and len(pool) >= _POOL_CAP:
return "delete" # keep the agent footprint bounded
return op
def _pg_dml(op: str) -> str:
pool = _pools["postgres"]
op = _resolve_op(op, pool)
conn = _pg_conn()
try:
conn.autocommit = True
with conn.cursor() as cur:
if op == "insert":
rid = uuid.uuid4().hex[:8]
cur.execute(
"""INSERT INTO public.sales_orders
(customer_id, product_id, region, sales_channel, order_ts, amount, currency, order_status, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING order_id""",
(
random.randint(1, 50000), random.randint(1, 2000),
random.choice(REGIONS), random.choice(CHANNELS),
datetime.now(timezone.utc), round(random.uniform(10, 9999), 2),
random.choice(CURRENCIES), random.choice(ORDER_STATUS), _tag(rid),
),
)
oid = cur.fetchone()[0]
pool.append(oid)
return f"INSERT sales_orders order_id={oid} ({_tag(rid)})"
if op == "update":
oid = random.choice(list(pool))
cur.execute(
"UPDATE public.sales_orders SET order_status=%s, amount=round(amount*%s,2) WHERE order_id=%s",
(random.choice(ORDER_STATUS), round(random.uniform(0.9, 1.2), 2), oid),
)
return f"UPDATE sales_orders order_id={oid}"
oid = pool.popleft()
cur.execute("DELETE FROM public.sales_orders WHERE order_id=%s", (oid,))
return f"DELETE sales_orders order_id={oid}"
finally:
conn.close()
def _mysql_dml(op: str) -> str:
pool = _pools["mysql"]
op = _resolve_op(op, pool)
conn = _mysql_conn()
try:
with conn.cursor() as cur:
if op == "insert":
rid = uuid.uuid4().hex[:8]
cur.execute(
"""INSERT INTO hr.employee_events
(employee_id, department, role_name, region, event_type, salary_change, event_ts, notes)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
(
random.randint(1, 20000), random.choice(DEPARTMENTS), random.choice(ROLES),
random.choice(REGIONS), random.choice(EVENT_TYPES),
round(random.uniform(-5000, 15000), 2), datetime.now(timezone.utc), _tag(rid),
),
)
eid = cur.lastrowid
pool.append(eid)
return f"INSERT employee_events event_id={eid} ({_tag(rid)})"
if op == "update":
eid = random.choice(list(pool))
cur.execute(
"UPDATE hr.employee_events SET salary_change=%s, event_type=%s WHERE event_id=%s",
(round(random.uniform(-5000, 15000), 2), random.choice(EVENT_TYPES), eid),
)
return f"UPDATE employee_events event_id={eid}"
eid = pool.popleft()
cur.execute("DELETE FROM hr.employee_events WHERE event_id=%s", (eid,))
return f"DELETE employee_events event_id={eid}"
finally:
conn.close()
def _mongo_dml(op: str) -> str:
pool = _pools["mongodb"]
op = _resolve_op(op, pool)
client, coll = _mongo_coll()
try:
if op == "insert":
rid = uuid.uuid4().hex[:8]
doc = {
"event_id": str(uuid.uuid4()),
"type": random.choice(MONGO_TYPES),
"region": random.choice(REGIONS),
"source": random.choice(MONGO_SOURCES),
"amount": round(random.uniform(10, 50000), 4),
"ts": datetime.now(timezone.utc),
"payload": "X" * 200,
"atc_agent": True,
"agent_run": rid,
}
res = coll.insert_one(doc)
pool.append(res.inserted_id)
return f"INSERT events _id={res.inserted_id} (agent_run={rid})"
if op == "update":
oid = random.choice(list(pool))
coll.update_one(
{"_id": oid},
{"$set": {"type": random.choice(MONGO_TYPES), "amount": round(random.uniform(10, 50000), 4)}},
)
return f"UPDATE events _id={oid}"
oid = pool.popleft()
coll.delete_one({"_id": oid})
return f"DELETE events _id={oid}"
finally:
client.close()
_DISPATCH = {"postgres": _pg_dml, "mysql": _mysql_dml, "mongodb": _mongo_dml}
_SRC_LABEL = {"postgres": "PostgreSQL sales_orders", "mysql": "MySQL employee_events", "mongodb": "MongoDB events"}
def _pick_op() -> str:
# Insert-heavy so a pool of agent rows exists for safe UPDATE/DELETE.
return random.choices(["insert", "update", "delete"], weights=[0.5, 0.3, 0.2], k=1)[0]
async def _run_one(source: str | None = None, op: str | None = None) -> dict[str, Any]:
source = source or random.choice(list(_DISPATCH.keys()))
op = op or _pick_op()
fn = _DISPATCH.get(source)
if not fn:
return {"ok": False, "error": f"unknown source {source}"}
try:
detail = await asyncio.to_thread(fn, op)
_state["ops_total"] += 1
_state["by_source"][source] = _state["by_source"].get(source, 0) + 1
actual_op = detail.split(" ", 1)[0].lower()
if actual_op in _state["by_op"]:
_state["by_op"][actual_op] += 1
_state["last_op"] = {"source": source, "op": op, "detail": detail, "ts": datetime.now(timezone.utc).isoformat()}
_state["last_error"] = None
await _emit(f"[agent-dml] {_SRC_LABEL[source]}: {detail} — Debezium will capture this change", "info")
return {"ok": True, "source": source, "op": op, "detail": detail}
except Exception as exc:
_state["last_error"] = str(exc)
await _emit(f"[agent-dml] {_SRC_LABEL.get(source, source)}: operation failed: {str(exc)[:120]}", "err")
return {"ok": False, "source": source, "op": op, "error": str(exc)}
async def agent_dml_loop() -> None:
"""Background loop: continuously perform guard-railed DML on the sources."""
_state["started"] = True
await asyncio.sleep(8) # let the app settle / DB reachable
await _emit("[agent-dml] Autonomous DML agent online — generating live changes for Debezium", "info")
while True:
try:
if _state["enabled"]:
await _run_one()
except Exception as exc: # never let the loop die
_state["last_error"] = str(exc)
await asyncio.sleep(max(5.0, float(_state["interval"])))
async def etl_agent_loop() -> None:
"""Background loop: ETL-agents autonomously trigger data movements and log
each run (rows, duration, status) to the feed."""
_etl_state["started"] = True
await asyncio.sleep(30) # let the platform settle
while True:
try:
if _etl_state["enabled"] and _etl_state["rotation"]:
from movements import trigger_and_watch, MOVEMENT_BY_ID
mid = _etl_state["rotation"][_etl_state["idx"] % len(_etl_state["rotation"])]
_etl_state["idx"] += 1
if mid in MOVEMENT_BY_ID:
result = await trigger_and_watch(mid, autonomous=True)
_etl_state["runs_total"] += 1
_etl_state["last"] = {"movement_id": mid, "state": result.get("state"),
"rows": result.get("rows"), "duration_s": result.get("duration_s"),
"ts": datetime.now(timezone.utc).isoformat()}
except Exception as exc:
_etl_state["last"] = {"error": str(exc), "ts": datetime.now(timezone.utc).isoformat()}
await asyncio.sleep(max(30.0, float(_etl_state["interval"])))
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/status")
async def status() -> JSONResponse:
pools = {k: len(v) for k, v in _pools.items()}
return JSONResponse({"ok": True, "pools": pools, "etl": _etl_state, **_state})
@router.post("/etl/toggle")
async def etl_toggle(body: dict[str, Any] = Body(default={})) -> JSONResponse:
if "enabled" in body:
_etl_state["enabled"] = bool(body["enabled"])
else:
_etl_state["enabled"] = not _etl_state["enabled"]
if "interval" in body:
try:
_etl_state["interval"] = max(30.0, float(body["interval"]))
except (TypeError, ValueError):
pass
await _emit(f"[etl-agent] Autonomous ETL movements {'ENABLED' if _etl_state['enabled'] else 'PAUSED'} by operator", "warn")
return JSONResponse({"ok": True, "enabled": _etl_state["enabled"], "interval": _etl_state["interval"]})
@router.post("/toggle")
async def toggle(body: dict[str, Any] = Body(default={})) -> JSONResponse:
if "enabled" in body:
_state["enabled"] = bool(body["enabled"])
else:
_state["enabled"] = not _state["enabled"]
if "interval" in body:
try:
_state["interval"] = max(5.0, float(body["interval"]))
except (TypeError, ValueError):
pass
await _emit(f"[agent-dml] Autonomous DML {'ENABLED' if _state['enabled'] else 'PAUSED'} by operator", "warn")
return JSONResponse({"ok": True, "enabled": _state["enabled"], "interval": _state["interval"]})
@router.post("/run-once")
async def run_once(body: dict[str, Any] = Body(default={})) -> JSONResponse:
source = body.get("source")
op = body.get("op")
result = await _run_one(source=source, op=op)
return JSONResponse(result)