perf: optimize table data loading

This commit is contained in:
t8y2
2026-05-31 22:10:02 +08:00
parent bd77670770
commit e28495f3c1
10 changed files with 547 additions and 98 deletions
+83 -1
View File
@@ -221,6 +221,10 @@ const props = defineProps<{
}) => Promise<void>;
}>();
const dataGridTraceId = uuid().slice(0, 8);
const dataGridCreatedAt = performance.now();
const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedAt)}ms`;
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
@@ -228,6 +232,48 @@ const emit = defineEmits<{
"update:whereInput": [value: string];
}>();
console.info("[DBX][DataGrid:setup]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
rowCount: props.result.rows.length,
columnCount: props.result.columns.length,
backendMs: props.result.execution_time_ms,
loading: props.loading,
});
watch(
() => props.result,
(result) => {
const startedAt = performance.now();
console.info("[DBX][DataGrid:result:prop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
rowCount: result.rows.length,
columnCount: result.columns.length,
backendMs: result.execution_time_ms,
loading: props.loading,
elapsedSinceSetup: dataGridElapsed(),
});
nextTick(() => {
console.info("[DBX][DataGrid:result:nextTick]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
loading: props.loading,
});
requestAnimationFrame(() => {
console.info("[DBX][DataGrid:result:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
loading: props.loading,
});
});
});
},
{ immediate: true },
);
const hasData = computed(() => props.result.columns.length > 0);
const columnTypeMap = computed(() => {
@@ -1971,15 +2017,36 @@ const displayItems = computed<RowItem[]>(() => {
watch(
() => displayItems.value.length,
() => {
(length) => {
const startedAt = performance.now();
console.info("[DBX][DataGrid:display-items:ready]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
displayItemCount: length,
sourceRowCount: props.result.rows.length,
elapsedSinceSetup: dataGridElapsed(),
});
nextTick(() => {
const scrollerEl = gridRef.value?.querySelector<HTMLElement>(".data-grid-scroller");
if (scrollerEl) {
updateGridScrollbarGutter(scrollerEl);
updateGridHorizontalViewport(scrollerEl);
}
requestAnimationFrame(() => {
const renderedRows = gridRef.value?.querySelectorAll(".vue-recycle-scroller__item-view").length;
console.info("[DBX][DataGrid:display-items:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
displayItemCount: length,
renderedRows,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
elapsedSinceSetup: dataGridElapsed(),
loading: props.loading,
});
});
});
},
{ immediate: true },
);
interface SearchMatch {
@@ -4158,12 +4225,27 @@ watch(
() => props.loading,
(isLoading) => {
clearInterval(_loadingTimer);
console.info(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
if (isLoading) {
_loadingStart = Date.now();
loadingElapsed.value = 0;
_loadingTimer = setInterval(() => {
loadingElapsed.value = Date.now() - _loadingStart;
}, 100);
} else {
nextTick(() => {
requestAnimationFrame(() => {
console.info("[DBX][DataGrid:loading:stop:first-frame]", {
traceId: dataGridTraceId,
cacheKey: props.cacheKey,
elapsedSinceSetup: dataGridElapsed(),
});
});
});
}
},
);
@@ -153,8 +153,8 @@ const showPinnedDataTabsMenu = computed(
const dataTabMenuItems = computed(() =>
dataTabs.value.map((tab) => ({
value: tab.id,
label: tabDisplayTitle(tab),
title: tabDisplayTitle(tab),
label: tabDisplayTitle(tab, t),
title: tabDisplayTitle(tab, t),
icon: Table2,
iconClass: "text-emerald-600 dark:text-emerald-400",
})),
@@ -248,7 +248,7 @@ const dataTabsMenuContainerClass = computed(() =>
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
@@ -270,7 +270,7 @@ const dataTabsMenuContainerClass = computed(() =>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
<template v-for="line in tabTooltipLines(tab)" :key="line.label">
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
<span class="text-muted-foreground">{{ line.label }}</span>
<span>{{ line.value }}</span>
</template>
@@ -22,7 +22,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
const DataGrid = defineAsyncComponent(async () => {
const startedAt = performance.now();
console.info("[DBX][DataGrid:load:start]");
const component = await import("@/components/grid/DataGrid.vue");
console.info("[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
return component;
});
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
@@ -171,6 +177,35 @@ watch(
},
);
watch(
() => props.activeTab.result,
(result) => {
if (!result) return;
const startedAt = performance.now();
console.info("[DBX][ContentArea:result:observed]", {
tabId: props.activeTab.id,
rowCount: result.rows.length,
columnCount: result.columns.length,
backendMs: result.execution_time_ms,
isExecuting: props.activeTab.isExecuting,
});
nextTick(() => {
console.info("[DBX][ContentArea:result:nextTick]", {
tabId: props.activeTab.id,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
isExecuting: props.activeTab.isExecuting,
});
requestAnimationFrame(() => {
console.info("[DBX][ContentArea:result:first-frame]", {
tabId: props.activeTab.id,
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
isExecuting: props.activeTab.isExecuting,
});
});
});
},
);
watch(
() => props.activeTab.isExecuting,
(isExecuting, wasExecuting) => {
@@ -499,7 +534,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate"
>
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
</span>
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
@@ -65,7 +65,7 @@ import {
setActiveTableReferencePayload,
type QueryEditorTableReferencePayload,
} from "@/lib/queryEditorTableDrop";
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { editablePrimaryKeys } from "@/lib/tableEditing";
import {
supportsDatabaseCreation,
supportsDatabaseSearch,
@@ -523,49 +523,57 @@ async function openData() {
if (!config) throw new Error("Connection config not found");
const querySchema = node.schema || node.database;
console.info("[DBX][openData:get-columns:start]", {
traceId,
database: node.database,
schema: querySchema,
table: node.label,
elapsed: elapsed(),
});
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
console.info("[DBX][openData:get-columns:done]", {
traceId,
columnCount: columns.length,
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
elapsed: elapsed(),
});
const pks = editablePrimaryKeys(config.db_type, columns);
const limit = settingsStore.editorSettings.pageSize;
const sql = await buildTableSelectSql({
databaseType: config.db_type,
schema: node.schema,
tableName: node.label,
columns: columns.map((column) => column.name),
primaryKeys: pks,
columns: [],
primaryKeys: [],
limit,
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
includeRowId: false,
});
console.info("[DBX][openData:sql-built]", {
traceId,
primaryKeys: pks,
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
primaryKeys: [],
includeRowId: false,
sql,
elapsed: elapsed(),
});
queryStore.updateSql(tabId, sql);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns,
primaryKeys: pks,
});
const loadTableMeta = async () => {
try {
console.info("[DBX][openData:get-columns:start]", {
traceId,
database: node.database,
schema: querySchema,
table: node.label,
elapsed: elapsed(),
});
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
console.info("[DBX][openData:get-columns:done]", {
traceId,
columnCount: columns.length,
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
elapsed: elapsed(),
});
const pks = editablePrimaryKeys(config.db_type, columns);
queryStore.setTableMeta(tabId, {
schema: node.schema,
tableName: node.label,
columns,
primaryKeys: pks,
});
} catch (error) {
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
}
};
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
await queryStore.executeTabSql(tabId, sql);
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
void loadTableMeta();
} catch (e: any) {
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
queryStore.setErrorResult(tabId, e);
+8 -10
View File
@@ -1,8 +1,9 @@
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
export function connectionDisplayName(connectionId: string): string {
const connectionStore = useConnectionStore();
return connectionStore.getConfig(connectionId)?.name || connectionId;
@@ -13,8 +14,7 @@ export function connectionColor(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.color || "";
}
export function databaseDisplayNameForTab(connectionId: string, database: string): string {
const { t } = useI18n();
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
@@ -27,8 +27,8 @@ export function isPreviewTab(tab: QueryTab): boolean {
return !!config?.name.startsWith("[Preview]");
}
export function tabDisplayTitle(tab: QueryTab): string {
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
const settingsStore = useSettingsStore();
const compact = settingsStore.editorSettings.compactTabTitle;
if (isPreviewTab(tab)) return tab.title;
@@ -60,10 +60,9 @@ export function tabDisplayTitle(tab: QueryTab): string {
return tab.title;
}
export function tabTooltipLines(tab: QueryTab): { label: string; value: string }[] {
const { t } = useI18n();
export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; value: string }[] {
const connName = connectionDisplayName(tab.connectionId);
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
const lines: { label: string; value: string }[] = [
{ label: t("tabs.tooltipConnection"), value: connName },
{ label: t("tabs.tooltipDatabase"), value: database },
@@ -80,8 +79,7 @@ export function tabTooltipLines(tab: QueryTab): { label: string; value: string }
return lines;
}
export function tabModeLabel(tab: QueryTab): string {
const { t } = useI18n();
export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "data") return t("tabs.table");
if (tab.mode === "query") return t("tabs.sql");
if (tab.mode === "mongo") return t("tabs.mongo");
+141 -47
View File
@@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/utils";
import { ref, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import type { DatabaseType, QueryTab } from "@/types/database";
import type { DatabaseType, QueryResult, QueryTab } from "@/types/database";
import { orderPinnedFirst } from "@/lib/pinnedItems";
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
@@ -83,14 +83,20 @@ export const useQueryStore = defineStore("query", () => {
}
}
function touchResult(tab: QueryTab | undefined, accessedAt = Date.now()) {
if (tab?.result || tab?.results) tab.resultAccessedAt = accessedAt;
}
function clearResultPayload(tab: QueryTab, options: { evicted?: boolean } = {}) {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.resultSessionId = undefined;
tab.resultAccessedAt = undefined;
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
if (tab.mode === "query") tab.tableMeta = undefined;
tab.resultEvicted = options.evicted ? true : undefined;
}
@@ -447,34 +453,59 @@ export const useQueryStore = defineStore("query", () => {
await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined });
}
/**
* Analyze query metadata for result tooltips and editability.
*/
async function analyzeQueryMetadata(tab: QueryTab, sql: string) {
type QueryMetadataPatch = Pick<
QueryTab,
"queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta"
>;
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
tab.queryAnalysis = patch.queryAnalysis;
tab.querySourceColumns = patch.querySourceColumns;
tab.queryEditabilityReason = patch.queryEditabilityReason;
tab.tableMeta = patch.tableMeta;
}
async function buildQueryMetadataPatch(
tab: QueryTab,
sql: string,
traceId?: string,
elapsed?: () => string,
): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.tableMeta = undefined;
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: undefined,
tableMeta: undefined,
};
}
console.info("[DBX][executeTabSql:metadata:editability:start]", { traceId, elapsed: elapsed?.() });
const editability = await api.analyzeEditableQueryEditability(sql);
console.info("[DBX][executeTabSql:metadata:editability:done]", {
traceId,
editable: editability.editable,
reason: editability.editable ? undefined : editability.reason,
elapsed: elapsed?.(),
});
if (!editability.editable) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = editability.reason;
tab.tableMeta = undefined;
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: editability.reason,
tableMeta: undefined,
};
}
const analysis = editability.analysis;
if (!tab.connectionId || !tab.database) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "metadata-unavailable";
tab.tableMeta = undefined;
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "metadata-unavailable",
tableMeta: undefined,
};
}
// Resolve schema per database type
@@ -488,10 +519,20 @@ export const useQueryStore = defineStore("query", () => {
}
try {
console.info("[DBX][executeTabSql:metadata:get-columns:start]", {
traceId,
schema,
table: analysis.tableName,
elapsed: elapsed?.(),
});
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
console.info("[DBX][executeTabSql:metadata:get-columns:done]", {
traceId,
columnCount: columns.length,
elapsed: elapsed?.(),
});
const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns);
tab.tableMeta = {
const tableMeta = {
schema: schema || undefined,
tableName: analysis.tableName,
columns,
@@ -499,38 +540,71 @@ export const useQueryStore = defineStore("query", () => {
};
if (primaryKeys.length === 0) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "no-primary-key";
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "no-primary-key",
tableMeta,
};
}
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, analysis)) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "primary-key-not-returned";
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "primary-key-not-returned",
tableMeta,
};
}
if (!allEditableColumnsWriteable(analysis, tab.result.columns)) {
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "aliased-columns";
return;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "aliased-columns",
tableMeta,
};
}
tab.queryAnalysis = analysis;
tab.querySourceColumns = sourceColumnsForResult(analysis, tab.result.columns);
tab.queryEditabilityReason = undefined;
return {
queryAnalysis: analysis,
querySourceColumns: sourceColumnsForResult(analysis, tab.result.columns),
queryEditabilityReason: undefined,
tableMeta,
};
} catch (err) {
console.error("[DBX] ERROR fetching columns for query metadata:", err);
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = "metadata-unavailable";
tab.tableMeta = undefined;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "metadata-unavailable",
tableMeta: undefined,
};
}
}
function analyzeQueryMetadataInBackground(
tabId: string,
sql: string,
result: QueryResult,
traceId: string,
elapsed: () => string,
) {
void (async () => {
const tab = tabs.value.find((t) => t.id === tabId);
if (!tab || tab.result !== result) return;
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed);
const current = tabs.value.find((t) => t.id === tabId);
if (patch && current?.result === result) {
applyQueryMetadataPatch(current, patch);
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
} else {
console.warn("[DBX][executeTabSql:metadata:stale]", { traceId, elapsed: elapsed() });
}
})();
}
async function executeTabSql(
id: string,
sql: string,
@@ -626,6 +700,7 @@ export const useQueryStore = defineStore("query", () => {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = mongoDocumentsToQueryResult(result.documents, performance.now() - startedAt, result.total);
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
@@ -657,6 +732,7 @@ export const useQueryStore = defineStore("query", () => {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = mongoCountToQueryResult(result.total, performance.now() - startedAt);
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
@@ -693,6 +769,7 @@ export const useQueryStore = defineStore("query", () => {
current.results = undefined;
current.activeResultIndex = undefined;
current.result = mongoDocumentsToQueryResult(result.documents, performance.now() - startedAt, result.total);
touchResult(current);
current.queryAnalysis = undefined;
current.querySourceColumns = undefined;
current.queryEditabilityReason = undefined;
@@ -704,6 +781,7 @@ export const useQueryStore = defineStore("query", () => {
}
console.info("[DBX][executeTabSql:execute-multi:start]", { traceId, elapsed: elapsed() });
const clientSessionId = useAgentResultSession ? tab.id : undefined;
const executionOptions = {
...(typeof pageLimit === "number"
? useAgentResultSession
@@ -715,13 +793,14 @@ export const useQueryStore = defineStore("query", () => {
}
: { maxRows: pageLimit, fetchSize: pageLimit }
: {}),
clientSessionId: tab.id,
...(clientSessionId ? { clientSessionId } : {}),
timeoutSecs: queryTimeoutSecs,
};
const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
const timeoutError = new Error(t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }));
const executionSchema = tab.mode === "data" ? undefined : tab.schema;
const results = await Promise.race([
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, tab.schema, executionId, executionOptions),
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, executionSchema, executionId, executionOptions),
new Promise<never>((_, reject) => setTimeout(() => reject(timeoutError), frontendTimeoutSecs * 1000)),
]);
console.info("[DBX][executeTabSql:execute-multi:done]", {
@@ -749,6 +828,15 @@ export const useQueryStore = defineStore("query", () => {
current.resultPageOffset = pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = current.result?.session_id ?? undefined;
touchResult(current);
console.info("[DBX][executeTabSql:result:assigned]", {
traceId,
activeResultIndex: current.activeResultIndex,
rowCount: current.result?.rows.length ?? 0,
columnCount: current.result?.columns.length ?? 0,
backendMs: current.result?.execution_time_ms,
elapsed: elapsed(),
});
if (countSql && current.result?.rows.length) {
// When the result set is smaller than the page size we already have
// all rows — compute the total directly instead of running COUNT(*).
@@ -778,9 +866,8 @@ export const useQueryStore = defineStore("query", () => {
});
}
}
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
await analyzeQueryMetadata(current, queryBaseSql);
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
if (current.mode === "query" && current.result)
analyzeQueryMetadataInBackground(id, queryBaseSql, current.result, traceId, elapsed);
} else {
console.warn("[DBX][executeTabSql:stale-result]", {
traceId,
@@ -806,6 +893,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultPageOffset = pageOffset;
current.resultCountSql = countSql;
current.resultSessionId = undefined;
touchResult(current);
}
} finally {
const current = tabs.value.find((t) => t.id === id);
@@ -851,7 +939,6 @@ export const useQueryStore = defineStore("query", () => {
tab.lastExplainedSql = sql;
try {
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
clientSessionId: tab.id,
timeoutSecs: queryTimeoutSecs,
});
const current = tabs.value.find((t) => t.id === id);
@@ -926,6 +1013,7 @@ export const useQueryStore = defineStore("query", () => {
if (!tab?.results || index < 0 || index >= tab.results.length) return;
tab.activeResultIndex = index;
tab.result = tab.results[index];
touchResult(tab);
tab.queryAnalysis = undefined;
tab.querySourceColumns = undefined;
tab.queryEditabilityReason = undefined;
@@ -944,13 +1032,19 @@ export const useQueryStore = defineStore("query", () => {
}
async function trimResultCache() {
const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results));
const inactive = tabs.value
.filter((t) => t.id !== activeTabId.value && (t.result || t.results))
.sort((a, b) => (a.resultAccessedAt ?? 0) - (b.resultAccessedAt ?? 0));
if (inactive.length > MAX_CACHED_RESULTS) {
const toEvict = inactive.slice(0, inactive.length - MAX_CACHED_RESULTS);
await Promise.all(toEvict.map((t) => evictCachedResult(t)));
}
}
watch(activeTabId, (id) => {
touchResult(tabs.value.find((tab) => tab.id === id));
});
async function reloadEvictedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || !tab.resultEvicted) return;
+1
View File
@@ -335,6 +335,7 @@ export interface QueryTab {
resultCountSql?: string;
resultTotalRowCount?: number;
resultSessionId?: string;
resultAccessedAt?: number;
pinned?: boolean;
result?: QueryResult;
results?: QueryResult[];
+48
View File
@@ -248,16 +248,30 @@ async fn execute_select_prepared(
start: Instant,
row_limit: usize,
) -> Result<QueryResult, tokio_postgres::Error> {
let prepared_start = Instant::now();
let stmt = client.prepare_cached(sql).await?;
log::info!(
"[postgres][select:prepare_cached:done] elapsed_ms={} total_ms={}",
prepared_start.elapsed().as_millis(),
start.elapsed().as_millis()
);
let columns: Vec<String> = stmt.columns().iter().map(|c| c.name().to_string()).collect();
let column_types: Vec<String> = stmt.columns().iter().map(|c| c.type_().name().to_string()).collect();
let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
let query_start = Instant::now();
let stream = client.query_raw(&stmt, params).await?;
log::info!(
"[postgres][select:query_raw:done] elapsed_ms={} total_ms={} column_count={}",
query_start.elapsed().as_millis(),
start.elapsed().as_millis(),
columns.len()
);
tokio::pin!(stream);
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut truncated = false;
let rows_start = Instant::now();
while let Some(row_result) = stream.next().await {
if result_rows.len() >= row_limit {
truncated = true;
@@ -270,6 +284,13 @@ async fn execute_select_prepared(
.collect(),
);
}
log::info!(
"[postgres][select:rows:done] elapsed_ms={} total_ms={} row_count={} truncated={}",
rows_start.elapsed().as_millis(),
start.elapsed().as_millis(),
result_rows.len(),
truncated
);
Ok(QueryResult {
columns,
@@ -950,13 +971,40 @@ pub async fn execute_query_with_schema_and_max_rows(
sql: &str,
max_rows: Option<usize>,
) -> Result<QueryResult, String> {
let start = Instant::now();
let checkout_start = Instant::now();
let client = pool.get().await.map_err(|e| e.to_string())?;
log::info!(
"[postgres][execute_with_schema:pool:done] elapsed_ms={} total_ms={} schema={}",
checkout_start.elapsed().as_millis(),
start.elapsed().as_millis(),
schema
);
let set_schema_start = Instant::now();
client.execute(&format!("SET search_path TO {}", pg_quote_ident(schema)), &[]).await.map_err(pg_error_to_string)?;
log::info!(
"[postgres][execute_with_schema:set-search-path:done] elapsed_ms={} total_ms={}",
set_schema_start.elapsed().as_millis(),
start.elapsed().as_millis()
);
let query_start = Instant::now();
let result = execute_query_with_max_rows_inner(&client, sql, max_rows).await;
log::info!(
"[postgres][execute_with_schema:query:done] elapsed_ms={} total_ms={} ok={}",
query_start.elapsed().as_millis(),
start.elapsed().as_millis(),
result.is_ok()
);
// Always reset search_path so the connection is clean when returned to the pool
let reset_start = Instant::now();
let _ = client.execute("RESET search_path", &[]).await;
log::info!(
"[postgres][execute_with_schema:reset-search-path:done] elapsed_ms={} total_ms={}",
reset_start.elapsed().as_millis(),
start.elapsed().as_millis()
);
result
}
+178 -4
View File
@@ -136,6 +136,79 @@ test("evicting cached tab results releases multi-result payloads and sessions",
}
});
test("result cache eviction keeps recently accessed inactive tabs", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
let executeCount = 0;
connectionStore.addEphemeralConnection(conn("conn-1"));
globalThis.fetch = (async (input) => {
const url = String(input);
if (url === "/api/query/execute-multi") {
executeCount++;
const results: QueryResult[] = [
{
columns: ["id"],
rows: [[executeCount]],
affected_rows: 0,
execution_time_ms: 1,
session_id: `session-${executeCount}`,
},
];
return new Response(JSON.stringify(results), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/query/close-session") {
return new Response(JSON.stringify(true), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
const tabIds: string[] = [];
for (let i = 0; i < 6; i++) {
const tabId = store.createTab("conn-1", "db", `Query ${i + 1}`);
tabIds.push(tabId);
await store.executeTabSql(tabId, `select ${i + 1}`);
}
store.activeTabId = tabIds[0];
await new Promise((resolve) => setTimeout(resolve, 1));
const tabId = store.createTab("conn-1", "db", "Query 7");
tabIds.push(tabId);
await store.executeTabSql(tabId, "select 7");
const recentlyViewed = store.tabs.find((tab) => tab.id === tabIds[0]);
const leastRecentlyUsed = store.tabs.find((tab) => tab.id === tabIds[1]);
assert.ok(recentlyViewed?.result);
assert.equal(recentlyViewed?.resultEvicted, undefined);
assert.equal(leastRecentlyUsed?.result, undefined);
assert.equal(leastRecentlyUsed?.resultEvicted, true);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("closing tabs clears removed result payloads before dropping tab references", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
@@ -201,10 +274,10 @@ test("starting a new query clears the previous result payload immediately", asyn
});
}
if (url === "/api/query/execute-multi") {
return new Response(
JSON.stringify([{ columns: ["new"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
return new Response(JSON.stringify([{ columns: ["new"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
@@ -227,6 +300,107 @@ test("starting a new query clears the previous result payload immediately", asyn
}
});
test("query execution finishes without waiting for metadata analysis", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query");
const tab = store.tabs.find((item) => item.id === tabId);
assert.ok(tab);
let resolveMetadata: ((value: Response) => void) | undefined;
globalThis.fetch = (async (input) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select id from users", useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/analyze-editability") {
return new Promise<Response>((resolve) => {
resolveMetadata = resolve;
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
await store.executeTabSql(tabId, "select id from users");
assert.equal(tab.isExecuting, false);
assert.equal(tab.executionId, undefined);
assert.deepEqual(tab.result?.columns, ["id"]);
resolveMetadata?.(
new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("normal query execution does not create a tab-scoped client session", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
connectionStore.addEphemeralConnection(conn("conn-1"));
const tabId = store.createTab("conn-1", "db", "Query");
let executeBody: any;
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
executeBody = JSON.parse(String(init?.body ?? "{}"));
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
}) as typeof fetch;
try {
await store.executeTabSql(tabId, "select 1");
assert.equal(executeBody.clientSessionId, undefined);
assert.equal(executeBody.timeoutSecs, 30);
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("tab reuse is scoped by mode and schema instead of title alone", () => {
const restoreStorage = installMemoryStorage();
try {
+12 -3
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Instant;
use tauri::State;
use crate::commands::connection::AppState;
@@ -63,6 +64,7 @@ pub async fn execute_multi(
execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone()));
let cancel_token = registered_query.as_ref().map(|query| query.token());
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id");
let started_at = Instant::now();
log::info!(
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?} sql={}",
trace_id,
@@ -91,12 +93,19 @@ pub async fn execute_multi(
.await;
match &result {
Ok(results) => log::info!(
"[query][execute_multi:done] trace_id={} result_count={} row_counts={:?}",
"[query][execute_multi:done] trace_id={} elapsed_ms={} result_count={} row_counts={:?} backend_execution_times_ms={:?}",
trace_id,
started_at.elapsed().as_millis(),
results.len(),
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>()
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>(),
results.iter().map(|result| result.execution_time_ms).collect::<Vec<_>>()
),
Err(error) => log::error!(
"[query][execute_multi:error] trace_id={} elapsed_ms={} error={}",
trace_id,
started_at.elapsed().as_millis(),
error
),
Err(error) => log::error!("[query][execute_multi:error] trace_id={} error={}", trace_id, error),
}
result
}