From d05fe403a28270cd3eea678894f0fa92189f7a7a Mon Sep 17 00:00:00 2001 From: mo Date: Sun, 28 Jun 2026 10:10:17 +0000 Subject: [PATCH] fix(datasources): MySQL browser race + slow row counts When switching source engine, the sample effect fired with the previous engine selected object (e.g. public.sales_orders against MySQL), surfacing "Table hr.sales_orders doesn't exist". Track which engine the selected object belongs to and only sample when it matches the active engine, plus guard against stale responses overwriting newer ones. Row-count for the browser used an exact count(*) which full-scanned huge tables (~30s on 24M rows). Use planner/statistics estimates and only run a time-bounded exact count for small (<=50k) tables. --- api/sql_console.py | 34 ++++++++++++++++--- .../components/features/DataSourcesView.tsx | 18 ++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/api/sql_console.py b/api/sql_console.py index 19d0899..12695dd 100644 --- a/api/sql_console.py +++ b/api/sql_console.py @@ -693,8 +693,20 @@ def _table_row_count(engine: str, object_name: str) -> int | None: ) try: cur = conn.cursor() - cur.execute(f'SELECT count(*) FROM "{schema}"."{table}"') - return int(cur.fetchone()[0]) + # Planner estimate first; only do a (bounded) exact count for small + # tables so huge tables don't trigger a slow full scan. + cur.execute("SELECT reltuples::bigint FROM pg_class WHERE oid = %s::regclass", (f'"{schema}"."{table}"',)) + row = cur.fetchone() + est = int(row[0]) if row and row[0] is not None and row[0] >= 0 else 0 + if est <= 50000: + try: + cur.execute("SET LOCAL statement_timeout = 4000") + cur.execute(f'SELECT count(*) FROM "{schema}"."{table}"') + return int(cur.fetchone()[0]) + except Exception: + conn.rollback() + return est + return est finally: conn.close() if engine == "mysql": @@ -705,8 +717,22 @@ def _table_row_count(engine: str, object_name: str) -> int | None: ) try: cur = conn.cursor() - cur.execute(f"SELECT count(*) FROM `{table}`") - return int(cur.fetchone()[0]) + # Fast estimate from statistics; only do a (bounded) exact count for + # small tables to avoid full scans on huge ones (e.g. 24M rows). + cur.execute( + "SELECT table_rows FROM information_schema.tables WHERE table_schema=%s AND table_name=%s", + (schema, table), + ) + row = cur.fetchone() + est = int(row[0]) if row and row[0] is not None else 0 + if est <= 50000: + try: + cur.execute("SET SESSION MAX_EXECUTION_TIME=4000") + cur.execute(f"SELECT count(*) FROM `{table}`") + return int(cur.fetchone()[0]) + except Exception: + return est + return est finally: conn.close() if engine == "mongodb": diff --git a/ui/src/components/features/DataSourcesView.tsx b/ui/src/components/features/DataSourcesView.tsx index 183ee93..09a768f 100644 --- a/ui/src/components/features/DataSourcesView.tsx +++ b/ui/src/components/features/DataSourcesView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Activity, ChevronRight, @@ -88,6 +88,8 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { const [catalog, setCatalog] = useState(null) const [catalogLoading, setCatalogLoading] = useState(false) const [selectedObject, setSelectedObject] = useState(null) + const [objEngine, setObjEngine] = useState(null) + const sampleReq = useRef(0) const [sample, setSample] = useState(null) const [sampleLoading, setSampleLoading] = useState(false) const [page, setPage] = useState(1) @@ -114,6 +116,7 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { setCatalogLoading(true) setCatalog(null) setSelectedObject(null) + setObjEngine(null) setSample(null) try { const r = await fetch(`/api/sql/catalog/${engine}`) @@ -121,7 +124,7 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { const j: CatalogResponse = await r.json() setCatalog(j) const first = j.objects?.find((o) => o.type === 'table' || o.type === 'collection' || o.type === 'node_label') - if (first) setSelectedObject(first) + if (first) { setSelectedObject(first); setObjEngine(engine) } } } catch { /* */ } finally { setCatalogLoading(false) @@ -129,6 +132,7 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { }, []) const loadSample = useCallback(async (engine: SourceEngine, obj: CatalogObject, pg = page, ps = pageSize) => { + const myId = ++sampleReq.current setSampleLoading(true) setSample(null) const offset = (pg - 1) * ps @@ -137,11 +141,13 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { `/api/sql/sample/${engine}?object=${encodeURIComponent(obj.fqn)}&limit=${ps}&offset=${offset}`, ) const j = await r.json() + if (myId !== sampleReq.current) return setSample(j) } catch { + if (myId !== sampleReq.current) return setSample({ ok: false, error: 'Sample API unavailable' }) } finally { - setSampleLoading(false) + if (myId === sampleReq.current) setSampleLoading(false) } }, [page, pageSize]) @@ -155,8 +161,8 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) { }, [selectedObject?.fqn, active]) useEffect(() => { - if (selectedObject && subTab === 'browser') loadSample(active, selectedObject, page, pageSize) - }, [selectedObject, active, subTab, page, pageSize, loadSample]) + if (selectedObject && objEngine === active && subTab === 'browser') loadSample(active, selectedObject, page, pageSize) + }, [selectedObject, objEngine, active, subTab, page, pageSize, loadSample]) const refreshAll = () => { loadHealth() @@ -276,7 +282,7 @@ export function DataSourcesView({ focusEngine, onPulse }: Props) {