feat(transactions): add manual transactions to more execution paths
This commit is contained in:
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user