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.
This commit is contained in:
parent
db00051632
commit
792c816a17
|
|
@ -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(
|
|||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="query-timeout-secs">{{ t("settings.queryTimeoutSecs") }}</Label>
|
||||
<Input
|
||||
id="query-timeout-secs"
|
||||
type="number"
|
||||
min="0"
|
||||
:model-value="String(editQueryTimeoutSecs)"
|
||||
@update:model-value="
|
||||
(v: any) => {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n >= 0) editQueryTimeoutSecs = n;
|
||||
}
|
||||
"
|
||||
class="h-9 w-28"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.queryTimeoutSecsDescription") }}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.preview") }}</Label>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1342,6 +1342,8 @@ export default {
|
|||
shortcutPressShortcut: "按下快捷键",
|
||||
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
|
||||
preview: "实时预览",
|
||||
queryTimeoutSecs: "查询超时(秒)",
|
||||
queryTimeoutSecsDescription: "每条查询的最大执行时间。0 = 不限制。默认 30 秒。",
|
||||
jdbcPlugin: "DBX JDBC 插件",
|
||||
jdbcPluginInstall: "安装 JDBC 插件",
|
||||
jdbcPluginInstallSuccess: "JDBC 插件已安装",
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ export async function executeQuery(
|
|||
pageSize?: number;
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
},
|
||||
): Promise<QueryResult> {
|
||||
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<QueryResult[]> {
|
||||
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ export async function executeQuery(
|
|||
pageSize?: number;
|
||||
resultSessionId?: string;
|
||||
clientSessionId?: string;
|
||||
timeoutSecs?: number;
|
||||
},
|
||||
): Promise<QueryResult> {
|
||||
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<QueryResult[]> {
|
||||
return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ export interface EditorSettings {
|
|||
columnFormatters: Record<string, ColumnFormatterConfig>;
|
||||
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
|
||||
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<EditorSettings>, existing?: EditorSettings): EditorSettings {
|
||||
return {
|
||||
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
|
||||
|
|
@ -306,6 +314,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, 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) }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ pub struct QueryExecutionOptions {
|
|||
pub page_size: Option<usize>,
|
||||
pub result_session_id: Option<String>,
|
||||
pub client_session_id: Option<String>,
|
||||
/// Query timeout in seconds. `None` uses the default (30s).
|
||||
/// `Some(0)` disables the timeout entirely.
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn query_result_row_limit(max_rows: Option<usize>) -> 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<F>(
|
||||
cancel_token: Option<CancellationToken>,
|
||||
timeout_duration: Option<Duration>,
|
||||
future: F,
|
||||
) -> Result<db::QueryResult, String>
|
||||
where
|
||||
F: Future<Output = Result<db::QueryResult, String>>,
|
||||
{
|
||||
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<u64>) -> Option<Duration> {
|
||||
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<CancellationToken>,
|
||||
options: QueryExecutionOptions,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
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::<db::QueryResult>("executeQuery", params).await
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub struct ExecuteQueryRequest {
|
|||
pub page_size: Option<usize>,
|
||||
pub result_session_id: Option<String>,
|
||||
pub client_session_id: Option<String>,
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub async fn execute_query(
|
|||
page_size: Option<usize>,
|
||||
result_session_id: Option<String>,
|
||||
client_session_id: Option<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
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<usize>,
|
||||
result_session_id: Option<String>,
|
||||
client_session_id: Option<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<Vec<db::QueryResult>, 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue