diff --git a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts index 1481fae0ea..e0262a330a 100644 --- a/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts +++ b/apps/desktop/src/i18n/__tests__/backendErrors.spec.ts @@ -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; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9c6c097735..48be767079 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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.", }, diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ac87b6e2b8..f0439cc108 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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.", }, diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index bb3a64fef8..b5cb65776b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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.", }, diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 698ba5dc6c..af4312342a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2131,6 +2131,9 @@ export default withEnglishFallback({ contractInvalid: "データベース Agent が無効なエラー契約を返しました。", legacyFailure: "データベース Agent が分類できないエラーを返しました。", }, + transaction: { + sessionExpired: "手動トランザクションは {timeoutSecs} 秒間操作がなかったため期限切れとなり、ロールバックされました。新しいトランザクションを自動的に開始します。", + }, legacy: "バックエンドがエラーを返しました。", unknown: "予期しないバックエンドエラーが発生しました。", }, diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index fd4c3cb711..946a82e5f5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2005,6 +2005,9 @@ export default withEnglishFallback({ contractInvalid: "데이터베이스 Agent가 잘못된 오류 계약을 반환했습니다.", legacyFailure: "데이터베이스 Agent가 분류되지 않은 오류를 반환했습니다.", }, + transaction: { + sessionExpired: "수동 트랜잭션이 {timeoutSecs}초 동안 활동이 없어 만료되고 롤백되었습니다. 새 트랜잭션을 자동으로 시작합니다.", + }, legacy: "백엔드에서 오류를 반환했습니다.", unknown: "예상하지 못한 백엔드 오류가 발생했습니다.", }, diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d846dba1de..dc1771ef1e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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.", }, diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index daae58b83e..755f9d747f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2095,6 +2095,9 @@ export default withEnglishFallback({ contractInvalid: "数据库 Agent 返回了无效的错误契约。", legacyFailure: "数据库 Agent 返回了未分类错误。", }, + transaction: { + sessionExpired: "手动事务因 {timeoutSecs} 秒无操作已过期并回滚,DBX 将自动开始新事务。", + }, legacy: "后端返回了错误。", unknown: "发生了未预期的后端错误。", }, diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 62a2378c48..557003d9e9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2105,6 +2105,9 @@ export default withEnglishFallback({ contractInvalid: "資料庫 Agent 回傳了無效的錯誤契約。", legacyFailure: "資料庫 Agent 回傳了未分類錯誤。", }, + transaction: { + sessionExpired: "手動交易因 {timeoutSecs} 秒無操作而過期並回滾,DBX 將自動開始新的交易。", + }, legacy: "後端回傳了錯誤。", unknown: "發生未預期的後端錯誤。", }, diff --git a/apps/desktop/src/lib/backend/errorUtils.ts b/apps/desktop/src/lib/backend/errorUtils.ts index d7ddde6f58..409a1adfd7 100644 --- a/apps/desktop/src/lib/backend/errorUtils.ts +++ b/apps/desktop/src/lib/backend/errorUtils.ts @@ -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(), 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, depth: number): BackendError | null { if (depth > MAX_ERROR_PARSE_DEPTH) return null; diff --git a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts new file mode 100644 index 0000000000..8169d7cc74 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts @@ -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(); + 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); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f883a93a66..da4ac492d1 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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 diff --git a/crates/dbx-core/src/backend_error.rs b/crates/dbx-core/src/backend_error.rs index 67dbe0e02f..70adcc1650 100644 --- a/crates/dbx-core/src/backend_error.rs +++ b/crates/dbx-core/src/backend_error.rs @@ -234,6 +234,24 @@ impl BackendError { ) } + /// Create a retryable envelope for a manual transaction session that DBX + /// already rolled back before the requested SQL was executed. + pub fn from_manual_transaction_session_expired(timeout_secs: u64) -> Self { + let entry = catalog_entry(CatalogCode::TransactionSessionExpired); + let mut params = BTreeMap::new(); + params + .insert("timeoutSecs".to_string(), BackendMessageParam::Integer(timeout_secs.min(i64::MAX as u64) as i64)); + Self::new( + entry, + BackendErrorSource::LegacyBackend, + BackendErrorOrigin::database(BackendErrorAdapter::Native), + BackendOperationOutcome::NotStarted, + params, + None, + Some(diagnostics_for_local("transaction", AgentErrorStage::Execute)), + ) + } + /// Create a SQL failure envelope while retaining bounded native driver detail. /// /// The caller must pass a typed SQL execution error, not a connection or @@ -364,6 +382,7 @@ enum CatalogCode { ConnectionInterrupted, TimeoutNotStarted, TimeoutUnknown, + TransactionSessionExpired, Canceled, BusyRetryLater, RuntimeReplaced, @@ -396,6 +415,7 @@ struct CatalogEntry { } const STAGE_PARAM: &[ParamSpec] = &[ParamSpec { name: "stage", kind: ParamKind::String }]; +const TRANSACTION_TIMEOUT_PARAM: &[ParamSpec] = &[ParamSpec { name: "timeoutSecs", kind: ParamKind::Integer }]; const NO_PARAMS: &[ParamSpec] = &[]; fn catalog_entry(code: CatalogCode) -> &'static CatalogEntry { @@ -436,6 +456,15 @@ fn catalog_entry(code: CatalogCode) -> &'static CatalogEntry { help_url: None, }, ), + ( + CatalogCode::TransactionSessionExpired, + CatalogEntry { + code: "DBX-TXN-1001", + message_key: "backendErrors.transaction.sessionExpired", + params: TRANSACTION_TIMEOUT_PARAM, + help_url: None, + }, + ), ( CatalogCode::Canceled, CatalogEntry { @@ -1031,6 +1060,7 @@ mod tests { (CatalogCode::ConnectionInterrupted, "DBX-JDBC-1002", "backendErrors.jdbc.connectionInterrupted"), (CatalogCode::TimeoutNotStarted, "DBX-JDBC-2001", "backendErrors.jdbc.operationTimedOut"), (CatalogCode::TimeoutUnknown, "DBX-JDBC-2002", "backendErrors.jdbc.operationTimedOut"), + (CatalogCode::TransactionSessionExpired, "DBX-TXN-1001", "backendErrors.transaction.sessionExpired"), (CatalogCode::Canceled, "DBX-JDBC-2003", "backendErrors.jdbc.operationCanceled"), (CatalogCode::BusyRetryLater, "DBX-JDBC-3001", "backendErrors.jdbc.busyRetryLater"), (CatalogCode::RuntimeReplaced, "DBX-JDBC-3002", "backendErrors.jdbc.runtimeReplaced"), @@ -1049,6 +1079,18 @@ mod tests { } } + #[test] + fn manual_transaction_session_expiry_is_retryable_before_execution() { + let error = BackendError::from_manual_transaction_session_expired(300); + let payload = serde_json::to_value(&error).unwrap(); + + assert_eq!(error.code(), "DBX-TXN-1001"); + assert_eq!(error.operation_outcome(), BackendOperationOutcome::NotStarted); + assert_eq!(payload["messageKey"], "backendErrors.transaction.sessionExpired"); + assert_eq!(payload["messageParams"]["timeoutSecs"], 300); + assert_eq!(payload["diagnostics"]["category"], "transaction"); + } + #[test] fn catalog_maps_valid_variants_and_emits_allowlisted_params() { let cases = [ diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index b5f5b06f15..604c880a0f 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -33,6 +33,15 @@ pub const MAX_ROWS: usize = 10000; pub const AGENT_PROTOCOL_MAX_ROWS: usize = i32::MAX as usize; pub const QUERY_CANCELED: &str = "Query canceled"; pub const METADATA_POOL_BUSY_ERROR: &str = "DBX metadata pool is busy; please retry"; +pub const MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS: u64 = 300; +pub const MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR: &str = + "Transaction session not found or expired; it may have been auto-rolled back due to inactivity"; +pub const MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR: &str = + "Transaction was auto-rolled back due to 5 minutes of inactivity"; + +pub fn is_manual_transaction_session_expired_error(error: &str) -> bool { + error.starts_with(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR) || error == MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR +} /// Returns true when a metadata request failed because all pool/client slots /// were temporarily occupied. This is deliberately separate from @@ -4476,9 +4485,8 @@ pub async fn keep_manual_transaction_alive( ) -> Result { { let mut sessions = state.transaction_sessions.write().await; - let session = sessions.get_mut(txn_session_id).ok_or_else(|| { - "Transaction session not found or expired; it may have been auto-rolled back due to inactivity".to_string() - })?; + let session = + sessions.get_mut(txn_session_id).ok_or_else(|| MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string())?; if !session.busy { session.last_activity = std::time::Instant::now(); } @@ -4534,16 +4542,14 @@ pub async fn execute_in_manual_transaction_with_options( max_rows: Option, table_data_preview: bool, ) -> Result, String> { - const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); // Resolve statements and validate before taking the per-session connection // lock. The session stays visible in the map so close/disconnect cleanup can // remove it and roll back once the current DB operation releases the lock. let (pool_key, connection_id) = { let sessions = state.transaction_sessions.read().await; - let session = sessions - .get(txn_session_id) - .ok_or("Transaction session not found or expired; it may have been auto-rolled back due to inactivity")?; + let session = sessions.get(txn_session_id).ok_or(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR)?; (session.pool_key.clone(), session.connection_id.clone()) }; @@ -4566,10 +4572,7 @@ pub async fn execute_in_manual_transaction_with_options( let connection = { let mut sessions = state.transaction_sessions.write().await; let Some(session) = sessions.get_mut(txn_session_id) else { - return Err( - "Transaction session not found or expired; it may have been auto-rolled back due to inactivity" - .to_string(), - ); + return Err(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string()); }; if session.busy { return Err("Transaction session is already executing".to_string()); @@ -4587,7 +4590,7 @@ pub async fn execute_in_manual_transaction_with_options( let mut conn = session.connection.lock().await; let _ = rollback_manual_txn_connection(&mut conn).await; release_manual_txn_agent_pool(state, &session.connection_id, &mut conn).await; - return Err("Transaction was auto-rolled back due to 5 minutes of inactivity".to_string()); + return Err(MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR.to_string()); } let connection = { @@ -4595,7 +4598,7 @@ pub async fn execute_in_manual_transaction_with_options( sessions .get(txn_session_id) .map(|session| Arc::clone(&session.connection)) - .ok_or("Transaction session not found or expired; it may have been auto-rolled back due to inactivity")? + .ok_or(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR)? }; let row_limit = max_rows.unwrap_or(MAX_ROWS).max(1); let mut results = Vec::with_capacity(statements.len()); @@ -4674,15 +4677,12 @@ pub async fn stream_rows_in_manual_transaction( where F: FnMut(Vec>) -> Result<(), String> + Send, { - const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); let expired_connection = { let mut sessions = state.transaction_sessions.write().await; let Some(session) = sessions.get_mut(txn_session_id) else { - return Err( - "Transaction session not found or expired; it may have been auto-rolled back due to inactivity" - .to_string(), - ); + return Err(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string()); }; if session.busy { return Err("Transaction session is already executing".to_string()); @@ -4698,7 +4698,7 @@ where if let Some(connection) = expired_connection { let mut conn = connection.lock().await; let _ = rollback_manual_txn_connection(&mut conn).await; - return Err("Transaction was auto-rolled back due to 5 minutes of inactivity".to_string()); + return Err(MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR.to_string()); } let connection = { @@ -4706,7 +4706,7 @@ where sessions .get(txn_session_id) .map(|session| Arc::clone(&session.connection)) - .ok_or("Transaction session not found or expired; it may have been auto-rolled back due to inactivity")? + .ok_or(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR)? }; let batch_size = batch_size.max(1); let mut conn = connection.lock().await; @@ -4882,7 +4882,8 @@ fn spawn_txn_idle_watcher_for_sessions( txn_session_id: String, ) { tokio::spawn(async move { - const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + const TXN_IDLE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); tokio::time::sleep(TXN_IDLE_TIMEOUT).await; let removed: Option = { diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 16d6496e98..f06a47001d 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -23,6 +23,13 @@ struct ExecuteMultiProgress { error: Option, } +#[derive(Debug, serde::Serialize)] +#[serde(untagged)] +pub enum ManualTransactionCommandError { + Structured(BackendError), + Legacy(String), +} + #[tauri::command] #[allow(clippy::too_many_arguments)] pub async fn execute_query( @@ -456,7 +463,7 @@ pub async fn execute_in_manual_transaction( schema: Option, max_rows: Option, table_data_preview: Option, -) -> Result, String> { +) -> Result, ManualTransactionCommandError> { dbx_core::query::execute_in_manual_transaction_with_options( &state, &txn_session_id, @@ -467,6 +474,15 @@ pub async fn execute_in_manual_transaction( table_data_preview.unwrap_or(false), ) .await + .map_err(|error| { + if dbx_core::query::is_manual_transaction_session_expired_error(&error) { + ManualTransactionCommandError::Structured(BackendError::from_manual_transaction_session_expired( + dbx_core::query::MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS, + )) + } else { + ManualTransactionCommandError::Legacy(error) + } + }) } #[tauri::command]