demoprep / docs /async_tml_import_handoff.md
mikeboone's picture
perf(deploy): scope TS connection to demo DB + monthly-rotating database (250s -> <1s table imports)
21ee86d
|
Raw
History Blame Contribute Delete
12.2 kB
# Handoff: switch ThoughtSpot table import from sync to async
> **SUPERSEDED IN PART, 2026-08-10.** The async switch shipped and stands (it
> remains the right defense against gateway 504s), but the "~250s server time
> is inherent / not reducible" premise below is WRONG. Root cause: table TML
> import scans all external metadata visible to the connection; our
> connections were unscoped (role sees ~507 Snowflake DBs). Scoping the
> connection with a `database` property + writing demos to a small
> monthly-rotating database takes the same import from ~250s to **<1s**
> (measured on sebe and secloud, 2026-08-10). See the RESOLVED banner in
> `sre_slow_tml_import.md`.
**Audience:** an engineer/agent implementing the change. Self-contained β€” you
do not need prior conversation context.
**Goal:** replace the synchronous TML import in `ThoughtSpotDeployer.deploy_all`
with ThoughtSpot's **async import API** for both the table-create phase and the
join phase. This eliminates the gateway 504s and the fragile post-504 recovery
polling, and gives a definitive completion signal.
**Explicitly NOT the goal:** making imports faster. Measured server-side time is
the same either way (~250–305s per phase on sebe). Async is a **reliability and
observability** fix, not a speed fix. Do not claim it speeds up demos.
---
## 1. Why (measured evidence)
Two harnesses were run against `sebe` staging (`https://sebe.thoughtspotstaging.cloud`).
- **Naming test** (`tests/ts_table_perf.py`): 10 logical tables, one per naming
convention, each a trivial 5-column empty table.
- Every import took **222–305s**. Naming had no measurable effect.
- **3/10 hit an nginx `504 Gateway Time-out` at exactly ~300s.** The import
still completed server-side afterward (the table appeared on the next scan).
- The full-connection scan was **<1s** at 10 tables; a duplicate-name
re-create errored **fast (0.17s)**. So table count / name collisions are
NOT the driver at this scale.
- **Sync vs async** (`tests/ts_import_sync_vs_async.py`): 4-table star schema,
phase 1 create + phase 2 joins, run both ways.
| metric | SYNC | ASYNC |
|---|--:|--:|
| phase 1 create β€” call/submit | 300.2s β†’ **504** | **0.13s** |
| phase 2 joins β€” call/submit | 300.1s β†’ **504** | **0.26s** |
| phase 1 total to verified | 305.7s | 252.5s |
| phase 2 total to verified | 300.4s | 263.3s |
| gateway 504s | **2 / 2** | **0** |
| tables / joins verified | 4/4, 3/3 | 4/4, 3/3 |
**Conclusion:** the ~4–5 min/phase is ThoughtSpot server-side metadata
processing (instance load). Sync blocks the whole time and slams the ~300s
gateway wall; async submits instantly and polls a light status endpoint to a
clean `COMPLETED`.
---
## 2. Async API contract (verified live on sebe)
### Submit β€” `POST /api/rest/2.0/metadata/tml/async/import`
Request body is **identical to the sync import**:
```json
{ "metadata_tmls": ["<tml-yaml>", "..."], "import_policy": "PARTIAL", "create_new": true }
```
Response `200` (returns in ~0.2s):
```json
{
"task_id": "2fb37fcb-1e8c-4bd3-99e2-a0967ff35ef7",
"task_name": "ASYNC_TML_29:Jul:2026-03:52:55",
"task_status": "IN_QUEUE",
"import_response": null,
"import_policy": "PARTIAL",
"total_object_count": 1,
"object_processed_count": null,
"created_at": null, "in_progress_at": null, "completed_at": null
}
```
Read `task_id` from the response.
### Poll β€” `POST /api/rest/2.0/metadata/tml/async/status`
Request:
```json
{ "task_ids": ["2fb37fcb-..."], "include_import_response": true }
```
Response `200`:
```json
{
"status_list": [{
"task_id": "2fb37fcb-...",
"task_status": "IN_PROGRESS",
"import_response": { "status": { "status_code": "OK", "error_code": 0, "error_message": "" } },
"total_object_count": 1,
"object_processed_count": 0,
"created_at": 1785297175686, "in_progress_at": 1785297175689, "completed_at": 0
}]
}
```
**State machine observed:** `IN_QUEUE β†’ IN_PROGRESS β†’ COMPLETED`.
**Terminal signal (use both):** `task_status == "COMPLETED"` OR
`completed_at > 0`. Treat `"FAILED"`/`"ERROR"` (or
`import_response.status.status_code == "ERROR"`) as failure and surface
`import_response.status.error_message`.
> **GUID caveat:** in the probe, the status `import_response` carried only
> `status` (no per-object `header.id_guid`). So after completion, resolve table
> name β†’ GUID with the existing `search_logical_tables_for_connection(...)`
> (Β§3). If your instance's `import_response` includes a per-object list with
> headers, prefer that and fall back to the connection scan.
---
## 3. Current (sync) implementation β€” what you're replacing
All in `thoughtspot_deployer.py`, inside `deploy_all` (starts **line 2539**):
| Piece | Location | Role |
|---|---|---|
| `_import_tml_chunk` (closure) | ~2768; sync `POST .../tml/import` at **2793**, `timeout=360`; 401 re-auth at 2814 | one synchronous batch import |
| `_import_tmls_chunked` | **3204**; `retriable_statuses = {502,503,504}` at 3206 | chunk + retry wrapper |
| `_resolve_existing_tables_after_timeout` | **3119**; default 900s / 30s via `TS_TML_504_POLL_TIMEOUT_SECONDS` / `TS_TML_504_POLL_INTERVAL_SECONDS` | 504-recovery poll (create) |
| `_verify_table_updates_after_timeout` | **3057** | 504-recovery poll (joins) |
| Phase 1 create call | **3338–3344** β€” `_import_tmls_chunked("Batch 1", …, create_new=True)` | creates tables |
| Phase 2 joins call | **3453–3460** β€” `_import_tmls_chunked("Batch 2", …, create_new=False, fatal_errors=False)` | adds joins |
Supporting (reuse as-is, do not reimplement):
- `create_table_tml(...)` β€” **line 920** β€” builds the table TML (phase 1:
`all_tables=None`; phase 2: `all_tables=tables, table_guid=<guid>`,
`foreign_keys=fks`). Keep passing `connection_fqn`.
- `search_logical_tables_for_connection(connection_guid, connection_name, expected_names)`
β€” **line 2209** β€” returns `{NAME: {"response": {"status": {"status_code":"OK"},
"header": {"id_guid","name"}}}}`. This is the return shape callers expect.
- `assign_tags_to_objects(guids, "LOGICAL_TABLE"/"DATA_SOURCE", tag)` β€” line 2383.
---
## 4. Target implementation
### 4a. Add one method (drop-in for `_import_tmls_chunked`)
Return the **same shape** `_import_tmls_chunked` returns β€” a dict
`{TABLE_NAME_UPPER: {"response": {"status": {...}, "header": {"id_guid","name"}}}}`
β€” so downstream processing at lines 3351–3376 (create) and 3462–3478 (joins) is
unchanged.
```python
def import_tmls_async(self, expected_names, tmls, create_new,
connection_guid, connection_name,
poll_interval_s=None, timeout_s=None,
log_progress=None, slog=None):
"""Async TML import: submit once, poll status to completion, then resolve
name->guid from the connection. Returns {NAME: import-object} like
_import_tmls_chunked. No gateway 504 handling needed."""
base = self.base_url
poll_interval_s = poll_interval_s or int(os.getenv("TS_TML_ASYNC_POLL_INTERVAL_SECONDS", "5"))
timeout_s = timeout_s or int(os.getenv("TS_TML_ASYNC_TIMEOUT_SECONDS", "900"))
r = self.session.post(f"{base}/api/rest/2.0/metadata/tml/async/import",
json={"metadata_tmls": tmls, "import_policy": "PARTIAL",
"create_new": create_new}, timeout=60)
if r.status_code == 401 and self.authenticate():
r = self.session.post(f"{base}/api/rest/2.0/metadata/tml/async/import",
json={"metadata_tmls": tmls, "import_policy": "PARTIAL",
"create_new": create_new}, timeout=60)
r.raise_for_status()
task_id = (r.json() or {}).get("task_id")
deadline = time.time() + timeout_s
final = None
while time.time() < deadline:
s = self.session.post(f"{base}/api/rest/2.0/metadata/tml/async/status",
json={"task_ids": [task_id], "include_import_response": True}, timeout=60)
if s.status_code == 200:
final = ((s.json() or {}).get("status_list") or [{}])[0]
st = final.get("task_status")
if final.get("completed_at") or st in ("COMPLETED", "SUCCESS", "FAILED", "ERROR", "PARTIAL_SUCCESS"):
break
time.sleep(poll_interval_s)
# Fail loudly on a terminal error.
imp = (final or {}).get("import_response") or {}
if (final or {}).get("task_status") in ("FAILED", "ERROR") or \
(imp.get("status") or {}).get("status_code") == "ERROR":
raise RuntimeError(f"async import failed: {(imp.get('status') or {}).get('error_message')}")
# Resolve name -> guid from the connection (import_response has no headers).
return self.search_logical_tables_for_connection(
connection_guid, connection_name,
expected_table_names=expected_names,
record_size=max(50, len(expected_names) * 2))
```
### 4b. Branch the two call sites behind a flag
Add near the top of `deploy_all` (or read once):
```python
use_async = os.getenv("TS_TML_IMPORT_MODE", "sync").lower() == "async"
```
Phase 1 (replace lines 3338–3344):
```python
if use_async:
objects = self.import_tmls_async(table_names_order, table_tmls_batch1, True,
connection_guid, connection_name,
log_progress=log_progress, slog=_slog)
else:
objects = _import_tmls_chunked("Batch 1", table_names_order, table_tmls_batch1,
create_new=True, chunk_size=create_chunk_size)
```
Phase 2 (replace lines 3453–3460): same pattern with
`table_names_order_batch2 / table_tmls_batch2`, `create_new=False`. On async the
existing `_resolve_/_verify_*_after_timeout` recovery paths are simply not used
(there is no 504), so no other logic changes.
### 4c. Rollout
- Land behind `TS_TML_IMPORT_MODE` (default `sync`) so it's a config flip and an
instant rollback. Validate on `hf-test`, then set `async` in prod settings.
- Keep the sync code path intact for fallback until async is proven in prod.
---
## 5. Edge cases / gotchas
- **fqn:** keep passing `connection_fqn` to `create_table_tml` (already done) β€”
docs say it reduces same-name validation ambiguity.
- **401 mid-flight:** re-auth on submit (shown) and, ideally, on status polls.
- **Timeout:** if the poll deadline elapses without terminal status, fall back
to a connection scan (`search_logical_tables_for_connection`) to see whether
the objects landed anyway β€” same philosophy as today's 504 recovery.
- **create_new=False (joins/update):** async supports it; verify the join
update returns `COMPLETED` and that `joins_with` is present on export.
- **Connection delete** (for test cleanup only): use
`POST /api/rest/2.0/connection/delete {"connection_identifier": "<guid>"}` β€”
`metadata/delete` rejects type `CONNECTION` ("not in DeleteMetadatatype enum").
---
## 6. Testing
- **A/B:** `python tests/ts_import_sync_vs_async.py --env "sebe - se" --yes`
(keeps the sandbox by default; `--cleanup` deletes that run's tables). It
already builds the star schema, runs both modes, and verifies tables + joins.
- **Full pipeline:** run a normal demo build (App tab GO β†’ `defined_go` β†’
`deploy_all`) with `TS_TML_IMPORT_MODE=async`; confirm all tables + joins are
created with **no 504 lines in the logs** and a clean completion.
- Sandbox available for manual testing: connection `ZPERFTEST_DONOTDELETE`,
Snowflake `DEMOBUILD.ZPERFTEST` on sebe.
## 7. Acceptance criteria
1. `TS_TML_IMPORT_MODE=async` builds a multi-table model (create + joins) end to
end with **zero gateway 504s** and a definitive completion signal.
2. Output object shape unchanged β†’ no downstream changes needed in `deploy_all`
or the model/liveboard steps.
3. `sync` remains available via the flag and behaves exactly as today.
4. Session logs record import mode, task_id(s), and per-phase completion.
## 8. Out of scope
The ~4–5 min/phase server-side latency. Async does not change it. Separately:
confirm the latency on a quieter env (prod `secloud`) and raise with the TS
infra team if sebe is unhealthy.