feat(transactions): add manual transactions to more execution paths

This commit is contained in:
efeire authored and GitHub committed 2026-09-20 11:07:11 +08:00
1 parent 88ba58a79c
commit a924285136
38 files changed
+2471 -146

No files matched your search

@@ -9,6 +9,116 @@ const targets = [
] as const;
describe("useMultiDbExecution", () => {
it("retains separate transactions until each target is explicitly settled", async () => {
const commits = targets.map(() => vi.fn().mockResolvedValue(undefined));
const executor = useMultiDbExecution(
{
executeTarget: async ({ target, context }) => {
expect(context.manualTransaction).toBe(true);
return { status: "pending_commit", transaction: { canCommit: true, finish: commits[targets.findIndex((candidate) => candidate.connectionId === target.connectionId)] } };
},
},
{ sourceTabId: "source" },
);
const batch = await executor.start("UPDATE t SET n = 1", targets, { manualTransaction: true });
expect(executor.hasTransactions.value).toBe(true);
expect(commits.every((commit) => commit.mock.calls.length === 0)).toBe(true);
executor.reset();
expect(executor.batch.value?.id).toBe(batch?.id);
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
await executor.finishTransaction(batch!.items[0].id, "commit");
await executor.finishTransaction(batch!.items[1].id, "rollback");
expect(batch!.items.map((item) => item.status)).toEqual(["success", "rolled_back", "pending_commit"]);
expect(commits[0]).toHaveBeenCalledWith("commit");
expect(commits[1]).toHaveBeenCalledWith("rollback");
expect(commits[2]).not.toHaveBeenCalled();
});
it("preserves a failed settlement for rollback and does not run it twice concurrently", async () => {
let reject!: (reason: Error) => void;
const finish = vi.fn(
() =>
new Promise<void>((_resolve, fail) => {
reject = fail;
}),
);
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: true, finish } }) }, { sourceTabId: "source" });
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
const committing = executor.finishTransaction(batch!.items[0].id, "commit");
expect(await executor.finishTransaction(batch!.items[0].id, "commit")).toBe(false);
reject(new Error("connection lost"));
expect(await committing).toBe(false);
expect(batch!.items[0].errorMessage).toBe("connection lost");
expect(executor.hasTransactions.value).toBe(true);
finish.mockResolvedValueOnce(undefined);
expect(await executor.finishTransaction(batch!.items[0].id, "rollback")).toBe(true);
expect(finish).toHaveBeenCalledTimes(2);
expect(executor.hasTransactions.value).toBe(false);
});
it("rolls back an execution that finishes after disposal without starting the next target", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const finish = vi.fn().mockResolvedValue(undefined);
const executeTarget = vi.fn(async () => {
await gate;
return { status: "pending_commit" as const, transaction: { canCommit: true, finish } };
});
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
const run = executor.start("UPDATE t SET n = 1", targets, { manualTransaction: true });
const disposing = executor.dispose();
expect(finish).not.toHaveBeenCalled();
release();
await run;
expect(await disposing).toBe(true);
expect(executeTarget).toHaveBeenCalledTimes(1);
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
expect(executor.batch.value!.items.map((item) => item.status)).toEqual(["rolled_back", "not_executed", "not_executed"]);
});
it("waits for a commit in flight before disposing the other transactions", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const finish = vi.fn(async (action: string) => {
if (action === "commit") await gate;
});
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: true, finish } }) }, { sourceTabId: "source" });
const batch = await executor.start("UPDATE t SET n = 1", targets.slice(0, 2), { manualTransaction: true });
const commit = executor.finishTransaction(batch!.items[0].id, "commit");
const disposing = executor.dispose();
release();
await commit;
expect(await disposing).toBe(true);
expect(batch!.items.map((item) => item.status)).toEqual(["success", "rolled_back"]);
expect(finish.mock.calls.map((call) => call[0])).toEqual(["commit", "rollback"]);
});
it("retains failed cleanup without allowing commit or reset", async () => {
const finish = vi.fn().mockRejectedValue(new Error("rollback unavailable"));
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "failed", transaction: { canCommit: false, finish } }) }, { sourceTabId: "source" });
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
expect(await executor.finishTransaction(batch!.items[0].id, "commit")).toBe(false);
expect(finish).not.toHaveBeenCalled();
expect(await executor.rollbackPending()).toBe(false);
executor.reset();
expect(executor.hasTransactions.value).toBe(true);
expect(batch!.items[0].errorMessage).toBe("rollback unavailable");
});
it("reports an unknown commit outcome instead of marking it rolled back", async () => {
const finish = vi.fn().mockResolvedValue("Verify data: commit result unknown");
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: false, finish } }) }, { sourceTabId: "source" });
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
expect(await executor.finishTransaction(batch!.items[0].id, "rollback")).toBe(false);
expect(batch!.items[0].status).toBe("failed");
expect(batch!.items[0].errorMessage).toBe("Verify data: commit result unknown");
expect(executor.hasTransactions.value).toBe(false);
});
it("executes serially and continues after a target failure", async () => {
const executionOrder: string[] = [];
const executor = useMultiDbExecution(
@@ -0,0 +1,203 @@
import { computed, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSqlExecution } from "../useSqlExecution";
import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import * as api from "@/lib/backend/api";
import type { ConnectionConfig, QueryTab } from "@/types/database";
vi.mock("vue-i18n", () => ({ createI18n: () => ({ global: { locale: { value: "en" }, setLocaleMessage: vi.fn() } }), useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/lib/backend/api", () => ({
saveEditorSettings: vi.fn(),
saveHistory: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
beginManualTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
rollbackManualTransaction: vi.fn(),
}));
function setup() {
const connection = { id: "target-connection", name: "Test", db_type: "oceanbase-oracle", host: "localhost", port: 2881, username: "test", password: "" } as ConnectionConfig;
const tab = { id: "source", connectionId: "source-connection", database: "source_db", sql: "INSERT INTO t VALUES (1)", title: "SQL", mode: "query", isDirty: false, isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab;
const store = useQueryStore();
store.tabs.push(tab);
const ordinary = vi.spyOn(store, "executeTabSql");
const cleanup = vi.spyOn(store, "removeMultiDbExecutionWorker").mockResolvedValue(undefined);
const capture = vi.spyOn(store, "captureMultiDbExecutionWorkerResult").mockReturnValue("result-run");
const history = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({ activeTab: computed(() => tab), activeConnection: computed(() => connection), executableSql: computed(() => tab.sql), activeOutputView: ref("result"), requestDangerConfirmation: async () => true });
const input = {
tab,
connection,
sql: tab.sql,
scopeId: "batch",
executionTarget: { connectionId: connection.id, database: "target_db", schema: "target_schema" },
resultRun: { batchId: "batch", title: "Target", target: { connectionId: connection.id, database: "target_db" } },
manualTransaction: true,
};
return { execution, input, ordinary, cleanup, capture, history };
}
describe("manual multi-database submission", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", { getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() });
setActivePinia(createPinia());
vi.mocked(api.beginManualTransaction).mockResolvedValue("session");
vi.mocked(api.executeInManualTransaction).mockResolvedValue([{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }]);
vi.mocked(api.commitManualTransaction).mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 });
vi.mocked(api.rollbackManualTransaction).mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 });
});
it("uses the captured target and records success only after an explicit commit", async () => {
const { execution, input, ordinary, cleanup, capture, history } = setup();
input.tab.resultCountSql = "SELECT COUNT(*) FROM unrelated_source";
input.tab.resultPageSql = "SELECT * FROM unrelated_source";
const result = await execution.executeTargetSql(input);
expect(api.beginManualTransaction).toHaveBeenCalledExactlyOnceWith("target-connection", "target_db", "target_schema", undefined);
expect(api.executeInManualTransaction).toHaveBeenCalledExactlyOnceWith("session", input.sql, "target_db", "target_schema", 100000);
expect(ordinary).not.toHaveBeenCalled();
expect(result.status).toBe("pending_commit");
expect(capture.mock.calls[0][3].status).toBe("pending_commit");
const worker = useQueryStore().getExecutionTab(capture.mock.calls[0][1]);
expect(worker?.resultCountSql).toBeUndefined();
expect(worker?.resultPageSql).toBeUndefined();
expect(worker?.resultBaseSql).toBe(input.sql);
expect(input.tab.resultCountSql).toBe("SELECT COUNT(*) FROM unrelated_source");
expect(history).not.toHaveBeenCalled();
expect(cleanup).not.toHaveBeenCalled();
await result.transaction!.finish("commit");
expect(api.commitManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
expect(history).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ connection_id: "target-connection", database: "target_db", success: true }));
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("rolls back pending changes without writing a successful history entry", async () => {
const { execution, input, history, cleanup } = setup();
const result = await execution.executeTargetSql(input);
await result.transaction!.finish("rollback");
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
expect(api.commitManualTransaction).not.toHaveBeenCalled();
expect(history).not.toHaveBeenCalled();
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not replay a failed statement or an expired session", async () => {
const { execution, input, ordinary } = setup();
vi.mocked(api.executeInManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
const result = await execution.executeTargetSql(input);
expect(result.status).toBe("failed");
expect(result.transaction).toBeUndefined();
expect(api.beginManualTransaction).toHaveBeenCalledTimes(1);
expect(api.executeInManualTransaction).toHaveBeenCalledTimes(1);
expect(ordinary).not.toHaveBeenCalled();
});
it("cleans up a late session if cancellation arrives while begin is pending", async () => {
const { execution, input } = setup();
let cancelled = false;
let release!: (session: string) => void;
vi.mocked(api.beginManualTransaction).mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const running = execution.executeTargetSql({ ...input, isCancellationRequested: () => cancelled });
await vi.waitFor(() => expect(api.beginManualTransaction).toHaveBeenCalled());
cancelled = true;
release("late-session");
const result = await running;
expect(result.status).toBe("cancelled");
expect(api.executeInManualTransaction).not.toHaveBeenCalled();
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("late-session");
});
it("retains failed rollback for cleanup and disallows commit", async () => {
const { execution, input, cleanup } = setup();
vi.mocked(api.executeInManualTransaction).mockRejectedValueOnce(new Error("statement failed"));
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("transport unavailable"));
const result = await execution.executeTargetSql(input);
expect(result.status).toBe("failed");
expect(result.transaction?.canCommit).toBe(false);
expect(cleanup).not.toHaveBeenCalled();
await result.transaction!.finish("rollback");
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not retry an uncertain commit or report a success history entry", async () => {
const { execution, input, history } = setup();
const result = await execution.executeTargetSql(input);
vi.mocked(api.commitManualTransaction).mockRejectedValueOnce(new Error("transport unavailable"));
await expect(result.transaction!.finish("commit")).rejects.toThrow("transport unavailable");
await expect(result.transaction!.finish("commit")).rejects.toThrow("multiDbExecute.manualCommitUnavailable");
expect(api.commitManualTransaction).toHaveBeenCalledTimes(1);
expect(history).not.toHaveBeenCalled();
await result.transaction!.finish("rollback");
});
it("does not turn a completed commit into a pending transaction when history fails", async () => {
const { execution, input, history, cleanup } = setup();
history.mockRejectedValueOnce(new Error("history unavailable"));
const result = await execution.executeTargetSql(input);
await expect(result.transaction!.finish("commit")).resolves.toBeUndefined();
await result.transaction!.finish("commit");
expect(api.commitManualTransaction).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not claim rollback after a lost commit response and a missing session", async () => {
const { execution, input, cleanup, history } = setup();
const result = await execution.executeTargetSql(input);
vi.mocked(api.commitManualTransaction).mockRejectedValueOnce(new Error("response lost"));
await expect(result.transaction!.finish("commit")).rejects.toThrow("response lost");
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
expect(await result.transaction!.finish("rollback")).toBe("toolbar.commitOutcomeUnknown");
expect(cleanup).toHaveBeenCalledTimes(1);
expect(history).not.toHaveBeenCalled();
});
it("stops later statements in the same target after cancellation", async () => {
const { execution, input } = setup();
let cancelled = false;
vi.mocked(api.executeInManualTransaction).mockImplementationOnce(async () => {
cancelled = true;
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
});
const result = await execution.executeTargetSql({ ...input, sql: "INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", isCancellationRequested: () => cancelled });
expect(result.status).toBe("cancelled");
expect(api.executeInManualTransaction).toHaveBeenCalledExactlyOnceWith("session", "INSERT INTO t VALUES (1)", "target_db", "target_schema", 100000);
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
});
it("keeps an Oracle procedural block intact while submitting subsequent statements on the same session", async () => {
const { execution, input } = setup();
const block = "BEGIN INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); END;";
const result = await execution.executeTargetSql({ ...input, sql: `${block}\nINSERT INTO t VALUES (3);` });
expect(result.status).toBe("pending_commit");
expect(api.executeInManualTransaction.mock.calls.map((call) => [call[0], call[1]])).toEqual([
["session", block],
["session", "INSERT INTO t VALUES (3)"],
]);
await result.transaction!.finish("rollback");
});
it("preserves each submitted statement and editor range for the result summary", async () => {
const { execution, input, capture } = setup();
const first = "INSERT INTO t VALUES (1)";
const second = "INSERT INTO t VALUES (2)";
const result = await execution.executeTargetSql({ ...input, sql: `${first};\n${second};`, sourceOffset: 10 });
const worker = useQueryStore().getExecutionTab(capture.mock.calls[0][1]);
expect(worker?.results).toEqual([
expect.objectContaining({ statement_index: 0, sourceStatement: first, sourceFrom: 10, sourceTo: 10 + first.length }),
expect.objectContaining({ statement_index: 1, sourceStatement: second, sourceFrom: 12 + first.length, sourceTo: 12 + first.length + second.length }),
]);
await result.transaction!.finish("rollback");
});
});
@@ -1,5 +1,5 @@
import { computed, reactive, ref, type ComputedRef, type Ref } from "vue";
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
export interface MultiDbExecutionItem {
id: string;
@@ -9,6 +9,8 @@ export interface MultiDbExecutionItem {
startedAt?: number;
completedAt?: number;
durationMs?: number;
transaction?: MultiDbManualTransaction;
settling?: boolean;
}
export interface MultiDbExecutionBatch {
@@ -39,9 +41,10 @@ export interface MultiDbExecutionContext {
readonly targets: readonly MultiDbExecutionTarget[];
/** Offset of the submitted SQL in the source editor, captured at confirmation time. */
readonly sourceOffset?: number;
readonly manualTransaction?: boolean;
}
export type MultiDbExecutionContextOverrides = Pick<MultiDbExecutionContext, "sourceOffset">;
export type MultiDbExecutionContextOverrides = Pick<MultiDbExecutionContext, "sourceOffset" | "manualTransaction">;
export interface MultiDbExecutionOptions {
sourceTabId: Ref<string> | ComputedRef<string> | string;
@@ -61,6 +64,46 @@ function normalizeError(error: unknown): string {
export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: MultiDbExecutionOptions) {
const batch = ref<MultiDbExecutionBatch>();
const isRunning = computed(() => batch.value?.status === "running" || batch.value?.status === "cancelling");
const hasTransactions = computed(() => batch.value?.items.some((item) => !!item.transaction) === true);
let activeRun: Promise<void> | undefined;
let disposed = false;
const settlements = new Set<Promise<unknown>>();
async function finishTransaction(itemId: string, action: "commit" | "rollback"): Promise<boolean> {
const item = batch.value?.items.find((candidate) => candidate.id === itemId);
if (!item?.transaction || item.settling || (action === "commit" && (!item.transaction.canCommit || disposed || isRunning.value))) return false;
item.settling = true;
let settled!: () => void;
const pending = new Promise<void>((resolve) => {
settled = resolve;
});
settlements.add(pending);
try {
const warning = await item.transaction.finish(action);
item.transaction = undefined;
if (warning) {
item.status = "failed";
item.errorMessage = warning;
return false;
}
item.status = action === "commit" ? "success" : "rolled_back";
item.errorMessage = undefined;
return true;
} catch (error) {
item.errorMessage = normalizeError(error);
return false;
} finally {
item.settling = false;
settlements.delete(pending);
settled();
}
}
async function rollbackPending(): Promise<boolean> {
const items = batch.value?.items ?? [];
const results = await Promise.all(items.filter((item) => item.transaction).map((item) => finishTransaction(item.id, "rollback")));
return results.every(Boolean) && !hasTransactions.value;
}
function sourceTabId(): string {
return typeof options.sourceTabId === "string" ? options.sourceTabId : options.sourceTabId.value;
@@ -115,6 +158,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
item.status = current.cancelRequested && result.status === "failed" ? "cancelled" : result.status;
item.errorMessage = result.errorMessage;
item.durationMs = result.durationMs;
item.transaction = result.transaction;
} catch (error) {
// One target is intentionally isolated from the queue. Adapter errors
// become target failures so later targets keep running.
@@ -136,14 +180,17 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
}
}
if (current.cancelRequested) markPendingNotExecuted();
if (current.cancelRequested) {
markPendingNotExecuted();
await rollbackPending();
}
current.status = current.cancelRequested ? "cancelled" : "completed";
current.completedAt = Date.now();
current.durationMs = current.completedAt - current.startedAt;
}
async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise<MultiDbExecutionBatch | undefined> {
if (isRunning.value || !sql.trim() || targets.length === 0) return undefined;
if (disposed || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined;
const sourceId = sourceTabId();
const id = executionId();
const targetSnapshot = targets.map((target) => Object.freeze({ ...target }));
@@ -169,7 +216,9 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
startedAt: Date.now(),
});
batch.value = current;
await executeBatch(current);
activeRun = executeBatch(current);
await activeRun;
activeRun = undefined;
return current;
}
@@ -201,13 +250,30 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
}
function reset(): void {
if (isRunning.value) return;
if (isRunning.value || hasTransactions.value) return;
batch.value = undefined;
}
async function cancelAndRollback(): Promise<boolean> {
await cancel();
await activeRun;
await Promise.all(settlements);
return rollbackPending();
}
async function dispose(): Promise<boolean> {
disposed = true;
return cancelAndRollback();
}
return {
batch,
isRunning,
hasTransactions,
finishTransaction,
rollbackPending,
dispose,
cancelAndRollback,
start,
cancel,
reset,
+145 -33
View File
@@ -7,6 +7,9 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { supportsConnectionScopedQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import * as api from "@/lib/backend/api";
import { agentProtocolQueryResultMaxRows, effectiveQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
@@ -25,7 +28,7 @@ import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -65,6 +68,7 @@ interface TargetSqlExecutionInput {
scopeId?: string;
isCancellationRequested?: () => boolean;
targetContext?: SqlExecutionTargetContext;
manualTransaction?: boolean;
}
export function stripSqlComments(sql: string): string {
@@ -548,6 +552,9 @@ export function useSqlExecution(deps: {
if (cancelRequested()) return finish({ status: "cancelled" });
const cancelRequestCount = tab.cancelRequestCount ?? 0;
if (input.manualTransaction && (!input.executionTarget || !supportsTransaction(effectiveDatabaseTypeForConnection(connection)))) {
return finish({ status: "failed", errorMessage: t("multiDbExecute.manualUnsupported") });
}
const workerId = input.executionTarget ? queryStore.createMultiDbExecutionWorker(tab.id, input.executionTarget, input.scopeId ?? "") : undefined;
if (input.executionTarget && !workerId) return finish({ status: "failed", errorMessage: t("multiDbExecute.targetMissingConnection") });
const executionTabId = workerId ?? tab.id;
@@ -563,46 +570,151 @@ export function useSqlExecution(deps: {
errorMessage,
});
};
let manualSessionId: string | undefined;
let retainedWorker = false;
let commitUncertain = false;
let recordedRunId: string | undefined;
let recordCommittedOutcome: (() => Promise<void>) | undefined;
const rollbackManualSession = async (sessionId: string) => {
try {
await api.rollbackManualTransaction(sessionId);
return true;
} catch (error) {
// Core already removes sessions on statement errors and idle expiry.
if (!/transaction session not found/i.test(String(error))) throw error;
return false;
}
};
const transaction: MultiDbManualTransaction = {
canCommit: false,
async finish(action) {
if (!manualSessionId) return;
if (action === "commit" && !transaction.canCommit) throw new Error(t("multiDbExecute.manualCommitUnavailable"));
let warning: string | undefined;
if (action === "commit") {
transaction.canCommit = false;
try {
await api.commitManualTransaction(manualSessionId);
} catch (error) {
commitUncertain = true;
throw error;
}
} else if (!(await rollbackManualSession(manualSessionId)) && commitUncertain) {
warning = t("toolbar.commitOutcomeUnknown");
}
// A later history/metadata/worker cleanup failure must never retry a commit.
manualSessionId = undefined;
transaction.canCommit = false;
const run = queryStore.tabs.find((candidate) => candidate.id === tab.id)?.resultRuns?.find((candidate) => candidate.id === recordedRunId);
if (run?.multiDbExecution) {
run.multiDbExecution.status = warning ? "failed" : action === "commit" ? "success" : "rolled_back";
run.multiDbExecution.errorMessage = warning;
}
try {
if (action === "commit") await recordCommittedOutcome?.();
} catch (error) {
toast(String(error), 5000);
} finally {
if (workerId) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId).catch((error) => toast(String(error), 5000));
}
return warning;
},
};
const failedManualResult = async (status: "failed" | "cancelled", errorMessage?: string): Promise<MultiDbTargetExecutionResult> => {
transaction.canCommit = false;
if (manualSessionId) {
try {
await rollbackManualSession(manualSessionId);
manualSessionId = undefined;
} catch (error) {
retainedWorker = true;
return finish({ status, errorMessage: [errorMessage, String(error)].filter(Boolean).join("\n"), transaction });
}
}
return finish({ status, errorMessage });
};
try {
await queryStore.executeTabSql(executionTabId, sql, {
resultBaseSql: sql,
...(input.targetContext ? { targetContext: input.targetContext } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
});
if (input.manualTransaction) {
manualSessionId = await api.beginManualTransaction(executionTab.connectionId, executionTab.database, executionTab.schema, executionTab.catalog);
if (cancelRequested()) return await failedManualResult("cancelled");
const worker = queryStore.getExecutionTab(executionTabId);
if (!worker) throw new Error(t("multiDbExecute.targetMissingConnection"));
// Submit once on the dedicated session. No ordinary-connection fallback,
// expired-session replay, or per-statement retry is allowed for this batch.
const maxRows = agentProtocolQueryResultMaxRows(effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows));
const databaseType = effectiveDatabaseTypeForConnection(connection);
const compatibility = databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(executionTab.connectionId, executionTab.database) : undefined;
const statements = splitSqlStatementRanges(sql, databaseType, sqlStatementParameterOptionsForCompatibility(databaseType, compatibility));
const results: NonNullable<QueryTab["results"]> = [];
for (const [statementIndex, statement] of (statements.length ? statements : [{ sql, from: 0, to: sql.length }]).entries()) {
if (cancelRequested()) return await failedManualResult("cancelled");
const statementResults = await api.executeInManualTransaction(manualSessionId, statement.sql, executionTab.database, executionTab.schema, maxRows);
results.push(
...statementResults.map((result) => ({
...result,
statement_index: statementIndex,
sourceStatement: statement.sql,
...(sourceOffset === undefined ? {} : { sourceFrom: sourceOffset + statement.from, sourceTo: sourceOffset + statement.to }),
})),
);
if (statementResults.some(isQueryExecutionErrorResult)) break;
}
worker.results = results;
worker.activeResultIndex = 0;
worker.result = results[0];
worker.resultBaseSql = sql;
worker.lastExecutedSql = sql;
} else
await queryStore.executeTabSql(executionTabId, sql, {
resultBaseSql: sql,
...(input.targetContext ? { targetContext: input.targetContext } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
});
const latest = queryStore.getExecutionTab(executionTabId) ?? tab;
if (cancelRequested() || tabCancelRequested(cancelRequestCount)) {
return finish({ status: "cancelled" });
return await failedManualResult("cancelled");
}
focusSqlServerDataResult(executionTabId, connection.db_type, latest);
const failure = firstQueryExecutionError(latest);
const errorMessage = failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? t("common.failed"))) : undefined;
const success = !failure;
const resultStatus = success ? "success" : "failed";
captureWorkerResult(resultStatus, errorMessage);
historyStore.add({
connection_id: executionTab.connectionId,
connection_name: connection.name || "",
database: executionTab.database,
sql,
execution_time_ms: Date.now() - startedAt,
success,
error: errorMessage,
activity_kind: classifySqlActivityKind(sql),
operation: primarySqlOperation(sql),
affected_rows: success ? latest.result?.affected_rows : undefined,
});
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(executionTab.connectionId);
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
const resultStatus = success ? (input.manualTransaction ? "pending_commit" : "success") : "failed";
recordedRunId = captureWorkerResult(resultStatus, errorMessage);
const executionDuration = Date.now() - startedAt;
const recordOutcome = async () => {
await historyStore.add({
connection_id: executionTab.connectionId,
connection_name: connection.name || "",
database: executionTab.database,
sql,
execution_time_ms: executionDuration,
success,
error: errorMessage,
activity_kind: classifySqlActivityKind(sql),
operation: primarySqlOperation(sql),
affected_rows: success ? latest.result?.affected_rows : undefined,
});
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(executionTab.connectionId);
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
}
}
};
if (input.manualTransaction && success) {
transaction.canCommit = true;
retainedWorker = true;
recordCommittedOutcome = recordOutcome;
return finish({ status: "pending_commit", transaction });
}
await recordOutcome();
if (input.manualTransaction) return await failedManualResult("failed", errorMessage);
// 多库 worker 路径(workerId 存在)下不切主编辑器输出视图:并行 Promise.all
// 会让多个 worker 几乎同时改这个共享 ref,导致主视图在 result/summary 间反复
// 跳动(闪烁/竞态)。worker 结果已由 captureMultiDbExecutionWorkerResult 记录
@@ -614,9 +726,9 @@ export function useSqlExecution(deps: {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
captureWorkerResult("failed", errorMessage);
return finish({ status: "failed", errorMessage });
return await failedManualResult("failed", errorMessage);
} finally {
if (workerId) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId);
if (workerId && !retainedWorker) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId);
}
}