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.
This commit is contained in:
mo
2026-06-28 10:10:17 +00:00
parent 1432a8429a
commit d05fe403a2
2 changed files with 42 additions and 10 deletions
+30 -4
View File
@@ -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":
+12 -6
View File
@@ -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<CatalogResponse | null>(null)
const [catalogLoading, setCatalogLoading] = useState(false)
const [selectedObject, setSelectedObject] = useState<CatalogObject | null>(null)
const [objEngine, setObjEngine] = useState<SourceEngine | null>(null)
const sampleReq = useRef(0)
const [sample, setSample] = useState<SampleResponse | null>(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) {
<button
key={obj.fqn}
type="button"
onClick={() => setSelectedObject(obj)}
onClick={() => { setSelectedObject(obj); setObjEngine(active) }}
className={cn(
'mb-0.5 flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-[10px] transition-colors',
selectedObject?.fqn === obj.fqn ? subTabActive : 'hover:bg-surface-overlay',