demoprep / tests /ts_table_perf.py
mikeboone's picture
perf(deploy): scope TS connection to demo DB + monthly-rotating database (250s -> <1s table imports)
21ee86d
Raw
History Blame Contribute Delete
26.4 kB
#!/usr/bin/env python3
"""
tests/ts_table_perf.py — ThoughtSpot table-create performance harness.
WHY
Creating a ThoughtSpot table (TML import) in the app has gone from ~2 min to
sometimes ~10 min, with heavy polling. This harness isolates and TIMES the
create path against OUR OWN dedicated connection so we can find the pattern
behind the slowness — without touching a real demo connection.
WHAT IT DOES (faithful to the app's real code path)
- Authenticates via the real ThoughtSpotDeployer (trusted auth).
- Creates OUR OWN connection (kept, labelled DONOTDELETE).
- Ensures a Snowflake schema (default DEMOBUILD.ZPERFTEST) + one EMPTY
physical table per logical table (0 rows — instant to create).
- Creates N logical tables, EACH WITH A DIFFERENT NAMING CONVENTION, using
the app's real ThoughtSpotDeployer.create_table_tml(), and imports each
via the SAME call the app makes:
POST /api/rest/2.0/metadata/tml/import
{metadata_tmls, import_policy: PARTIAL, create_new}, timeout=360
(mirrors thoughtspot_deployer.py deploy_all._import_tml_chunk, ~line 2793).
- Times, per table: physical-create, logical import (HTTP), the full
connection scan (search_logical_tables_for_connection — include_details,
record_size=-1: the prime suspect for "feeds through all of those"),
and visibility. Plus a bonus re-create ("already exists") probe.
- KEEPS everything by default (this is a volume/naming sandbox).
RESEARCH-INFORMED CONDITIONS (ThoughtSpot docs)
- Unique/random names avoid the same-name ambiguity the docs say slows TML
*validation*; common words (PRODUCTS) and a table name that equals a
column name trigger it. Including the connection `fqn` reduces ambiguity.
Sources noted in the accompanying chat/report.
- --import-mode async uses /api/rest/2.0/metadata/tml/async/import — the
documented fix for import timeouts. Default is sync, to match the app.
SAFETY
- Refuses production (se-thoughtspot-cloud) unless --allow-prod.
- All objects are prefixed and live only in the ZPERFTEST schema.
- Prints a plan and EXECUTES ONLY with --yes (otherwise it's a dry preview).
- Nothing is deleted unless you pass --cleanup.
USAGE
source ./demoprep/bin/activate
python tests/ts_table_perf.py --env "sebe - se" # dry preview
python tests/ts_table_perf.py --env "sebe - se" --yes # execute
python tests/ts_table_perf.py --env "sebe - se" --cleanup --yes
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import statistics
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
import yaml
from dotenv import load_dotenv
# Repo root on path so we can import the real app modules.
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
load_dotenv(REPO_ROOT / ".env")
from thoughtspot_deployer import ThoughtSpotDeployer # noqa: E402
from snowflake_auth import get_snowflake_connection # noqa: E402
RESULTS_DIR = REPO_ROOT / "tests" / "perf_results"
# --------------------------------------------------------------------------
# TS environment resolution (mirrors chat_interface.get_ts_env_* — inlined so
# we don't import the Gradio app just to read three env vars).
# --------------------------------------------------------------------------
def resolve_env(label: str) -> tuple[str, str]:
"""Return (base_url, trusted_auth_key) for a configured TS_ENV_* label."""
i = 1
while True:
env_label = os.getenv(f"TS_ENV_{i}_LABEL", "").strip()
if not env_label:
break
if env_label == label:
url = os.getenv(f"TS_ENV_{i}_URL", "").strip().rstrip("/")
key = os.getenv(f"TS_ENV_{i}_KEY_VAR", "").strip()
return url, key
i += 1
raise SystemExit(f"TS environment {label!r} is not configured in .env (TS_ENV_*_LABEL).")
def list_env_labels() -> list[str]:
labels, i = [], 1
while True:
env_label = os.getenv(f"TS_ENV_{i}_LABEL", "").strip()
if not env_label:
break
labels.append(env_label)
i += 1
return labels
def is_prod(base_url: str, label: str) -> bool:
u, l = base_url.lower(), label.lower()
return "se-thoughtspot-cloud" in u or l.startswith("secloud")
# --------------------------------------------------------------------------
# The 10 naming conventions. Each column is {"name", "type"} where type is a
# Snowflake type understood by both Snowflake DDL and deployer._map_data_type.
# `use_fqn=False` deliberately omits the connection fqn from the table TML.
# --------------------------------------------------------------------------
STD_COLUMNS = [
{"name": "ID", "type": "INT"},
{"name": "NAME", "type": "VARCHAR"},
{"name": "CATEGORY", "type": "VARCHAR"},
{"name": "AMOUNT", "type": "NUMBER(18,2)"},
{"name": "CREATED_DATE", "type": "DATE"},
]
def _naming_conditions() -> list[dict]:
"""10 distinct naming conventions, informed by the user + ThoughtSpot docs."""
return [
{"key": "01_random_letters", "name": "QXZJKLMNPR",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "random unique letters — no name ambiguity (expected fast)"},
{"key": "02_xyz_products", "name": "XYZ_PRODUCTS",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "user-requested; uncommon prefix + common suffix"},
{"key": "03_common_word", "name": "PRODUCTS",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "very common word — docs: same-name ambiguity slows validation"},
{"key": "04_name_eq_column", "name": "REVENUE",
"columns": STD_COLUMNS + [{"name": "REVENUE", "type": "NUMBER(18,2)"}],
"use_fqn": True,
"why": "table name == a column name — docs: triggers validation timeout"},
{"key": "05_fqn_included", "name": "PERF_FQN_ON",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "connection fqn included — docs: reduces ambiguity"},
{"key": "06_fqn_omitted", "name": "PERF_FQN_OFF",
"columns": STD_COLUMNS, "use_fqn": False,
"why": "connection fqn omitted (name only) — contrast to #5"},
{"key": "07_very_long_name",
"name": "PERF_VERY_LONG_TABLE_NAME_LENGTH_TEST_ABCDEFGHIJKLMNOP",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "long identifier — does name length affect create time?"},
{"key": "08_short_name", "name": "TB",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "minimal identifier"},
{"key": "09_digits_underscores", "name": "PERF_2026_Q3_METRICS_01",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "digits + underscores mix"},
{"key": "10_app_style_hash", "name": "DEMO_ZPT_A1B2C3",
"columns": STD_COLUMNS, "use_fqn": True,
"why": "mimics the app's real DEMO_XXX_<hash> naming"},
]
# --------------------------------------------------------------------------
# Snowflake helpers — create the schema + one empty physical table per name.
# --------------------------------------------------------------------------
def _sf_type(t: str) -> str:
"""Snowflake DDL column type (NUMBER/INT/VARCHAR/DATE are already valid)."""
return t
def ensure_physical_tables(database: str, schema: str, conditions: list[dict]) -> dict:
"""CREATE SCHEMA + one empty physical table per logical name. Returns timings."""
conn = get_snowflake_connection()
cur = conn.cursor()
timings = {}
try:
cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{database}"."{schema}"')
for c in conditions:
name = c["name"].upper() # create_table_tml uppercases db_table
cols_ddl = ", ".join(f'"{col["name"].upper()}" {_sf_type(col["type"])}'
for col in c["columns"])
ddl = f'CREATE TABLE IF NOT EXISTS "{database}"."{schema}"."{name}" ({cols_ddl})'
t0 = time.time()
cur.execute(ddl)
timings[c["key"]] = round(time.time() - t0, 3)
finally:
cur.close()
conn.close()
return timings
def drop_physical_tables(database: str, schema: str, conditions: list[dict]) -> None:
conn = get_snowflake_connection()
cur = conn.cursor()
try:
for c in conditions:
name = c["name"].upper()
cur.execute(f'DROP TABLE IF EXISTS "{database}"."{schema}"."{name}"')
finally:
cur.close()
conn.close()
# --------------------------------------------------------------------------
# ThoughtSpot import / scan / delete — mirrors the app's real HTTP calls.
# --------------------------------------------------------------------------
def _normalize_import_objects(result):
"""Mirror deploy_all._normalize_tml_import_objects."""
def _one(obj):
if not isinstance(obj, dict):
return obj
if "response" in obj:
return obj
if "status" in obj or "header" in obj:
return {"response": obj}
return obj
if isinstance(result, list):
return [_one(o) for o in result]
if isinstance(result, dict) and "object" in result:
return [_one(o) for o in result["object"]]
return None
def import_one_table(deployer: ThoughtSpotDeployer, tml: str, mode: str) -> dict:
"""Import a single table TML the way the app does; time it and read status.
Mirrors thoughtspot_deployer.deploy_all._import_tml_chunk (sync path) and
adds an optional async path (POST .../tml/async/import + /async/status).
"""
base = deployer.base_url
if mode == "async":
return _import_async(deployer, tml)
payload = {"metadata_tmls": [tml], "import_policy": "PARTIAL", "create_new": True}
t0 = time.time()
try:
resp = deployer.session.post(
f"{base}/api/rest/2.0/metadata/tml/import",
json=payload,
timeout=360,
)
except Exception as exc: # network / read timeout
return {"elapsed_s": round(time.time() - t0, 2), "http_status": None,
"gateway_timeout": False, "status_code": "EXCEPTION",
"guid": None, "error": f"{type(exc).__name__}: {exc}"[:400]}
elapsed = round(time.time() - t0, 2)
gateway_timeout = resp.status_code in (502, 503, 504)
result = {"elapsed_s": elapsed, "http_status": resp.status_code,
"gateway_timeout": gateway_timeout, "status_code": None,
"guid": None, "error": None}
if resp.status_code == 200:
objs = _normalize_import_objects(resp.json())
obj = (objs or [{}])[0]
r = obj.get("response", {}) if isinstance(obj, dict) else {}
status = r.get("status", {})
header = r.get("header", {})
result["status_code"] = status.get("status_code")
result["guid"] = header.get("id_guid")
if status.get("error_message"):
result["error"] = str(status["error_message"])[:400]
else:
result["error"] = resp.text[:400]
return result
def _import_async(deployer: ThoughtSpotDeployer, tml: str) -> dict:
"""Documented fix path: async import + poll status. Timed end-to-end."""
base = deployer.base_url
t0 = time.time()
try:
resp = deployer.session.post(
f"{base}/api/rest/2.0/metadata/tml/async/import",
json={"metadata_tmls": [tml], "import_policy": "PARTIAL", "create_new": True},
timeout=60,
)
except Exception as exc:
return {"elapsed_s": round(time.time() - t0, 2), "http_status": None,
"gateway_timeout": False, "status_code": "EXCEPTION",
"guid": None, "error": f"{type(exc).__name__}: {exc}"[:400]}
if resp.status_code not in (200, 202):
return {"elapsed_s": round(time.time() - t0, 2), "http_status": resp.status_code,
"gateway_timeout": resp.status_code in (502, 503, 504),
"status_code": "SUBMIT_FAILED", "guid": None, "error": resp.text[:400]}
task_id = (resp.json() or {}).get("task_id") or (resp.json() or {}).get("id")
# Poll status until terminal or 6 min.
deadline = time.time() + 360
last = None
while time.time() < deadline:
try:
s = deployer.session.post(
f"{base}/api/rest/2.0/metadata/tml/async/status",
json={"task_ids": [task_id], "include_import_response": True},
timeout=60,
)
last = s.json() if s.status_code == 200 else s.text[:200]
if s.status_code == 200:
blob = json.dumps(last).upper()
if any(k in blob for k in ('"OK"', "COMPLETED", "SUCCESS", "FAILED", "ERROR")):
break
except Exception:
pass
time.sleep(3)
return {"elapsed_s": round(time.time() - t0, 2), "http_status": resp.status_code,
"gateway_timeout": False, "status_code": "ASYNC_DONE",
"guid": None, "error": None, "async_status": str(last)[:400], "task_id": task_id}
def time_connection_scan(deployer: ThoughtSpotDeployer, conn_guid: str, conn_name: str) -> dict:
"""Time the full-connection detailed scan — the prime slowness suspect.
This is exactly search_logical_tables_for_connection: metadata/search on the
CONNECTION with include_details=True, record_size=-1.
"""
t0 = time.time()
resolved = deployer.search_logical_tables_for_connection(conn_guid, conn_name)
return {"scan_s": round(time.time() - t0, 2), "table_count": len(resolved)}
def delete_logical_table(deployer: ThoughtSpotDeployer, guid: str) -> dict:
"""POST /metadata/delete (the app has no delete today). Timed."""
t0 = time.time()
resp = deployer.session.post(
f"{deployer.base_url}/api/rest/2.0/metadata/delete",
json={"metadata": [{"type": "LOGICAL_TABLE", "identifier": guid}]},
timeout=120,
)
return {"elapsed_s": round(time.time() - t0, 2), "http_status": resp.status_code,
"error": None if resp.status_code in (200, 204) else resp.text[:300]}
# --------------------------------------------------------------------------
# Reporting
# --------------------------------------------------------------------------
def write_reports(stamp: str, meta: dict, rows: list[dict]) -> tuple[Path, Path]:
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
csv_path = RESULTS_DIR / f"{stamp}_ts_table_perf.csv"
md_path = RESULTS_DIR / f"{stamp}_ts_table_perf.md"
fields = ["condition", "logical_name", "use_fqn", "physical_create_s",
"import_s", "http_status", "gateway_timeout", "import_status",
"conn_scan_s", "conn_table_count", "guid", "why", "error"]
with csv_path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
for r in rows:
w.writerow(r)
ok = [r for r in rows if r.get("import_s") is not None]
imports = [r["import_s"] for r in ok if isinstance(r.get("import_s"), (int, float))]
scans = [r["conn_scan_s"] for r in ok if isinstance(r.get("conn_scan_s"), (int, float))]
def _stat(vals):
if not vals:
return "n/a"
vals = sorted(vals)
p95 = vals[min(len(vals) - 1, int(round(0.95 * (len(vals) - 1))))]
return (f"min {min(vals):.2f}s / median {statistics.median(vals):.2f}s / "
f"p95 {p95:.2f}s / max {max(vals):.2f}s")
lines = [
f"# ThoughtSpot table-create perf — {stamp}", "",
f"- Environment: **{meta['env']}** ({meta['base_url']})",
f"- User: {meta['user']}",
f"- Connection: **{meta['connection']}** (`{meta['connection_guid']}`)",
f"- Snowflake: {meta['database']}.{meta['schema']}",
f"- Import mode: **{meta['import_mode']}**",
f"- Connection create time: **{meta['connection_create_s']}s**",
"",
"## Summary", "",
f"- Import time across {len(imports)} tables: {_stat(imports)}",
f"- Connection-scan time (grows with table count): {_stat(scans)}",
f"- Gateway timeouts (502/503/504): "
f"**{sum(1 for r in rows if r.get('gateway_timeout'))}/{len(rows)}**",
]
if meta.get("recreate_probe"):
rp = meta["recreate_probe"]
lines += ["",
f"- **Re-create ('already exists') probe** on `{rp['name']}`: "
f"import {rp['import_s']}s, status {rp['import_status']}, "
f"conn-scan {rp['conn_scan_s']}s"]
lines += ["", "## Per-condition (slowest import first)", "",
"| condition | name | fqn | import s | conn-scan s | http | 504? | status | why |",
"|---|---|---|--:|--:|--:|:-:|---|---|"]
for r in sorted(rows, key=lambda x: (x.get("import_s") or -1), reverse=True):
lines.append(
f"| {r['condition']} | `{r['logical_name']}` | {'y' if r['use_fqn'] else 'n'} "
f"| {r.get('import_s')} | {r.get('conn_scan_s')} | {r.get('http_status')} "
f"| {'⚠️' if r.get('gateway_timeout') else ''} | {r.get('import_status')} "
f"| {r.get('why','')} |")
md_path.write_text("\n".join(lines) + "\n")
return csv_path, md_path
def print_console(rows: list[dict], meta: dict) -> None:
print("\n" + "=" * 78)
print(f"RESULTS — {meta['env']} — connection {meta['connection']} "
f"(created in {meta['connection_create_s']}s)")
print("=" * 78)
hdr = f"{'condition':22} {'import_s':>9} {'scan_s':>8} {'http':>5} {'504':>4} {'status':>8}"
print(hdr)
print("-" * len(hdr))
for r in sorted(rows, key=lambda x: (x.get("import_s") or -1), reverse=True):
print(f"{r['condition']:22} {str(r.get('import_s')):>9} {str(r.get('conn_scan_s')):>8} "
f"{str(r.get('http_status')):>5} {'yes' if r.get('gateway_timeout') else '':>4} "
f"{str(r.get('import_status')):>8}")
print("-" * len(hdr))
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(description="ThoughtSpot table-create perf harness")
ap.add_argument("--env", required=True, help="TS environment label (see .env TS_ENV_*_LABEL)")
ap.add_argument("--connection-name", default="ZPERFTEST_DONOTDELETE",
help="Name of the dedicated test connection to create/reuse")
ap.add_argument("--database", default="DEMOBUILD")
ap.add_argument("--schema", default="ZPERFTEST")
ap.add_argument("--import-mode", choices=["sync", "async"], default="sync",
help="sync = /metadata/tml/import (matches app); async = documented fix")
ap.add_argument("--recreate-probe", action="store_true", default=True,
help="After the 10, re-create table #1 to time the 'already exists' path")
ap.add_argument("--no-recreate-probe", dest="recreate_probe", action="store_false")
ap.add_argument("--cleanup", action="store_true",
help="Delete the logical + physical test tables at the end")
ap.add_argument("--drop-connection", action="store_true",
help="With --cleanup, also delete the test connection")
ap.add_argument("--allow-prod", action="store_true", help="Permit running against prod")
ap.add_argument("--yes", action="store_true", help="Execute (without this it's a dry preview)")
args = ap.parse_args()
base_url, key = resolve_env(args.env)
if not base_url or not key:
raise SystemExit(f"Environment {args.env!r} missing URL or trusted-auth key in .env.\n"
f"Configured: {list_env_labels()}")
user = os.getenv("TEST_USER") or os.getenv("THOUGHTSPOT_USERNAME")
if not user:
raise SystemExit("No user identity — set TEST_USER in .env.")
if is_prod(base_url, args.env) and not args.allow_prod:
raise SystemExit(f"REFUSING prod ({base_url}). This creates/keeps objects. "
f"Pass --allow-prod to override (not recommended).")
conditions = _naming_conditions()
# -------- Plan (always printed) --------
print("=" * 78)
print("ThoughtSpot table-create PERFORMANCE harness — PLAN")
print("=" * 78)
print(f" Environment : {args.env} ({base_url})")
print(f" User : {user}")
print(f" Connection : {args.connection_name} (KEPT unless --drop-connection)")
print(f" Snowflake target : {args.database}.{args.schema}")
print(f" Import mode : {args.import_mode}")
print(f" Tables ({len(conditions)}), one per naming convention:")
for c in conditions:
print(f" - {c['key']:22} name={c['name']!r:56} fqn={c['use_fqn']} # {c['why']}")
print(f" Re-create probe : {args.recreate_probe}")
print(f" Cleanup at end : {args.cleanup} (drop connection: {args.drop_connection})")
if not args.yes:
print("\nDRY PREVIEW — nothing created. Re-run with --yes to execute.")
return 0
# -------- Execute --------
print("\n▶ Authenticating…")
deployer = ThoughtSpotDeployer(base_url=base_url, username=user, secret_key=key)
if not deployer.authenticate():
raise SystemExit(f"Auth failed: {deployer.last_auth_error}")
print(" ✅ authenticated")
print(f"▶ Ensuring physical tables in {args.database}.{args.schema}…")
phys_timings = ensure_physical_tables(args.database, args.schema, conditions)
print(f" ✅ {len(phys_timings)} physical tables ready "
f"(max {max(phys_timings.values()):.2f}s)")
print(f"▶ Creating our own connection {args.connection_name!r}…")
t0 = time.time()
conn_guid, conn_fqn = deployer.create_connection_with_reconcile(
args.connection_name, args.database, log_progress=lambda m: print(f" {m}"))
conn_create_s = round(time.time() - t0, 2)
if not conn_guid:
raise SystemExit("Connection creation failed — see log above.")
print(f" ✅ connection guid={conn_guid} fqn={conn_fqn} ({conn_create_s}s)")
meta = {
"env": args.env, "base_url": base_url, "user": user,
"connection": args.connection_name, "connection_guid": conn_guid,
"database": args.database, "schema": args.schema,
"import_mode": args.import_mode, "connection_create_s": conn_create_s,
}
rows: list[dict] = []
created_guids: list[str] = []
for i, c in enumerate(conditions, 1):
print(f"\n[{i}/{len(conditions)}] {c['key']} name={c['name']!r}")
tml = deployer.create_table_tml(
c["name"], c["columns"], args.connection_name,
args.database, args.schema,
all_tables=None, foreign_keys=None,
connection_fqn=conn_fqn if c["use_fqn"] else None,
)
imp = import_one_table(deployer, tml, args.import_mode)
scan = time_connection_scan(deployer, conn_guid, args.connection_name)
if imp.get("guid"):
created_guids.append(imp["guid"])
print(f" import={imp['elapsed_s']}s http={imp['http_status']} "
f"status={imp['status_code']} 504={imp['gateway_timeout']} "
f"conn_scan={scan['scan_s']}s (tables now {scan['table_count']})"
+ (f" ERROR: {imp['error']}" if imp.get("error") else ""))
rows.append({
"condition": c["key"], "logical_name": c["name"], "use_fqn": c["use_fqn"],
"physical_create_s": phys_timings.get(c["key"]),
"import_s": imp["elapsed_s"], "http_status": imp["http_status"],
"gateway_timeout": imp["gateway_timeout"], "import_status": imp["status_code"],
"conn_scan_s": scan["scan_s"], "conn_table_count": scan["table_count"],
"guid": imp.get("guid"), "why": c["why"], "error": imp.get("error"),
})
# -------- Bonus: re-create probe (the 'already exists' path) --------
if args.recreate_probe:
c = conditions[0]
print(f"\n▶ Re-create probe: importing {c['name']!r} AGAIN (create_new) "
f"to time the 'already exists' path…")
tml = deployer.create_table_tml(
c["name"], c["columns"], args.connection_name, args.database, args.schema,
all_tables=None, foreign_keys=None, connection_fqn=conn_fqn)
imp = import_one_table(deployer, tml, args.import_mode)
scan = time_connection_scan(deployer, conn_guid, args.connection_name)
print(f" re-create import={imp['elapsed_s']}s status={imp['status_code']} "
f"conn_scan={scan['scan_s']}s"
+ (f" msg: {imp['error']}" if imp.get("error") else ""))
meta["recreate_probe"] = {"name": c["name"], "import_s": imp["elapsed_s"],
"import_status": imp["status_code"], "conn_scan_s": scan["scan_s"]}
# -------- Reports --------
stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
csv_path, md_path = write_reports(stamp, meta, rows)
print_console(rows, meta)
print(f"\n CSV : {csv_path}")
print(f" MD : {md_path}")
# -------- Cleanup (opt-in) --------
if args.cleanup:
print("\n▶ Cleanup: deleting logical + physical test tables…")
for guid in created_guids:
d = delete_logical_table(deployer, guid)
print(f" delete {guid}: http={d['http_status']} {d['elapsed_s']}s"
+ (f" {d['error']}" if d.get("error") else ""))
drop_physical_tables(args.database, args.schema, conditions)
print(" ✅ physical tables dropped")
if args.drop_connection:
d = delete_logical_table(deployer, conn_guid) # CONNECTION delete via metadata/delete
deployer.session.post(
f"{base_url}/api/rest/2.0/metadata/delete",
json={"metadata": [{"type": "CONNECTION", "identifier": conn_guid}]}, timeout=120)
print(" ✅ connection deleted")
else:
print("\n (kept all objects — pass --cleanup to remove)")
return 0
if __name__ == "__main__":
sys.exit(main())