2026-06-27 01:25:08 +02:00
|
|
|
"""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
|
2026-06-27 01:27:56 +02:00
|
|
|
from collections import deque
|
2026-06-27 01:25:08 +02:00
|
|
|
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"))
|
2026-06-27 01:59:35 +02:00
|
|
|
# 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]
|
2026-06-27 01:25:08 +02:00
|
|
|
# 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},
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:59:35 +02:00
|
|
|
_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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:25:08 +02:00
|
|
|
# ── 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
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:54:46 +00:00
|
|
|
async def _term(agent_id: str, message: str, level: str = "info", phase: str = "ops") -> None:
|
|
|
|
|
"""Stream a line to a specific agent terminal (no feed entry)."""
|
|
|
|
|
try:
|
|
|
|
|
from agent_terminal import terminal_log
|
|
|
|
|
await terminal_log(agent_id, message, level=level, phase=phase)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 01:25:08 +02:00
|
|
|
# ── 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}"
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 01:27:56 +02:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:54:46 +00:00
|
|
|
def _pg_dml(op: str) -> dict[str, str]:
|
2026-06-27 01:27:56 +02:00
|
|
|
pool = _pools["postgres"]
|
|
|
|
|
op = _resolve_op(op, pool)
|
2026-06-27 01:25:08 +02:00
|
|
|
conn = _pg_conn()
|
|
|
|
|
try:
|
|
|
|
|
conn.autocommit = True
|
|
|
|
|
with conn.cursor() as cur:
|
2026-06-27 01:27:56 +02:00
|
|
|
if op == "insert":
|
2026-06-27 01:25:08 +02:00
|
|
|
rid = uuid.uuid4().hex[:8]
|
2026-06-29 12:54:46 +00:00
|
|
|
cid, pid = random.randint(1, 50000), random.randint(1, 2000)
|
|
|
|
|
region, channel = random.choice(REGIONS), random.choice(CHANNELS)
|
|
|
|
|
amt, curr, status = round(random.uniform(10, 9999), 2), random.choice(CURRENCIES), random.choice(ORDER_STATUS)
|
2026-06-27 01:25:08 +02:00
|
|
|
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""",
|
2026-06-29 12:54:46 +00:00
|
|
|
(cid, pid, region, channel, datetime.now(timezone.utc), amt, curr, status, _tag(rid)),
|
2026-06-27 01:25:08 +02:00
|
|
|
)
|
|
|
|
|
oid = cur.fetchone()[0]
|
2026-06-27 01:27:56 +02:00
|
|
|
pool.append(oid)
|
2026-06-29 12:54:46 +00:00
|
|
|
sql = (f"INSERT INTO public.sales_orders (customer_id,product_id,region,sales_channel,"
|
|
|
|
|
f"amount,currency,order_status,notes) VALUES ({cid},{pid},'{region}','{channel}',"
|
|
|
|
|
f"{amt},'{curr}','{status}','{_tag(rid)}');")
|
|
|
|
|
return {"op": "insert", "detail": f"INSERT sales_orders order_id={oid} ({_tag(rid)})", "sql": sql}
|
2026-06-27 01:25:08 +02:00
|
|
|
if op == "update":
|
2026-06-27 01:27:56 +02:00
|
|
|
oid = random.choice(list(pool))
|
2026-06-29 12:54:46 +00:00
|
|
|
status, mult = random.choice(ORDER_STATUS), round(random.uniform(0.9, 1.2), 2)
|
2026-06-27 01:25:08 +02:00
|
|
|
cur.execute(
|
2026-06-27 01:27:56 +02:00
|
|
|
"UPDATE public.sales_orders SET order_status=%s, amount=round(amount*%s,2) WHERE order_id=%s",
|
2026-06-29 12:54:46 +00:00
|
|
|
(status, mult, oid),
|
2026-06-27 01:25:08 +02:00
|
|
|
)
|
2026-06-29 12:54:46 +00:00
|
|
|
sql = f"UPDATE public.sales_orders SET order_status='{status}', amount=round(amount*{mult},2) WHERE order_id={oid};"
|
|
|
|
|
return {"op": "update", "detail": f"UPDATE sales_orders order_id={oid}", "sql": sql}
|
2026-06-27 01:27:56 +02:00
|
|
|
oid = pool.popleft()
|
|
|
|
|
cur.execute("DELETE FROM public.sales_orders WHERE order_id=%s", (oid,))
|
2026-06-29 12:54:46 +00:00
|
|
|
return {"op": "delete", "detail": f"DELETE sales_orders order_id={oid}",
|
|
|
|
|
"sql": f"DELETE FROM public.sales_orders WHERE order_id={oid};"}
|
2026-06-27 01:25:08 +02:00
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:54:46 +00:00
|
|
|
def _mysql_dml(op: str) -> dict[str, str]:
|
2026-06-27 01:27:56 +02:00
|
|
|
pool = _pools["mysql"]
|
|
|
|
|
op = _resolve_op(op, pool)
|
2026-06-27 01:25:08 +02:00
|
|
|
conn = _mysql_conn()
|
|
|
|
|
try:
|
|
|
|
|
with conn.cursor() as cur:
|
2026-06-27 01:27:56 +02:00
|
|
|
if op == "insert":
|
2026-06-27 01:25:08 +02:00
|
|
|
rid = uuid.uuid4().hex[:8]
|
2026-06-29 12:54:46 +00:00
|
|
|
eid_in, dept, role = random.randint(1, 20000), random.choice(DEPARTMENTS), random.choice(ROLES)
|
|
|
|
|
region, etype, sal = random.choice(REGIONS), random.choice(EVENT_TYPES), round(random.uniform(-5000, 15000), 2)
|
2026-06-27 01:25:08 +02:00
|
|
|
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)""",
|
2026-06-29 12:54:46 +00:00
|
|
|
(eid_in, dept, role, region, etype, sal, datetime.now(timezone.utc), _tag(rid)),
|
2026-06-27 01:25:08 +02:00
|
|
|
)
|
2026-06-27 01:27:56 +02:00
|
|
|
eid = cur.lastrowid
|
|
|
|
|
pool.append(eid)
|
2026-06-29 12:54:46 +00:00
|
|
|
sql = (f"INSERT INTO hr.employee_events (employee_id,department,role_name,region,event_type,"
|
|
|
|
|
f"salary_change,notes) VALUES ({eid_in},'{dept}','{role}','{region}','{etype}',{sal},'{_tag(rid)}');")
|
|
|
|
|
return {"op": "insert", "detail": f"INSERT employee_events event_id={eid} ({_tag(rid)})", "sql": sql}
|
2026-06-27 01:25:08 +02:00
|
|
|
if op == "update":
|
2026-06-27 01:27:56 +02:00
|
|
|
eid = random.choice(list(pool))
|
2026-06-29 12:54:46 +00:00
|
|
|
sal, etype = round(random.uniform(-5000, 15000), 2), random.choice(EVENT_TYPES)
|
2026-06-27 01:25:08 +02:00
|
|
|
cur.execute(
|
2026-06-27 01:27:56 +02:00
|
|
|
"UPDATE hr.employee_events SET salary_change=%s, event_type=%s WHERE event_id=%s",
|
2026-06-29 12:54:46 +00:00
|
|
|
(sal, etype, eid),
|
2026-06-27 01:25:08 +02:00
|
|
|
)
|
2026-06-29 12:54:46 +00:00
|
|
|
sql = f"UPDATE hr.employee_events SET salary_change={sal}, event_type='{etype}' WHERE event_id={eid};"
|
|
|
|
|
return {"op": "update", "detail": f"UPDATE employee_events event_id={eid}", "sql": sql}
|
2026-06-27 01:27:56 +02:00
|
|
|
eid = pool.popleft()
|
|
|
|
|
cur.execute("DELETE FROM hr.employee_events WHERE event_id=%s", (eid,))
|
2026-06-29 12:54:46 +00:00
|
|
|
return {"op": "delete", "detail": f"DELETE employee_events event_id={eid}",
|
|
|
|
|
"sql": f"DELETE FROM hr.employee_events WHERE event_id={eid};"}
|
2026-06-27 01:25:08 +02:00
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:54:46 +00:00
|
|
|
def _mongo_dml(op: str) -> dict[str, str]:
|
2026-06-27 01:27:56 +02:00
|
|
|
pool = _pools["mongodb"]
|
|
|
|
|
op = _resolve_op(op, pool)
|
2026-06-27 01:25:08 +02:00
|
|
|
client, coll = _mongo_coll()
|
|
|
|
|
try:
|
2026-06-27 01:27:56 +02:00
|
|
|
if op == "insert":
|
2026-06-27 01:25:08 +02:00
|
|
|
rid = uuid.uuid4().hex[:8]
|
2026-06-29 12:54:46 +00:00
|
|
|
mtype, region, msrc = random.choice(MONGO_TYPES), random.choice(REGIONS), random.choice(MONGO_SOURCES)
|
|
|
|
|
amt = round(random.uniform(10, 50000), 4)
|
2026-06-27 01:25:08 +02:00
|
|
|
doc = {
|
2026-06-29 12:54:46 +00:00
|
|
|
"event_id": str(uuid.uuid4()), "type": mtype, "region": region, "source": msrc,
|
|
|
|
|
"amount": amt, "ts": datetime.now(timezone.utc), "payload": "X" * 200,
|
|
|
|
|
"atc_agent": True, "agent_run": rid,
|
2026-06-27 01:25:08 +02:00
|
|
|
}
|
|
|
|
|
res = coll.insert_one(doc)
|
2026-06-27 01:27:56 +02:00
|
|
|
pool.append(res.inserted_id)
|
2026-06-29 12:54:46 +00:00
|
|
|
sql = (f"db.events.insertOne({{type:'{mtype}', region:'{region}', source:'{msrc}', "
|
|
|
|
|
f"amount:{amt}, atc_agent:true, agent_run:'{rid}'}})")
|
|
|
|
|
return {"op": "insert", "detail": f"INSERT events _id={res.inserted_id} (agent_run={rid})", "sql": sql}
|
2026-06-27 01:25:08 +02:00
|
|
|
if op == "update":
|
2026-06-27 01:27:56 +02:00
|
|
|
oid = random.choice(list(pool))
|
2026-06-29 12:54:46 +00:00
|
|
|
mtype, amt = random.choice(MONGO_TYPES), round(random.uniform(10, 50000), 4)
|
|
|
|
|
coll.update_one({"_id": oid}, {"$set": {"type": mtype, "amount": amt}})
|
|
|
|
|
sql = f"db.events.updateOne({{_id:{oid!r}}}, {{$set:{{type:'{mtype}', amount:{amt}}}}})"
|
|
|
|
|
return {"op": "update", "detail": f"UPDATE events _id={oid}", "sql": sql}
|
2026-06-27 01:27:56 +02:00
|
|
|
oid = pool.popleft()
|
|
|
|
|
coll.delete_one({"_id": oid})
|
2026-06-29 12:54:46 +00:00
|
|
|
return {"op": "delete", "detail": f"DELETE events _id={oid}", "sql": f"db.events.deleteOne({{_id:{oid!r}}})"}
|
2026-06-27 01:25:08 +02:00
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 12:54:46 +00:00
|
|
|
_CLIENT_CMD = {"postgres": "psql sales", "mysql": "mysql hr", "mongodb": "mongosh supplychain"}
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 01:25:08 +02:00
|
|
|
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:
|
2026-06-29 12:54:46 +00:00
|
|
|
# Show the operator exactly what the Data Custodian is about to run.
|
|
|
|
|
await _term(DML_AGENT, f"$ {_CLIENT_CMD.get(source, source)} # autonomous DML on {_SRC_LABEL[source]}",
|
|
|
|
|
level="cmd", phase="dml")
|
|
|
|
|
res = await asyncio.to_thread(fn, op)
|
|
|
|
|
detail, sql, actual_op = res["detail"], res["sql"], res["op"]
|
|
|
|
|
await _term(DML_AGENT, f" {sql}", level="cmd", phase="dml")
|
2026-06-27 01:25:08 +02:00
|
|
|
_state["ops_total"] += 1
|
|
|
|
|
_state["by_source"][source] = _state["by_source"].get(source, 0) + 1
|
|
|
|
|
if actual_op in _state["by_op"]:
|
|
|
|
|
_state["by_op"][actual_op] += 1
|
2026-06-29 12:54:46 +00:00
|
|
|
_state["last_op"] = {"source": source, "op": actual_op, "detail": detail, "ts": datetime.now(timezone.utc).isoformat()}
|
2026-06-27 01:25:08 +02:00
|
|
|
_state["last_error"] = None
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term(DML_AGENT, f" ← {detail} · Debezium CDC will stream this to Kafka", level="ok", phase="dml")
|
|
|
|
|
# Keep the supervisor feed concise (single summary entry).
|
2026-06-27 01:25:08 +02:00
|
|
|
await _emit(f"[agent-dml] {_SRC_LABEL[source]}: {detail} — Debezium will capture this change", "info")
|
2026-06-29 12:54:46 +00:00
|
|
|
return {"ok": True, "source": source, "op": actual_op, "detail": detail}
|
2026-06-27 01:25:08 +02:00
|
|
|
except Exception as exc:
|
|
|
|
|
_state["last_error"] = str(exc)
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term(DML_AGENT, f" ✗ {source} {op} failed: {str(exc)[:140]}", level="err", phase="dml")
|
2026-06-27 01:25:08 +02:00
|
|
|
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"])))
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 01:59:35 +02:00
|
|
|
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:
|
2026-06-29 12:54:46 +00:00
|
|
|
mv = MOVEMENT_BY_ID[mid]
|
|
|
|
|
await _term("etl-guardian",
|
|
|
|
|
f"$ orchestrate movement '{mid}' ({mv.get('label')}) {mv.get('from')}→{mv.get('to')}",
|
|
|
|
|
level="cmd", phase="orchestrate")
|
2026-06-27 01:59:35 +02:00
|
|
|
result = await trigger_and_watch(mid, autonomous=True)
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term("etl-guardian",
|
|
|
|
|
f" ← {mv.get('label')}: {result.get('state')} · {result.get('rows')} rows in {result.get('duration_s')}s",
|
|
|
|
|
level="ok" if result.get("ok") else "err", phase="orchestrate")
|
2026-06-27 01:59:35 +02:00
|
|
|
_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"])))
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 19:37:50 +00:00
|
|
|
|
|
|
|
|
# ── Custodian Hadoop offload (batch counterpart to CDC) ─────────────────────
|
|
|
|
|
_CUST_INTERVAL = float(os.getenv("CUSTODIAN_OFFLOAD_INTERVAL_SECONDS", "120"))
|
|
|
|
|
_CUST_BATCH = int(os.getenv("CUSTODIAN_OFFLOAD_BATCH", "200"))
|
|
|
|
|
_CUST_TARGETS = [
|
|
|
|
|
{"label": "postgres sales_orders", "src": "postgres_sales.public.sales_orders",
|
2026-07-21 23:20:24 +00:00
|
|
|
"target": "iceberg.hadoop.sales_orders_offload",
|
|
|
|
|
"create_sql": (
|
|
|
|
|
"CREATE TABLE iceberg.hadoop.sales_orders_offload AS "
|
|
|
|
|
"SELECT * FROM postgres_sales.public.sales_orders WHERE 1=0"
|
|
|
|
|
),
|
|
|
|
|
"insert_sql": (
|
|
|
|
|
"INSERT INTO iceberg.hadoop.sales_orders_offload "
|
|
|
|
|
"SELECT * FROM postgres_sales.public.sales_orders LIMIT {batch}"
|
|
|
|
|
)},
|
2026-06-27 19:37:50 +00:00
|
|
|
{"label": "mysql employee_events", "src": "mysql_hr.hr.employee_events",
|
2026-07-21 23:20:24 +00:00
|
|
|
"target": "iceberg.hadoop.employee_events_offload",
|
|
|
|
|
"create_sql": (
|
|
|
|
|
"CREATE TABLE iceberg.hadoop.employee_events_offload AS SELECT "
|
|
|
|
|
"event_id, employee_id, department, role_name, region, event_type, "
|
|
|
|
|
"CAST(salary_change AS double) AS salary_change, "
|
|
|
|
|
"CAST(event_ts AS timestamp(6)) AS event_ts, notes, "
|
|
|
|
|
"employee_name, employee_email, employee_phone, national_id, home_address, "
|
|
|
|
|
"CAST(date_of_birth AS date) AS date_of_birth "
|
|
|
|
|
"FROM mysql_hr.hr.employee_events WHERE 1=0"
|
|
|
|
|
),
|
|
|
|
|
"insert_sql": (
|
|
|
|
|
"INSERT INTO iceberg.hadoop.employee_events_offload SELECT "
|
|
|
|
|
"event_id, employee_id, department, role_name, region, event_type, "
|
|
|
|
|
"CAST(salary_change AS double), CAST(event_ts AS timestamp(6)), notes, "
|
|
|
|
|
"employee_name, employee_email, employee_phone, national_id, home_address, "
|
|
|
|
|
"CAST(date_of_birth AS date) "
|
|
|
|
|
"FROM mysql_hr.hr.employee_events LIMIT {batch}"
|
|
|
|
|
)},
|
2026-06-27 19:37:50 +00:00
|
|
|
]
|
2026-07-21 23:20:24 +00:00
|
|
|
|
2026-06-27 19:37:50 +00:00
|
|
|
_custodian_state: dict[str, Any] = {
|
|
|
|
|
"enabled": os.getenv("CUSTODIAN_OFFLOAD_ENABLED", "1") not in ("0", "false", "False", ""),
|
|
|
|
|
"interval": _CUST_INTERVAL,
|
|
|
|
|
"targets": [c["target"] for c in _CUST_TARGETS],
|
|
|
|
|
"idx": 0,
|
|
|
|
|
"runs_total": 0,
|
|
|
|
|
"last": None,
|
|
|
|
|
"started": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _custodian_offload_once(idx: int | None = None) -> dict[str, Any]:
|
|
|
|
|
"""Offload a batch of source rows into the Hadoop Iceberg lake via Trino."""
|
|
|
|
|
from spark_workbench import _trino_collect
|
|
|
|
|
i = _custodian_state["idx"] if idx is None else idx
|
|
|
|
|
tgt = _CUST_TARGETS[i % len(_CUST_TARGETS)]
|
|
|
|
|
_custodian_state["idx"] = i + 1
|
2026-07-21 23:20:24 +00:00
|
|
|
await _trino_collect("CREATE SCHEMA IF NOT EXISTS iceberg.hadoop", 1)
|
|
|
|
|
probe = await _trino_collect(f"SELECT 1 FROM {tgt['target']} WHERE 1=0", 1)
|
|
|
|
|
if not probe.get("ok"):
|
|
|
|
|
ddl = tgt["create_sql"]
|
|
|
|
|
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']} → {tgt['target']}",
|
|
|
|
|
level="cmd", phase="offload")
|
|
|
|
|
await _term(DML_AGENT, f" {ddl};", level="cmd", phase="offload")
|
|
|
|
|
created = await _trino_collect(ddl, 1)
|
|
|
|
|
if not created.get("ok"):
|
|
|
|
|
err = created.get("error")
|
|
|
|
|
await _term(DML_AGENT, f" ✗ offload failed: {str(err)[:140]}", level="err", phase="offload")
|
|
|
|
|
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(err)[:120]}", "err")
|
|
|
|
|
_custodian_state["runs_total"] += 1
|
|
|
|
|
_custodian_state["last"] = {
|
|
|
|
|
"target": tgt["target"], "src": tgt["src"], "ok": False,
|
|
|
|
|
"rows": 0, "ts": datetime.now(timezone.utc).isoformat(), "error": err,
|
|
|
|
|
}
|
|
|
|
|
return _custodian_state["last"]
|
|
|
|
|
else:
|
|
|
|
|
await _term(DML_AGENT, f"$ trino --catalog iceberg # Hadoop offload: {tgt['label']} → {tgt['target']}",
|
|
|
|
|
level="cmd", phase="offload")
|
|
|
|
|
dml = tgt["insert_sql"].format(batch=_CUST_BATCH)
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term(DML_AGENT, f" {dml};", level="cmd", phase="offload")
|
|
|
|
|
ins = await _trino_collect(dml, 1)
|
2026-06-27 19:37:50 +00:00
|
|
|
ok = bool(ins.get("ok"))
|
|
|
|
|
_custodian_state["runs_total"] += 1
|
|
|
|
|
_custodian_state["last"] = {
|
|
|
|
|
"target": tgt["target"], "src": tgt["src"], "ok": ok,
|
|
|
|
|
"rows": _CUST_BATCH if ok else 0,
|
|
|
|
|
"ts": datetime.now(timezone.utc).isoformat(), "error": ins.get("error"),
|
|
|
|
|
}
|
|
|
|
|
if ok:
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term(DML_AGENT, f" ← offloaded ~{_CUST_BATCH} rows into the Hadoop Iceberg lake", level="ok", phase="offload")
|
2026-06-27 19:37:50 +00:00
|
|
|
await _emit(f"[custodian-offload] {tgt['label']} → {tgt['target']}: offloaded ~{_CUST_BATCH} rows to Hadoop", "info")
|
|
|
|
|
else:
|
2026-06-29 12:54:46 +00:00
|
|
|
await _term(DML_AGENT, f" ✗ offload failed: {str(ins.get('error'))[:140]}", level="err", phase="offload")
|
2026-06-27 19:37:50 +00:00
|
|
|
await _emit(f"[custodian-offload] {tgt['label']} failed: {str(ins.get('error'))[:120]}", "err")
|
|
|
|
|
return _custodian_state["last"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def custodian_offload_loop() -> None:
|
|
|
|
|
_custodian_state["started"] = True
|
|
|
|
|
await asyncio.sleep(45)
|
|
|
|
|
await _emit("[custodian-offload] Autonomous Hadoop offload online — batching source data into the Iceberg lake", "info")
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
if _custodian_state["enabled"]:
|
|
|
|
|
await _custodian_offload_once()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
_custodian_state["last"] = {"error": str(exc), "ts": datetime.now(timezone.utc).isoformat()}
|
|
|
|
|
await asyncio.sleep(max(30.0, float(_custodian_state["interval"])))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def custodian_recent() -> bool:
|
|
|
|
|
last = _custodian_state.get("last") or {}
|
|
|
|
|
ts = last.get("ts")
|
|
|
|
|
if not ts or not last.get("ok"):
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
from datetime import datetime as _dt
|
|
|
|
|
t = _dt.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
|
|
|
window = max(60.0, float(_custodian_state["interval"]) * 1.5)
|
|
|
|
|
return (datetime.now(timezone.utc) - t).total_seconds() < window
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-06-27 01:25:08 +02:00
|
|
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
|
|
|
@router.get("/status")
|
|
|
|
|
async def status() -> JSONResponse:
|
2026-06-27 01:27:56 +02:00
|
|
|
pools = {k: len(v) for k, v in _pools.items()}
|
2026-06-27 19:37:50 +00:00
|
|
|
return JSONResponse({"ok": True, "pools": pools, "etl": _etl_state, "custodian": _custodian_state, **_state})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/custodian/toggle")
|
|
|
|
|
async def custodian_toggle(body: dict[str, Any] = Body(default={})) -> JSONResponse:
|
|
|
|
|
if "enabled" in body:
|
|
|
|
|
_custodian_state["enabled"] = bool(body["enabled"])
|
|
|
|
|
else:
|
|
|
|
|
_custodian_state["enabled"] = not _custodian_state["enabled"]
|
|
|
|
|
if "interval" in body:
|
|
|
|
|
try:
|
|
|
|
|
_custodian_state["interval"] = max(30.0, float(body["interval"]))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
pass
|
|
|
|
|
await _emit(f"[custodian-offload] Hadoop offload {'ENABLED' if _custodian_state['enabled'] else 'PAUSED'} by operator", "warn")
|
|
|
|
|
return JSONResponse({"ok": True, "enabled": _custodian_state["enabled"], "interval": _custodian_state["interval"]})
|
2026-06-27 01:59:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"]})
|
2026-06-27 01:25:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|