fix(sidebar): cancel stale table search work across modes

This commit is contained in:
jearhe authored and GitHub committed 2026-08-16 14:09:50 +08:00
1 parent e671b3da7d
commit 71136377e5
8 files changed
+763 -24

No files matched your search

@@ -40,6 +40,7 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
import TreeItem from "./TreeItem.vue";
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
@@ -149,7 +150,10 @@ const searchRefreshedNodeIds = searchExpansionState.filteredNodeIds;
// touched group open and reloading it again.
const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds;
let searchTimer: number | undefined;
const tableSearchTimers = new Map<string, number>();
const remoteTableSearchDebouncer = createSidebarTableSearchDebouncer();
// Local-mode table searches debounce like remote ones: rapid typing coalesces
// into a single index load/build instead of one full build per keystroke.
const localTableSearchDebouncer = createSidebarTableSearchDebouncer();
const tableSearchFocusRestoreTokens = new Map<string, number>();
let tableSearchFocusRestoreTokenSeq = 0;
let latestTableSearchInteractionParentId: string | null = null;
@@ -244,8 +248,8 @@ watch(
if (parentNodeIds.length === 0) return;
for (const parentNodeId of parentNodeIds) {
window.clearTimeout(tableSearchTimers.get(parentNodeId));
tableSearchTimers.delete(parentNodeId);
remoteTableSearchDebouncer.cancel(parentNodeId);
localTableSearchDebouncer.cancel(parentNodeId);
tableSearchFocusRestoreTokens.delete(parentNodeId);
store.setSidebarTableSearchQuery(parentNodeId, "");
}
@@ -486,15 +490,17 @@ function clearSearchScopeFilter() {
}
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) {
window.clearTimeout(tableSearchTimers.get(parentNodeId));
if (isTreeSearchFiltering.value) return;
if (isTreeSearchFiltering.value) {
remoteTableSearchDebouncer.cancel(parentNodeId);
localTableSearchDebouncer.cancel(parentNodeId);
return;
}
const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
if (restoreToken) {
tableSearchFocusRestoreTokens.clear();
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
}
const timer = window.setTimeout(() => {
tableSearchTimers.delete(parentNodeId);
scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, remoteTableSearchDebouncer, localTableSearchDebouncer, () => {
void store.refreshSidebarTableSearch(parentNodeId).then(() => {
if (!restoreToken) return;
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
@@ -503,8 +509,7 @@ function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { foc
if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return;
restoreTableSearchInput(focusRestore);
});
}, 250);
tableSearchTimers.set(parentNodeId, timer);
});
}
function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore {
@@ -607,10 +612,35 @@ function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] {
return mergeSidebarRegexIndexScopes(nodes, matchingScopes);
}
function scheduleLocalSidebarTableSearchRefresh(parentNodeId: string, focusRestore?: TableSearchFocusRestore) {
if (isTreeSearchFiltering.value) {
localTableSearchDebouncer.cancel(parentNodeId);
remoteTableSearchDebouncer.cancel(parentNodeId);
return;
}
scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, localTableSearchDebouncer, remoteTableSearchDebouncer, () => {
void loadLocalTableSearchResults(parentNodeId, false, focusRestore);
});
}
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
try {
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
// A missing persisted index (null) means this scope was never indexed, so
// only the currently loaded first page of children is searchable. That
// silently misses alphabetically-late tables (e.g. "T_Erp_Nc_SuPlan_List"
// for "erpncs") until an explicit index refresh happens. Build the index
// on the first search so results always cover the complete table set,
// matching the behavior of an explicit refresh. The scope key deduplicates
// concurrent full builds, and an empty (cleared) query never builds.
const query = store.sidebarTableSearchQueries[parentNodeId]?.trim() ?? "";
const entries = await loadOrBuildSidebarTableSearchIndex(
parentNodeId,
query,
() => store.loadSidebarTableSearchIndex(parentNodeId),
() => store.refreshSidebarTableSearchIndex(parentNodeId),
refresh,
);
if (query || refresh) localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
} finally {
if (focusRestore) restoreTableSearchInput(focusRestore);
}
@@ -1099,7 +1129,7 @@ provide(sidebarTreeContextKey, {
restoreTableSearchInput(focusRestore);
localTableSearchFocusPending = false;
});
void loadLocalTableSearchResults(parentNodeId, false, focusRestore);
scheduleLocalSidebarTableSearchRefresh(parentNodeId, focusRestore);
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
},
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
@@ -1999,10 +2029,8 @@ onUnmounted(() => {
resetSidebarTreeDialogState();
window.removeEventListener("keydown", onWindowKeydown);
cancelPendingSidebarDataOpen();
for (const timer of tableSearchTimers.values()) {
window.clearTimeout(timer);
}
tableSearchTimers.clear();
remoteTableSearchDebouncer.cancelAll();
localTableSearchDebouncer.cancelAll();
tableSearchFocusRestoreTokens.clear();
latestTableSearchInteractionParentId = null;
latestTableSearchInteractionId = 0;
@@ -0,0 +1,103 @@
import type { TableInfo } from "@/types/database";
/**
* In-flight full-index builds, keyed by sidebar scope (parent node id).
*
* A missing persisted index (read returns null) means the scope was never
* indexed, so only the currently loaded first page of children would be
* searchable. That silently misses alphabetically-late tables — e.g.
* "T_Erp_Nc_SuPlan_List" for the fuzzy query "erpncs" — until an explicit
* index refresh happens (the refresh button). Building the index on the first
* search makes the complete table set searchable from the start, matching the
* behavior of an explicit refresh.
*
* Building pages through every table of a scope, so rapid consecutive first
* searches must never start concurrent full builds: while one build for a
* scope is in flight, further requests reuse the same promise instead of
* spawning a duplicate scan. Entries are removed once the build settles
* (success or failure) so a later search can retry.
*/
const inFlightSidebarTableSearchBuilds = new Map<string, Promise<TableInfo[]>>();
function dedupeInFlightBuild(scopeKey: string, build: () => Promise<TableInfo[]>): Promise<TableInfo[]> {
const existing = inFlightSidebarTableSearchBuilds.get(scopeKey);
if (existing) return existing;
const pending = (async () => {
try {
return await build();
} finally {
inFlightSidebarTableSearchBuilds.delete(scopeKey);
}
})();
inFlightSidebarTableSearchBuilds.set(scopeKey, pending);
return pending;
}
/**
* Load the local table search index for a sidebar scope, building it on first
* use — exactly once per scope, even under rapid consecutive input.
*
* - An empty (or cleared) query filters nothing, so no read or build happens:
* clearing the input must not scan every page of the table set for nothing.
* - An explicit refresh (`refresh = true`) always rebuilds, but still through
* the in-flight lock so double-clicks cannot start duplicate full builds.
* - A persisted index is reused without rebuilding; a missing one is built
* through the lock, and concurrent callers share the same build promise.
*/
export async function loadOrBuildSidebarTableSearchIndex(scopeKey: string, query: string, read: () => Promise<TableInfo[] | null>, build: () => Promise<TableInfo[]>, refresh = false): Promise<TableInfo[] | null> {
if (!refresh && !query.trim()) return null;
if (refresh) return dedupeInFlightBuild(scopeKey, build);
const entries = await read();
if (entries) return entries;
return dedupeInFlightBuild(scopeKey, build);
}
export interface SidebarTableSearchDebouncer {
/** Debounce a per-key load; consecutive calls for the same key within the window coalesce. */
schedule(key: string, run: () => void): void;
/** Cancel a pending (scheduled, not yet run) load for a key. */
cancel(key: string): void;
/** Cancel every pending load. */
cancelAll(): void;
/** Number of pending (scheduled, not yet run) loads. */
pendingCount(): number;
}
/**
* Per-key debouncer used to coalesce rapid table-search input into a single
* load/build. Each schedule() for the same key resets the window, so fast
* typing issues one load instead of one full index build per keystroke.
*/
export function createSidebarTableSearchDebouncer(delayMs = 250): SidebarTableSearchDebouncer {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
return {
schedule(key, run) {
const existing = timers.get(key);
if (existing !== undefined) clearTimeout(existing);
const timer = setTimeout(() => {
timers.delete(key);
run();
}, delayMs);
timers.set(key, timer);
},
cancel(key) {
const existing = timers.get(key);
if (existing !== undefined) {
clearTimeout(existing);
timers.delete(key);
}
},
cancelAll() {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
},
pendingCount() {
return timers.size;
},
};
}
export function scheduleExclusiveSidebarTableSearchDebounce(key: string, active: SidebarTableSearchDebouncer, inactive: SidebarTableSearchDebouncer, run: () => void): void {
inactive.cancel(key);
active.schedule(key, run);
}
@@ -126,4 +126,42 @@ describe("connectionStore Doris catalog tree", () => {
["iceberg", "sales", "tree.queries"],
]);
});
it("uses the bounded fuzzy-search budget for external catalog tables", async () => {
const listTables = vi.fn().mockResolvedValue([{ name: "late_orders", table_type: "BASE TABLE", comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const settingsStore = useSettingsStore();
settingsStore.editorSettings.sidebarObjectDisplay = "simple";
settingsStore.desktopSettings.sidebar_table_page_size = 500;
const connection = starRocksConnection();
const database = databaseNode(connection.id, "hive", "sales");
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [catalogNode(connection.id, "hive", "Hive", [database])] }];
store.setSidebarTableSearchQuery(database.id, "orders");
await store.loadTreeNodeChildren(database, {
force: true,
searchFilter: "orders",
sidebarTableSearchParentId: database.id,
expectedSidebarTableSearchQuery: "orders",
});
expect(listTables.mock.calls[0]?.[4]).toBe(2000);
expect(listTables.mock.calls[0]?.[5]).toBeUndefined();
expect(listTables.mock.calls[0]?.[7]).toBe("hive");
});
});
@@ -12,6 +12,11 @@ function installLocalStorage() {
});
}
// Mirrors SIDEBAR_TABLE_SEARCH_RESULT_BUDGET in connectionStore.ts (4× the
// default sidebar_table_page_size of 500). Kept as a local constant because
// connectionStore must stay dynamically imported for vi.doMock isolation.
const SIDEBAR_SEARCH_RESULT_BUDGET = 2000;
function postgresConnection(): ConnectionConfig {
return {
id: "pg-1",
@@ -3165,7 +3170,7 @@ describe("connectionStore metadata loading", () => {
expect(tablesGroup.objectCount).toBe(5);
expect(tablesGroup.isLoading).toBe(false);
expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([
["0003", 2, undefined],
["0003", SIDEBAR_SEARCH_RESULT_BUDGET, undefined],
[undefined, 3, 0],
[undefined, 3, 0],
[undefined, 3, 2],
@@ -3219,7 +3224,7 @@ describe("connectionStore metadata loading", () => {
store.sidebarSearchQuery = "orders";
await store.loadObjectGroupChildren(tablesGroup, { force: true });
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", 200, undefined, ["TABLE"]);
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", SIDEBAR_SEARCH_RESULT_BUDGET, undefined, ["TABLE"]);
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
let resolveAncestorLoad!: (tables: TableInfo[]) => void;
+32 -6
View File
@@ -198,6 +198,19 @@ function sidebarObjectGroupPageSize(): number {
return typeof size === "number" && size > 0 ? size : 500;
}
/**
* Upper bound for a single remote fuzzy table-search result set.
*
* Every fuzzy match travels database → IPC → store → tree rendering, so an
* unbounded result set (e.g. a single-letter query against a large schema)
* would push thousands of rows through the whole pipeline. The budget is 4×
* the default page size (500) and comfortably covers the reported #6190
* schema (801 fuzzy matches) while keeping a single IPC payload bounded.
* Queries whose fuzzy match set exceeds the budget are truncated; narrowing
* the query reaches later tables.
*/
export const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000;
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true;
@@ -1855,7 +1868,14 @@ export const useConnectionStore = defineStore("connection", () => {
catalog: options.node.catalog,
});
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
// A search must never truncate the fuzzy result set to the first page: the
// target table can sort beyond it (e.g. "T_Erp_Nc_SuPlan_List" for
// "erpncs" in a large ERP schema), which silently drops it from the first
// search even though later, narrower queries succeed. Results are bounded
// by SIDEBAR_TABLE_SEARCH_RESULT_BUDGET so a wide fuzzy query cannot push
// an unbounded result set through database → IPC → store → tree rendering.
// Unfiltered loads keep the page+1 probe used for load-more detection.
const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
metadataListCacheScope({
@@ -1964,7 +1984,11 @@ export const useConnectionStore = defineStore("connection", () => {
schema: options.effectiveSchema ?? options.querySchema,
nodeKind: "simple-tables",
});
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
// A search must never truncate the fuzzy result set to the first page (see
// loadPagedTableGroupChildren); results are bounded by
// SIDEBAR_TABLE_SEARCH_RESULT_BUDGET, and unfiltered loads keep the
// page+1 probe.
const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
metadataListCacheScope({
@@ -4674,6 +4698,8 @@ export const useConnectionStore = defineStore("connection", () => {
const configForScope = getConfig(connectionId);
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
const searchFilterForScope = activeTreeLoadSearchFilter(options);
const pageSizeForScope = sidebarObjectGroupPageSize();
return runTreeMetadataLoad(
{
kind: "schema-tables",
@@ -4682,8 +4708,8 @@ export const useConnectionStore = defineStore("connection", () => {
schema: undefined,
nodeKind: "database",
objectTypes: objectTypesForScope,
searchFilter: activeTreeLoadSearchFilter(options),
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
searchFilter: searchFilterForScope,
limit: simpleObjectDisplayForScope ? (searchFilterForScope ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSizeForScope + 1) : undefined,
offset: 0,
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
driverProfile: metadataDriverProfile(configForScope),
@@ -4710,8 +4736,8 @@ export const useConnectionStore = defineStore("connection", () => {
return;
}
}
const pageSize = sidebarObjectGroupPageSize();
const fetchLimit = searchFilter ? pageSize : pageSize + 1;
const pageSize = pageSizeForScope;
const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSize + 1;
const fetchOffset = searchFilter ? undefined : 0;
const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables");
const hasMore = searchFilter ? false : tables.length > pageSize;