From f441d781ec57d85dee1b5d4e3e1aa83a6dc23216 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Fri, 16 Oct 2026 13:51:29 +0800 Subject: [PATCH] feat(transaction): configurable manual transaction idle timeout Add a per-connection manual_transaction_timeout_secs (default 300s, 30-86400s) honored by every idle-rollback path. When a session that may hold uncommitted work expires, stop and show the rollback notice instead of replaying the statement into a fresh transaction; proven read-only sessions still restart silently. Error and notice texts no longer hard-code five minutes. --- .../connection/ConnectionDialog.vue | 25 ++++- apps/desktop/src/i18n/locales/az.ts | 6 +- apps/desktop/src/i18n/locales/en.ts | 8 +- apps/desktop/src/i18n/locales/es.ts | 8 +- apps/desktop/src/i18n/locales/id.ts | 6 +- apps/desktop/src/i18n/locales/it.ts | 8 +- apps/desktop/src/i18n/locales/ja.ts | 8 +- apps/desktop/src/i18n/locales/ko.ts | 8 +- apps/desktop/src/i18n/locales/pt-BR.ts | 8 +- apps/desktop/src/i18n/locales/ru.ts | 8 +- apps/desktop/src/i18n/locales/th.ts | 8 +- apps/desktop/src/i18n/locales/tr.ts | 6 +- apps/desktop/src/i18n/locales/zh-CN.ts | 8 +- apps/desktop/src/i18n/locales/zh-TW.ts | 8 +- .../connectionSubmitNormalization.spec.ts | 18 ++++ .../connectionSubmitNormalization.ts | 3 +- .../src/lib/connection/timeoutLimits.ts | 9 ++ .../desktop/src/lib/plugins/frontendPlugin.ts | 2 + ...queryStore.manualTransactionExpiry.spec.ts | 36 +++++--- ...eryStore.provenReadOnlyStickyState.spec.ts | 30 +++++- apps/desktop/src/stores/connectionStore.ts | 3 +- apps/desktop/src/stores/queryStore.ts | 15 ++- apps/desktop/src/types/database.ts | 2 + .../examples/table_import_live_bench.rs | 1 + crates/dbx-core/src/admin/mq/config.rs | 1 + crates/dbx-core/src/admin/mq/service.rs | 1 + crates/dbx-core/src/admin/nacos/config.rs | 1 + crates/dbx-core/src/admin/nacos/service.rs | 2 + crates/dbx-core/src/ai/agent_tools.rs | 1 + .../src/connection/connection_secrets.rs | 1 + crates/dbx-core/src/connection/mod.rs | 5 + crates/dbx-core/src/data/transfer.rs | 2 + crates/dbx-core/src/persistence/cloud_sync.rs | 3 + crates/dbx-core/src/persistence/storage.rs | 2 + crates/dbx-core/src/query/mod.rs | 92 +++++++++++++++---- .../src/query/sqlserver_manual_transaction.rs | 21 +++-- .../dbx-core/src/safety/production_safety.rs | 1 + crates/dbx-core/src/schema/mod.rs | 1 + crates/dbx-core/src/schema/normalization.rs | 1 + .../tests/connection_url_compatibility.rs | 4 +- crates/dbx-core/tests/dump_docs_fixture.rs | 1 + .../tests/live_postgres_docs_annotations.rs | 1 + .../tests/live_postgres_docs_snapshot.rs | 1 + .../tests/live_postgres_error_position.rs | 1 + .../live_postgres_query_result_export.rs | 1 + .../dbx-core/tests/live_postgres_transfer.rs | 1 + .../live_sqlite_to_sqlserver_transfer.rs | 1 + .../tests/live_sqlserver_completion.rs | 1 + .../live_sqlserver_query_result_export.rs | 1 + .../tests/live_sqlserver_split_zip_e2e.rs | 1 + .../dbx-core/tests/live_sqlserver_transfer.rs | 1 + crates/dbx-core/tests/lob_cancel_wait.rs | 1 + crates/dbx-core/tests/support/mod.rs | 1 + .../dbx-driver-agent/src/agent_connection.rs | 3 +- crates/dbx-driver-agent/src/backend_error.rs | 17 ++-- crates/dbx-driver-redis/src/lib.rs | 1 + crates/dbx-types/src/models/connection.rs | 49 ++++++++++ crates/dbx-web/src/credentials.rs | 1 + crates/dbx-web/src/routes/connection.rs | 1 + crates/dbx-web/src/routes/docs.rs | 3 +- crates/dbx-web/src/routes/transfer.rs | 3 +- src-tauri/src/commands/connection.rs | 1 + src-tauri/src/commands/query.rs | 4 +- 63 files changed, 378 insertions(+), 99 deletions(-) diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 1ad594c470..ab2b2ad6df 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -77,7 +77,17 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isWindows } from "@/lib/backend/platform"; import { applyMeilisearchBasePathToExternalConfig, applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; import { hasXuguConnectionDatabase } from "@/lib/connection/xuguDatabase"; -import { DEFAULT_QUERY_TIMEOUT_SECS, MAX_CONNECT_TIMEOUT_SECS, MAX_IDLE_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS, supportsIdleTimeout } from "@/lib/connection/timeoutLimits"; +import { + DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS, + DEFAULT_QUERY_TIMEOUT_SECS, + MAX_CONNECT_TIMEOUT_SECS, + MAX_IDLE_TIMEOUT_SECS, + MAX_MANUAL_TRANSACTION_TIMEOUT_SECS, + MAX_QUERY_TIMEOUT_SECS, + MIN_MANUAL_TRANSACTION_TIMEOUT_SECS, + normalizeManualTransactionTimeoutSecs, + supportsIdleTimeout, +} from "@/lib/connection/timeoutLimits"; import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection"; import { applyConnectionDeepLinkUpdate, resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate"; import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseConnectionDeepLinkUpdate, parseServiceConnectionUrl, type ConnectionDeepLinkDraft, type ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; @@ -156,7 +166,7 @@ import { import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleObjectFiltersNeedReset } from "@/lib/connection/connectionVisibleDatabases"; import { resolveVisibleDatabaseSaveAction } from "@/components/sidebar/visibleDatabasesDialogState"; import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases"; -import { isSchemaAware, isSingleDatabase, supportsDataDictionary } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, isSingleDatabase, supportsDataDictionary, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization"; import { databaseConnectionFormKind, databaseManifestEntry } from "@/lib/database/databaseDriverManifest"; import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue"; @@ -423,6 +433,7 @@ const defaultForm = (): ConnectionForm => ({ query_timeout_secs: settingsStore.editorSettings.globalQueryTimeoutSecs, query_timeout_inherit: true, idle_timeout_secs: 60, + manual_transaction_timeout_secs: DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS, keepalive_interval_secs: 30, ssl: false, ca_cert_path: "", @@ -3120,6 +3131,7 @@ watch( query_timeout_secs: config.query_timeout_inherit === true ? settingsStore.editorSettings.globalQueryTimeoutSecs : (config.query_timeout_secs ?? DEFAULT_QUERY_TIMEOUT_SECS), query_timeout_inherit: config.query_timeout_inherit === true, idle_timeout_secs: config.idle_timeout_secs ?? 60, + manual_transaction_timeout_secs: normalizeManualTransactionTimeoutSecs(config.manual_transaction_timeout_secs), keepalive_interval_secs: config.keepalive_interval_secs ?? 30, sysdba: config.sysdba || isOracleSysUser(config), oracle_oci_nls_lang: config.oracle_oci_nls_lang || "", @@ -3856,6 +3868,7 @@ const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]); const supportsMysqlTlsOptions = computed(() => mysqlTlsOptionsSupported(form.value.db_type, selectedType.value)); const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)); const supportsIdleTimeoutSetting = computed(() => supportsIdleTimeout(form.value.db_type)); +const supportsManualTransactionTimeoutSetting = computed(() => supportsTransaction(form.value.db_type)); const supportsDoltSystemTables = computed(() => isDoltDriverProfile(form.value.driver_profile)); const showDoltSystemTables = computed({ get: () => doltSystemTablesVisible(form.value), @@ -4846,6 +4859,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi config.query_timeout_secs = form.value.query_timeout_secs; config.query_timeout_inherit = form.value.query_timeout_inherit; config.idle_timeout_secs = form.value.idle_timeout_secs; + config.manual_transaction_timeout_secs = normalizeManualTransactionTimeoutSecs(form.value.manual_transaction_timeout_secs); config.keepalive_interval_secs = form.value.keepalive_interval_secs; config.read_only = form.value.read_only; config.save_password = form.value.save_password; @@ -10324,6 +10338,13 @@ function openExternalUrl(url: string) {

{{ t("connection.idleTimeoutHint") }}

+
+ +
+ +

{{ t("connection.manualTransactionTimeoutHint", { min: MIN_MANUAL_TRANSACTION_TIMEOUT_SECS, max: MAX_MANUAL_TRANSACTION_TIMEOUT_SECS }) }}

+
+
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index c40cf3ca74..5ce381a6e8 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -263,7 +263,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Əl ilə tranzaksiya (Agent server hissəsindən asılıdır)", commit: "Təsdiqlə", rollback: "Geri qaytar", - txnAutoRolledBack: "5 dəqiqə fəaliyyətsizlikdən sonra tranzaksiya avtomatik geri qaytarıldı", + txnAutoRolledBack: "Tranzaksiya fəaliyyətsizlik səbəbindən avtomatik geri qaytarıldı; təsdiqlənməmiş dəyişikliklər ləğv edildi", autoCommitTxnRolledBack: 'Bu nişanın açıq qoyduğu tranzaksiya avtomatik geri qaytarıldı; icralar arasında saxlamaq üçün ayarlarda "Avtomatik commit rejimində açıq tranzaksiyaları saxla" seçimini aktivləşdirin', autoCommitSessionTxnRolledBack: "Bu bağlantı autocommit söndürülmüş (SET autocommit = 0) işləyir, ona görə örtük tranzaksiya icradan sonra geri qaytarıldı; DBX bunu hər bağlantı üçün bir dəfə bildirir", previewSql: "SQL önbaxışı", @@ -1315,6 +1315,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Əlaqə", idleTimeout: "Boşdayanma vaxt limiti (saniyə)", idleTimeoutHint: "Bağlantı hovuzundakı boşda qalan bağlantıların geri qaytarılmasına tətbiq edilir.", + manualTransactionTimeout: "Tranzaksiyanın boşdayanma vaxt limiti (saniyə)", + manualTransactionTimeoutHint: "Bu müddət ərzində heç bir ifadə icra edilməyən əl ilə idarə olunan tranzaksiya avtomatik geri qaytarılır. İcazə verilən aralıq: {min}–{max} saniyə.", keepaliveInterval: "Əlaqənin canlı saxlanma intervalı (saniyə)", readOnly: "Yalnız oxuma", readOnlyHint: "Bütün yazma əməliyyatlarını blokla (INSERT, UPDATE, DELETE və s.)", @@ -2751,7 +2753,7 @@ export default withEnglishFallback({ legacyFailure: "Verilənlər bazası agenti təsnif edilməmiş xəta qaytardı.", }, transaction: { - sessionExpired: "Əl ilə idarə olunan tranzaksiya {timeoutSecs} saniyə fəaliyyətsizlikdən sonra vaxtı bitdiyi üçün geri qaytarıldı. Yeni tranzaksiya avtomatik başladılacaq.", + sessionExpired: "Əl ilə idarə olunan tranzaksiya boşdayanma müddətini aşdı və geri qaytarıldı; təsdiqlənməmiş dəyişikliklər ləğv edildi. İfadə icra edilmədi — yeni tranzaksiya başlatmaq üçün yenidən icra edin.", }, legacy: "Server tərəfi xəta qaytardı.", unknown: "Server tərəfində gözlənilməz xəta baş verdi.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index bf40b4fb3f..2afcc0a822 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -781,7 +781,7 @@ export default { manualTransactionAgent: "Manual Transaction (depends on Agent backend)", commit: "Commit", rollback: "Rollback", - txnAutoRolledBack: "Transaction auto-rolled back after 5 minutes of inactivity", + txnAutoRolledBack: "Transaction auto-rolled back after being idle. Uncommitted changes were discarded.", salesforceIdentityAdmin: "Profile has “Modify All Data” — bypasses field-level security and record sharing", salesforceIdentityNonAdmin: "Profile has no “Modify All Data” — field permissions and record sharing apply", salesforceIdentityUnknownRights: "Could not determine whether the profile has “Modify All Data”", @@ -1951,6 +1951,8 @@ export default { useConnectionQueryTimeout: "Connection", idleTimeout: "Idle Timeout (seconds)", idleTimeoutHint: "Applies to idle connection reclamation within the connection pool.", + manualTransactionTimeout: "Transaction Idle Timeout (seconds)", + manualTransactionTimeoutHint: "A manual transaction with no statement executed for this long is rolled back automatically. Allowed range: {min}–{max} seconds.", keepaliveInterval: "Keepalive Interval (seconds)", readOnly: "Read Only", readOnlyHint: "Block all write operations (INSERT, UPDATE, DELETE, etc.)", @@ -3538,10 +3540,10 @@ export default { rollbackFailed: "The transaction ended, but rollback could not be confirmed. Verify the database before running the SQL again.", commitUnknown: "The COMMIT response was lost. The transaction may already be committed. Verify the database before running the SQL again.", controlConflict: "Manual mode manages transaction control and the database context. Remove BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS or USE from the script.", - idleTimeout: "The transaction ended after five minutes of inactivity. Start a new transaction to continue.", + idleTimeout: "The transaction was rolled back after going idle. Start a new transaction to continue.", executionFailed: "SQL execution failed and this manual transaction ended.", - sessionExpired: "The manual transaction expired after {timeoutSecs} seconds of inactivity and was rolled back. A new transaction will be started automatically.", + sessionExpired: "The manual transaction timed out while idle and was rolled back; uncommitted changes were discarded. The statement was not executed — run it again to start a new transaction.", }, 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 7e2960c04e..6c75ecc0b9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -630,7 +630,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Transacción Manual (depende del backend del Agente)", commit: "Confirmar", rollback: "Revertir", - txnAutoRolledBack: "Transacción revertida automáticamente tras 5 minutos de inactividad", + txnAutoRolledBack: "Transacción revertida automáticamente por inactividad; se descartaron los cambios sin confirmar", salesforceIdentityAdmin: "El perfil tiene “Modify All Data”: omite la seguridad a nivel de campo y el uso compartido de registros", salesforceIdentityNonAdmin: "El perfil no tiene “Modify All Data”: los permisos de campos y el uso compartido siguen aplicándose", salesforceIdentityUnknownRights: "No se pudo determinar si el perfil tiene “Modify All Data”", @@ -1542,6 +1542,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Conexión", idleTimeout: "Tiempo de espera inactivo (segundos)", idleTimeoutHint: "Se aplica a la recuperación de conexiones inactivas dentro del grupo de conexiones.", + manualTransactionTimeout: "Tiempo de inactividad de la transacción (segundos)", + manualTransactionTimeoutHint: "Una transacción manual sin ninguna sentencia ejecutada durante este tiempo se revierte automáticamente. Rango permitido: {min}–{max} segundos.", keepaliveInterval: "Intervalo de keepalive (segundos)", readOnly: "Solo lectura", readOnlyHint: "Bloquear todas las operaciones de escritura (INSERT, UPDATE, DELETE, etc.)", @@ -3303,9 +3305,9 @@ export default withEnglishFallback({ rollbackFailed: "La transacción terminó, pero no se pudo confirmar la reversión. Verifique la base de datos antes de volver a ejecutar el SQL.", commitUnknown: "Se perdió la respuesta de COMMIT. La transacción puede haberse confirmado. Verifique la base de datos antes de volver a ejecutar el SQL.", controlConflict: "El modo manual administra las transacciones y el contexto de la base de datos. Quite BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS o USE del script.", - idleTimeout: "La transacción terminó tras cinco minutos de inactividad. Inicie una nueva transacción para continuar.", + idleTimeout: "La transacción se revirtió por inactividad. Inicie una nueva transacción para continuar.", executionFailed: "La ejecución del SQL falló y esta transacción manual terminó.", - sessionExpired: "La transacción manual expiró después de {timeoutSecs} segundos de inactividad y se revirtió. Se iniciará una nueva transacción automáticamente.", + sessionExpired: "La transacción manual agotó su tiempo de inactividad y se revirtió; se descartaron los cambios sin confirmar. La sentencia no se ejecutó: vuelva a ejecutarla para iniciar una nueva transacción.", }, legacy: "El backend devolvió un error.", unknown: "Ocurrió un error inesperado del backend.", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index a09416b6ef..3dfd37e035 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -409,7 +409,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Transaksi Manual (bergantung pada backend Agent)", commit: "Commit", rollback: "Rollback", - txnAutoRolledBack: "Transaksi di-rollback otomatis setelah 5 menit tidak aktif", + txnAutoRolledBack: "Transaksi di-rollback otomatis karena tidak aktif; perubahan yang belum di-commit dibuang", salesforceIdentityAdmin: "Profil memiliki “Modify All Data” — melewati keamanan tingkat field dan berbagi record", salesforceIdentityNonAdmin: "Profil tidak memiliki “Modify All Data” — izin field dan berbagi record berlaku", salesforceIdentityUnknownRights: "Tidak dapat menentukan apakah profil memiliki “Modify All Data”", @@ -1578,6 +1578,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Koneksi", idleTimeout: "Timeout Idle (detik)", idleTimeoutHint: "Berlaku untuk pemulihan koneksi idle dalam connection pool.", + manualTransactionTimeout: "Timeout Idle Transaksi (detik)", + manualTransactionTimeoutHint: "Transaksi manual tanpa pernyataan yang dijalankan selama durasi ini akan di-rollback otomatis. Rentang yang diizinkan: {min}–{max} detik.", keepaliveInterval: "Interval Keepalive (detik)", readOnly: "Read Only", readOnlyHint: "Blokir semua operasi tulis (INSERT, UPDATE, DELETE, dll.)", @@ -3074,7 +3076,7 @@ export default withEnglishFallback({ legacyFailure: "Agent database mengembalikan galat yang tidak terklasifikasi.", }, transaction: { - sessionExpired: "Transaksi manual kedaluwarsa setelah {timeoutSecs} detik tidak ada aktivitas dan telah di-rollback. Transaksi baru akan dimulai secara otomatis.", + sessionExpired: "Transaksi manual melewati batas waktu tidak aktif dan di-rollback; perubahan yang belum di-commit dibuang. Pernyataan tidak dijalankan — jalankan lagi untuk memulai transaksi baru.", }, legacy: "Backend mengembalikan galat.", unknown: "Terjadi galat backend yang tidak terduga.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6b034920bd..c65ec99ed1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -624,7 +624,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Transazione Manuale (dipende dal backend dell'Agente)", commit: "Commit", rollback: "Rollback", - txnAutoRolledBack: "Transazione annullata automaticamente dopo 5 minuti di inattività", + txnAutoRolledBack: "Transazione annullata automaticamente per inattività; le modifiche non confermate sono state scartate", autoCommitTxnRolledBack: 'La transazione esplicita lasciata aperta da questa scheda è stata annullata automaticamente; attiva "Mantieni le transazioni esplicite in autocommit" per conservarla tra le esecuzioni', autoCommitSessionTxnRolledBack: "Questa connessione lavora con autocommit disattivato (SET autocommit = 0), quindi la sua transazione implicita è stata annullata dopo l'esecuzione; DBX lo segnala una volta per connessione", previewSql: "Anteprima SQL", @@ -1464,6 +1464,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Connessione", idleTimeout: "Timeout Inattività (secondi)", idleTimeoutHint: "Si applica al recupero delle connessioni inattive all'interno del pool di connessioni.", + manualTransactionTimeout: "Timeout inattività transazione (secondi)", + manualTransactionTimeoutHint: "Una transazione manuale senza istruzioni eseguite per questo periodo viene annullata automaticamente. Intervallo consentito: {min}–{max} secondi.", keepaliveInterval: "Intervallo keepalive (secondi)", readOnly: "Sola lettura", readOnlyHint: "Blocca tutte le operazioni di scrittura (INSERT, UPDATE, DELETE, ecc.)", @@ -3159,9 +3161,9 @@ export default withEnglishFallback({ rollbackFailed: "La transazione è terminata, ma il rollback non è stato confermato. Verificare il database prima di rieseguire il SQL.", commitUnknown: "La risposta a COMMIT è stata persa. La transazione potrebbe essere già stata confermata. Verificare il database prima di rieseguire il SQL.", controlConflict: "La modalità manuale gestisce le transazioni e il contesto del database. Rimuovere BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS o USE dallo script.", - idleTimeout: "La transazione è terminata dopo cinque minuti di inattività. Avviare una nuova transazione per continuare.", + idleTimeout: "La transazione è stata annullata per inattività. Avviare una nuova transazione per continuare.", executionFailed: "L'esecuzione del SQL è fallita e questa transazione manuale è terminata.", - sessionExpired: "La transazione manuale è scaduta dopo {timeoutSecs} secondi di inattività ed è stata annullata. Verrà avviata automaticamente una nuova transazione.", + sessionExpired: "La transazione manuale è scaduta per inattività ed è stata annullata; le modifiche non confermate sono state scartate. L'istruzione non è stata eseguita: eseguirla di nuovo per avviare 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 19d831ed73..a145b2a6d8 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -625,7 +625,7 @@ export default withEnglishFallback({ manualTransactionAgent: "手動トランザクション(Agentバックエンドに依存)", commit: "コミット", rollback: "ロールバック", - txnAutoRolledBack: "5分間操作がなかったため、トランザクションは自動的にロールバックされました", + txnAutoRolledBack: "アイドルタイムアウトによりトランザクションが自動的にロールバックされ、未コミットの変更は破棄されました", autoCommitTxnRolledBack: "このタブに残っていた明示的トランザクションは自動的にロールバックされました。実行をまたいで保持するには設定の「自動コミットでも明示的なトランザクションを保持」を有効にしてください", autoCommitSessionTxnRolledBack: "この接続は autocommit=0 で動作しているため、実行後に暗黙のトランザクションがロールバックされました(同じ接続では一度だけ通知します)", previewSql: "SQLをプレビュー", @@ -1434,6 +1434,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "接続", idleTimeout: "アイドルタイムアウト(秒)", idleTimeoutHint: "コネクションプール内のアイドル接続の回収・解放に適用されます。", + manualTransactionTimeout: "トランザクションのアイドルタイムアウト(秒)", + manualTransactionTimeoutHint: "手動トランザクションでこの時間ステートメントが実行されない場合、自動的にロールバックされます。指定可能な範囲: {min}~{max} 秒。", keepaliveInterval: "Keepalive 間隔(秒)", readOnly: "読み取り専用", readOnlyHint: "すべての書き込み操作(INSERT, UPDATE, DELETEなど)をブロックします", @@ -3183,9 +3185,9 @@ export default withEnglishFallback({ rollbackFailed: "トランザクションは終了しましたが、ロールバックは確認できませんでした。SQL を再実行する前にデータベースを確認してください。", commitUnknown: "COMMIT の応答が失われました。トランザクションは既にコミットされている可能性があります。SQL を再実行する前にデータベースを確認してください。", controlConflict: "手動モードがトランザクション制御とデータベースのコンテキストを管理します。スクリプトから BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS、USE を削除してください。", - idleTimeout: "5 分間操作がなかったためトランザクションが終了しました。続行するには新しいトランザクションを開始してください。", + idleTimeout: "アイドルタイムアウトによりトランザクションがロールバックされました。続行するには新しいトランザクションを開始してください。", executionFailed: "SQL の実行に失敗し、この手動トランザクションは終了しました。", - sessionExpired: "手動トランザクションは {timeoutSecs} 秒間操作がなかったため期限切れとなり、ロールバックされました。新しいトランザクションを自動的に開始します。", + sessionExpired: "手動トランザクションはアイドルタイムアウトによりロールバックされ、未コミットの変更は破棄されました。このステートメントは実行されていません。再実行して新しいトランザクションを開始してください。", }, legacy: "バックエンドがエラーを返しました。", unknown: "予期しないバックエンドエラーが発生しました。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 788bc2c3f6..0ec983e376 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -632,7 +632,7 @@ export default withEnglishFallback({ manualTransactionAgent: "수동 트랜잭션 (Agent 백엔드에 따라)", commit: "커밋", rollback: "롤백", - txnAutoRolledBack: "5분간 활동이 없어 트랜잭션이 자동으로 롤백되었습니다", + txnAutoRolledBack: "유휴 제한 시간 초과로 트랜잭션이 자동으로 롤백되어 커밋되지 않은 변경 사항이 취소되었습니다", autoCommitTxnRolledBack: '이 탭에 남아 있던 명시적 트랜잭션이 자동으로 롤백되었습니다. 실행 간에 유지하려면 설정에서 "자동 커밋 모드에서 명시적 트랜잭션 유지"를 켜세요', autoCommitSessionTxnRolledBack: "이 연결은 autocommit=0 상태이므로 실행 후 암시적 트랜잭션이 롤백되었습니다(같은 연결에서는 한 번만 알립니다)", previewSql: "SQL 미리보기", @@ -1618,6 +1618,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "연결", idleTimeout: "유휴 제한 시간 (초)", idleTimeoutHint: "연결 풀 내 유휴 연결의 회수 및 해제에 적용됩니다.", + manualTransactionTimeout: "트랜잭션 유휴 제한 시간 (초)", + manualTransactionTimeoutHint: "수동 트랜잭션에서 이 시간 동안 문이 실행되지 않으면 자동으로 롤백됩니다. 허용 범위: {min}~{max}초.", keepaliveInterval: "킵얼라이브 간격 (초)", readOnly: "읽기 전용", readOnlyHint: "모든 쓰기 작업 차단 (INSERT, UPDATE, DELETE 등)", @@ -3110,9 +3112,9 @@ export default withEnglishFallback({ rollbackFailed: "트랜잭션은 종료되었지만 롤백을 확인할 수 없습니다. SQL을 다시 실행하기 전에 데이터베이스를 확인하세요.", commitUnknown: "COMMIT 응답이 손실되었습니다. 트랜잭션이 이미 커밋되었을 수 있습니다. SQL을 다시 실행하기 전에 데이터베이스를 확인하세요.", controlConflict: "수동 모드에서 트랜잭션 제어와 데이터베이스 컨텍스트를 관리합니다. 스크립트에서 BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS 또는 USE를 제거하세요.", - idleTimeout: "5분간 활동이 없어 트랜잭션이 종료되었습니다. 계속하려면 새 트랜잭션을 시작하세요.", + idleTimeout: "유휴 제한 시간 초과로 트랜잭션이 롤백되었습니다. 계속하려면 새 트랜잭션을 시작하세요.", executionFailed: "SQL 실행이 실패하여 이 수동 트랜잭션이 종료되었습니다.", - sessionExpired: "수동 트랜잭션이 {timeoutSecs}초 동안 활동이 없어 만료되고 롤백되었습니다. 새 트랜잭션을 자동으로 시작합니다.", + sessionExpired: "수동 트랜잭션이 유휴 제한 시간 초과로 롤백되어 커밋되지 않은 변경 사항이 취소되었습니다. 이 문은 실행되지 않았습니다. 다시 실행하여 새 트랜잭션을 시작하세요.", }, legacy: "백엔드에서 오류를 반환했습니다.", unknown: "예상하지 못한 백엔드 오류가 발생했습니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d551f65ef3..25bbac20f9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -625,7 +625,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Transação Manual (depende do backend do Agente)", commit: "Confirmar", rollback: "Reverter", - txnAutoRolledBack: "Transação revertida automaticamente após 5 minutos de inatividade", + txnAutoRolledBack: "Transação revertida automaticamente por inatividade; as alterações não confirmadas foram descartadas", autoCommitTxnRolledBack: 'A transação explícita deixada aberta por esta aba foi revertida automaticamente; ative "Manter transações explícitas no autocommit" para mantê-la entre execuções', autoCommitSessionTxnRolledBack: "Esta conexão está com o autocommit desativado (SET autocommit = 0), então sua transação implícita foi revertida após a execução; o DBX avisa uma vez por conexão", previewSql: "Visualizar SQL", @@ -1465,6 +1465,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Conexão", idleTimeout: "Timeout de Inatividade (segundos)", idleTimeoutHint: "Aplica-se à recuperação de conexões inativas no pool de conexões.", + manualTransactionTimeout: "Tempo limite de inatividade da transação (segundos)", + manualTransactionTimeoutHint: "Uma transação manual sem nenhuma instrução executada durante esse período é revertida automaticamente. Intervalo permitido: {min}–{max} segundos.", keepaliveInterval: "Intervalo de keepalive (segundos)", readOnly: "Somente leitura", readOnlyHint: "Bloquear todas as operações de escrita (INSERT, UPDATE, DELETE, etc.)", @@ -3159,9 +3161,9 @@ export default withEnglishFallback({ rollbackFailed: "A transação terminou, mas a reversão não pôde ser confirmada. Verifique o banco de dados antes de executar o SQL novamente.", commitUnknown: "A resposta de COMMIT foi perdida. A transação pode já ter sido confirmada. Verifique o banco de dados antes de executar o SQL novamente.", controlConflict: "O modo manual gerencia as transações e o contexto do banco de dados. Remova BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS ou USE do script.", - idleTimeout: "A transação terminou após cinco minutos de inatividade. Inicie uma nova transação para continuar.", + idleTimeout: "A transação foi revertida por inatividade. Inicie uma nova transação para continuar.", executionFailed: "A execução do SQL falhou e esta transação manual terminou.", - sessionExpired: "A transação manual expirou após {timeoutSecs} segundos de inatividade e foi revertida. Uma nova transação será iniciada automaticamente.", + sessionExpired: "A transação manual expirou por inatividade e foi revertida; as alterações não confirmadas foram descartadas. A instrução não foi executada: execute-a novamente para iniciar uma nova transação.", }, legacy: "O backend retornou um erro.", unknown: "Ocorreu um erro inesperado no backend.", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 6c5d743394..e933f0db35 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -605,7 +605,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Ручная транзакция (зависит от бэкенда агента)", commit: "Зафиксировать", rollback: "Откатить", - txnAutoRolledBack: "Транзакция автоматически откатана после 5 минут бездействия", + txnAutoRolledBack: "Транзакция автоматически откатана из-за простоя; незафиксированные изменения отменены", autoCommitTxnRolledBack: "Явная транзакция, оставленная открытой этой вкладкой, была автоматически откатана; включите «Сохранять явные транзакции с автофиксацией», чтобы сохранять её между выполнениями", autoCommitSessionTxnRolledBack: "Это соединение работает с отключённой автофиксацией (SET autocommit = 0), поэтому его неявная транзакция была откатана после выполнения; DBX сообщает об этом один раз на соединение", previewSql: "Предпросмотр SQL", @@ -1716,6 +1716,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Подключение", idleTimeout: "Тайм-аут простоя (секунды)", idleTimeoutHint: "Применяется для освобождения простаивающих соединений в пуле соединений.", + manualTransactionTimeout: "Тайм-аут простоя транзакции (секунды)", + manualTransactionTimeoutHint: "Ручная транзакция, в которой за это время не выполнялось ни одной инструкции, автоматически откатывается. Допустимый диапазон: {min}–{max} секунд.", keepaliveInterval: "Интервал keepalive (секунды)", readOnly: "Только чтение", readOnlyHint: "Блокировать все операции записи (INSERT, UPDATE, DELETE и т. д.)", @@ -3260,9 +3262,9 @@ export default withEnglishFallback({ rollbackFailed: "Транзакция завершена, но откат не подтверждён. Проверьте базу данных перед повторным выполнением SQL.", commitUnknown: "Ответ на COMMIT потерян. Возможно, транзакция уже зафиксирована. Проверьте базу данных перед повторным выполнением SQL.", controlConflict: "Ручной режим управляет транзакциями и контекстом базы данных. Удалите из скрипта BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS или USE.", - idleTimeout: "Транзакция завершена после пяти минут бездействия. Начните новую транзакцию, чтобы продолжить.", + idleTimeout: "Транзакция откатана из-за простоя. Начните новую транзакцию, чтобы продолжить.", executionFailed: "Выполнение SQL завершилось ошибкой; эта ручная транзакция завершена.", - sessionExpired: "Ручная транзакция истекла после {timeoutSecs} секунд бездействия и была отменена. Новая транзакция будет запущена автоматически.", + sessionExpired: "Ручная транзакция истекла из-за простоя и была отменена; незафиксированные изменения отброшены. Инструкция не выполнена — запустите её снова, чтобы начать новую транзакцию.", }, legacy: "Бэкенд вернул ошибку.", unknown: "Произошла непредвиденная ошибка бэкенда.", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index d65ebfb145..cf3182c0ff 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -748,7 +748,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Transaction แบบ Manual (ขึ้นอยู่กับ Backend ของ Agent)", commit: "Commit", rollback: "ย้อนกลับ", - txnAutoRolledBack: "Rollback Transaction อัตโนมัติหลังไม่มีการใช้งาน 5 นาที", + txnAutoRolledBack: "Rollback Transaction อัตโนมัติเพราะไม่มีการใช้งาน การเปลี่ยนแปลงที่ยังไม่ Commit ถูกยกเลิก", salesforceIdentityAdmin: "โปรไฟล์มี “Modify All Data” — ข้ามการรักษาความปลอดภัยระดับฟิลด์และการแชร์ระเบียน", salesforceIdentityNonAdmin: "โปรไฟล์ไม่มี “Modify All Data” — สิทธิ์ระดับฟิลด์และการแชร์ระเบียนมีผล", salesforceIdentityUnknownRights: "ไม่สามารถระบุได้ว่าโปรไฟล์มี “Modify All Data” หรือไม่", @@ -1913,6 +1913,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "การเชื่อมต่อ", idleTimeout: "Timeout เมื่อไม่มีการใช้งาน (วินาที)", idleTimeoutHint: "มีผลกับการเรียกคืนการเชื่อมต่อที่ไม่มีการใช้งานภายใน Pool การเชื่อมต่อ", + manualTransactionTimeout: "Timeout ของ Transaction เมื่อไม่มีการใช้งาน (วินาที)", + manualTransactionTimeoutHint: "Transaction แบบ Manual ที่ไม่มีการรันคำสั่งภายในเวลานี้จะถูก Rollback อัตโนมัติ ช่วงที่อนุญาต: {min}–{max} วินาที", keepaliveInterval: "ช่วง Keepalive (วินาที)", readOnly: "อ่านอย่างเดียว", readOnlyHint: "บล็อกการดำเนินการเขียนทั้งหมด (INSERT, UPDATE, DELETE ฯลฯ)", @@ -3485,9 +3487,9 @@ export default withEnglishFallback({ rollbackFailed: "Transaction จบแล้ว แต่ยืนยัน Rollback ไม่ได้ ตรวจสอบฐานข้อมูลก่อนเรียกใช้ SQL อีกครั้ง", commitUnknown: "การตอบกลับ COMMIT สูญหาย Transaction อาจ Commit แล้ว ตรวจสอบฐานข้อมูลก่อนเรียกใช้ SQL อีกครั้ง", controlConflict: "โหมด Manual จัดการการควบคุม Transaction และ Context ฐานข้อมูล นำ BEGIN/COMMIT/ROLLBACK/SAVE, SET IMPLICIT_TRANSACTIONS หรือ USE ออกจาก Script", - idleTimeout: "Transaction จบหลังไม่มีการใช้งานห้านาที เริ่ม Transaction ใหม่เพื่อดำเนินการต่อ", + idleTimeout: "Transaction ถูก Rollback เพราะไม่มีการใช้งาน เริ่ม Transaction ใหม่เพื่อดำเนินการต่อ", executionFailed: "การเรียกใช้ SQL ล้มเหลวและ Transaction แบบ Manual นี้จบลง", - sessionExpired: "Transaction แบบ Manual หมดอายุหลังไม่มีการใช้งาน {timeoutSecs} วินาทีและถูก Rollback จะเริ่ม Transaction ใหม่โดยอัตโนมัติ", + sessionExpired: "Transaction แบบ Manual หมดเวลาเพราะไม่มีการใช้งานและถูก Rollback การเปลี่ยนแปลงที่ยังไม่ Commit ถูกยกเลิก คำสั่งนี้ยังไม่ได้ถูกรัน รันอีกครั้งเพื่อเริ่ม Transaction ใหม่", }, legacy: "Backend ส่งข้อผิดพลาดกลับมา", unknown: "เกิดข้อผิดพลาด Backend ที่ไม่คาดคิด", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index a4480cdbb9..5e681fc24a 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -263,7 +263,7 @@ export default withEnglishFallback({ manualTransactionAgent: "Manuel İşlem (Agent arka ucuna bağlıdır)", commit: "Commit", rollback: "Rollback", - txnAutoRolledBack: "İşlem, 5 dakikalık hareketsizlik sonrası otomatik olarak geri alındı", + txnAutoRolledBack: "İşlem, hareketsizlik nedeniyle otomatik olarak geri alındı; kaydedilmemiş değişiklikler atıldı", autoCommitTxnRolledBack: 'Bu sekmenin açık bıraktığı açık işlem otomatik olarak geri alındı; yürütmeler arasında korumak için ayarlardan "Otomatik işlemede açık işlemleri koru" seçeneğini açın', autoCommitSessionTxnRolledBack: "Bu bağlantı autocommit kapalı (SET autocommit = 0) çalışıyor; bu nedenle örtük işlem yürütmeden sonra geri alındı. DBX bunu bağlantı başına bir kez bildirir", previewSql: "SQL Önizleme", @@ -1315,6 +1315,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "Bağlantı", idleTimeout: "Boşta Kalma Zaman Aşımı (saniye)", idleTimeoutHint: "Bağlantı havuzundaki boşta kalan bağlantıların geri kazanılması için geçerlidir.", + manualTransactionTimeout: "İşlem Boşta Kalma Zaman Aşımı (saniye)", + manualTransactionTimeoutHint: "Bu süre boyunca hiçbir ifade çalıştırılmayan manuel işlem otomatik olarak geri alınır. İzin verilen aralık: {min}–{max} saniye.", keepaliveInterval: "Keepalive Aralığı (saniye)", readOnly: "Salt Okunur", readOnlyHint: "Tüm yazma işlemlerini engelle (INSERT, UPDATE, DELETE vb.)", @@ -2727,7 +2729,7 @@ export default withEnglishFallback({ legacyFailure: "Veritabanı agent'ı sınıflandırılmamış bir hata döndürdü.", }, transaction: { - sessionExpired: "Manuel işlem, {timeoutSecs} saniyelik hareketsizlik sonrası sona erdi ve geri alındı. Otomatik olarak yeni bir işlem başlatılacak.", + sessionExpired: "Manuel işlem boşta kalma süresini aştı ve geri alındı; kaydedilmemiş değişiklikler atıldı. İfade çalıştırılmadı; yeni bir işlem başlatmak için yeniden çalıştırın.", }, legacy: "Arka uç bir hata döndürdü.", unknown: "Beklenmeyen bir arka uç hatası oluştu.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5ac3eb450b..64c8fc7610 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -717,7 +717,7 @@ export default withEnglishFallback({ manualTransactionAgent: "手动事务(取决于 Agent 后端数据库)", commit: "提交", rollback: "回滚", - txnAutoRolledBack: "事务已因 5 分钟无操作而自动回滚", + txnAutoRolledBack: "事务因空闲超时已自动回滚,未提交的改动已丢弃。", salesforceIdentityAdmin: "简档具有“修改所有数据”权限——会绕过字段级安全与记录共享", salesforceIdentityNonAdmin: "简档不含“修改所有数据”权限——字段权限与记录共享照常生效", salesforceIdentityUnknownRights: "无法确认简档是否具有“修改所有数据”权限", @@ -1889,6 +1889,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "当前连接", idleTimeout: "空闲超时(秒)", idleTimeoutHint: "作用于连接池内空闲连接的回收释放。", + manualTransactionTimeout: "事务空闲超时(秒)", + manualTransactionTimeoutHint: "手动事务在此时长内没有执行任何语句时将自动回滚,范围 {min}–{max} 秒。", keepaliveInterval: "保持连接间隔(秒)", readOnly: "只读模式", readOnlyHint: "阻止所有写操作(INSERT、UPDATE、DELETE 等)", @@ -3471,10 +3473,10 @@ export default withEnglishFallback({ rollbackFailed: "事务会话已结束,但无法确认回滚结果;再次执行 SQL 前请核对数据库。", commitUnknown: "提交响应丢失,事务可能已经提交;再次执行 SQL 前请核对数据库。", controlConflict: "手动模式统一管理事务与数据库上下文,请移除 SQL 中的 BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS 或 USE。", - idleTimeout: "事务已因 5 分钟无操作而结束,请重新执行以开启新事务。", + idleTimeout: "事务因空闲超时已回滚,请重新执行以开启新事务。", executionFailed: "SQL 执行失败,本次手动事务已结束。", - sessionExpired: "手动事务因 {timeoutSecs} 秒无操作已过期并回滚,DBX 将自动开始新事务。", + sessionExpired: "手动事务因空闲超时已回滚,未提交的改动已丢弃。本次语句未执行,请重新执行以开启新事务。", }, legacy: "后端返回了错误。", unknown: "发生了未预期的后端错误。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 51876c5060..a98f0a16e5 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -628,7 +628,7 @@ export default withEnglishFallback({ manualTransactionAgent: "手動事務(取決於 Agent 後端資料庫)", commit: "提交", rollback: "回溯", - txnAutoRolledBack: "事務已因 5 分鐘無操作而自動回溯", + txnAutoRolledBack: "交易因閒置逾時已自動回滾,未提交的變更已捨棄。", autoCommitTxnRolledBack: "該分頁遺留的顯式交易已被自動回溯;如需跨多次執行保留交易,可在設定中開啟「自動提交模式下保留顯式交易」", autoCommitSessionTxnRolledBack: "該連線處於 autocommit=0,本次執行留下的隱式交易已被自動回溯;同一連線只提示一次", previewSql: "預覽 SQL", @@ -1475,6 +1475,8 @@ export default withEnglishFallback({ useConnectionQueryTimeout: "目前連線", idleTimeout: "閒置逾時(秒)", idleTimeoutHint: "作用於連線池內閒置連線的回收釋放。", + manualTransactionTimeout: "交易閒置逾時(秒)", + manualTransactionTimeoutHint: "手動交易在此時間內未執行任何語句時將自動回滾,範圍 {min}–{max} 秒。", keepaliveInterval: "保持連線間隔(秒)", readOnly: "唯讀模式", readOnlyHint: "阻止所有寫入操作(INSERT、UPDATE、DELETE 等)", @@ -3163,10 +3165,10 @@ export default withEnglishFallback({ rollbackFailed: "交易工作階段已結束,但無法確認復原結果;再次執行 SQL 前請核對資料庫。", commitUnknown: "提交回應遺失,交易可能已經提交;再次執行 SQL 前請核對資料庫。", controlConflict: "手動模式統一管理交易與資料庫上下文,請移除 SQL 中的 BEGIN/COMMIT/ROLLBACK/SAVE、SET IMPLICIT_TRANSACTIONS 或 USE。", - idleTimeout: "交易已因 5 分鐘無操作而結束,請重新執行以開啟新交易。", + idleTimeout: "交易因閒置逾時已回滾,請重新執行以開啟新交易。", executionFailed: "SQL 執行失敗,本次手動交易已結束。", - sessionExpired: "手動交易因 {timeoutSecs} 秒無操作而過期並回滾,DBX 將自動開始新的交易。", + sessionExpired: "手動交易因閒置逾時已回滾,未提交的變更已捨棄。本次語句未執行,請重新執行以開啟新交易。", }, legacy: "後端回傳了錯誤。", unknown: "發生未預期的後端錯誤。", diff --git a/apps/desktop/src/lib/connection/__tests__/connectionSubmitNormalization.spec.ts b/apps/desktop/src/lib/connection/__tests__/connectionSubmitNormalization.spec.ts index 13ed60e5c2..3b86f1ff41 100644 --- a/apps/desktop/src/lib/connection/__tests__/connectionSubmitNormalization.spec.ts +++ b/apps/desktop/src/lib/connection/__tests__/connectionSubmitNormalization.spec.ts @@ -38,6 +38,24 @@ describe("connection submit normalization", () => { expect(invalid).toMatchObject({ query_timeout_secs: 60, idle_timeout_secs: 60, keepalive_interval_secs: 30 }); }); + it("clamps the manual transaction idle timeout and falls back to the default for unset values", () => { + const unset = config({}); + normalizeConnectionTimeouts(unset, 12, 34); + expect(unset.manual_transaction_timeout_secs).toBe(300); + + const tooSmall = config({ manual_transaction_timeout_secs: 5 }); + normalizeConnectionTimeouts(tooSmall, 12, 34); + expect(tooSmall.manual_transaction_timeout_secs).toBe(30); + + const tooLarge = config({ manual_transaction_timeout_secs: 999_999 }); + normalizeConnectionTimeouts(tooLarge, 12, 34); + expect(tooLarge.manual_transaction_timeout_secs).toBe(86400); + + const custom = config({ manual_transaction_timeout_secs: 1800 }); + normalizeConnectionTimeouts(custom, 12, 34); + expect(custom.manual_transaction_timeout_secs).toBe(1800); + }); + it("canonicalizes scope flags and production database names", () => { const value = config({ db_type: "postgres", one_time: false, read_only: false, save_password: undefined, production_databases: [" app ", "app", ""] }); normalizeConnectionScope(value); diff --git a/apps/desktop/src/lib/connection/connectionSubmitNormalization.ts b/apps/desktop/src/lib/connection/connectionSubmitNormalization.ts index 81d184f674..4342e24eef 100644 --- a/apps/desktop/src/lib/connection/connectionSubmitNormalization.ts +++ b/apps/desktop/src/lib/connection/connectionSubmitNormalization.ts @@ -1,5 +1,5 @@ import { sidebarProductionBadgeScope } from "@/lib/sidebar/sidebarProductionBadge"; -import { normalizeIdleTimeoutSecs } from "@/lib/connection/timeoutLimits"; +import { normalizeIdleTimeoutSecs, normalizeManualTransactionTimeoutSecs } from "@/lib/connection/timeoutLimits"; import { isSingleDatabase } from "@/lib/database/databaseFeatureSupport"; import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs } from "@/stores/settingsStore"; import type { ConnectionConfig } from "@/types/database"; @@ -9,6 +9,7 @@ export function normalizeConnectionTimeouts(config: ConnectionConfig, connectTim const queryTimeout = Number(config.query_timeout_secs); config.query_timeout_secs = config.query_timeout_inherit === true ? normalizeGlobalQueryTimeoutSecs(queryTimeoutSecs) : normalizeGlobalQueryTimeoutSecs(queryTimeout); config.idle_timeout_secs = normalizeIdleTimeoutSecs(config.idle_timeout_secs); + config.manual_transaction_timeout_secs = normalizeManualTransactionTimeoutSecs(config.manual_transaction_timeout_secs); const keepaliveInterval = Number(config.keepalive_interval_secs); config.keepalive_interval_secs = Number.isFinite(keepaliveInterval) && keepaliveInterval >= 0 ? keepaliveInterval : 30; } diff --git a/apps/desktop/src/lib/connection/timeoutLimits.ts b/apps/desktop/src/lib/connection/timeoutLimits.ts index f3c92e0f02..ad40653bf0 100644 --- a/apps/desktop/src/lib/connection/timeoutLimits.ts +++ b/apps/desktop/src/lib/connection/timeoutLimits.ts @@ -6,6 +6,10 @@ export const DEFAULT_QUERY_TIMEOUT_SECS = 60; export const MAX_QUERY_TIMEOUT_SECS = 3600; export const DEFAULT_IDLE_TIMEOUT_SECS = 60; export const MAX_IDLE_TIMEOUT_SECS = 3600; +// Mirrors dbx-types: idle window of a manual transaction before DBX rolls it back. +export const DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS = 300; +export const MIN_MANUAL_TRANSACTION_TIMEOUT_SECS = 30; +export const MAX_MANUAL_TRANSACTION_TIMEOUT_SECS = 86400; export function normalizeConnectTimeoutSecs(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CONNECT_TIMEOUT_SECS; @@ -22,6 +26,11 @@ export function normalizeIdleTimeoutSecs(value: unknown): number { return Math.min(MAX_IDLE_TIMEOUT_SECS, Math.round(value)); } +export function normalizeManualTransactionTimeoutSecs(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS; + return Math.min(MAX_MANUAL_TRANSACTION_TIMEOUT_SECS, Math.max(MIN_MANUAL_TRANSACTION_TIMEOUT_SECS, Math.round(value))); +} + export function supportsIdleTimeout(dbType?: DatabaseType): boolean { return dbType === "mongodb" || dbType === "mysql" || dbType === "doris" || dbType === "starrocks" || dbType === "manticoresearch"; } diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.ts b/apps/desktop/src/lib/plugins/frontendPlugin.ts index 3ffc671dcc..6a4fc6e523 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.ts @@ -18,6 +18,7 @@ import type { PluginUiContribution, PluginWorkbenchContribution, } from "@/types/database"; +import { DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; import { uuid } from "@/lib/common/utils"; import { clonePluginData } from "./pluginData"; @@ -279,6 +280,7 @@ export function buildPluginConnectionConfig(pluginId: string, contribution: Plug connect_timeout_secs: existing?.connect_timeout_secs || pluginConnectionConnectTimeoutDefault(contribution) || 10, query_timeout_secs: existing?.query_timeout_secs || 60, idle_timeout_secs: existing?.idle_timeout_secs || 60, + manual_transaction_timeout_secs: existing?.manual_transaction_timeout_secs || DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS, keepalive_interval_secs: existing?.keepalive_interval_secs || 30, read_only: existing?.read_only || false, save_password: existing?.save_password !== false, diff --git a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts index 55170d2e78..b08289fbab 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts @@ -66,7 +66,7 @@ function expiredTransactionError() { version: 1 as const, code: "DBX-TXN-1001", messageKey: "backendErrors.transaction.sessionExpired", - messageParams: { timeoutSecs: 300 }, + messageParams: {}, source: "legacyBackend" as const, operationOutcome: "not_started" as const, origin: { subsystem: "database", adapter: "native" }, @@ -111,26 +111,35 @@ describe("queryStore manual transaction expiry recovery", () => { mocks.saveOpenTabsState.mockResolvedValue(undefined); }); - it("restarts an expired transaction and retries the SQL exactly once", async () => { + it("stops at an expired transaction that held uncommitted work instead of replaying the SQL", 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"); - // Tabs default to auto-commit; the expiry recovery path only applies to manual transactions. + // Tabs default to auto-commit; the expiry handling only applies to manual transactions. store.setAutoCommit(tabId, false); await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 1"); - await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 1"); + await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 2"); const tab = store.tabs.find((item) => item.id === tabId)!; + // The first UPDATE was rolled back with the old session; running the second one in a + // fresh transaction would silently build on work that no longer exists. + expect(mocks.beginManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(2); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.txnAutoRolledBack).toBe(true); + expect(tab.result?.execution_error).toBe(true); + + // The user re-runs explicitly and gets a brand new transaction. + await store.executeTabSql(tabId, "UPDATE USERS SET ACTIVE = 2"); + 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); }); @@ -154,7 +163,7 @@ describe("queryStore manual transaction expiry recovery", () => { expect(tab.txnAutoRolledBack).not.toBe(true); }); - it("keeps the rollback notice for a manual dialect without sticky read-only tracking", async () => { + it("treats an expired transaction as dirty for a manual dialect without sticky read-only tracking", async () => { mocks.getConnectionConfig.mockReturnValue({ id: "dameng-1", name: "DM", @@ -162,8 +171,8 @@ describe("queryStore manual transaction expiry recovery", () => { database: "DMHR", query_timeout_secs: 30, }); - mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new"); - mocks.executeInManualTransaction.mockResolvedValueOnce(successfulSelect()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(successfulSelect()); + mocks.beginManualTransaction.mockResolvedValueOnce("txn-old"); + mocks.executeInManualTransaction.mockResolvedValueOnce(successfulSelect()).mockRejectedValueOnce(expiredTransactionError()); const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); @@ -174,10 +183,13 @@ describe("queryStore manual transaction expiry recovery", () => { await store.executeTabSql(tabId, "SELECT 1 FROM DUAL"); const tab = store.tabs.find((item) => item.id === tabId)!; - expect(tab.txnSessionId).toBe("txn-new"); - expect(tab.result?.rows).toEqual([[1]]); - // 不跟踪只读状态的方言无法判断会话是否干净,保留原有提示。 + // Without read-only tracking a clean session cannot be told from a dirty one, so + // the statement is not replayed and the rollback is reported. + expect(mocks.beginManualTransaction).toHaveBeenCalledOnce(); + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(2); + expect(tab.txnSessionId).toBeUndefined(); expect(tab.txnAutoRolledBack).toBe(true); + expect(tab.result?.execution_error).toBe(true); }); it("normalizes a stale manual tab for a non-transactional database before query dispatch", async () => { diff --git a/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts index 8c9c7a6e7f..1831b409c0 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts @@ -80,7 +80,7 @@ function expiredTransactionError() { version: 1 as const, code: "DBX-TXN-1001", messageKey: "backendErrors.transaction.sessionExpired", - messageParams: { timeoutSecs: 300 }, + messageParams: {}, source: "legacyBackend" as const, operationOutcome: "not_started" as const, origin: { subsystem: "database", adapter: "native" }, @@ -363,7 +363,7 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior expect(tab.txnPossiblyDirty).toBe(true); }); - it("resets the sticky state on session-expiry recovery and recomputes from the retry", async () => { + it("resets the sticky state when a dirty session expires and recomputes it on the next run", async () => { mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new"); mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect()); @@ -375,10 +375,36 @@ describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior await store.executeTabSql(tabId, "SELECT * FROM EMP"); const tab = store.tabs.find((item) => item.id === tabId)!; + // The dirty session lost its work: the SELECT is not replayed into a fresh transaction. + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(2); + expect(tab.txnSessionId).toBeUndefined(); + expect(tab.txnPossiblyDirty).not.toBe(true); + expect(tab.txnAutoRolledBack).toBe(true); + + await store.executeTabSql(tabId, "SELECT * FROM EMP"); + expect(tab.txnSessionId).toBe("txn-new"); expect(tab.txnPossiblyDirty).not.toBe(true); }); + it("silently rebuilds a session that expired before running anything that could be dirty", async () => { + mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new"); + mocks.executeInManualTransaction.mockResolvedValueOnce(cleanSelect()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect()); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = await setupManualTab(store); + + await store.executeTabSql(tabId, "SELECT * FROM EMP"); + await store.executeTabSql(tabId, "SELECT * FROM EMP"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(3); + expect(tab.txnSessionId).toBe("txn-new"); + expect(tab.txnPossiblyDirty).not.toBe(true); + expect(tab.txnAutoRolledBack).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( diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index e282568a94..720325b37e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -4,7 +4,7 @@ import type { SqlFilePreview } from "@/lib/backend/api"; import { uuid } from "@/lib/common/utils"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { ref, computed, watch, markRaw, reactive } from "vue"; -import { DEFAULT_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; +import { DEFAULT_QUERY_TIMEOUT_SECS, normalizeManualTransactionTimeoutSecs } from "@/lib/connection/timeoutLimits"; import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents"; import type { ColumnInfo, @@ -1818,6 +1818,7 @@ export const useConnectionStore = defineStore("connection", () => { query_timeout_secs: queryTimeoutInherit ? settingsStore.editorSettings.globalQueryTimeoutSecs : (config.query_timeout_secs ?? DEFAULT_QUERY_TIMEOUT_SECS), query_timeout_inherit: queryTimeoutInherit, idle_timeout_secs: config.idle_timeout_secs ?? 60, + manual_transaction_timeout_secs: normalizeManualTransactionTimeoutSecs(config.manual_transaction_timeout_secs), keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS, redis_database_aliases: normalizeRedisDatabaseAliases(config.redis_database_aliases), redis_key_filter: dbType === "redis" && typeof config.redis_key_filter === "string" ? config.redis_key_filter.trim() || undefined : undefined, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 624c72b51b..7fd398a1d4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -8653,15 +8653,20 @@ export const useQueryStore = defineStore("query", () => { } catch (error) { if (effectiveDbType === "sqlserver" || options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error; if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) throw error; + // Only a session that ran proven read-only statements lost nothing to + // the idle rollback and can be rebuilt transparently. Otherwise + // uncommitted work was discarded (or we cannot prove it was not), and + // replaying this statement into a fresh transaction would silently + // act on state that no longer exists. Stop here: the outer handler + // clears the dead session and shows the rollback notice, and the + // user decides whether to run the statement again. + if (manualTransactionRollbackNoticeRequired(tab, effectiveDbType)) throw error; manualTransactionRecoveryAttempted = true; - // A session that only ever ran proven read-only statements lost - // nothing to the idle rollback, so it restarts without the notice. - const rollbackNoticeRequired = manualTransactionRollbackNoticeRequired(tab, effectiveDbType); // The expired session was discarded by the backend; the replacement // session starts fresh, so the old sticky state resets with it. clearTxnPossiblyDirty(tab); tab.txnSessionId = undefined; - tab.txnAutoRolledBack = rollbackNoticeRequired; + tab.txnAutoRolledBack = false; queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() }); const refreshedSessionId = await ensureManualTransactionSession(id, executionDatabase, executionSchema, executionCatalog); if (tab.executionId !== executionId || tab.autoCommit !== false || manualTransactionTargetEpoch(tab) !== executionTargetEpoch) { @@ -8981,7 +8986,7 @@ export const useQueryStore = defineStore("query", () => { reportSqlServerTransactionError(tab, e); } else if (tab.autoCommit === false) { const errMsg: string = e?.message ?? String(e); - const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚"); + const idleTimeout = isManualTransactionSessionExpired(e) || errMsg.includes("已自动回滚"); if (idleTimeout) { // Backend session was removed and rolled back after idle expiry: clear // the sticky dirty state together with the session. Same rule as the diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index ccfa11cd36..8be519062b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -174,6 +174,8 @@ export interface ConnectionConfig { query_timeout_secs?: number; query_timeout_inherit?: boolean; idle_timeout_secs?: number; + /** Seconds a manual transaction may sit idle before DBX rolls it back. */ + manual_transaction_timeout_secs?: number; keepalive_interval_secs?: number; ssl?: boolean; ca_cert_path?: string; diff --git a/crates/dbx-core/examples/table_import_live_bench.rs b/crates/dbx-core/examples/table_import_live_bench.rs index aca90889d4..6c082ce364 100644 --- a/crates/dbx-core/examples/table_import_live_bench.rs +++ b/crates/dbx-core/examples/table_import_live_bench.rs @@ -184,6 +184,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result>, pub pool_key: String, pub last_activity: std::time::Instant, + /// How long the session may go without a statement before it is rolled back. + /// Fixed at BEGIN from the connection's `manual_transaction_timeout_secs`. + pub idle_timeout: std::time::Duration, pub busy: bool, pub snapshot_rotation_safe: bool, pub connection_id: String, @@ -7446,6 +7449,7 @@ mod tests { connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: crate::models::connection::default_query_timeout_secs(), idle_timeout_secs: crate::models::connection::default_idle_timeout_secs(), + manual_transaction_timeout_secs: crate::models::connection::default_manual_transaction_timeout_secs(), keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), @@ -9998,6 +10002,7 @@ sleep 30 connect_timeout_secs: 1, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: interval_secs, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs index 2282b0fb52..1297ef2ed2 100644 --- a/crates/dbx-core/src/data/transfer.rs +++ b/crates/dbx-core/src/data/transfer.rs @@ -13691,6 +13691,7 @@ for line in sys.stdin: connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), @@ -20918,6 +20919,7 @@ SELECT 1 FROM dual"# connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/persistence/cloud_sync.rs b/crates/dbx-core/src/persistence/cloud_sync.rs index 57be19dd95..5bcb4c1cca 100644 --- a/crates/dbx-core/src/persistence/cloud_sync.rs +++ b/crates/dbx-core/src/persistence/cloud_sync.rs @@ -3672,6 +3672,7 @@ mod tests { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), @@ -3761,6 +3762,7 @@ mod tests { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), @@ -4074,6 +4076,7 @@ mod tests { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index 3e305a6f18..4c19599d79 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -12010,6 +12010,7 @@ mod tests { connect_timeout_secs: 30, query_timeout_secs: 300, idle_timeout_secs: 600, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), @@ -12090,6 +12091,7 @@ mod tests { connect_timeout_secs: 30, query_timeout_secs: 300, idle_timeout_secs: 600, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/query/mod.rs b/crates/dbx-core/src/query/mod.rs index bdf37be826..f11c04d783 100644 --- a/crates/dbx-core/src/query/mod.rs +++ b/crates/dbx-core/src/query/mod.rs @@ -50,8 +50,7 @@ pub const METADATA_POOL_BUSY_ERROR: &str = "DBX metadata pool is busy; please re 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 const MANUAL_TRANSACTION_IDLE_ROLLBACK_ERROR: &str = "Transaction was auto-rolled back due to 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 @@ -856,6 +855,13 @@ async fn connection_is_mongodb(state: &AppState, connection_id: &str) -> bool { configs.get(connection_id).is_some_and(|config| config.db_type == DatabaseType::MongoDb) } +/// Idle window for a new manual transaction on `connection_id`. +pub(crate) async fn manual_transaction_idle_timeout(state: &AppState, connection_id: &str) -> std::time::Duration { + let configs = state.configs.read().await; + let secs = configs.get(connection_id).map(ConnectionConfig::effective_manual_transaction_timeout_secs); + std::time::Duration::from_secs(secs.unwrap_or(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS)) +} + async fn connection_database_type(state: &AppState, connection_id: &str) -> Option { let configs = state.configs.read().await; configs.get(connection_id).map(|config| config.db_type) @@ -3193,7 +3199,7 @@ async fn request_large_value_with_cancel( let session = sessions.get_mut(txn_id).ok_or(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR)?; if session.connection_id != request.connection_id || session.busy - || session.last_activity.elapsed().as_secs() >= MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS + || session.last_activity.elapsed() >= session.idle_timeout { return Err("LOB transaction context expired or busy".to_string()); } @@ -6299,6 +6305,7 @@ async fn begin_transaction_session( connection: Arc::new(tokio::sync::Mutex::new(txn_conn)), pool_key: pool_key.clone(), last_activity: std::time::Instant::now(), + idle_timeout: manual_transaction_idle_timeout(state, connection_id).await, busy: false, snapshot_rotation_safe: !consistent_snapshot, connection_id: connection_id.to_string(), @@ -6426,8 +6433,6 @@ pub async fn execute_in_manual_transaction_with_options( } return sqlserver_manual_transaction::execute(state, txn_session_id, sql, database, schema, options).await; } - 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. @@ -6533,7 +6538,7 @@ pub async fn execute_in_manual_transaction_with_options( if session.busy { return Err("Transaction session is already executing".to_string()); } - if session.last_activity.elapsed() > TXN_IDLE_TIMEOUT { + if session.last_activity.elapsed() > session.idle_timeout { let session = sessions.remove(txn_session_id).expect("session exists"); Some(session) } else { @@ -6730,8 +6735,6 @@ pub(crate) async fn stream_rows_in_manual_transaction_with_cancel( where F: FnMut(Vec>) -> Result<(), String> + Send, { - 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 { @@ -6740,7 +6743,7 @@ where if session.busy { return Err("Transaction session is already executing".to_string()); } - if session.last_activity.elapsed() > TXN_IDLE_TIMEOUT { + if session.last_activity.elapsed() > session.idle_timeout { Some(sessions.remove(txn_session_id).expect("session exists").connection) } else { session.busy = true; @@ -7162,7 +7165,7 @@ async fn execute_manual_txn_external_driver_statement( } /// Spawn a background task that removes and rolls back a transaction session -/// after 5 minutes of inactivity. The task does not hold the global lock across +/// after its idle timeout. The task does not hold the global lock across /// I/O: it briefly checks the map, and if the session exists and is expired, /// removes it, drops the lock, then rolls back the held connection. /// @@ -7180,14 +7183,16 @@ 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(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); - tokio::time::sleep(TXN_IDLE_TIMEOUT).await; + // The window is fixed per session at BEGIN; a vanished session needs no watcher. + let Some(idle_timeout) = sessions.read().await.get(&txn_session_id).map(|session| session.idle_timeout) else { + return; + }; + tokio::time::sleep(idle_timeout).await; let removed: Option = { let mut guard = sessions.write().await; match guard.get(&txn_session_id) { - Some(session) if !session.busy && session.last_activity.elapsed() >= TXN_IDLE_TIMEOUT => { + Some(session) if !session.busy && session.last_activity.elapsed() >= session.idle_timeout => { guard.remove(&txn_session_id) } _ => None, @@ -7198,8 +7203,9 @@ fn spawn_txn_idle_watcher_for_sessions( let mut conn = session.connection.lock().await; let _ = rollback_manual_txn_connection(&mut conn).await; log::info!( - "[query][manual_txn:idle_timeout] session_id={} auto-rolled back after 5 minutes of inactivity", - txn_session_id + "[query][manual_txn:idle_timeout] session_id={} auto-rolled back after {}s of inactivity", + txn_session_id, + session.idle_timeout.as_secs() ); } }); @@ -7520,6 +7526,7 @@ mod tests { connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::Postgres(Box::new(connection)))), pool_key: "conn-1".to_string(), last_activity: std::time::Instant::now(), + idle_timeout: std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS), busy: false, snapshot_rotation_safe: true, connection_id: "conn-1".to_string(), @@ -8514,6 +8521,55 @@ for line in sys.stdin: assert!(validate_query_execution_mode(Some(DatabaseType::Postgres), "SELECT 1; SELECT 2", &options).is_err()); } + fn idle_test_session(idle_timeout: Duration) -> TransactionSession { + TransactionSession { + sqlserver: None, + connection: Arc::new(tokio::sync::Mutex::new(TxnConnection::Mysql(None))), + pool_key: "conn-1".to_string(), + last_activity: std::time::Instant::now(), + idle_timeout, + busy: false, + snapshot_rotation_safe: false, + connection_id: "conn-1".to_string(), + database: "test".to_string(), + schema: None, + } + } + + #[tokio::test] + async fn idle_watcher_rolls_back_after_the_session_own_timeout() { + let sessions = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); + { + let mut guard = sessions.write().await; + guard.insert("short".to_string(), idle_test_session(Duration::from_millis(50))); + guard.insert("long".to_string(), idle_test_session(Duration::from_secs(3600))); + } + spawn_txn_idle_watcher_for_sessions(Arc::clone(&sessions), "short".to_string()); + spawn_txn_idle_watcher_for_sessions(Arc::clone(&sessions), "long".to_string()); + + tokio::time::sleep(Duration::from_millis(400)).await; + + let guard = sessions.read().await; + assert!(!guard.contains_key("short"), "a 50ms session must be reaped long before the 300s default"); + assert!(guard.contains_key("long"), "a session with a longer window must survive the same wait"); + } + + #[tokio::test] + async fn manual_transaction_idle_timeout_follows_connection_config() { + let dir = std::env::temp_dir().join(format!("dbx-txn-timeout-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = crate::persistence::test_storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new(storage); + let mut config = test_connection_config(DatabaseType::Postgres); + config.manual_transaction_timeout_secs = 1_800; + state.configs.write().await.insert(config.id.clone(), config); + + assert_eq!(manual_transaction_idle_timeout(&state, "conn-1").await, Duration::from_secs(1_800)); + // An unknown connection keeps the historical five-minute window. + assert_eq!(manual_transaction_idle_timeout(&state, "missing").await, Duration::from_secs(300)); + let _ = std::fs::remove_dir_all(dir); + } + fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig { ConnectionConfig { oracle_oci_nls_lang: None, @@ -8546,6 +8602,7 @@ for line in sys.stdin: connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 30, ssl: false, ca_cert_path: String::new(), @@ -11038,6 +11095,7 @@ for line in sys.stdin: })), pool_key: pool_key.to_string(), last_activity: std::time::Instant::now(), + idle_timeout: std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS), busy: false, snapshot_rotation_safe: true, connection_id: "jdbc-conn".to_string(), @@ -11670,6 +11728,7 @@ for line in sys.stdin: connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), @@ -12278,6 +12337,7 @@ for line in sys.stdin: })), pool_key: "agent-conn".to_string(), last_activity: std::time::Instant::now(), + idle_timeout: std::time::Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS), busy: false, snapshot_rotation_safe: true, connection_id: "agent-conn".to_string(), diff --git a/crates/dbx-core/src/query/sqlserver_manual_transaction.rs b/crates/dbx-core/src/query/sqlserver_manual_transaction.rs index 63f0f70ab2..01548896da 100644 --- a/crates/dbx-core/src/query/sqlserver_manual_transaction.rs +++ b/crates/dbx-core/src/query/sqlserver_manual_transaction.rs @@ -46,7 +46,8 @@ pub mod live_test_hooks { } const PREFIX: &str = "sqlserver-txn-"; const ERROR_PREFIX: &str = "DBX_MANUAL_TRANSACTION_ERROR:"; -const IDLE: Duration = Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); +/// How long an ended session's outcome stays queryable for late callers. +const ENDED_SESSION_RETENTION: Duration = Duration::from_secs(MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS); const MAX_ENDED_SESSIONS: usize = 1024; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -78,7 +79,7 @@ pub struct EndedSessions { } impl EndedSessions { fn prune(&mut self) { - self.entries.retain(|_, (time, _)| time.elapsed() < IDLE); + self.entries.retain(|_, (time, _)| time.elapsed() < ENDED_SESSION_RETENTION); } fn record(&mut self, id: &str, failure: Failure) { self.prune(); @@ -289,6 +290,7 @@ pub async fn begin( connection: Arc::new(tokio::sync::Mutex::new(conn)), pool_key, last_activity: Instant::now(), + idle_timeout: super::manual_transaction_idle_timeout(state, connection_id).await, busy: false, snapshot_rotation_safe: false, connection_id: connection_id.to_owned(), @@ -422,7 +424,7 @@ pub async fn execute( return Err(failure(Kind::Busy, Outcome::Unknown, "The transaction is currently executing")); } let context_changed = session.database != database || session.schema.as_deref() != schema; - let expired = session.last_activity.elapsed() >= IDLE; + let expired = session.last_activity.elapsed() >= session.idle_timeout; session.busy = true; ( session.connection.clone(), @@ -669,7 +671,7 @@ pub async fn finish(state: &AppState, id: &str, commit: bool) -> Result= IDLE { + if metadata.lifecycle.cancellation().is_cancelled() || session.last_activity.elapsed() >= session.idle_timeout { let error = cleanup_failure( state, id, @@ -793,14 +795,16 @@ fn spawn_idle_watcher(sessions: Arc= IDLE => map.remove(&id), + Some(session) if !session.busy && session.last_activity.elapsed() >= session.idle_timeout => { + map.remove(&id) + } None => return, _ => None, } @@ -817,7 +821,7 @@ fn spawn_idle_watcher(sessions: Arc Failure::new( Kind::IdleTimeout, Outcome::RolledBack, - "Transaction rolled back after five minutes of inactivity", + "Transaction rolled back after its idle timeout", ), Ok(Err(error)) => Failure::new( Kind::RollbackFailed, @@ -862,7 +866,7 @@ mod tests { assert_eq!(ended.entries.len(), MAX_ENDED_SESSIONS); ended.entries.insert( "old".into(), - (Instant::now() - IDLE, Failure::new(Kind::IdleTimeout, Outcome::RolledBack, "Idle")), + (Instant::now() - ENDED_SESSION_RETENTION, Failure::new(Kind::IdleTimeout, Outcome::RolledBack, "Idle")), ); assert!(ended.missing("old").contains("connection_lost")); assert!(!ended.entries.contains_key("old")); @@ -959,6 +963,7 @@ for line in sys.stdin: })), pool_key: "mock-sqlserver".to_owned(), last_activity: Instant::now(), + idle_timeout: ENDED_SESSION_RETENTION, busy: false, snapshot_rotation_safe: false, connection_id: "mock-sqlserver".to_owned(), diff --git a/crates/dbx-core/src/safety/production_safety.rs b/crates/dbx-core/src/safety/production_safety.rs index a24319bec2..d8aea7d87f 100644 --- a/crates/dbx-core/src/safety/production_safety.rs +++ b/crates/dbx-core/src/safety/production_safety.rs @@ -910,6 +910,7 @@ mod tests { connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 30, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/schema/mod.rs b/crates/dbx-core/src/schema/mod.rs index 3a0b51fb55..6380db003f 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -3415,6 +3415,7 @@ mod tests { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/src/schema/normalization.rs b/crates/dbx-core/src/schema/normalization.rs index b09fe49d61..c055a9e272 100644 --- a/crates/dbx-core/src/schema/normalization.rs +++ b/crates/dbx-core/src/schema/normalization.rs @@ -173,6 +173,7 @@ mod tests { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/connection_url_compatibility.rs b/crates/dbx-core/tests/connection_url_compatibility.rs index 682a2645b4..7b52b7c1a3 100644 --- a/crates/dbx-core/tests/connection_url_compatibility.rs +++ b/crates/dbx-core/tests/connection_url_compatibility.rs @@ -1,6 +1,7 @@ use dbx_core::models::connection::{ default_connect_timeout_secs, default_idle_timeout_secs, default_keepalive_interval_secs, - default_query_timeout_secs, default_redis_key_separator, ConnectionConfig, DatabaseType, + default_manual_transaction_timeout_secs, default_query_timeout_secs, default_redis_key_separator, ConnectionConfig, + DatabaseType, }; use std::str::FromStr; @@ -36,6 +37,7 @@ fn mysql_config(username: &str, password: &str, database: Option<&str>) -> Conne connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: default_idle_timeout_secs(), + manual_transaction_timeout_secs: default_manual_transaction_timeout_secs(), keepalive_interval_secs: default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/dump_docs_fixture.rs b/crates/dbx-core/tests/dump_docs_fixture.rs index 4e77dba503..7d5b3fc633 100644 --- a/crates/dbx-core/tests/dump_docs_fixture.rs +++ b/crates/dbx-core/tests/dump_docs_fixture.rs @@ -48,6 +48,7 @@ fn live_postgres_config( connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_postgres_docs_annotations.rs b/crates/dbx-core/tests/live_postgres_docs_annotations.rs index 2cdfeb592e..3db2f25325 100644 --- a/crates/dbx-core/tests/live_postgres_docs_annotations.rs +++ b/crates/dbx-core/tests/live_postgres_docs_annotations.rs @@ -44,6 +44,7 @@ fn live_postgres_config( connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_postgres_docs_snapshot.rs b/crates/dbx-core/tests/live_postgres_docs_snapshot.rs index d15189b91c..11deb78508 100644 --- a/crates/dbx-core/tests/live_postgres_docs_snapshot.rs +++ b/crates/dbx-core/tests/live_postgres_docs_snapshot.rs @@ -39,6 +39,7 @@ fn live_postgres_config( connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_postgres_error_position.rs b/crates/dbx-core/tests/live_postgres_error_position.rs index 5d97e159d9..23216bfe9e 100644 --- a/crates/dbx-core/tests/live_postgres_error_position.rs +++ b/crates/dbx-core/tests/live_postgres_error_position.rs @@ -193,6 +193,7 @@ fn live_connection_config( connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_postgres_query_result_export.rs b/crates/dbx-core/tests/live_postgres_query_result_export.rs index 1ec328ce46..e117578024 100644 --- a/crates/dbx-core/tests/live_postgres_query_result_export.rs +++ b/crates/dbx-core/tests/live_postgres_query_result_export.rs @@ -44,6 +44,7 @@ fn live_postgres_config( connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 2dc1998e58..8453ed022c 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -39,6 +39,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_sqlite_to_sqlserver_transfer.rs b/crates/dbx-core/tests/live_sqlite_to_sqlserver_transfer.rs index 20864a9eb9..0321d772aa 100644 --- a/crates/dbx-core/tests/live_sqlite_to_sqlserver_transfer.rs +++ b/crates/dbx-core/tests/live_sqlite_to_sqlserver_transfer.rs @@ -43,6 +43,7 @@ fn sqlite_config(id: &str, path: &str) -> ConnectionConfig { connect_timeout_secs: 15, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index dc5f2e2104..0194deec99 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -45,6 +45,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs index 4c28a295ad..ad1e18f61b 100644 --- a/crates/dbx-core/tests/live_sqlserver_query_result_export.rs +++ b/crates/dbx-core/tests/live_sqlserver_query_result_export.rs @@ -34,6 +34,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_sqlserver_split_zip_e2e.rs b/crates/dbx-core/tests/live_sqlserver_split_zip_e2e.rs index 270f138f3d..3e78a6f47c 100644 --- a/crates/dbx-core/tests/live_sqlserver_split_zip_e2e.rs +++ b/crates/dbx-core/tests/live_sqlserver_split_zip_e2e.rs @@ -49,6 +49,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti connect_timeout_secs: 10, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/live_sqlserver_transfer.rs b/crates/dbx-core/tests/live_sqlserver_transfer.rs index d561f00ea8..ac08e3f616 100644 --- a/crates/dbx-core/tests/live_sqlserver_transfer.rs +++ b/crates/dbx-core/tests/live_sqlserver_transfer.rs @@ -38,6 +38,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig { connect_timeout_secs: 15, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-core/tests/lob_cancel_wait.rs b/crates/dbx-core/tests/lob_cancel_wait.rs index d8f1c48d4a..b29a276d39 100644 --- a/crates/dbx-core/tests/lob_cancel_wait.rs +++ b/crates/dbx-core/tests/lob_cancel_wait.rs @@ -105,6 +105,7 @@ async fn public_lob_request_cancels_while_waiting_for_transaction_connection() { connection: connection.clone(), pool_key: "lob".into(), last_activity: Instant::now(), + idle_timeout: std::time::Duration::from_secs(dbx_core::query::MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS), busy: false, snapshot_rotation_safe: false, connection_id: "lob".into(), diff --git a/crates/dbx-core/tests/support/mod.rs b/crates/dbx-core/tests/support/mod.rs index 0a8510ab5f..2823526ba3 100644 --- a/crates/dbx-core/tests/support/mod.rs +++ b/crates/dbx-core/tests/support/mod.rs @@ -159,6 +159,7 @@ pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig { connect_timeout_secs: 5, query_timeout_secs: 30, idle_timeout_secs: 60, + manual_transaction_timeout_secs: 300, keepalive_interval_secs: 0, ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-driver-agent/src/agent_connection.rs b/crates/dbx-driver-agent/src/agent_connection.rs index b90621e22e..6cacc4f4a8 100644 --- a/crates/dbx-driver-agent/src/agent_connection.rs +++ b/crates/dbx-driver-agent/src/agent_connection.rs @@ -780,7 +780,7 @@ mod tests { use super::*; use crate::models::connection::{ default_connect_timeout_secs, default_idle_timeout_secs, default_keepalive_interval_secs, - default_query_timeout_secs, default_redis_key_separator, + default_manual_transaction_timeout_secs, default_query_timeout_secs, default_redis_key_separator, }; fn config(db_type: DatabaseType, database: Option<&str>) -> ConnectionConfig { @@ -815,6 +815,7 @@ mod tests { connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: default_idle_timeout_secs(), + manual_transaction_timeout_secs: default_manual_transaction_timeout_secs(), keepalive_interval_secs: default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-driver-agent/src/backend_error.rs b/crates/dbx-driver-agent/src/backend_error.rs index 2968e21946..40497e8ab7 100644 --- a/crates/dbx-driver-agent/src/backend_error.rs +++ b/crates/dbx-driver-agent/src/backend_error.rs @@ -303,17 +303,17 @@ 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 { + /// + /// The idle window is a per-connection setting and the session is already + /// gone when this is raised, so the message carries no duration. + pub fn from_manual_transaction_session_expired() -> 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, + BTreeMap::new(), None, Some(diagnostics_for_local("transaction", AgentErrorStage::Execute)), ) @@ -505,7 +505,6 @@ 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 { @@ -632,7 +631,7 @@ fn catalog_entry(code: CatalogCode) -> &'static CatalogEntry { CatalogEntry { code: "DBX-TXN-1001", message_key: "backendErrors.transaction.sessionExpired", - params: TRANSACTION_TIMEOUT_PARAM, + params: NO_PARAMS, help_url: None, }, ), @@ -1257,13 +1256,13 @@ mod tests { #[test] fn manual_transaction_session_expiry_is_retryable_before_execution() { - let error = BackendError::from_manual_transaction_session_expired(300); + let error = BackendError::from_manual_transaction_session_expired(); 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!(payload["messageParams"].as_object().is_none_or(|params| params.is_empty())); assert_eq!(payload["diagnostics"]["category"], "transaction"); } diff --git a/crates/dbx-driver-redis/src/lib.rs b/crates/dbx-driver-redis/src/lib.rs index f16b839611..841d128774 100644 --- a/crates/dbx-driver-redis/src/lib.rs +++ b/crates/dbx-driver-redis/src/lib.rs @@ -10371,6 +10371,7 @@ mod tests { connect_timeout_secs: dbx_types::models::connection::default_connect_timeout_secs(), query_timeout_secs: dbx_types::models::connection::default_query_timeout_secs(), idle_timeout_secs: dbx_types::models::connection::default_idle_timeout_secs(), + manual_transaction_timeout_secs: dbx_types::models::connection::default_manual_transaction_timeout_secs(), keepalive_interval_secs: dbx_types::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-types/src/models/connection.rs b/crates/dbx-types/src/models/connection.rs index 958f0f5e86..44ee6bde08 100644 --- a/crates/dbx-types/src/models/connection.rs +++ b/crates/dbx-types/src/models/connection.rs @@ -187,6 +187,8 @@ pub struct ConnectionConfig { pub query_timeout_secs: u64, #[serde(default = "default_idle_timeout_secs")] pub idle_timeout_secs: u64, + #[serde(default = "default_manual_transaction_timeout_secs")] + pub manual_transaction_timeout_secs: u64, #[serde(default = "default_keepalive_interval_secs")] pub keepalive_interval_secs: u64, #[serde(default)] @@ -593,6 +595,16 @@ pub fn default_idle_timeout_secs() -> u64 { 60 } +/// Seconds a manual transaction may sit without executing a statement before DBX +/// rolls it back. +pub const DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS: u64 = 300; +pub const MIN_MANUAL_TRANSACTION_TIMEOUT_SECS: u64 = 30; +pub const MAX_MANUAL_TRANSACTION_TIMEOUT_SECS: u64 = 86_400; + +pub fn default_manual_transaction_timeout_secs() -> u64 { + DEFAULT_MANUAL_TRANSACTION_TIMEOUT_SECS +} + pub fn default_keepalive_interval_secs() -> u64 { 30 } @@ -674,6 +686,8 @@ struct ConnectionConfigData { pub query_timeout_secs: u64, #[serde(default = "default_idle_timeout_secs")] pub idle_timeout_secs: u64, + #[serde(default = "default_manual_transaction_timeout_secs")] + pub manual_transaction_timeout_secs: u64, #[serde(default = "default_keepalive_interval_secs")] pub keepalive_interval_secs: u64, #[serde(default)] @@ -787,6 +801,7 @@ impl From for ConnectionConfig { connect_timeout_secs: data.connect_timeout_secs, query_timeout_secs: data.query_timeout_secs, idle_timeout_secs: data.idle_timeout_secs, + manual_transaction_timeout_secs: data.manual_transaction_timeout_secs, keepalive_interval_secs: data.keepalive_interval_secs, ssl: data.ssl, ca_cert_path: data.ca_cert_path, @@ -996,6 +1011,17 @@ impl ConnectionConfig { } } + /// Idle window of a manual transaction. `0` (legacy or unset payloads) falls + /// back to the default instead of rolling back immediately. + pub fn effective_manual_transaction_timeout_secs(&self) -> u64 { + if self.manual_transaction_timeout_secs == 0 { + default_manual_transaction_timeout_secs() + } else { + self.manual_transaction_timeout_secs + .clamp(MIN_MANUAL_TRANSACTION_TIMEOUT_SECS, MAX_MANUAL_TRANSACTION_TIMEOUT_SECS) + } + } + pub fn effective_query_timeout_secs(&self) -> u64 { if self.query_timeout_secs == 0 { // An explicit 0 is the UI's "no limit"; a floor must not impose one. @@ -3188,6 +3214,7 @@ mod tests { connect_timeout_secs: super::default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: super::default_idle_timeout_secs(), + manual_transaction_timeout_secs: super::default_manual_transaction_timeout_secs(), keepalive_interval_secs: super::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), @@ -3721,6 +3748,28 @@ mod tests { assert_eq!(config.effective_query_timeout_secs(), 0); } + #[test] + fn manual_transaction_timeout_defaults_when_absent_or_zero_and_is_clamped() { + let mut config = mysql_config("root", "", None); + assert_eq!(config.effective_manual_transaction_timeout_secs(), 300); + + // Payloads saved before the setting existed deserialize to the default. + let mut json = serde_json::to_value(&config).unwrap(); + json.as_object_mut().unwrap().remove("manual_transaction_timeout_secs"); + let legacy: ConnectionConfig = serde_json::from_value(json).unwrap(); + assert_eq!(legacy.manual_transaction_timeout_secs, 300); + + // 0 must never mean "roll back immediately". + config.manual_transaction_timeout_secs = 0; + assert_eq!(config.effective_manual_transaction_timeout_secs(), 300); + config.manual_transaction_timeout_secs = 1; + assert_eq!(config.effective_manual_transaction_timeout_secs(), super::MIN_MANUAL_TRANSACTION_TIMEOUT_SECS); + config.manual_transaction_timeout_secs = 1_800; + assert_eq!(config.effective_manual_transaction_timeout_secs(), 1_800); + config.manual_transaction_timeout_secs = u64::MAX; + assert_eq!(config.effective_manual_transaction_timeout_secs(), super::MAX_MANUAL_TRANSACTION_TIMEOUT_SECS); + } + #[test] fn query_timeout_floor_is_scoped_to_spanner() { let mut config = mysql_config("root", "", None); diff --git a/crates/dbx-web/src/credentials.rs b/crates/dbx-web/src/credentials.rs index 5a5360544c..fdc6d54adc 100644 --- a/crates/dbx-web/src/credentials.rs +++ b/crates/dbx-web/src/credentials.rs @@ -160,6 +160,7 @@ fn credential_target(config: &ConnectionConfig) -> Value { "connect_timeout_secs", "query_timeout_secs", "idle_timeout_secs", + "manual_transaction_timeout_secs", "keepalive_interval_secs", "redis_key_separator", "redis_scan_page_size", diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index d8dcbd7fe0..3573af1770 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -1092,6 +1092,7 @@ mod tests { connect_timeout_secs: dbx_core::models::connection::default_connect_timeout_secs(), query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(), idle_timeout_secs: dbx_core::models::connection::default_idle_timeout_secs(), + manual_transaction_timeout_secs: dbx_core::models::connection::default_manual_transaction_timeout_secs(), keepalive_interval_secs: dbx_core::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-web/src/routes/docs.rs b/crates/dbx-web/src/routes/docs.rs index 79f249b3fb..566596c3bf 100644 --- a/crates/dbx-web/src/routes/docs.rs +++ b/crates/dbx-web/src/routes/docs.rs @@ -195,7 +195,7 @@ mod tests { use super::*; use dbx_core::models::connection::{ default_connect_timeout_secs, default_idle_timeout_secs, default_keepalive_interval_secs, - default_query_timeout_secs, DatabaseType, + default_manual_transaction_timeout_secs, default_query_timeout_secs, DatabaseType, }; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -233,6 +233,7 @@ mod tests { connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: default_idle_timeout_secs(), + manual_transaction_timeout_secs: default_manual_transaction_timeout_secs(), keepalive_interval_secs: default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index d2dcdb3f9e..5a1bbd2f59 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -315,7 +315,7 @@ mod tests { use dbx_core::connection::AppState; use dbx_core::models::connection::{ default_connect_timeout_secs, default_idle_timeout_secs, default_keepalive_interval_secs, - default_query_timeout_secs, ConnectionConfig, DatabaseType, + default_manual_transaction_timeout_secs, default_query_timeout_secs, ConnectionConfig, DatabaseType, }; use dbx_core::persistence::task_history::{TaskRunItemsQuery, TaskRunStatus, TransferObjectSelectionMode}; use dbx_core::transfer::{ @@ -354,6 +354,7 @@ mod tests { connect_timeout_secs: default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: default_idle_timeout_secs(), + manual_transaction_timeout_secs: default_manual_transaction_timeout_secs(), keepalive_interval_secs: default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 2ff62c009a..6c828d7deb 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -266,6 +266,7 @@ mod tests { connect_timeout_secs: dbx_core::models::connection::default_connect_timeout_secs(), query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(), idle_timeout_secs: dbx_core::models::connection::default_idle_timeout_secs(), + manual_transaction_timeout_secs: dbx_core::models::connection::default_manual_transaction_timeout_secs(), keepalive_interval_secs: dbx_core::models::connection::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index d497917605..31b11d1b49 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -46,9 +46,7 @@ fn manual_transaction_command_error(error: String) -> ManualTransactionCommandEr return ManualTransactionCommandError::Structured(Box::new(error)); } if dbx_core::query::is_manual_transaction_session_expired_error(&error) { - ManualTransactionCommandError::Structured(Box::new(BackendError::from_manual_transaction_session_expired( - dbx_core::query::MANUAL_TRANSACTION_IDLE_TIMEOUT_SECS, - ))) + ManualTransactionCommandError::Structured(Box::new(BackendError::from_manual_transaction_session_expired())) } else { ManualTransactionCommandError::Legacy(error) }