From 792c816a171d6948fd44f2e16bf018e10f8ec05b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 25 May 2026 18:30:55 +0800 Subject: [PATCH] feat: make query timeout configurable via global settings (default 30s) Add queryTimeoutSecs to editor settings with UI control in the settings dialog. 0 means no timeout. Applied to all query execution paths including sidebar table data loading and EXPLAIN queries. Closes #441. --- .../editor/EditorSettingsDialog.vue | 25 ++++++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/lib/http.ts | 2 + apps/desktop/src/lib/tauri.ts | 2 + apps/desktop/src/stores/queryStore.ts | 13 +-- apps/desktop/src/stores/settingsStore.ts | 12 +++ crates/dbx-core/src/query.rs | 80 ++++++++++++++++--- crates/dbx-web/src/routes/query.rs | 3 + src-tauri/src/commands/query.rs | 4 + 10 files changed, 127 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 9dc60f3a7..36f91b449 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -66,6 +66,7 @@ const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon); const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize); +const editQueryTimeoutSecs = ref(settingsStore.editorSettings.queryTimeoutSecs); const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts)); const editSidebarActivation = ref(settingsStore.editorSettings.sidebarActivation); const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode); @@ -198,6 +199,7 @@ watch( editAppLayout.value = settingsStore.editorSettings.appLayout; editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon; editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize; + editQueryTimeoutSecs.value = settingsStore.editorSettings.queryTimeoutSecs; editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); editSidebarActivation.value = settingsStore.editorSettings.sidebarActivation; editAutoSelectActiveSidebarNode.value = settingsStore.editorSettings.autoSelectActiveSidebarNode; @@ -238,6 +240,7 @@ function hasChanges(): boolean { editAppLayout.value !== settingsStore.editorSettings.appLayout || editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon || editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize || + editQueryTimeoutSecs.value !== settingsStore.editorSettings.queryTimeoutSecs || JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) || editSidebarActivation.value !== settingsStore.editorSettings.sidebarActivation || editAutoSelectActiveSidebarNode.value !== settingsStore.editorSettings.autoSelectActiveSidebarNode || @@ -257,6 +260,7 @@ async function applySettings() { wordWrap: editWordWrap.value, appLayout: editAppLayout.value, redisScanPageSize: editRedisScanPageSize.value, + queryTimeoutSecs: editQueryTimeoutSecs.value, shortcuts: editShortcuts.value, sidebarActivation: editSidebarActivation.value, autoSelectActiveSidebarNode: editAutoSelectActiveSidebarNode.value, @@ -278,6 +282,7 @@ function resetDefaults() { editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon; editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize; + editQueryTimeoutSecs.value = DEFAULT_EDITOR_SETTINGS.queryTimeoutSecs; editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts); editSidebarActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarActivation; editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode; @@ -884,6 +889,26 @@ watch( +
+ + +

{{ t("settings.queryTimeoutSecsDescription") }}

