mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:50:12 +00:00
fix(oceanbase-oracle): handle manual transactions on grid save/switch
This commit is contained in:
@@ -534,6 +534,7 @@ interface DataGridProps {
|
||||
exportFileBaseName?: string;
|
||||
customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler;
|
||||
manualTransactionSessionId?: string;
|
||||
ensureManualTransactionSession?: () => Promise<string>;
|
||||
onManualTransactionMutation?: () => void;
|
||||
mongoUpdateTarget?: MongoCopyUpdateTarget;
|
||||
/** Enables MongoDB collection-grid presentation for BSON null values. */
|
||||
@@ -3706,6 +3707,7 @@ const editor = useDataGridEditor({
|
||||
onExecuteSql: computed(() => props.onExecuteSql),
|
||||
customSaveHandler: computed(() => props.customSaveHandler),
|
||||
manualTransactionSessionId: computed(() => props.manualTransactionSessionId),
|
||||
ensureManualTransactionSession: computed(() => props.ensureManualTransactionSession),
|
||||
onManualTransactionMutation: () => props.onManualTransactionMutation?.(),
|
||||
sql: computed(() => props.sql),
|
||||
searchText,
|
||||
|
||||
@@ -2091,6 +2091,7 @@ defineExpose({
|
||||
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
|
||||
:query-editability-reason="activeTab.queryEditabilityReason"
|
||||
:manual-transaction-session-id="activeTab.txnSessionId"
|
||||
:ensure-manual-transaction-session="activeTab.autoCommit === false ? () => queryStore.ensureManualTransactionSession(activeTab.id, activeResultDatabase, activeResultSchema) : undefined"
|
||||
:on-manual-transaction-mutation="() => queryStore.markManualTransactionDirty(activeTab.id)"
|
||||
:allow-insert-rows="activeTab.queryAnalysis?.allowInsert ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
|
||||
:allow-delete-rows="activeTab.queryAnalysis?.allowDelete ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
|
||||
|
||||
@@ -949,6 +949,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
prepareFullReload?: () => void;
|
||||
customSaveHandler?: { save: ReturnType<typeof vi.fn> };
|
||||
manualTransactionSessionId?: string;
|
||||
ensureManualTransactionSession?: () => Promise<string>;
|
||||
refreshSavedRows?: ReturnType<typeof vi.fn>;
|
||||
onManualTransactionMutation?: ReturnType<typeof vi.fn>;
|
||||
connectionId?: string;
|
||||
@@ -958,6 +959,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
) {
|
||||
const emit = vi.fn();
|
||||
const currentPage = options.currentPage ?? ref(1);
|
||||
const ensureManualTransactionSession = ref(options.ensureManualTransactionSession);
|
||||
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
|
||||
columns: ["id", "status"],
|
||||
rows: [
|
||||
@@ -985,6 +987,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
onExecuteSql: computed(() => options.onExecuteSql),
|
||||
customSaveHandler: computed(() => options.customSaveHandler),
|
||||
manualTransactionSessionId: computed(() => options.manualTransactionSessionId),
|
||||
ensureManualTransactionSession: computed(() => ensureManualTransactionSession.value),
|
||||
onManualTransactionMutation: options.onManualTransactionMutation,
|
||||
sql: computed(() => undefined),
|
||||
searchText: ref(""),
|
||||
@@ -1001,7 +1004,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
refreshSavedRows: options.refreshSavedRows,
|
||||
emit,
|
||||
});
|
||||
return { editor, emit, currentPage };
|
||||
return { editor, emit, currentPage, ensureManualTransactionSession };
|
||||
}
|
||||
|
||||
// https://github.com/t8y2/dbx/issues/8321: without a primary key the row is
|
||||
@@ -1148,6 +1151,53 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
|
||||
});
|
||||
|
||||
it("starts a manual transaction before saving an existing result grid", async () => {
|
||||
const statement = "UPDATE orders_test SET status='shipped' WHERE id=1";
|
||||
const ensureManualTransactionSession = vi.fn().mockResolvedValue("txn-grid-1");
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: [statement], rollbackStatements: [] });
|
||||
mocks.executeInManualTransaction.mockResolvedValue([{ affected_rows: 1 }]);
|
||||
|
||||
const { editor, ensureManualTransactionSession: transactionMode } = createSaveTestEditor();
|
||||
transactionMode.value = ensureManualTransactionSession;
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
await editor.saveChanges();
|
||||
|
||||
expect(ensureManualTransactionSession).toHaveBeenCalledOnce();
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("txn-grid-1", statement, "app", undefined);
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks a keyless edit on the newly opened manual transaction session", async () => {
|
||||
mocks.prepareDataGridSave.mockResolvedValue({
|
||||
statements: ["UPDATE orders_test SET status='shipped' WHERE status = 'pending'"],
|
||||
rollbackStatements: [],
|
||||
keylessGuards: [keylessGuard],
|
||||
});
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce([{ columns: ["matches"], rows: [[1]] }]).mockResolvedValueOnce([{ affected_rows: 1 }]);
|
||||
const { editor } = createSaveTestEditor({ primaryKeys: [], ensureManualTransactionSession: vi.fn().mockResolvedValue("txn-grid-2") });
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
await editor.saveChanges();
|
||||
|
||||
expect(mocks.executeInManualTransaction.mock.calls[0]?.[0]).toBe("txn-grid-2");
|
||||
expect(mocks.executeInManualTransaction.mock.calls[0]?.[1]).toBe(keylessGuard.sql);
|
||||
expect(mocks.executeInManualTransaction.mock.calls[1]?.[0]).toBe("txn-grid-2");
|
||||
expect(mocks.executeQuery).not.toHaveBeenCalled();
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps edits pending if a manual transaction cannot start", async () => {
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] });
|
||||
const ensureManualTransactionSession = vi.fn().mockRejectedValue(new Error("BEGIN failed"));
|
||||
const { editor } = createSaveTestEditor({ ensureManualTransactionSession });
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
await editor.saveChanges();
|
||||
|
||||
expect(editor.saveError.value).toContain("BEGIN failed");
|
||||
expect(editor.dirtyRows.value.get(0)?.get(1)).toBe("shipped");
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the manual transaction dirty before a result-grid mutation can fail", async () => {
|
||||
const onManualTransactionMutation = vi.fn();
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE orders_test SET status='shipped' WHERE id=1"], rollbackStatements: [] });
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface UseDataGridEditorOptions {
|
||||
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
|
||||
customSaveHandler?: ComputedRef<CustomSaveHandler | undefined>;
|
||||
manualTransactionSessionId?: ComputedRef<string | undefined>;
|
||||
ensureManualTransactionSession?: ComputedRef<(() => Promise<string>) | undefined>;
|
||||
onManualTransactionMutation?: () => void;
|
||||
sql: ComputedRef<string | undefined>;
|
||||
searchText: Ref<string>;
|
||||
@@ -1598,9 +1599,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
// A keyless save can only be trusted once the server confirms that each
|
||||
// predicate it sends addresses a single physical row; the loaded page cannot
|
||||
// see rows outside it. Returns the error to fail with, or undefined to allow.
|
||||
async function verifyKeylessGuards(guards: DataGridSaveGuard[], executionSchema?: string) {
|
||||
async function verifyKeylessGuards(guards: DataGridSaveGuard[], executionSchema?: string, txnSessionId = manualTransactionSessionId.value) {
|
||||
if (!guards.length) return undefined;
|
||||
const txnSessionId = manualTransactionSessionId.value;
|
||||
// Without a connection to count against there is no way to verify the
|
||||
// predicate, and an unverified keyless write is exactly what must not run.
|
||||
if (!hasBackendSaveTarget.value) return KEYLESS_GUARD_UNVERIFIED_ERROR;
|
||||
@@ -1948,8 +1948,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
return;
|
||||
}
|
||||
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
|
||||
let txnSessionId = manualTransactionSessionId.value;
|
||||
if (options.ensureManualTransactionSession?.value && hasBackendSaveTarget.value) {
|
||||
try {
|
||||
txnSessionId = await options.ensureManualTransactionSession.value();
|
||||
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
|
||||
} catch (error) {
|
||||
saveError.value = normalizeDataGridSaveError(databaseType.value, error);
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const guardError = await verifyKeylessGuards(preparedSave?.keylessGuards ?? [], preparedSave?.executionSchema);
|
||||
const guardError = await verifyKeylessGuards(preparedSave?.keylessGuards ?? [], preparedSave?.executionSchema, txnSessionId);
|
||||
if (guardError) {
|
||||
saveError.value = guardError;
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
@@ -1991,10 +2002,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
statements: stmts,
|
||||
rollbackStatements: rollbackStmts,
|
||||
});
|
||||
if (manualTransactionSessionId.value && hasBackendSaveTarget.value) {
|
||||
if (txnSessionId && hasBackendSaveTarget.value) {
|
||||
options.onManualTransactionMutation?.();
|
||||
try {
|
||||
const results = await api.executeInManualTransaction(manualTransactionSessionId.value, stmts.join(";\n"), database.value ?? "", preparedSave?.executionSchema);
|
||||
const results = await api.executeInManualTransaction(txnSessionId, stmts.join(";\n"), database.value ?? "", preparedSave?.executionSchema);
|
||||
apiResult = {
|
||||
affected_rows: results.reduce((total, result) => total + (result.affected_rows ?? 0), 0),
|
||||
};
|
||||
@@ -2038,7 +2049,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
applyDirtyRowsToResult(snapshot);
|
||||
options.onResultPayloadMutated?.();
|
||||
let savedRowsRefreshed = false;
|
||||
if (!joinedWriteTargets.value?.length && !manualTransactionSessionId.value && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
|
||||
if (!joinedWriteTargets.value?.length && !txnSessionId && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
|
||||
try {
|
||||
savedRowsRefreshed = await options.refreshSavedRows({
|
||||
dirtyRows: snapshot.dirtyRows,
|
||||
|
||||
+164
@@ -118,6 +118,122 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior
|
||||
return tabId;
|
||||
}
|
||||
|
||||
it("starts one session for a result-grid save before the next SQL execution", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
const sessionId = await store.ensureManualTransactionSession(tabId, "ORCL", "APP");
|
||||
expect(sessionId).toBe("txn-oracle");
|
||||
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("oracle-1", "ORCL", "APP", undefined);
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBe("txn-oracle");
|
||||
await store.ensureManualTransactionSession(tabId, "ORCL", "APP");
|
||||
expect(mocks.beginManualTransaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rolls back a session opened after the tab leaves manual mode", async () => {
|
||||
let finishBegin!: (sessionId: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finishBegin = resolve;
|
||||
}),
|
||||
);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
|
||||
store.setAutoCommit(tabId, false);
|
||||
|
||||
const starting = store.ensureManualTransactionSession(tabId, "ORCL", "APP");
|
||||
store.setAutoCommit(tabId, true);
|
||||
finishBegin("txn-late");
|
||||
|
||||
await expect(starting).rejects.toThrow("Query tab changed");
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-late");
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rolls back a session opened for a database the tab has left", async () => {
|
||||
let finishBegin!: (sessionId: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finishBegin = resolve;
|
||||
}),
|
||||
);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
|
||||
store.setAutoCommit(tabId, false);
|
||||
|
||||
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
|
||||
store.updateDatabase(tabId, "OTHER_DB");
|
||||
finishBegin("txn-old-database");
|
||||
|
||||
await expect(starting).rejects.toThrow("Query tab changed");
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old-database");
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not attach a pending session after leaving and returning to the same database", async () => {
|
||||
let finishBegin!: (sessionId: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finishBegin = resolve;
|
||||
}),
|
||||
);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
|
||||
store.setAutoCommit(tabId, false);
|
||||
|
||||
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
|
||||
store.updateDatabase(tabId, "OTHER_DB");
|
||||
store.updateDatabase(tabId, "ORCL");
|
||||
finishBegin("txn-before-switch");
|
||||
|
||||
await expect(starting).rejects.toThrow("Query tab changed");
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-before-switch");
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rolls back a session opened for a catalog the tab has left", async () => {
|
||||
let finishBegin!: (sessionId: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
finishBegin = resolve;
|
||||
}),
|
||||
);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
|
||||
store.setAutoCommit(tabId, false);
|
||||
|
||||
const starting = store.ensureManualTransactionSession(tabId, "ORCL");
|
||||
store.updateCatalog(tabId, "NEW_CATALOG", "ORCL");
|
||||
finishBegin("txn-old-catalog");
|
||||
|
||||
await expect(starting).rejects.toThrow("Query tab changed");
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old-catalog");
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reuse a previous database's transaction while rollback is pending", async () => {
|
||||
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new");
|
||||
mocks.rollbackManualTransaction.mockReturnValueOnce(new Promise<void>(() => {}));
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query");
|
||||
store.setAutoCommit(tabId, false);
|
||||
await store.ensureManualTransactionSession(tabId, "ORCL");
|
||||
|
||||
store.updateDatabase(tabId, "OTHER_DB");
|
||||
const newSession = await store.ensureManualTransactionSession(tabId, "OTHER_DB");
|
||||
|
||||
expect(newSession).toBe("txn-new");
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-old");
|
||||
expect(mocks.beginManualTransaction).toHaveBeenNthCalledWith(2, "oracle-1", "OTHER_DB", undefined, undefined);
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBe("txn-new");
|
||||
});
|
||||
|
||||
it("sends classificationSql only for the initial Oracle manual execution", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
|
||||
|
||||
@@ -261,6 +377,54 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior
|
||||
expect(tab.txnPossiblyDirty).not.toBe(true);
|
||||
});
|
||||
|
||||
it("does not replay an expired transaction's SQL after its database changes during recovery", async () => {
|
||||
let finishBegin!: (sessionId: string) => void;
|
||||
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishBegin = resolve;
|
||||
}),
|
||||
);
|
||||
mocks.executeInManualTransaction.mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect());
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
const running = store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalledTimes(2));
|
||||
store.updateDatabase(tabId, "OTHER_DB");
|
||||
finishBegin("txn-recovered-old-database");
|
||||
await running;
|
||||
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-recovered-old-database");
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.txnSessionId).toBeUndefined();
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not publish results from a database the tab has left", async () => {
|
||||
let finishQuery!: (results: StickyResult[]) => void;
|
||||
mocks.executeInManualTransaction.mockReturnValueOnce(
|
||||
new Promise<StickyResult[]>((resolve) => {
|
||||
finishQuery = resolve;
|
||||
}),
|
||||
);
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
const running = store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
await vi.waitFor(() => expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1));
|
||||
store.updateDatabase(tabId, "OTHER_DB");
|
||||
finishQuery(cleanSelect());
|
||||
await running;
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.database).toBe("OTHER_DB");
|
||||
expect(tab.result).toBeUndefined();
|
||||
expect(tab.isExecuting).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the sticky state together with the session on DBX rollback", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
|
||||
|
||||
|
||||
@@ -4661,9 +4661,52 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const manualTransactionTargetEpochs = new WeakMap<QueryTab, number>();
|
||||
const pendingManualTransactionStarts = new Map<string, { epoch: number; promise: Promise<string> }>();
|
||||
|
||||
function manualTransactionTargetEpoch(tab: QueryTab): number {
|
||||
return manualTransactionTargetEpochs.get(tab) ?? 0;
|
||||
}
|
||||
|
||||
function invalidateManualTransactionTarget(tab: QueryTab) {
|
||||
manualTransactionTargetEpochs.set(tab, manualTransactionTargetEpoch(tab) + 1);
|
||||
}
|
||||
|
||||
async function ensureManualTransactionSession(id: string, database: string, schema?: string, catalog?: string): Promise<string> {
|
||||
const tab = tabs.value.find((item) => item.id === id);
|
||||
if (!tab || tab.mode !== "query" || tab.autoCommit !== false || !tab.connectionId) {
|
||||
throw new Error("Manual transaction mode is no longer active for this query tab");
|
||||
}
|
||||
if (tab.txnSessionId) return tab.txnSessionId;
|
||||
const epoch = manualTransactionTargetEpoch(tab);
|
||||
const pending = pendingManualTransactionStarts.get(id);
|
||||
if (pending?.epoch === epoch) return pending.promise;
|
||||
|
||||
const connectionId = tab.connectionId;
|
||||
const originalDatabase = tab.database;
|
||||
const originalCatalog = tab.catalog;
|
||||
const originalSchema = tab.schema;
|
||||
const start = api
|
||||
.beginManualTransaction(connectionId, database, schema, catalog)
|
||||
.then(async (sessionId) => {
|
||||
if (tabs.value.find((item) => item.id === id) !== tab || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== epoch || tab.connectionId !== connectionId || tab.database !== originalDatabase || tab.catalog !== originalCatalog || tab.schema !== originalSchema) {
|
||||
await api.rollbackManualTransaction(sessionId);
|
||||
throw new Error("Query tab changed while the manual transaction was starting");
|
||||
}
|
||||
tab.txnSessionId = sessionId;
|
||||
return sessionId;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingManualTransactionStarts.get(id)?.promise === start) pendingManualTransactionStarts.delete(id);
|
||||
});
|
||||
pendingManualTransactionStarts.set(id, { epoch, promise: start });
|
||||
return start;
|
||||
}
|
||||
|
||||
function setAutoCommit(id: string, autoCommit: boolean) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (tab) {
|
||||
if (tab.autoCommit !== autoCommit) invalidateManualTransactionTarget(tab);
|
||||
const wasManual = tab.autoCommit === false;
|
||||
tab.autoCommit = autoCommit;
|
||||
if (autoCommit && wasManual) {
|
||||
@@ -4701,6 +4744,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean; resetAutoCommitDbType?: string }) {
|
||||
invalidateManualTransactionTarget(tab);
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
if (options?.resetAutoCommit) {
|
||||
// Callers switching a tab to another connection pass the target db type
|
||||
@@ -4727,11 +4771,11 @@ export const useQueryStore = defineStore("query", () => {
|
||||
async function rollbackTransaction(id: string) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab?.txnSessionId) return;
|
||||
try {
|
||||
await api.rollbackManualTransaction(tab.txnSessionId);
|
||||
} finally {
|
||||
clearManualTransactionSession(tab);
|
||||
}
|
||||
const sessionId = tab.txnSessionId;
|
||||
// Remove the old session before the backend responds: a target switch may
|
||||
// start a new transaction while this rollback is still in flight.
|
||||
clearManualTransactionSession(tab);
|
||||
await api.rollbackManualTransaction(sessionId);
|
||||
}
|
||||
|
||||
function updateEditorViewport(id: string, viewport: { scrollTop: number; scrollLeft: number }) {
|
||||
@@ -6246,6 +6290,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const executionEditorFingerprint = tab.mode === "query" ? sqlTextFingerprint(tab.sql) : undefined;
|
||||
const traceId = executionId.slice(0, 8);
|
||||
const startedAt = performance.now();
|
||||
const executionTargetEpoch = manualTransactionTargetEpoch(tab);
|
||||
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
|
||||
const batchResume = options?.batchResume;
|
||||
const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch;
|
||||
@@ -7101,7 +7146,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const sourceLabelDatabase = targetDatabase || conn?.database;
|
||||
const executionClientSessionId = options?.pagination?.clientSessionId ?? (tab.mode === "query" || tab.mode === "data" ? tabClientSessionId(tab) : undefined);
|
||||
const currentBeforeDispatch = findExecutionTab(id);
|
||||
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling) {
|
||||
if (currentBeforeDispatch?.executionId !== executionId || currentBeforeDispatch.isCancelling || manualTransactionTargetEpoch(currentBeforeDispatch) !== executionTargetEpoch) {
|
||||
queryExecutionLog("info", "dispatch:skipped-cancelled", { traceId, elapsed: elapsed() });
|
||||
return false;
|
||||
}
|
||||
@@ -7199,7 +7244,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (!tab.txnSessionId) {
|
||||
queryExecutionLog("info", "begin-manual-txn:start", { traceId, elapsed: elapsed() });
|
||||
try {
|
||||
tab.txnSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
|
||||
tab.txnSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
|
||||
queryExecutionLog("info", "begin-manual-txn:done", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
|
||||
} catch (error) {
|
||||
const risk = classifySqlRisk(sqlToExecute, { dialect: effectiveDbType }).risk;
|
||||
@@ -7240,6 +7285,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return await executeInTransaction(txnSessionId);
|
||||
} catch (error) {
|
||||
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
|
||||
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error;
|
||||
manualTransactionRecoveryAttempted = true;
|
||||
// The expired session was discarded by the backend; the replacement
|
||||
// session starts fresh, so the old sticky state resets with it.
|
||||
@@ -7247,7 +7293,12 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = true;
|
||||
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
|
||||
const refreshedSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
|
||||
const refreshedSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog);
|
||||
if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) {
|
||||
if (tab.txnSessionId === refreshedSessionId) clearManualTransactionSession(tab);
|
||||
await api.rollbackManualTransaction(refreshedSessionId);
|
||||
throw new Error("Query tab changed while the manual transaction was restarting");
|
||||
}
|
||||
tab.txnSessionId = refreshedSessionId;
|
||||
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
|
||||
return executeInTransaction(refreshedSessionId);
|
||||
@@ -7257,20 +7308,11 @@ export const useQueryStore = defineStore("query", () => {
|
||||
} else {
|
||||
executionPromise = executeWithoutManualTransaction();
|
||||
}
|
||||
const annotatedResults = annotateQueryResultSources(
|
||||
markQueryResultsRowsRaw(
|
||||
await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }), () => {
|
||||
void api.cancelQuery(executionId).catch((error) => queryExecutionLog("warn", "frontend-timeout:cancel-failed", { traceId, error }));
|
||||
}),
|
||||
),
|
||||
queryBaseSql,
|
||||
sourceLabelDatabase,
|
||||
effectiveDbType,
|
||||
options?.sourceOffset,
|
||||
sqlStatementParameterOptions,
|
||||
sqlToExecute,
|
||||
options?.sourceOffset === undefined ? undefined : tab.sql,
|
||||
);
|
||||
const responseResults = await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }), () => {
|
||||
void api.cancelQuery(executionId).catch((error) => queryExecutionLog("warn", "frontend-timeout:cancel-failed", { traceId, error }));
|
||||
});
|
||||
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
|
||||
const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(responseResults), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset, sqlStatementParameterOptions, sqlToExecute, options?.sourceOffset === undefined ? undefined : tab.sql);
|
||||
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
|
||||
if (paginationRowNumberColumn && results.length === 1) {
|
||||
results[0] = markQueryResultsRowsRaw([stripPaginationRowNumber(results[0]!, paginationRowNumberColumn)])[0]!;
|
||||
@@ -7319,7 +7361,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
}
|
||||
const current = findExecutionTab(id);
|
||||
if (current?.executionId === executionId) {
|
||||
if (current?.executionId === executionId && manualTransactionTargetEpoch(current) === executionTargetEpoch) {
|
||||
if (captureResultRun && current.isCancelling && restorePendingResultRun(current, executionId)) return false;
|
||||
if (successfulOracleSchemaChanges > 0) {
|
||||
current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges;
|
||||
@@ -7503,6 +7545,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
queryExecutionLog("error", "error", { traceId, elapsed: elapsed(), error: e });
|
||||
// Sync connection state if the error indicates a lost connection
|
||||
useConnectionStore().recordConnectionLostError(executionConnectionId ?? tab.connectionId, e);
|
||||
if (findExecutionTab(id) !== tab || tab.executionId !== executionId || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) return false;
|
||||
// Handle manual transaction auto-rollback (idle timeout only for the banner;
|
||||
// other statement failures still clear the session without the 5-minute notice).
|
||||
if (tab.autoCommit === false) {
|
||||
@@ -8802,6 +8845,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
markManualTransactionDirty,
|
||||
commitTransaction,
|
||||
rollbackTransaction,
|
||||
ensureManualTransactionSession,
|
||||
renameTab,
|
||||
openDatabaseBrowser,
|
||||
openDriverProfileWorkspace,
|
||||
|
||||
Reference in New Issue
Block a user