fix(query): throttle batch progress events and apply them incrementally

This commit is contained in:
t8y2 committed 2026-09-23 19:29:03 +08:00
1 parent 8cfac35990
commit 67dd9a6229
5 files changed
+313 -41

No files matched your search

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
defaultAutoKeepResults: false,
continueOnErrorOnBatch: false,
analyzeEditableQueryEditability: vi.fn(),
cancelQuery: vi.fn(),
closeClientConnectionSession: vi.fn(),
@@ -44,7 +45,7 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { autoCalculateTotalRows: false, pageSize: 100, continueOnErrorOnBatch: false, defaultAutoKeepResults: mocks.defaultAutoKeepResults },
editorSettings: { autoCalculateTotalRows: false, pageSize: 100, continueOnErrorOnBatch: mocks.continueOnErrorOnBatch, defaultAutoKeepResults: mocks.defaultAutoKeepResults },
}),
}));
@@ -115,6 +116,7 @@ describe("queryStore multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.defaultAutoKeepResults = false;
mocks.continueOnErrorOnBatch = false;
vi.unstubAllGlobals();
mocks.tabResultSnapshots.clear();
installLocalStorage();
@@ -496,6 +498,52 @@ describe("queryStore multi-statement errors", () => {
await execution;
});
it("settles the statements between coalesced progress events without overwriting a failure", async () => {
mocks.continueOnErrorOnBatch = true;
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 = Array.from({ length: 6 }, (_, index) => `INSERT INTO t VALUES (${index + 1});`).join("\n");
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
const batch = () => store.tabs.find((item) => item.id === tabId)?.batchSqlExecution;
const execution = store.executeTabSql(tabId, sql);
await vi.waitFor(() => expect(batch()?.items[0]?.status).toBe("running"));
const executionId = store.tabs.find((item) => item.id === tabId)!.executionId!;
const report = (statementIndex: number, success: boolean) =>
reportProgress({
executionId,
statementIndex,
completed: statementIndex + 1,
total: 6,
success,
executionTimeMs: 1,
affectedRows: success ? 1 : 0,
error: success ? undefined : structuredSqlError(),
});
report(1, true);
expect(batch()).toMatchObject({ completed: 2, items: [{ status: "success" }, { status: "success" }, { status: "running" }, { status: "pending" }, { status: "pending" }, { status: "pending" }] });
report(3, false);
expect(batch()).toMatchObject({ completed: 4, items: [{ status: "success" }, { status: "success" }, { status: "success" }, { status: "error" }, { status: "running" }, { status: "pending" }] });
report(5, true);
expect(batch()).toMatchObject({ completed: 6, items: [{ status: "success" }, { status: "success" }, { status: "success" }, { status: "error" }, { status: "success" }, { status: "success" }] });
pendingExecution.resolve(
Array.from({ length: 6 }, (_, index) =>
index === 3 ? { columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, statement_index: index, execution_error: true, error: structuredSqlError() } : { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: index },
),
);
await execution;
expect(batch()).toMatchObject({ completed: 6, items: [{ status: "success" }, { status: "success" }, { status: "success" }, { status: "error" }, { status: "success" }, { status: "success" }] });
});
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");
+44 -12
View File
@@ -2,13 +2,29 @@ import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/upd
import { defineStore } from "pinia";
import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, toRaw, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { DeletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import type {
BatchSqlExecution,
BatchStatementExecutionItem,
ConnectionConfig,
DatabaseType,
IndexInfo,
NacosConfigEditorViewport,
ObjectBrowserFilter,
ObjectBrowserViewport,
ObjectSource,
ObjectSourceKind,
QueryResult,
QueryResultSourceColumnRef,
QueryTab,
TableInfoTab,
TableStructureEditorTarget,
} from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { isSqlErrorPositionDebugEnabled, logSqlErrorPosition, sqlErrorHasMessagePosition, sqlErrorMessageText } from "@/lib/sql/errorPosition";
@@ -444,6 +460,13 @@ function annotateSingleStatementErrorResult(errorResult: QueryResult, sourceSql:
const NON_STREAMING_BATCH_DATABASE_TYPES = new Set<DatabaseType>(["sqlserver", "turso", "cloudflare-d1"]);
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
// Per running batch: statements before this index are already settled by an
// earlier progress event of the same run.
const batchSqlProgressSettledEnds = new WeakMap<BatchSqlExecution, number>();
function isCompletedBatchStatement(item: BatchStatementExecutionItem): boolean {
return item.status === "success" || item.status === "error";
}
function cloneBatchSqlExecution(batch: BatchSqlExecution | undefined): BatchSqlExecution | undefined {
return batch ? { ...batch, executionTarget: batch.executionTarget ? { ...batch.executionTarget } : undefined, items: batch.items.map((item) => ({ ...item })) } : undefined;
@@ -503,20 +526,29 @@ function applyBatchSqlProgress(
const statementIndex = statementOffset + progress.statementIndex;
const item = batch.items[statementIndex];
if (!item) return;
if (progress.completed > 1) {
for (let index = statementOffset; index < statementOffset + progress.completed - 1; index += 1) {
const completedItem = batch.items[index];
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
completedItem.status = "success";
}
// Statements complete in order and the desktop backend coalesces successful
// ones, so an event also settles every statement since the previous event.
// Walking only that gap and counting incrementally keeps a batch linear;
// rescanning every statement per event made large scripts O(N²).
const rawBatch = toRaw(batch);
const settledStart = Math.max(statementOffset, batchSqlProgressSettledEnds.get(rawBatch) ?? statementOffset);
const settledEnd = statementOffset + progress.completed - 1;
let completed = batch.completed;
for (let index = settledStart; index < settledEnd; index += 1) {
const completedItem = batch.items[index];
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
completedItem.status = "success";
completed += 1;
}
}
if (settledEnd > settledStart) batchSqlProgressSettledEnds.set(rawBatch, settledEnd);
if (!isCompletedBatchStatement(item)) completed += 1;
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
item.errorDetails = progress.error;
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
batch.completed = completed;
if ((progress.success || continueOnError) && progress.completed < progress.total) {
const next = batch.items[statementOffset + progress.completed];
if (next?.status === "pending") next.status = "running";
@@ -539,7 +571,7 @@ function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: Q
item.errorDetails = failed ? result.error : undefined;
item.error = failed ? (result.error ? translateBackendError(i18n.global.t, result.error, result.rows[0]?.[0]) : String(result.rows[0]?.[0] ?? "")) : undefined;
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
}
function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknown, cancelled: boolean) {
@@ -550,7 +582,7 @@ function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknow
item.status = cancelled ? "cancelled" : "error";
item.errorDetails = cancelled ? undefined : (normalizeBackendError(error) ?? undefined);
item.error = cancelled ? undefined : translateBackendError(i18n.global.t, error, error instanceof Error ? error.message : undefined);
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
}
function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled: boolean) {
@@ -572,7 +604,7 @@ function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled:
item.status = "skipped";
}
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
batch.completed = batch.items.filter(isCompletedBatchStatement).length;
batch.finishedAt = Date.now();
}