Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| tests/ts_import_sync_vs_async.py β sync vs async TML import, apples-to-apples. | |
| Compares the app's current SYNCHRONOUS table-import path against the | |
| ThoughtSpot ASYNC import API, on the real two-phase flow the app uses: | |
| Phase 1 create all tables (no joins) create_new=True | |
| Phase 2 re-import the tables WITH joins_with create_new=False (update) | |
| Both modes build a small star schema (SALES fact + PRODUCT/STORE/CUSTOMER | |
| dims, 3 FK joins) via the app's real ThoughtSpotDeployer.create_table_tml(), | |
| so the join wiring is identical to production. Each mode is timed as | |
| WALL-CLOCK-TO-VERIFIED: | |
| - phase 1 done = all 4 tables visible under the connection | |
| - phase 2 done = SALES export contains its 3 joins_with | |
| SYNC : POST /metadata/tml/import (timeout 360; may 504 at the ~300s gateway) | |
| then poll the connection until verified β mirrors deploy_all. | |
| ASYNC : POST /metadata/tml/async/import -> task_id (returns instantly), | |
| then poll /metadata/tml/async/status until completed_at>0. | |
| Everything created is TAGGED 'PERF_TEST_DELETE' and CLEANED UP by default | |
| (all logical tables on the connection are deleted, the Snowflake schema is | |
| dropped, and the connection is deleted). Pass --keep to leave objects. | |
| SAFETY: refuses prod unless --allow-prod. Prints a plan; executes with --yes. | |
| USAGE: | |
| source ./demoprep/bin/activate | |
| python tests/ts_import_sync_vs_async.py --env "sebe - se" # preview | |
| python tests/ts_import_sync_vs_async.py --env "sebe - se" --yes # run+clean | |
| python tests/ts_import_sync_vs_async.py --env "sebe - se" --yes --keep | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| 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" | |
| TAG = "PERF_TEST_DELETE" | |
| DB, SCHEMA = "DEMOBUILD", "ZPERFTEST" | |
| CONNECTION = "ZPERFTEST_DONOTDELETE" # reused if it already exists | |
| POLL_DEADLINE_S = 600 | |
| POLL_INTERVAL_S = 5 | |
| # ---- env resolution (inlined; see chat_interface.get_ts_env_*) -------------- | |
| def resolve_env(label: str) -> tuple[str, str]: | |
| i = 1 | |
| while True: | |
| env_label = os.getenv(f"TS_ENV_{i}_LABEL", "").strip() | |
| if not env_label: | |
| break | |
| if env_label == label: | |
| return (os.getenv(f"TS_ENV_{i}_URL", "").strip().rstrip("/"), | |
| os.getenv(f"TS_ENV_{i}_KEY_VAR", "").strip()) | |
| i += 1 | |
| raise SystemExit(f"TS environment {label!r} not configured in .env") | |
| def is_prod(base_url: str, label: str) -> bool: | |
| return "se-thoughtspot-cloud" in base_url.lower() or label.lower().startswith("secloud") | |
| # ---- star schema builder ---------------------------------------------------- | |
| def build_star(prefix: str) -> tuple[dict, list]: | |
| """Return (tables{name: columns}, foreign_keys[]) for a prefixed star schema.""" | |
| P = prefix | |
| tables = { | |
| f"{P}SALES": [ | |
| {"name": "SALE_ID", "type": "INT"}, | |
| {"name": "PRODUCT_ID", "type": "INT"}, | |
| {"name": "STORE_ID", "type": "INT"}, | |
| {"name": "CUSTOMER_ID", "type": "INT"}, | |
| {"name": "AMOUNT", "type": "NUMBER(18,2)"}, | |
| {"name": "SALE_DATE", "type": "DATE"}, | |
| ], | |
| f"{P}PRODUCT": [ | |
| {"name": "PRODUCT_ID", "type": "INT"}, | |
| {"name": "PRODUCT_NAME", "type": "VARCHAR"}, | |
| {"name": "CATEGORY", "type": "VARCHAR"}, | |
| ], | |
| f"{P}STORE": [ | |
| {"name": "STORE_ID", "type": "INT"}, | |
| {"name": "STORE_NAME", "type": "VARCHAR"}, | |
| {"name": "REGION", "type": "VARCHAR"}, | |
| ], | |
| f"{P}CUSTOMER": [ | |
| {"name": "CUSTOMER_ID", "type": "INT"}, | |
| {"name": "CUSTOMER_NAME", "type": "VARCHAR"}, | |
| {"name": "SEGMENT", "type": "VARCHAR"}, | |
| ], | |
| } | |
| fks = [ | |
| {"from_table": f"{P}SALES", "to_table": f"{P}PRODUCT", | |
| "from_column": "PRODUCT_ID", "to_column": "PRODUCT_ID"}, | |
| {"from_table": f"{P}SALES", "to_table": f"{P}STORE", | |
| "from_column": "STORE_ID", "to_column": "STORE_ID"}, | |
| {"from_table": f"{P}SALES", "to_table": f"{P}CUSTOMER", | |
| "from_column": "CUSTOMER_ID", "to_column": "CUSTOMER_ID"}, | |
| ] | |
| return tables, fks | |
| def ensure_physical(tables: dict) -> None: | |
| conn = get_snowflake_connection() | |
| cur = conn.cursor() | |
| try: | |
| cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{DB}"."{SCHEMA}"') | |
| for name, cols in tables.items(): | |
| ddl_cols = ", ".join(f'"{c["name"].upper()}" {c["type"]}' for c in cols) | |
| cur.execute(f'CREATE TABLE IF NOT EXISTS "{DB}"."{SCHEMA}"."{name.upper()}" ({ddl_cols})') | |
| finally: | |
| cur.close() | |
| conn.close() | |
| # ---- import helpers --------------------------------------------------------- | |
| def _normalize(result): | |
| def _one(o): | |
| if not isinstance(o, dict): | |
| return o | |
| return o if "response" in o else ({"response": o} if ("status" in o or "header" in o) else o) | |
| 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 sync_import_call(dep, tmls, create_new) -> dict: | |
| """One synchronous batch import β mirrors deploy_all._import_tml_chunk.""" | |
| t0 = time.time() | |
| try: | |
| r = dep.session.post(f"{dep.base_url}/api/rest/2.0/metadata/tml/import", | |
| json={"metadata_tmls": tmls, "import_policy": "PARTIAL", | |
| "create_new": create_new}, timeout=360) | |
| except Exception as e: | |
| return {"call_s": round(time.time() - t0, 2), "http": None, "gw": False, "err": str(e)[:300]} | |
| return {"call_s": round(time.time() - t0, 2), "http": r.status_code, | |
| "gw": r.status_code in (502, 503, 504), | |
| "err": None if r.status_code == 200 else r.text[:200]} | |
| def async_import_call(dep, tmls, create_new) -> dict: | |
| """Submit async import and poll status until completed_at>0 or deadline.""" | |
| t0 = time.time() | |
| r = dep.session.post(f"{dep.base_url}/api/rest/2.0/metadata/tml/async/import", | |
| json={"metadata_tmls": tmls, "import_policy": "PARTIAL", | |
| "create_new": create_new}, timeout=60) | |
| submit_s = round(time.time() - t0, 2) | |
| if r.status_code not in (200, 202): | |
| return {"submit_s": submit_s, "complete_s": None, "http": r.status_code, | |
| "task_status": "SUBMIT_FAILED", "err": r.text[:200]} | |
| task_id = (r.json() or {}).get("task_id") | |
| final = None | |
| deadline = time.time() + POLL_DEADLINE_S | |
| while time.time() < deadline: | |
| s = dep.session.post(f"{dep.base_url}/api/rest/2.0/metadata/tml/async/status", | |
| json={"task_ids": [task_id], "include_import_response": True}, timeout=60) | |
| if s.status_code == 200: | |
| sl = ((s.json() or {}).get("status_list") or [{}])[0] | |
| final = sl | |
| if sl.get("completed_at") or sl.get("task_status") in ( | |
| "COMPLETED", "SUCCESS", "FAILED", "ERROR", "PARTIAL_SUCCESS"): | |
| break | |
| time.sleep(POLL_INTERVAL_S) | |
| return {"submit_s": submit_s, "complete_s": round(time.time() - t0, 2), | |
| "http": r.status_code, "task_status": (final or {}).get("task_status"), | |
| "processed": (final or {}).get("object_processed_count"), | |
| "err": None} | |
| # ---- connection scan / verification ----------------------------------------- | |
| def scan(dep, conn_guid) -> dict: | |
| """name -> guid for logical tables under the connection.""" | |
| resolved = dep.search_logical_tables_for_connection(conn_guid, CONNECTION) | |
| return {n: v["response"]["header"]["id_guid"] for n, v in resolved.items()} | |
| def wait_visible(dep, conn_guid, names) -> tuple[float, dict]: | |
| want = {n.upper() for n in names} | |
| t0 = time.time() | |
| deadline = t0 + POLL_DEADLINE_S | |
| while True: | |
| have = scan(dep, conn_guid) | |
| if want <= set(have): | |
| return round(time.time() - t0, 2), have | |
| if time.time() >= deadline: | |
| return round(time.time() - t0, 2), have | |
| time.sleep(POLL_INTERVAL_S) | |
| def joins_on(dep, guid) -> int: | |
| r = dep.session.post(f"{dep.base_url}/api/rest/2.0/metadata/tml/export", | |
| json={"metadata": [{"identifier": guid, "type": "LOGICAL_TABLE"}], | |
| "export_associated": False, "format_type": "YAML"}, timeout=60) | |
| if r.status_code != 200: | |
| return -1 | |
| data = r.json() or [] | |
| if not data or "edoc" not in data[0]: | |
| return -1 | |
| tml = dep._parse_tml_edoc(data[0]["edoc"]) | |
| return len((tml.get("table", {}) or {}).get("joins_with") or []) | |
| def wait_joins(dep, sales_guid, expected) -> tuple[float, int]: | |
| t0 = time.time() | |
| deadline = t0 + POLL_DEADLINE_S | |
| while True: | |
| n = joins_on(dep, sales_guid) | |
| if n >= expected: | |
| return round(time.time() - t0, 2), n | |
| if time.time() >= deadline: | |
| return round(time.time() - t0, 2), n | |
| time.sleep(POLL_INTERVAL_S) | |
| # ---- one full mode run (create + join) -------------------------------------- | |
| def run_mode(dep, conn_guid, conn_fqn, mode, prefix) -> dict: | |
| tables, fks = build_star(prefix) | |
| names = list(tables.keys()) | |
| sales = f"{prefix}SALES" | |
| print(f"\n{'='*70}\nMODE={mode.upper()} prefix={prefix} tables={names}\n{'='*70}") | |
| ensure_physical(tables) | |
| print(" physical tables ready") | |
| # ---- Phase 1: create (no joins) ---- | |
| p1_tmls = [dep.create_table_tml(n, cols, CONNECTION, DB, SCHEMA, | |
| all_tables=None, foreign_keys=fks, connection_fqn=conn_fqn) | |
| for n, cols in tables.items()] | |
| t_phase1 = time.time() | |
| if mode == "sync": | |
| call = sync_import_call(dep, p1_tmls, create_new=True) | |
| print(f" P1 sync call: {call['call_s']}s http={call['http']} 504={call['gw']}") | |
| else: | |
| call = async_import_call(dep, p1_tmls, create_new=True) | |
| print(f" P1 async: submit={call['submit_s']}s complete={call['complete_s']}s " | |
| f"status={call['task_status']}") | |
| vis_s, have = wait_visible(dep, conn_guid, names) | |
| p1_total = round(time.time() - t_phase1, 2) | |
| guids = {n.upper(): have[n.upper()] for n in names if n.upper() in have} | |
| print(f" P1 verified {len(guids)}/{len(names)} tables visible; phase1 total={p1_total}s") | |
| # ---- Phase 2: add joins (update) ---- | |
| p2_tmls, p2_names = [], [] | |
| for n, cols in tables.items(): | |
| g = guids.get(n.upper()) | |
| if not g: | |
| continue | |
| p2_tmls.append(dep.create_table_tml(n, cols, CONNECTION, DB, SCHEMA, | |
| all_tables=tables, table_guid=g, | |
| foreign_keys=fks, connection_fqn=conn_fqn)) | |
| p2_names.append(n.upper()) | |
| t_phase2 = time.time() | |
| if mode == "sync": | |
| call2 = sync_import_call(dep, p2_tmls, create_new=False) | |
| print(f" P2 sync call: {call2['call_s']}s http={call2['http']} 504={call2['gw']}") | |
| else: | |
| call2 = async_import_call(dep, p2_tmls, create_new=False) | |
| print(f" P2 async: submit={call2['submit_s']}s complete={call2['complete_s']}s " | |
| f"status={call2['task_status']}") | |
| join_s, njoins = (0.0, -1) | |
| if sales.upper() in guids: | |
| join_s, njoins = wait_joins(dep, guids[sales.upper()], expected=len(fks)) | |
| p2_total = round(time.time() - t_phase2, 2) | |
| print(f" P2 verified joins on {sales}: {njoins}/{len(fks)}; phase2 total={p2_total}s") | |
| # tag everything PERF_TEST_DELETE | |
| try: | |
| dep.assign_tags_to_objects(list(guids.values()), "LOGICAL_TABLE", TAG) | |
| print(f" tagged {len(guids)} tables '{TAG}'") | |
| except Exception as e: | |
| print(f" tag warning: {e}") | |
| return { | |
| "mode": mode, "prefix": prefix, | |
| "p1_call_s": call.get("call_s"), "p1_submit_s": call.get("submit_s"), | |
| "p1_http": call.get("http"), "p1_gw": call.get("gw", False), | |
| "p1_total_s": p1_total, "p1_tables": f"{len(guids)}/{len(names)}", | |
| "p2_call_s": call2.get("call_s"), "p2_submit_s": call2.get("submit_s"), | |
| "p2_http": call2.get("http"), "p2_gw": call2.get("gw", False), | |
| "p2_total_s": p2_total, "joins": f"{njoins}/{len(fks)}", | |
| "grand_total_s": round(p1_total + p2_total, 2), | |
| "guids": list(guids.values()), | |
| } | |
| # ---- cleanup ---------------------------------------------------------------- | |
| def cleanup(dep, conn_guid, run_guids, drop_sandbox) -> None: | |
| """Delete ONLY this run's throwaway logical tables. The sandbox (connection | |
| + schema + physical tables) is KEPT so we can keep testing β unless | |
| --drop-sandbox is explicitly given.""" | |
| print(f"\n{'='*70}\nCLEANUP (this run's tables only)\n{'='*70}") | |
| print(f" deleting {len(run_guids)} logical tables created by this runβ¦") | |
| for guid in run_guids: | |
| r = dep.session.post(f"{dep.base_url}/api/rest/2.0/metadata/delete", | |
| json={"metadata": [{"type": "LOGICAL_TABLE", "identifier": guid}]}, timeout=120) | |
| print(f" - {guid}: http={r.status_code}") | |
| if not drop_sandbox: | |
| print(f" KEPT sandbox: connection {CONNECTION} + schema {DB}.{SCHEMA} (+ physical tables)") | |
| return | |
| # Full teardown (explicit opt-in only). | |
| try: | |
| conn = get_snowflake_connection() | |
| cur = conn.cursor() | |
| cur.execute(f'DROP SCHEMA IF EXISTS "{DB}"."{SCHEMA}" CASCADE') | |
| cur.close() | |
| conn.close() | |
| print(f" dropped Snowflake schema {DB}.{SCHEMA}") | |
| except Exception as e: | |
| print(f" snowflake drop warning: {e}") | |
| # NOTE: metadata/delete rejects type CONNECTION ("does not exist in | |
| # DeleteMetadatatype enum"). Connections use the dedicated v2 endpoint | |
| # /connection/delete with a singular string `connection_identifier`. | |
| r = dep.session.post(f"{dep.base_url}/api/rest/2.0/connection/delete", | |
| json={"connection_identifier": conn_guid}, timeout=120) | |
| print(f" deleted connection {CONNECTION}: http={r.status_code}") | |
| # ---- reporting -------------------------------------------------------------- | |
| def report(stamp, meta, sync_r, async_r) -> Path: | |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) | |
| md = RESULTS_DIR / f"{stamp}_sync_vs_async.md" | |
| def _speedup(a, b): | |
| return f"{a / b:.1f}Γ" if (a and b) else "n/a" | |
| lines = [ | |
| f"# Sync vs Async TML import β {stamp}", "", | |
| f"- Environment: **{meta['env']}** ({meta['base_url']})", | |
| f"- Connection: {CONNECTION} β’ Snowflake: {DB}.{SCHEMA}", | |
| f"- Flow: 4-table star schema, phase 1 create + phase 2 joins (3 FKs on SALES)", | |
| f"- Timing = wall-clock to VERIFIED (tables visible / joins present)", "", | |
| "## Comparison", "", | |
| "| metric | SYNC | ASYNC | note |", | |
| "|---|--:|--:|---|", | |
| f"| Phase 1 β import call/submit | {sync_r['p1_call_s']}s | {async_r['p1_submit_s']}s " | |
| f"| async submit returns instantly |", | |
| f"| Phase 1 β 504? | {'β οΈ yes' if sync_r['p1_gw'] else 'no'} | " | |
| f"{'β οΈ yes' if async_r['p1_gw'] else 'no'} | sync hits the ~300s gateway wall |", | |
| f"| Phase 1 β total to verified | {sync_r['p1_total_s']}s | {async_r['p1_total_s']}s | |", | |
| f"| Phase 2 β import call/submit | {sync_r['p2_call_s']}s | {async_r['p2_submit_s']}s | |", | |
| f"| Phase 2 β total to verified | {sync_r['p2_total_s']}s | {async_r['p2_total_s']}s | |", | |
| f"| Tables created | {sync_r['p1_tables']} | {async_r['p1_tables']} | |", | |
| f"| Joins verified on SALES | {sync_r['joins']} | {async_r['joins']} | |", | |
| f"| **GRAND TOTAL** | **{sync_r['grand_total_s']}s** | **{async_r['grand_total_s']}s** " | |
| f"| **{_speedup(sync_r['grand_total_s'], async_r['grand_total_s'])} faster** |", | |
| "", "## Raw", "", "```json", | |
| json.dumps({"sync": sync_r, "async": async_r}, indent=2, default=str), "```", "", | |
| ] | |
| md.write_text("\n".join(lines) + "\n") | |
| return md | |
| # ---- main ------------------------------------------------------------------- | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--env", required=True) | |
| ap.add_argument("--allow-prod", action="store_true") | |
| ap.add_argument("--cleanup", action="store_true", | |
| help="Delete THIS run's logical tables (keeps connection + schema + physical)") | |
| ap.add_argument("--drop-sandbox", action="store_true", | |
| help="With --cleanup, also drop the schema and delete the connection") | |
| ap.add_argument("--yes", action="store_true") | |
| args = ap.parse_args() | |
| base_url, key = resolve_env(args.env) | |
| user = os.getenv("TEST_USER") or os.getenv("THOUGHTSPOT_USERNAME") | |
| if not base_url or not key or not user: | |
| raise SystemExit("Missing env URL/key or TEST_USER in .env") | |
| if is_prod(base_url, args.env) and not args.allow_prod: | |
| raise SystemExit(f"REFUSING prod ({base_url}) β pass --allow-prod to override") | |
| print("=" * 70) | |
| print("SYNC vs ASYNC TML import comparison β PLAN") | |
| print("=" * 70) | |
| print(f" Env : {args.env} ({base_url})") | |
| print(f" Connection : {CONNECTION} Snowflake: {DB}.{SCHEMA}") | |
| print(f" Flow : 4-table star schema; P1 create + P2 joins; both sync & async") | |
| print(f" Tag : {TAG} on all objects") | |
| _cl = "KEEP everything (sandbox persists)" | |
| if args.cleanup: | |
| _cl = "delete this run's tables" + ( | |
| " + DROP SANDBOX (schema + connection)" if args.drop_sandbox else " (keep sandbox)") | |
| print(f" Cleanup : {_cl}") | |
| if not args.yes: | |
| print("\nDRY PREVIEW β nothing created. Re-run with --yes.") | |
| return 0 | |
| dep = ThoughtSpotDeployer(base_url=base_url, username=user, secret_key=key) | |
| if not dep.authenticate(): | |
| raise SystemExit(f"Auth failed: {dep.last_auth_error}") | |
| print("\nβ authenticated") | |
| print(f"βΆ Ensuring connection {CONNECTION}β¦") | |
| conn_guid, conn_fqn = dep.create_connection_with_reconcile( | |
| CONNECTION, DB, log_progress=lambda m: print(f" {m}")) | |
| print(f" connection guid={conn_guid}") | |
| try: | |
| dep.assign_tags_to_objects([conn_guid], "DATA_SOURCE", TAG) | |
| except Exception as e: | |
| print(f" connection tag warning: {e}") | |
| # Run ASYNC first (fast) then SYNC (slow), distinct prefixes β clean creates. | |
| async_r = run_mode(dep, conn_guid, conn_fqn, "async", "PT_ASYNC_") | |
| sync_r = run_mode(dep, conn_guid, conn_fqn, "sync", "PT_SYNC_") | |
| stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") | |
| meta = {"env": args.env, "base_url": base_url} | |
| md = report(stamp, meta, sync_r, async_r) | |
| print(f"\n{'='*70}\nCOMPARISON\n{'='*70}") | |
| print(f" SYNC grand total: {sync_r['grand_total_s']}s " | |
| f"(P1 {sync_r['p1_total_s']}s, P2 {sync_r['p2_total_s']}s, 504s: " | |
| f"{int(sync_r['p1_gw']) + int(sync_r['p2_gw'])})") | |
| print(f" ASYNC grand total: {async_r['grand_total_s']}s " | |
| f"(submit P1 {async_r['p1_submit_s']}s, P2 {async_r['p2_submit_s']}s, 504s: 0)") | |
| print(f" Report: {md}") | |
| if args.cleanup: | |
| cleanup(dep, conn_guid, sync_r["guids"] + async_r["guids"], args.drop_sandbox) | |
| else: | |
| print("\n Objects KEPT (connection + schema + tables), tagged PERF_TEST_DELETE.") | |
| print(" Use --cleanup to delete a run's tables; --cleanup --drop-sandbox to remove all.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |