fix: 修复应用长时间挂后台后恢复时表格数据永久加载的问题
三层防护: - MySQL 连接获取时执行 ping 健康检查,死连接自动重连 - macOS Reopen 事件 + 前端 visibilitychange 触发连接刷新 - 前端 Promise.race 超时保护,确保 loading 状态不会超过 60s
This commit is contained in:
parent
6c0db6f517
commit
b54eda1889
|
|
@ -25,6 +25,7 @@ import { useDialogSources } from "@/composables/useDialogSources";
|
|||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import { useTauriEvents } from "@/composables/useTauriEvents";
|
||||
import { useVisibilityChange } from "@/composables/useVisibilityChange";
|
||||
import "@/i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import * as api from "@/lib/api";
|
||||
|
|
@ -211,6 +212,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
|||
openSqlFilePath,
|
||||
openConnectionDeepLink,
|
||||
});
|
||||
useVisibilityChange();
|
||||
|
||||
const appVersion = ref("");
|
||||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { onMounted, onUnmounted } from "vue";
|
||||
import { refreshConnections } from "@/lib/api";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
let hiddenAt: number | null = null;
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
hiddenAt = Date.now();
|
||||
} else {
|
||||
const wasHidden = hiddenAt;
|
||||
hiddenAt = null;
|
||||
if (wasHidden && Date.now() - wasHidden > 30_000) {
|
||||
refreshConnections().catch(() => {});
|
||||
const queryStore = useQueryStore();
|
||||
const stuckTabs = queryStore.tabs.filter((t) => t.isExecuting);
|
||||
if (stuckTabs.length > 0) {
|
||||
queryStore.notifyConnectionMayBeLost();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useVisibilityChange() {
|
||||
onMounted(() => {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
}
|
||||
|
|
@ -708,14 +708,12 @@ export const useQueryStore = defineStore("query", () => {
|
|||
clientSessionId: tab.id,
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
};
|
||||
const results = await api.executeMulti(
|
||||
tab.connectionId,
|
||||
tab.database,
|
||||
sqlToExecute,
|
||||
tab.schema,
|
||||
executionId,
|
||||
executionOptions,
|
||||
);
|
||||
const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
|
||||
const timeoutError = new Error(`查询超时 (${frontendTimeoutSecs}s),请检查数据库连接是否正常`);
|
||||
const results = await Promise.race([
|
||||
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, tab.schema, executionId, executionOptions),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(timeoutError), frontendTimeoutSecs * 1000)),
|
||||
]);
|
||||
console.info("[DBX][executeTabSql:execute-multi:done]", {
|
||||
traceId,
|
||||
resultCount: results.length,
|
||||
|
|
@ -916,6 +914,18 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.queryEditabilityReason = undefined;
|
||||
}
|
||||
|
||||
function notifyConnectionMayBeLost() {
|
||||
const stuck = tabs.value.filter((t) => t.isExecuting);
|
||||
if (stuck.length > 0) {
|
||||
stuck.forEach((t) => {
|
||||
t.isExecuting = false;
|
||||
t.isCancelling = false;
|
||||
t.executionId = undefined;
|
||||
t.result = toErrorResult(new Error("连接可能已断开,请刷新数据重试"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function trimResultCache() {
|
||||
const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results));
|
||||
if (inactive.length > MAX_CACHED_RESULTS) {
|
||||
|
|
@ -964,5 +974,6 @@ export const useQueryStore = defineStore("query", () => {
|
|||
cancelTabExecution,
|
||||
cancelTabExplain,
|
||||
reloadEvictedTab,
|
||||
notifyConnectionMayBeLost,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -541,6 +541,86 @@ impl AppState {
|
|||
self.proxy_tunnels.stop_tunnel(connection_id).await;
|
||||
}
|
||||
|
||||
pub async fn refresh_connections(&self) {
|
||||
let mut dead_keys = Vec::new();
|
||||
|
||||
// Check health of all connection pools
|
||||
{
|
||||
let conns = self.connections.read().await;
|
||||
for (key, pool) in conns.iter() {
|
||||
let healthy = match pool {
|
||||
PoolKind::Mysql(p, _) => {
|
||||
match db::mysql::get_conn_with_health_check(p).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!("MySQL connection pool '{key}' is unhealthy: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::Postgres(p) => {
|
||||
match p.get().await {
|
||||
Ok(client) => {
|
||||
match client.simple_query("SELECT 1").await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!("PostgreSQL connection pool '{key}' is unhealthy: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("PostgreSQL connection pool '{key}' is unhealthy: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => true, // Skip non-SQL pools
|
||||
};
|
||||
if !healthy {
|
||||
dead_keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead pools
|
||||
if !dead_keys.is_empty() {
|
||||
let mut conns = self.connections.write().await;
|
||||
for key in &dead_keys {
|
||||
if let Some(pool) = conns.remove(key) {
|
||||
close_pool_kind(pool).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-establish SSH tunnels that have died
|
||||
let tunnel_connection_ids: Vec<String> = {
|
||||
let configs = self.configs.read().await;
|
||||
configs
|
||||
.iter()
|
||||
.filter(|(_, c)| c.ssh_enabled && !c.ssh_host.is_empty())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
for connection_id in tunnel_connection_ids {
|
||||
self.tunnels.stop_tunnel(&connection_id).await;
|
||||
// Tunnels will be re-created on next pool access via connection_host_port
|
||||
}
|
||||
|
||||
// Re-establish proxy tunnels
|
||||
let proxy_connection_ids: Vec<String> = {
|
||||
let configs = self.configs.read().await;
|
||||
configs
|
||||
.iter()
|
||||
.filter(|(_, c)| c.proxy_enabled && !c.proxy_host.is_empty())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
};
|
||||
for connection_id in proxy_connection_ids {
|
||||
self.proxy_tunnels.stop_tunnel(&connection_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_connection_pools(&self, connection_id: &str) {
|
||||
let mut conns = self.connections.write().await;
|
||||
let keys_to_remove: Vec<String> = conns
|
||||
|
|
|
|||
|
|
@ -798,13 +798,26 @@ fn query_result_row_limit(max_rows: Option<usize>) -> usize {
|
|||
max_rows.unwrap_or(crate::query::MAX_ROWS).max(1)
|
||||
}
|
||||
|
||||
/// Get a connection from the pool with a health check. If the connection is dead
|
||||
/// (e.g. after app was backgrounded), it tries again with a fresh connection.
|
||||
pub async fn get_conn_with_health_check(pool: &MySqlPool) -> Result<mysql_async::Conn, String> {
|
||||
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
|
||||
match conn.ping().await {
|
||||
Ok(()) => Ok(conn),
|
||||
Err(_) => {
|
||||
let _ = conn.disconnect().await;
|
||||
pool.get_conn().await.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_result_set_with_text_protocol(
|
||||
pool: &MySqlPool,
|
||||
sql: &str,
|
||||
row_limit: usize,
|
||||
start: Instant,
|
||||
) -> Result<QueryResult, String> {
|
||||
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
|
||||
let mut conn = get_conn_with_health_check(pool).await?;
|
||||
let mut result = conn.query_iter(sql).await.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
|
||||
|
||||
|
|
@ -846,7 +859,7 @@ async fn execute_result_set_with_prepared_protocol(
|
|||
row_limit: usize,
|
||||
start: Instant,
|
||||
) -> Result<QueryResult, String> {
|
||||
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
|
||||
let mut conn = get_conn_with_health_check(pool).await?;
|
||||
let mut result = conn.exec_iter(sql, ()).await.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<String> = result.columns_ref().iter().map(|c| c.name_str().to_string()).collect();
|
||||
|
||||
|
|
@ -908,7 +921,7 @@ pub async fn execute_query_with_max_rows(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
let mut conn = pool.get_conn().await.map_err(|e| e.to_string())?;
|
||||
let mut conn = get_conn_with_health_check(pool).await?;
|
||||
let previous_explicit_timestamp_defaults = enable_explicit_timestamp_defaults_for_query(&mut conn, sql).await;
|
||||
let result = match conn.query_iter(sql).await {
|
||||
Ok(result) => result,
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ pub async fn connect(url: &str, fallback_timeout: Duration) -> Result<Pool, Stri
|
|||
let pg_config = tokio_postgres::Config::from_str(&postgres_url.url)
|
||||
.map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
|
||||
|
||||
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast };
|
||||
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Verified };
|
||||
let tls_config = postgres_tls_config(
|
||||
&pg_config,
|
||||
&postgres_url.ssl_files,
|
||||
|
|
|
|||
|
|
@ -1071,7 +1071,7 @@ async fn exec_tx_mysql_inner(
|
|||
statements: &[String],
|
||||
start: std::time::Instant,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let mut conn = pool.get_conn().await.map_err(|e| format!("Failed to acquire connection: {}", e))?;
|
||||
let mut conn = db::mysql::get_conn_with_health_check(&pool).await?;
|
||||
conn.query_drop("START TRANSACTION").await.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
let mut total_affected: u64 = 0;
|
||||
for (i, sql) in statements.iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -511,3 +511,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
|
|||
state.reset_connection_transport(&connection_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
state.refresh_connections().await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue