Spaces:
Running
Running
mike boone commited on
Commit ·
7aff9ba
1
Parent(s): c04c93f
fix: harden ThoughtSpot table polling and model readiness
Browse files- tests/test_liveboard_creation_context.py +19 -0
- thoughtspot_deployer.py +155 -26
tests/test_liveboard_creation_context.py
CHANGED
|
@@ -121,6 +121,24 @@ table:
|
|
| 121 |
assert parsed["table"]["connection"]["fqn"] == "conn-guid"
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def test_tml_504_recovery_verifies_state_before_retrying_full_payload():
|
| 125 |
source = inspect.getsource(thoughtspot_deployer.ThoughtSpotDeployer.deploy_all)
|
| 126 |
|
|
@@ -128,3 +146,4 @@ def test_tml_504_recovery_verifies_state_before_retrying_full_payload():
|
|
| 128 |
assert "_verify_table_updates_after_timeout" in source
|
| 129 |
assert "retrying full {retry_kind} payload once" in source
|
| 130 |
assert "connection_fqn=connection_fqn" in source
|
|
|
|
|
|
| 121 |
assert parsed["table"]["connection"]["fqn"] == "conn-guid"
|
| 122 |
|
| 123 |
|
| 124 |
+
def test_metadata_header_parser_accepts_search_response_shape():
|
| 125 |
+
deployer = thoughtspot_deployer.ThoughtSpotDeployer.__new__(
|
| 126 |
+
thoughtspot_deployer.ThoughtSpotDeployer
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
header = deployer._metadata_header(
|
| 130 |
+
{
|
| 131 |
+
"metadata_header": {
|
| 132 |
+
"name": "DATES",
|
| 133 |
+
"id_guid": "table-guid",
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
assert header["name"] == "DATES"
|
| 139 |
+
assert header["id_guid"] == "table-guid"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
def test_tml_504_recovery_verifies_state_before_retrying_full_payload():
|
| 143 |
source = inspect.getsource(thoughtspot_deployer.ThoughtSpotDeployer.deploy_all)
|
| 144 |
|
|
|
|
| 146 |
assert "_verify_table_updates_after_timeout" in source
|
| 147 |
assert "retrying full {retry_kind} payload once" in source
|
| 148 |
assert "connection_fqn=connection_fqn" in source
|
| 149 |
+
assert "wait_for_model_answer_ready" in source
|
thoughtspot_deployer.py
CHANGED
|
@@ -531,6 +531,102 @@ class ThoughtSpotDeployer:
|
|
| 531 |
print(f" ⚠️ Error getting model columns: {e}")
|
| 532 |
return []
|
| 533 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
def parse_ddl(self, ddl: str) -> Tuple[Dict, List]:
|
| 535 |
"""
|
| 536 |
Parse DDL to extract table definitions and foreign key relationships
|
|
@@ -1693,6 +1789,17 @@ class ThoughtSpotDeployer:
|
|
| 1693 |
print(f" ⚠️ Could not check existing connections: {e}")
|
| 1694 |
return None
|
| 1695 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1696 |
def _parse_tml_edoc(self, edoc):
|
| 1697 |
"""Parse ThoughtSpot TML export content whether the API returns YAML, JSON, or a dict."""
|
| 1698 |
if isinstance(edoc, dict):
|
|
@@ -1712,9 +1819,10 @@ class ThoughtSpotDeployer:
|
|
| 1712 |
connection_fqn: str = None) -> Dict:
|
| 1713 |
"""Find an existing ThoughtSpot logical table by name and optional backing table context."""
|
| 1714 |
try:
|
| 1715 |
-
response = self.session.
|
| 1716 |
f"{self.base_url}/api/rest/2.0/metadata/search",
|
| 1717 |
-
|
|
|
|
| 1718 |
"metadata": [{"type": "LOGICAL_TABLE", "identifier": table_name}],
|
| 1719 |
"record_size": 100,
|
| 1720 |
},
|
|
@@ -1725,16 +1833,20 @@ class ThoughtSpotDeployer:
|
|
| 1725 |
candidates = response.json() or []
|
| 1726 |
exact_name = table_name.upper()
|
| 1727 |
for candidate in candidates:
|
|
|
|
| 1728 |
candidate_name = (
|
| 1729 |
candidate.get("metadata_name")
|
| 1730 |
or candidate.get("name")
|
| 1731 |
-
or
|
|
|
|
|
|
|
| 1732 |
or ""
|
| 1733 |
)
|
| 1734 |
candidate_guid = (
|
| 1735 |
candidate.get("metadata_id")
|
| 1736 |
or candidate.get("id_guid")
|
| 1737 |
-
or
|
|
|
|
| 1738 |
)
|
| 1739 |
if candidate_name.upper() != exact_name or not candidate_guid:
|
| 1740 |
continue
|
|
@@ -2670,29 +2782,38 @@ class ThoughtSpotDeployer:
|
|
| 2670 |
chunk_tmls,
|
| 2671 |
create_new,
|
| 2672 |
)
|
| 2673 |
-
if isinstance(result, requests.Response)
|
| 2674 |
-
if
|
| 2675 |
-
|
| 2676 |
-
|
| 2677 |
-
|
| 2678 |
-
|
| 2679 |
-
|
| 2680 |
-
|
| 2681 |
-
|
| 2682 |
-
|
| 2683 |
-
|
| 2684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2685 |
all_objects.update(resolved)
|
| 2686 |
-
|
| 2687 |
-
|
| 2688 |
-
error = (
|
| 2689 |
-
f"{chunk_label} timed out and {len(missing_names)} table(s) "
|
| 2690 |
-
f"could not be verified after full-payload retry: {', '.join(missing_names)}"
|
| 2691 |
-
)
|
| 2692 |
-
_record_import_problem(error, fatal_errors=fatal_errors)
|
| 2693 |
-
if fatal_errors:
|
| 2694 |
-
return None
|
| 2695 |
-
continue
|
| 2696 |
|
| 2697 |
if isinstance(result, requests.Response):
|
| 2698 |
if create_new:
|
|
@@ -3139,6 +3260,14 @@ class ThoughtSpotDeployer:
|
|
| 3139 |
model_columns = liveboard_context['model_columns']
|
| 3140 |
matrix_config = liveboard_context['matrix_config']
|
| 3141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3142 |
log_progress(" Step 1/2: MCP creating liveboard...")
|
| 3143 |
log_progress(f" [MCP] Model: {model_name}, GUID: {model_guid}")
|
| 3144 |
log_progress(f" [MCP] Use case: {use_case or 'General Analytics'}")
|
|
|
|
| 531 |
print(f" ⚠️ Error getting model columns: {e}")
|
| 532 |
return []
|
| 533 |
|
| 534 |
+
def wait_for_model_answer_ready(
|
| 535 |
+
self,
|
| 536 |
+
model_guid: str,
|
| 537 |
+
model_columns: List[Dict],
|
| 538 |
+
log_callback=None,
|
| 539 |
+
session_logger=None,
|
| 540 |
+
timeout_seconds: int = None,
|
| 541 |
+
poll_interval_seconds: int = None,
|
| 542 |
+
) -> bool:
|
| 543 |
+
"""Wait until ThoughtSpot's answer service can query the newly-created model."""
|
| 544 |
+
timeout_seconds = timeout_seconds if timeout_seconds is not None else int(
|
| 545 |
+
os.getenv("TS_MODEL_READY_TIMEOUT_SECONDS", "300")
|
| 546 |
+
)
|
| 547 |
+
poll_interval_seconds = poll_interval_seconds if poll_interval_seconds is not None else int(
|
| 548 |
+
os.getenv("TS_MODEL_READY_POLL_INTERVAL_SECONDS", "30")
|
| 549 |
+
)
|
| 550 |
+
poll_interval_seconds = max(1, poll_interval_seconds)
|
| 551 |
+
timeout_seconds = max(poll_interval_seconds, timeout_seconds)
|
| 552 |
+
|
| 553 |
+
def _log(message):
|
| 554 |
+
if log_callback:
|
| 555 |
+
log_callback(message)
|
| 556 |
+
else:
|
| 557 |
+
print(message, flush=True)
|
| 558 |
+
|
| 559 |
+
measure = next((col for col in model_columns if col.get("type") == "NUMBER"), None)
|
| 560 |
+
if measure:
|
| 561 |
+
probe_query = f"sum [{measure.get('name')}]"
|
| 562 |
+
elif model_columns:
|
| 563 |
+
probe_query = f"[{model_columns[0].get('name')}]"
|
| 564 |
+
else:
|
| 565 |
+
probe_query = None
|
| 566 |
+
|
| 567 |
+
if not probe_query:
|
| 568 |
+
_log(" ⚠️ Model readiness check skipped: no columns available")
|
| 569 |
+
return False
|
| 570 |
+
|
| 571 |
+
deadline = time.time() + timeout_seconds
|
| 572 |
+
attempt = 0
|
| 573 |
+
last_error = ""
|
| 574 |
+
|
| 575 |
+
while True:
|
| 576 |
+
attempt += 1
|
| 577 |
+
try:
|
| 578 |
+
response = self.session.post(
|
| 579 |
+
f"{self.base_url}/api/rest/2.0/ai/answer/create",
|
| 580 |
+
json={
|
| 581 |
+
"query": probe_query,
|
| 582 |
+
"metadata_identifier": model_guid,
|
| 583 |
+
},
|
| 584 |
+
timeout=60,
|
| 585 |
+
)
|
| 586 |
+
if response.status_code == 200:
|
| 587 |
+
data = response.json() or {}
|
| 588 |
+
if data.get("session_identifier") and data.get("tokens"):
|
| 589 |
+
_log(f" [OK] Model answer-ready after {attempt} probe(s)")
|
| 590 |
+
if session_logger:
|
| 591 |
+
session_logger.log(
|
| 592 |
+
"thoughtspot",
|
| 593 |
+
"model answer-ready",
|
| 594 |
+
model_guid=model_guid,
|
| 595 |
+
probe_query=probe_query,
|
| 596 |
+
attempts=attempt,
|
| 597 |
+
)
|
| 598 |
+
return True
|
| 599 |
+
last_error = (
|
| 600 |
+
"answer response missing session/tokens "
|
| 601 |
+
f"(keys={list(data.keys())})"
|
| 602 |
+
)
|
| 603 |
+
else:
|
| 604 |
+
last_error = f"HTTP {response.status_code}: {response.text[:300]}"
|
| 605 |
+
except Exception as exc:
|
| 606 |
+
last_error = f"{type(exc).__name__}: {exc}"
|
| 607 |
+
|
| 608 |
+
_log(
|
| 609 |
+
f" ⏳ Model answer-ready poll {attempt}: not ready "
|
| 610 |
+
f"({last_error[:180]})"
|
| 611 |
+
)
|
| 612 |
+
if session_logger:
|
| 613 |
+
session_logger.log(
|
| 614 |
+
"thoughtspot",
|
| 615 |
+
"model answer-ready poll",
|
| 616 |
+
model_guid=model_guid,
|
| 617 |
+
probe_query=probe_query,
|
| 618 |
+
attempt=attempt,
|
| 619 |
+
ready=False,
|
| 620 |
+
error=last_error[:1000],
|
| 621 |
+
timeout_seconds=timeout_seconds,
|
| 622 |
+
poll_interval_seconds=poll_interval_seconds,
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
if time.time() >= deadline:
|
| 626 |
+
_log(" ⚠️ Model answer-ready check timed out; continuing to liveboard creation")
|
| 627 |
+
return False
|
| 628 |
+
time.sleep(min(poll_interval_seconds, max(0, deadline - time.time())))
|
| 629 |
+
|
| 630 |
def parse_ddl(self, ddl: str) -> Tuple[Dict, List]:
|
| 631 |
"""
|
| 632 |
Parse DDL to extract table definitions and foreign key relationships
|
|
|
|
| 1789 |
print(f" ⚠️ Could not check existing connections: {e}")
|
| 1790 |
return None
|
| 1791 |
|
| 1792 |
+
def _metadata_header(self, metadata_object: Dict) -> Dict:
|
| 1793 |
+
"""Return the metadata header regardless of ThoughtSpot API response shape."""
|
| 1794 |
+
if not isinstance(metadata_object, dict):
|
| 1795 |
+
return {}
|
| 1796 |
+
return (
|
| 1797 |
+
metadata_object.get("metadata_header")
|
| 1798 |
+
or metadata_object.get("header")
|
| 1799 |
+
or metadata_object.get("response", {}).get("header")
|
| 1800 |
+
or {}
|
| 1801 |
+
)
|
| 1802 |
+
|
| 1803 |
def _parse_tml_edoc(self, edoc):
|
| 1804 |
"""Parse ThoughtSpot TML export content whether the API returns YAML, JSON, or a dict."""
|
| 1805 |
if isinstance(edoc, dict):
|
|
|
|
| 1819 |
connection_fqn: str = None) -> Dict:
|
| 1820 |
"""Find an existing ThoughtSpot logical table by name and optional backing table context."""
|
| 1821 |
try:
|
| 1822 |
+
response = self.session.post(
|
| 1823 |
f"{self.base_url}/api/rest/2.0/metadata/search",
|
| 1824 |
+
headers=self.headers,
|
| 1825 |
+
json={
|
| 1826 |
"metadata": [{"type": "LOGICAL_TABLE", "identifier": table_name}],
|
| 1827 |
"record_size": 100,
|
| 1828 |
},
|
|
|
|
| 1833 |
candidates = response.json() or []
|
| 1834 |
exact_name = table_name.upper()
|
| 1835 |
for candidate in candidates:
|
| 1836 |
+
header = self._metadata_header(candidate)
|
| 1837 |
candidate_name = (
|
| 1838 |
candidate.get("metadata_name")
|
| 1839 |
or candidate.get("name")
|
| 1840 |
+
or header.get("name")
|
| 1841 |
+
or header.get("display_name")
|
| 1842 |
+
or header.get("displayName")
|
| 1843 |
or ""
|
| 1844 |
)
|
| 1845 |
candidate_guid = (
|
| 1846 |
candidate.get("metadata_id")
|
| 1847 |
or candidate.get("id_guid")
|
| 1848 |
+
or header.get("id_guid")
|
| 1849 |
+
or header.get("id")
|
| 1850 |
)
|
| 1851 |
if candidate_name.upper() != exact_name or not candidate_guid:
|
| 1852 |
continue
|
|
|
|
| 2782 |
chunk_tmls,
|
| 2783 |
create_new,
|
| 2784 |
)
|
| 2785 |
+
if isinstance(result, requests.Response):
|
| 2786 |
+
if result.status_code in retriable_statuses:
|
| 2787 |
+
if create_new:
|
| 2788 |
+
resolved = _resolve_existing_tables_after_timeout(
|
| 2789 |
+
chunk_names,
|
| 2790 |
+
phase_label=f"{chunk_label} retry",
|
| 2791 |
+
)
|
| 2792 |
+
else:
|
| 2793 |
+
resolved = _verify_table_updates_after_timeout(
|
| 2794 |
+
chunk_names,
|
| 2795 |
+
chunk_tmls,
|
| 2796 |
+
f"{chunk_label} retry",
|
| 2797 |
+
)
|
| 2798 |
+
all_objects.update(resolved)
|
| 2799 |
+
missing_names = [name for name in chunk_names if name not in resolved]
|
| 2800 |
+
if missing_names:
|
| 2801 |
+
error = (
|
| 2802 |
+
f"{chunk_label} timed out and {len(missing_names)} table(s) "
|
| 2803 |
+
f"could not be verified after full-payload retry: {', '.join(missing_names)}"
|
| 2804 |
+
)
|
| 2805 |
+
_record_import_problem(error, fatal_errors=fatal_errors)
|
| 2806 |
+
if fatal_errors:
|
| 2807 |
+
return None
|
| 2808 |
+
continue
|
| 2809 |
+
elif result is None and create_new:
|
| 2810 |
+
resolved = _resolve_existing_tables_after_timeout(
|
| 2811 |
+
chunk_names,
|
| 2812 |
+
phase_label=f"{chunk_label} retry empty response",
|
| 2813 |
+
)
|
| 2814 |
all_objects.update(resolved)
|
| 2815 |
+
if all(name in resolved for name in chunk_names):
|
| 2816 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2817 |
|
| 2818 |
if isinstance(result, requests.Response):
|
| 2819 |
if create_new:
|
|
|
|
| 3260 |
model_columns = liveboard_context['model_columns']
|
| 3261 |
matrix_config = liveboard_context['matrix_config']
|
| 3262 |
|
| 3263 |
+
log_progress(" Checking model answer readiness before liveboard creation...")
|
| 3264 |
+
self.wait_for_model_answer_ready(
|
| 3265 |
+
model_guid=model_guid,
|
| 3266 |
+
model_columns=model_columns,
|
| 3267 |
+
log_callback=log_progress,
|
| 3268 |
+
session_logger=_slog,
|
| 3269 |
+
)
|
| 3270 |
+
|
| 3271 |
log_progress(" Step 1/2: MCP creating liveboard...")
|
| 3272 |
log_progress(f" [MCP] Model: {model_name}, GUID: {model_guid}")
|
| 3273 |
log_progress(f" [MCP] Use case: {use_case or 'General Analytics'}")
|