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

+1
View File
@@ -37,6 +37,7 @@ const STRUCTURED_BACKEND_ERROR_KEYS = [
"backendErrors.jdbc.protocolFailed",
"backendErrors.jdbc.contractInvalid",
"backendErrors.jdbc.legacyFailure",
"backendErrors.transaction.sessionExpired",
"backendErrors.legacy",
"backendErrors.unknown",
] as const;
+3
View File
@@ -2171,6 +2171,9 @@ export default {
contractInvalid: "Database agent returned an invalid error contract.",
legacyFailure: "Database agent returned an unclassified error.",
},
transaction: {
sessionExpired: "The manual transaction expired after {timeoutSecs} seconds of inactivity and was rolled back. A new transaction will be started automatically.",
},
legacy: "Backend returned an error.",
unknown: "An unexpected backend error occurred.",
},
+3
View File
@@ -2106,6 +2106,9 @@ export default withEnglishFallback({
contractInvalid: "El agente de la base de datos devolvió un contrato de error no válido.",
legacyFailure: "El agente de la base de datos devolvió un error no clasificado.",
},
transaction: {
sessionExpired: "La transacción manual expiró después de {timeoutSecs} segundos de inactividad y se revirtió. Se iniciará una nueva transacción automáticamente.",
},
legacy: "El backend devolvió un error.",
unknown: "Ocurrió un error inesperado del backend.",
},
+3
View File
@@ -2104,6 +2104,9 @@ export default withEnglishFallback({
contractInvalid: "L'agente del database ha restituito un contratto di errore non valido.",
legacyFailure: "L'agente del database ha restituito un errore non classificato.",
},
transaction: {
sessionExpired: "La transazione manuale è scaduta dopo {timeoutSecs} secondi di inattività ed è stata annullata. Verrà avviata automaticamente una nuova transazione.",
},
legacy: "Il backend ha restituito un errore.",
unknown: "Si è verificato un errore backend imprevisto.",
},
+3
View File
@@ -2131,6 +2131,9 @@ export default withEnglishFallback({
contractInvalid: "データベース Agent が無効なエラー契約を返しました。",
legacyFailure: "データベース Agent が分類できないエラーを返しました。",
},
transaction: {
sessionExpired: "手動トランザクションは {timeoutSecs} 秒間操作がなかったため期限切れとなり、ロールバックされました。新しいトランザクションを自動的に開始します。",
},
legacy: "バックエンドがエラーを返しました。",
unknown: "予期しないバックエンドエラーが発生しました。",
},
+3
View File
@@ -2005,6 +2005,9 @@ export default withEnglishFallback({
contractInvalid: "데이터베이스 Agent가 잘못된 오류 계약을 반환했습니다.",
legacyFailure: "데이터베이스 Agent가 분류되지 않은 오류를 반환했습니다.",
},
transaction: {
sessionExpired: "수동 트랜잭션이 {timeoutSecs}초 동안 활동이 없어 만료되고 롤백되었습니다. 새 트랜잭션을 자동으로 시작합니다.",
},
legacy: "백엔드에서 오류를 반환했습니다.",
unknown: "예상하지 못한 백엔드 오류가 발생했습니다.",
},
+3
View File
@@ -2106,6 +2106,9 @@ export default withEnglishFallback({
contractInvalid: "O Agent do banco retornou um contrato de erro inválido.",
legacyFailure: "O Agent do banco retornou um erro não classificado.",
},
transaction: {
sessionExpired: "A transação manual expirou após {timeoutSecs} segundos de inatividade e foi revertida. Uma nova transação será iniciada automaticamente.",
},
legacy: "O backend retornou um erro.",
unknown: "Ocorreu um erro inesperado no backend.",
},
+3
View File
@@ -2095,6 +2095,9 @@ export default withEnglishFallback({
contractInvalid: "数据库 Agent 返回了无效的错误契约。",
legacyFailure: "数据库 Agent 返回了未分类错误。",
},
transaction: {
sessionExpired: "手动事务因 {timeoutSecs} 秒无操作已过期并回滚,DBX 将自动开始新事务。",
},
legacy: "后端返回了错误。",
unknown: "发生了未预期的后端错误。",
},
+3
View File
@@ -2105,6 +2105,9 @@ export default withEnglishFallback({
contractInvalid: "資料庫 Agent 回傳了無效的錯誤契約。",
legacyFailure: "資料庫 Agent 回傳了未分類錯誤。",
},
transaction: {
sessionExpired: "手動交易因 {timeoutSecs} 秒無操作而過期並回滾,DBX 將自動開始新的交易。",
},
legacy: "後端回傳了錯誤。",
unknown: "發生未預期的後端錯誤。",
},
@@ -22,6 +22,8 @@ export interface BackendError {
helpUrl?: string;
}
export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
const MAX_FALLBACK_CHARS = 64 * 1024;
const MAX_ERROR_PARSE_DEPTH = 16;
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
@@ -84,6 +86,12 @@ export function normalizeBackendError(error: unknown): BackendError | null {
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
}
export function isManualTransactionSessionExpired(error: unknown): boolean {
if (normalizeBackendError(error)?.code === MANUAL_TRANSACTION_SESSION_EXPIRED_CODE) return true;
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
return message?.startsWith("Transaction session not found or expired;") === true || message === "Transaction was auto-rolled back due to 5 minutes of inactivity";
}
function normalizeBackendErrorAtDepth(error: unknown, seen: WeakSet<object>, depth: number): BackendError | null {
if (depth > MAX_ERROR_PARSE_DEPTH) return null;
@@ -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