perf(mysql): batch adjacent DML into multi-statement COM_QUERY for large scripts

This commit is contained in:
zipg authored and GitHub committed 2026-08-11 13:06:21 +08:00
1 parent adb2b4c84b
commit 7c1d094946
9 files changed
+683 -82

No files matched your search

@@ -9,6 +9,7 @@ import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, CheckSquare2, Columns3Cog, Copy, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -1412,14 +1413,14 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 flex-col bg-background">
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
<template v-else>{{ t("executionSummary.empty") }}</template>
</div>
<div v-else class="min-w-[46rem]">
<div v-if="batchExecutionProgress" class="sticky top-0 z-10 border-b bg-background/95 px-3 py-2 backdrop-blur">
<div v-else class="flex h-full min-h-0 min-w-[46rem] flex-col">
<div v-if="batchExecutionProgress" class="z-10 shrink-0 border-b bg-background/95 px-3 py-2 backdrop-blur">
<div class="mb-1.5 flex items-center gap-3 text-xs">
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
<span class="tabular-nums text-muted-foreground">{{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }}</span>
@@ -1429,59 +1430,61 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
</div>
</div>
<div class="overflow-hidden border-b">
<div class="grid grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<div>{{ t("executionSummary.statement") }}</div>
<div>{{ t("executionSummary.sql") }}</div>
<div>{{ t("executionSummary.status") }}</div>
<div class="text-right">{{ t("executionSummary.rows") }}</div>
<div class="text-right">{{ t("executionSummary.time") }}</div>
</div>
<div v-for="item in summaryItems" :key="item.statementIndex" class="relative grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs last:border-b-0">
<button
type="button"
class="absolute inset-0 z-0 cursor-pointer text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
:title="item.error || item.sql"
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
@click="previewExecutionSummaryItem(item)"
@dblclick="focusExecutionSummaryItem(item)"
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
/>
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
<Copy class="h-3 w-3" />
</button>
</LightTooltip>
</div>
</div>
<div class="pointer-events-none relative z-[1]">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
:class="{
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
}"
>
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
<SquareDashed v-else class="h-3 w-3" />
{{ t(`executionSummary.statuses.${item.status}`) }}
</span>
</div>
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.rowCount.toLocaleString() }}</div>
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
</div>
<div class="grid shrink-0 grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<div>{{ t("executionSummary.statement") }}</div>
<div>{{ t("executionSummary.sql") }}</div>
<div>{{ t("executionSummary.status") }}</div>
<div class="text-right">{{ t("executionSummary.rows") }}</div>
<div class="text-right">{{ t("executionSummary.time") }}</div>
</div>
<div class="px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
<DynamicScroller v-slot="{ item, index, active }" class="min-h-0 flex-1 border-b" :items="summaryItems" :min-item-size="37" :buffer="600" :skip-hover="true" key-field="statementIndex">
<DynamicScrollerItem :item="item" :active="active" :data-index="index" :size-dependencies="[item.error]">
<div class="relative grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs last:border-b-0">
<button
type="button"
class="absolute inset-0 z-0 cursor-pointer text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
:title="item.error || item.sql"
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
@click="previewExecutionSummaryItem(item)"
@dblclick="focusExecutionSummaryItem(item)"
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
/>
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
<Copy class="h-3 w-3" />
</button>
</LightTooltip>
</div>
</div>
<div class="pointer-events-none relative z-[1]">
<span
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
:class="{
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
}"
>
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
<SquareDashed v-else class="h-3 w-3" />
{{ t(`executionSummary.statuses.${item.status}`) }}
</span>
</div>
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.rowCount.toLocaleString() }}</div>
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
</div>
</DynamicScrollerItem>
</DynamicScroller>
<div class="shrink-0 px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
</div>
</div>
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
describe("queryTimeout", () => {
it("lets PostgreSQL row queries use the backend inactivity timeout", () => {
@@ -19,6 +19,12 @@ describe("queryTimeout", () => {
expect(queryTimeoutSecsForConnection({ query_timeout_secs: undefined })).toBe(30);
});
it("does not schedule frontend timeouts beyond the browser timer limit", () => {
expect(frontendQueryTimeoutDelayMs(60)).toBe(60_000);
expect(frontendQueryTimeoutDelayMs(11_401_200)).toBeUndefined();
expect(frontendQueryTimeoutDelayMs(0)).toBeUndefined();
});
it("uses the global timeout only for inheriting connections", () => {
expect(queryTimeoutSecsForConnection({ query_timeout_secs: 30, query_timeout_inherit: true }, 12)).toBe(12);
expect(queryTimeoutSecsForConnection({ query_timeout_secs: 30, query_timeout_inherit: false }, 12)).toBe(30);
@@ -234,6 +234,35 @@ describe("execution summary", () => {
{ status: "error", isError: true },
]);
});
it("maps out-of-order results to their explicit statement indexes", () => {
const items = executionSummaryItems({
results: [
{ columns: ["value"], rows: [["third"]], affected_rows: 0, execution_time_ms: 3, statement_index: 2 },
{ columns: ["value"], rows: [["first"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0 },
],
batchSqlExecution: {
executionId: "run-out-of-order",
submittedSql: "SELECT 'first'; SELECT 'second'; SELECT 'third'",
editorFingerprint: "fingerprint",
sourceOffset: 0,
completed: 2,
total: 3,
startedAt: 1,
items: [
{ statementIndex: 0, sql: "SELECT 'first'", from: 0, to: 14, status: "success" },
{ statementIndex: 1, sql: "SELECT 'second'", from: 16, to: 31, status: "skipped" },
{ statementIndex: 2, sql: "SELECT 'third'", from: 33, to: 47, status: "success" },
],
},
});
expect(items.map((item) => [item.statementIndex, item.result?.rows[0]?.[0]])).toEqual([
[0, "first"],
[1, undefined],
[2, "third"],
]);
});
});
describe("statement execution markers", () => {
+7
View File
@@ -3,6 +3,7 @@ import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
export const DEFAULT_QUERY_TIMEOUT_SECS = 30;
const MAX_BROWSER_TIMER_DELAY_MS = 2_147_483_647;
const POSTGRES_ROW_STATEMENT_KEYWORDS = new Set(["select", "show", "explain", "table", "with"]);
const POSTGRES_RETURNING_STATEMENT_KEYWORDS = new Set(["insert", "update", "delete", "merge"]);
@@ -30,6 +31,12 @@ export function frontendQueryTimeoutSecsForSql(sql: string, databaseType: Databa
return baseTimeoutSecs * statementCount;
}
export function frontendQueryTimeoutDelayMs(timeoutSecs: number): number | undefined {
const timeoutMs = timeoutSecs * 1000;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_BROWSER_TIMER_DELAY_MS) return undefined;
return timeoutMs;
}
function postgresQueryMayReturnRows(sql: string, databaseType: DatabaseType): boolean {
return splitSqlStatementRanges(sql, databaseType).some(({ sql: statement }) => {
const tokens = tokenizeSqlSemantic(statement, "postgres");
+5 -1
View File
@@ -368,8 +368,12 @@ export interface ExecutionSummaryItem {
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" | "batchSqlExecution">): ExecutionSummaryItem[] {
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
if (tab.batchSqlExecution?.items.length) {
const resultsByStatementIndex = new Map<number, QueryResult>();
results.forEach((result, resultIndex) => {
resultsByStatementIndex.set(result.statement_index ?? resultIndex, result);
});
return tab.batchSqlExecution.items.map((item, index) => {
const result = results.find((candidate, resultIndex) => (candidate.statement_index ?? resultIndex) === item.statementIndex);
const result = resultsByStatementIndex.get(item.statementIndex);
const returnedRows = result?.rows.length ?? 0;
const affectedRows = item.affectedRows ?? result?.affected_rows ?? 0;
const hasTabularResult = (result?.columns.length ?? 0) > 0;
@@ -197,6 +197,47 @@ describe("queryStore multi-statement errors", () => {
});
});
it("marks every statement completed by a pipelined progress event", async () => {
const pendingExecution = deferred<any[]>();
let reportProgress!: (progress: any) => void;
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress) => {
reportProgress = onProgress;
return pendingExecution.promise;
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const sql = "SET @n = 0;\nINSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nSELECT COUNT(*) FROM t";
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
const execution = store.executeTabSql(tabId, sql);
await vi.waitFor(() => expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items[0]?.status).toBe("running"));
const executionId = store.tabs.find((item) => item.id === tabId)!.executionId!;
reportProgress({
executionId,
statementIndex: 2,
completed: 3,
total: 4,
success: true,
executionTimeMs: 8,
affectedRows: 1,
});
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 3,
total: 4,
items: [{ status: "success" }, { status: "success" }, { status: "success" }, { status: "running" }],
});
pendingExecution.resolve([
{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 2, statement_index: 0 },
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 4, statement_index: 1 },
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 8, statement_index: 2 },
{ columns: ["COUNT(*)"], rows: [[2]], affected_rows: 0, execution_time_ms: 2, statement_index: 3 },
]);
await execution;
});
it("records a top-level batch failure on the current statement", async () => {
mocks.executeMultiWithProgress.mockRejectedValueOnce(new Error("transport failed"));
const { useQueryStore } = await import("@/stores/queryStore");
+14 -4
View File
@@ -43,7 +43,7 @@ import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { getCachedTableMetadata, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache";
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
@@ -295,8 +295,17 @@ function applyBatchSqlProgress(
) {
const batch = batchSqlExecutionFor(tab, progress.executionId);
if (!batch) return;
const previousCompleted = batch.completed;
const item = batch.items[progress.statementIndex];
if (!item) return;
if (progress.success && progress.completed > previousCompleted + 1) {
for (let index = previousCompleted; index < progress.completed - 1; index += 1) {
const completedItem = batch.items[index];
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
completedItem.status = "success";
}
}
}
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
@@ -304,7 +313,7 @@ function applyBatchSqlProgress(
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
batch.completed = Math.max(batch.completed, progress.completed);
if ((progress.success || continueOnError) && progress.completed < batch.total) {
const next = batch.items[progress.statementIndex + 1];
const next = batch.items[progress.completed];
if (next?.status === "pending") next.status = "running";
}
}
@@ -430,14 +439,15 @@ function displayedQueryMetadataSql(tab: QueryTab, fallbackSql: string): string {
}
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string): Promise<T> {
if (timeoutSecs === 0) return promise;
const timeoutMs = frontendQueryTimeoutDelayMs(timeoutSecs);
if (timeoutMs === undefined) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutSecs * 1000);
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {