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 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.get(
1716
  f"{self.base_url}/api/rest/2.0/metadata/search",
1717
- params={
 
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 candidate.get("header", {}).get("name")
 
 
1732
  or ""
1733
  )
1734
  candidate_guid = (
1735
  candidate.get("metadata_id")
1736
  or candidate.get("id_guid")
1737
- or candidate.get("header", {}).get("id_guid")
 
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) and result.status_code in retriable_statuses:
2674
- if create_new:
2675
- resolved = _resolve_existing_tables_after_timeout(
2676
- chunk_names,
2677
- phase_label=f"{chunk_label} retry",
2678
- )
2679
- else:
2680
- resolved = _verify_table_updates_after_timeout(
2681
- chunk_names,
2682
- chunk_tmls,
2683
- f"{chunk_label} retry",
2684
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2685
  all_objects.update(resolved)
2686
- missing_names = [name for name in chunk_names if name not in resolved]
2687
- if missing_names:
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'}")