fix(query): recover expired manual transaction sessions

This commit is contained in:
t8y2 committed 2026-08-24 12:19:27 +08:00
1 parent 0047d79974
commit e03cc2d269
15 files changed
+252 -24

No files matched your search

@@ -0,0 +1,119 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
analyzeEditableQueryEditability: vi.fn(),
beginManualTransaction: vi.fn(),
closeClientConnectionSession: vi.fn(),
closeQuerySession: vi.fn(),
executeInManualTransaction: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
saveOpenTabsState: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
beginManualTransaction: mocks.beginManualTransaction,
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
executeInManualTransaction: mocks.executeInManualTransaction,
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: mocks.getConnectionConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
autoCalculateTotalRows: false,
continueOnErrorOnBatch: false,
pageSize: 100,
queryResultMaxRowsEnabled: false,
queryResultMaxRows: 1000,
openTabsRestoreMode: "all",
confirmUnsavedSqlClose: false,
},
}),
}));
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function expiredTransactionError() {
return {
version: 1 as const,
code: "DBX-TXN-1001",
messageKey: "backendErrors.transaction.sessionExpired",
messageParams: { timeoutSecs: 300 },
source: "legacyBackend" as const,
operationOutcome: "not_started" as const,
origin: { subsystem: "database", adapter: "native" },
diagnostics: { category: "transaction", stage: "execute" },
};
}
function successfulUpdate() {
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
}
describe("queryStore manual transaction expiry recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConnectionConfig.mockReturnValue({
id: "oracle-1",
name: "Oracle",
db_type: "oracle",
database: "ORCL",
query_timeout_secs: 30,
});
mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "not-select" });
mocks.saveOpenTabsState.mockResolvedValue(undefined);
});
it("restarts an expired transaction and retries the SQL exactly once", async () => {
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new");
mocks.executeInManualTransaction.mockResolvedValueOnce(successfulUpdate()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(successfulUpdate());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 1");
await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 1");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(mocks.beginManualTransaction).toHaveBeenCalledTimes(2);
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(3);
expect(mocks.executeInManualTransaction.mock.calls[1]?.[0]).toBe("txn-old");
expect(mocks.executeInManualTransaction.mock.calls[2]?.[0]).toBe("txn-new");
expect(tab.txnSessionId).toBe("txn-new");
expect(tab.txnAutoRolledBack).toBe(true);
expect(tab.result?.execution_error).not.toBe(true);
expect(tab.result?.affected_rows).toBe(1);
});
});
+19 -2
View File
@@ -70,7 +70,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useExportTracker } from "@/composables/useExportTracker";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { BackendErrorException, formatError, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { BackendErrorException, formatError, isManualTransactionSessionExpired, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
@@ -5109,7 +5109,24 @@ export const useQueryStore = defineStore("query", () => {
}
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
executionDispatched = true;
executionPromise = api.executeInManualTransaction(tab.txnSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
let manualTransactionRecoveryAttempted = false;
executionPromise = (async () => {
const txnSessionId = tab.txnSessionId;
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
try {
return await api.executeInManualTransaction(txnSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
} catch (error) {
if (manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
manualTransactionRecoveryAttempted = true;
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
const refreshedSessionId = await api.beginManualTransaction(executionConnectionId, executionDatabase, executionSchema, executionCatalog);
tab.txnSessionId = refreshedSessionId;
queryExecutionLog("info", "manual-txn:restarted", { traceId, txnSessionId: refreshedSessionId, elapsed: elapsed() });
return api.executeInManualTransaction(refreshedSessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
}
})();
} else {
queryExecutionLog("info", "execute-multi:start", { traceId, elapsed: elapsed() });
// Query and data tabs use a tab-scoped pool so repeated executions keep