fix(sidebar): cancel stale table search work across modes
This commit is contained in:
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user