+
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 05334ff8d..403e9de84 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1367,6 +1367,8 @@ export default { shortcutPressShortcut: "Press shortcut", shortcutConflict: "This shortcut conflicts with another action in the same scope.", preview: "Live Preview", + queryTimeoutSecs: "Query Timeout (seconds)", + queryTimeoutSecsDescription: "Maximum execution time per query. 0 = no timeout. Default 30s.", jdbcPlugin: "DBX JDBC plugin", jdbcPluginInstall: "Install JDBC plugin", jdbcPluginInstallSuccess: "JDBC plugin installed", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ff6a4c18e..72da02f0b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1342,6 +1342,8 @@ export default { shortcutPressShortcut: "按下快捷键", shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。", preview: "实时预览", + queryTimeoutSecs: "查询超时(秒)", + queryTimeoutSecsDescription: "每条查询的最大执行时间。0 = 不限制。默认 30 秒。", jdbcPlugin: "DBX JDBC 插件", jdbcPluginInstall: "安装 JDBC 插件", jdbcPluginInstallSuccess: "JDBC 插件已安装", diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 9d786ea91..a05b6c027 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -413,6 +413,7 @@ export async function executeQuery( pageSize?: number; resultSessionId?: string; clientSessionId?: string; + timeoutSecs?: number; }, ): Promise { return post("/api/query/execute", { connectionId, database, sql, schema, executionId, ...options }); @@ -430,6 +431,7 @@ export async function executeMulti( pageSize?: number; resultSessionId?: string; clientSessionId?: string; + timeoutSecs?: number; }, ): Promise { return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options }); diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 20814e370..457efaa10 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -367,6 +367,7 @@ export async function executeQuery( pageSize?: number; resultSessionId?: string; clientSessionId?: string; + timeoutSecs?: number; }, ): Promise { return invoke("execute_query", { connectionId, database, sql, schema, executionId, ...options }); @@ -384,6 +385,7 @@ export async function executeMulti( pageSize?: number; resultSessionId?: string; clientSessionId?: string; + timeoutSecs?: number; }, ): Promise { return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 6e0625bb1..60fae3e3f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -643,18 +643,20 @@ export const useQueryStore = defineStore("query", () => { } console.info("[DBX][executeTabSql:execute-multi:start]", { traceId, elapsed: elapsed() }); - const executionOptions = - typeof pageLimit === "number" + const executionOptions = { + ...(typeof pageLimit === "number" ? useAgentResultSession ? { maxRows: pageLimit, fetchSize: pageLimit, pageSize: pageLimit, resultSessionId: options?.pagination?.sessionId, - clientSessionId: tab.id, } - : { maxRows: pageLimit, fetchSize: pageLimit, clientSessionId: tab.id } - : undefined; + : { maxRows: pageLimit, fetchSize: pageLimit } + : {}), + clientSessionId: tab.id, + timeoutSecs: settingsStore.editorSettings.queryTimeoutSecs, + }; const results = await api.executeMulti( tab.connectionId, tab.database, @@ -755,6 +757,7 @@ export const useQueryStore = defineStore("query", () => { try { const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, { clientSessionId: tab.id, + timeoutSecs: useSettingsStore().editorSettings.queryTimeoutSecs, }); const current = tabs.value.find((t) => t.id === id); if (current?.explainExecutionId === executionId) { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 45cc4aa43..ea6116cb3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -187,6 +187,8 @@ export interface EditorSettings { columnFormatters: Record; customColumnFormatters: Record; snippets: SqlSnippet[]; + /** Query timeout in seconds. 0 = no timeout. Default 30s. */ + queryTimeoutSecs: number; } export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [ @@ -232,6 +234,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { columnFormatters: {}, customColumnFormatters: {}, snippets: DEFAULT_SQL_SNIPPETS, + queryTimeoutSecs: 30, }; export const STORAGE_KEY = "dbx-editor-settings"; @@ -283,6 +286,11 @@ function normalizeSqlSnippets(value: unknown, existing?: SqlSnippet[]): SqlSnipp return valid; } +function normalizeQueryTimeoutSecs(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value; + return DEFAULT_EDITOR_SETTINGS.queryTimeoutSecs; +} + export function normalizeEditorSettings(settings: Partial, existing?: EditorSettings): EditorSettings { return { fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily, @@ -306,6 +314,7 @@ export function normalizeEditorSettings(settings: Partial, exist columnFormatters: normalizeColumnFormatters(settings.columnFormatters), customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters), snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets), + queryTimeoutSecs: normalizeQueryTimeoutSecs(settings.queryTimeoutSecs), }; } @@ -408,6 +417,9 @@ export const useSettingsStore = defineStore("settings", () => { const normalizedPartial = { ...partial, ...(partial.pageSize !== undefined ? { pageSize: normalizeResultPageSize(partial.pageSize) } : {}), + ...(partial.queryTimeoutSecs !== undefined + ? { queryTimeoutSecs: normalizeQueryTimeoutSecs(partial.queryTimeoutSecs) } + : {}), ...(partial.sidebarHiddenTablePrefixes !== undefined ? { sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes) } : {}), diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index d4d2a3f0c..9b909632a 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -27,6 +27,9 @@ pub struct QueryExecutionOptions { pub page_size: Option, pub result_session_id: Option, pub client_session_id: Option, + /// Query timeout in seconds. `None` uses the default (30s). + /// `Some(0)` disables the timeout entirely. + pub timeout_secs: Option, } fn query_result_row_limit(max_rows: Option) -> usize { @@ -399,6 +402,39 @@ where } } +/// Like `wait_for_query_with_timeout` but with an optional timeout. +/// `None` means no timeout (only cancellation can stop the query). +pub async fn wait_for_query_opt( + cancel_token: Option, + timeout_duration: Option, + future: F, +) -> Result +where + F: Future>, +{ + match timeout_duration { + Some(d) => wait_for_query_with_timeout(cancel_token, d, future).await, + None => match cancel_token { + Some(token) => { + tokio::select! { + biased; + _ = token.cancelled() => Err(canceled_error()), + result = future => result, + } + } + None => future.await, + }, + } +} + +fn resolve_query_timeout(timeout_secs: Option) -> Option { + match timeout_secs { + Some(0) => None, + Some(n) => Some(Duration::from_secs(n)), + None => Some(QUERY_TIMEOUT), + } +} + pub async fn do_execute( state: &AppState, pool_key: &str, @@ -408,6 +444,7 @@ pub async fn do_execute( cancel_token: Option, options: QueryExecutionOptions, ) -> Result { + let query_timeout = resolve_query_timeout(options.timeout_secs); let duckdb_attached_names = state .configs .read() @@ -426,7 +463,7 @@ pub async fn do_execute( let attached_names = duckdb_attached_names; let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, async move { + wait_for_query_opt(cancel_token, query_timeout, async move { let task = tokio::task::spawn_blocking(move || { let con = con.lock().map_err(|e| e.to_string())?; duckdb_execute_for_database(&con, &attached_names, database.as_deref(), &sql, max_rows) @@ -440,7 +477,12 @@ pub async fn do_execute( let bare = *mode == crate::connection::MysqlMode::Bare; let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::mysql::execute_query_with_max_rows(&p, sql, bare, max_rows)).await + wait_for_query_opt( + cancel_token, + query_timeout, + db::mysql::execute_query_with_max_rows(&p, sql, bare, max_rows), + ) + .await } PoolKind::Postgres(p) => { let p = p.clone(); @@ -448,28 +490,36 @@ pub async fn do_execute( let max_rows = options.max_rows; drop(connections); if let Some(schema) = schema { - wait_for_query( + wait_for_query_opt( cancel_token, + query_timeout, db::postgres::execute_query_with_schema_and_max_rows(&p, &schema, sql, max_rows), ) .await } else { - wait_for_query(cancel_token, db::postgres::execute_query_with_max_rows(&p, sql, max_rows)).await + wait_for_query_opt( + cancel_token, + query_timeout, + db::postgres::execute_query_with_max_rows(&p, sql, max_rows), + ) + .await } } PoolKind::Sqlite(p) => { let p = p.clone(); let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::sqlite::execute_query_with_max_rows(&p, sql, max_rows)).await + wait_for_query_opt(cancel_token, query_timeout, db::sqlite::execute_query_with_max_rows(&p, sql, max_rows)) + .await } PoolKind::ClickHouse(client) => { let client = client.clone(); let database = pool_key.split(':').nth(1).unwrap_or("default").to_string(); let max_rows = options.max_rows; drop(connections); - wait_for_query( + wait_for_query_opt( cancel_token, + query_timeout, db::clickhouse_driver::execute_query_with_max_rows(&client, &database, sql, max_rows), ) .await @@ -487,16 +537,20 @@ pub async fn do_execute( }, None => client.lock().await, }; - wait_for_query(cancel_token, db::sqlserver::execute_query_with_max_rows(&mut client, sql, max_rows)) - .await - .map(|result| truncate_result_with_max_rows(result, max_rows)) + wait_for_query_opt( + cancel_token, + query_timeout, + db::sqlserver::execute_query_with_max_rows(&mut client, sql, max_rows), + ) + .await + .map(|result| truncate_result_with_max_rows(result, max_rows)) } PoolKind::Elasticsearch(client) => { let client = client.clone(); let sql = sql.to_string(); let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::elasticsearch_driver::execute_rest_query(&client, &sql)) + wait_for_query_opt(cancel_token, query_timeout, db::elasticsearch_driver::execute_rest_query(&client, &sql)) .await .map(|result| truncate_result_with_max_rows(result, max_rows)) } @@ -508,7 +562,7 @@ pub async fn do_execute( let schema = schema.map(|s| s.to_string()); let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, async move { + wait_for_query_opt(cancel_token, query_timeout, async move { let mut client = client.lock().await; if let Some(session_id) = options.result_session_id.as_deref() { let params = agent_fetch_query_page_params(session_id, options.page_size.unwrap_or(MAX_ROWS)); @@ -532,7 +586,7 @@ pub async fn do_execute( let sql = sql.to_string(); let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, async move { + wait_for_query_opt(cancel_token, query_timeout, async move { let task = tokio::task::spawn_blocking(move || { let con = con.lock().map_err(|e| e.to_string())?; duckdb_execute_with_max_rows(&con, &sql, max_rows) @@ -549,7 +603,7 @@ pub async fn do_execute( let database = config.effective_database().unwrap_or("").to_string(); let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, async move { + wait_for_query_opt(cancel_token, query_timeout, async move { let params = external_driver_query_params(config.as_ref(), &sql, &database, schema.as_deref()); session.invoke::("executeQuery", params).await }) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index dfad94408..da0cf4f0d 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -20,6 +20,7 @@ pub struct ExecuteQueryRequest { pub page_size: Option, pub result_session_id: Option, pub client_session_id: Option, + pub timeout_secs: Option, } #[derive(Deserialize)] @@ -271,6 +272,7 @@ pub async fn execute_query( page_size: req.page_size, result_session_id: req.result_session_id, client_session_id: req.client_session_id, + timeout_secs: req.timeout_secs, }, ) .await @@ -302,6 +304,7 @@ pub async fn execute_multi( page_size: req.page_size, result_session_id: req.result_session_id, client_session_id: req.client_session_id, + timeout_secs: req.timeout_secs, }, ) .await diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 15ba96270..f83c9b9b5 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -22,6 +22,7 @@ pub async fn execute_query( page_size: Option, result_session_id: Option, client_session_id: Option, + timeout_secs: Option, ) -> Result { let registered_query = execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone())); @@ -40,6 +41,7 @@ pub async fn execute_query( page_size, result_session_id, client_session_id, + timeout_secs, }, ) .await @@ -58,6 +60,7 @@ pub async fn execute_multi( page_size: Option, result_session_id: Option, client_session_id: Option, + timeout_secs: Option, ) -> Result, String> { let registered_query = execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone())); @@ -85,6 +88,7 @@ pub async fn execute_multi( page_size, result_session_id, client_session_id, + timeout_secs, }, ) .await;