diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2986c1515f..be120b9379 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -534,6 +534,7 @@ interface DataGridProps { exportFileBaseName?: string; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; manualTransactionSessionId?: string; + ensureManualTransactionSession?: () => Promise; 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, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 2b8a00cc66..917e8bd131 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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" diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index 8ec15611a3..0106bad0e7 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -949,6 +949,7 @@ describe("useDataGridEditor saveChanges reload", () => { prepareFullReload?: () => void; customSaveHandler?: { save: ReturnType }; manualTransactionSessionId?: string; + ensureManualTransactionSession?: () => Promise; refreshSavedRows?: ReturnType; onManualTransactionMutation?: ReturnType; 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: [] }); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 30eda58851..3240971dd0 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -109,6 +109,7 @@ export interface UseDataGridEditorOptions { onExecuteSql: ComputedRef<((sql: string) => Promise) | undefined>; customSaveHandler?: ComputedRef; manualTransactionSessionId?: ComputedRef; + ensureManualTransactionSession?: ComputedRef<(() => Promise) | undefined>; onManualTransactionMutation?: () => void; sql: ComputedRef; searchText: Ref; @@ -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, diff --git a/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts index b0a8457600..b745d0becd 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts @@ -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((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((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((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((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(() => {})); + 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((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((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()); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b11304f534..5549efb290 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4661,9 +4661,52 @@ export const useQueryStore = defineStore("query", () => { } } + const manualTransactionTargetEpochs = new WeakMap(); + const pendingManualTransactionStarts = new Map }>(); + + 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 { + 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,