fix(query): recover expired manual transaction sessions
This commit is contained in:
15 files changed
+252
-24
No files matched your search
+119
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user