From 1a042321d2a6e8828ac575ea9adba98f8b49f98a Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:27:08 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(connection):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=B1=E8=B4=A5=E8=BF=9E=E6=8E=A5=E9=AB=98=E9=A2=91?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=B9=B6=E6=9A=82=E5=81=9C=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=85=83=E6=95=B0=E6=8D=AE=E6=8B=89=E5=8F=96?= =?UTF-8?q?=20#331?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端为失败数据库连接增加冷却窗口,避免短时间内重复真实建连 - 补充失败冷却回归测试,覆盖重复失败、冷却后重试和成功后清理场景 - 前端在后台态暂停查询页、侧边栏和表概览的自动元数据拉取 - 保持手动刷新、手动展开等显式操作行为不变 --- frontend/src/components/QueryEditor.tsx | 14 +- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/components/TableOverview.tsx | 9 +- .../src/utils/autoFetchVisibility.test.ts | 22 +++ frontend/src/utils/autoFetchVisibility.ts | 54 ++++++ internal/app/app.go | 78 +++++++++ .../app/app_startup_connect_retry_test.go | 164 ++++++++++++++++++ 7 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 frontend/src/utils/autoFetchVisibility.test.ts create mode 100644 frontend/src/utils/autoFetchVisibility.ts diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 9b2fd44..8b952bc 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -11,6 +11,7 @@ import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { convertMongoShellToJsonCommand } from '../utils/mongodb'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; +import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; const SQL_KEYWORDS = [ @@ -249,6 +250,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const setQueryOptions = useStore(state => state.setQueryOptions); const shortcutOptions = useStore(state => state.shortcutOptions); const activeTabId = useStore(state => state.activeTabId); + const autoFetchVisible = useAutoFetchVisibility(); const currentSavedQuery = useMemo(() => { const savedId = String(tab.savedQueryId || '').trim(); @@ -324,6 +326,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // Fetch Database List useEffect(() => { + if (!autoFetchVisible) { + return; + } + const fetchDbs = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; @@ -367,10 +373,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; void fetchDbs(); - }, [currentConnectionId, connections]); + }, [autoFetchVisible, currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { + if (!autoFetchVisible) { + return; + } + const fetchMetadata = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; @@ -424,7 +434,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; void fetchMetadata(); - }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 + }, [autoFetchVisible, currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 // Query ID management helpers const setQueryId = (id: string) => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ed3743b..80688b0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; @@ -118,6 +119,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const autoFetchVisible = useAutoFetchVisibility(); const [treeData, setTreeData] = useState([]); // Background Helper (Duplicate logic for now, ideally shared) @@ -292,6 +294,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' }); useEffect(() => { + if (!autoFetchVisible) { + return; + } + // Refresh queries for expanded databases const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => { for (const node of nodes) { @@ -310,7 +316,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> loadTables(node); } }); - }, [savedQueries]); + }, [autoFetchVisible, savedQueries]); useEffect(() => { setTreeData((prev) => { diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index bf687a1..f18c272 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -4,6 +4,7 @@ import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, D import { useStore } from '../store'; import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; +import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; interface TableOverviewProps { @@ -151,6 +152,7 @@ const TableOverview: React.FC = ({ tab }) => { const [viewMode, setViewMode] = useState('card'); const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]); + const autoFetchVisible = useAutoFetchVisibility(); const loadData = useCallback(async () => { if (!connection) return; @@ -179,7 +181,12 @@ const TableOverview: React.FC = ({ tab }) => { } }, [connection, tab.dbName]); - useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { + if (!autoFetchVisible) { + return; + } + void loadData(); + }, [autoFetchVisible, loadData]); const sortedFiltered = useMemo(() => { let list = [...tables]; diff --git a/frontend/src/utils/autoFetchVisibility.test.ts b/frontend/src/utils/autoFetchVisibility.test.ts new file mode 100644 index 0000000..4e0794f --- /dev/null +++ b/frontend/src/utils/autoFetchVisibility.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { isAutoFetchVisible } from './autoFetchVisibility'; + +describe('isAutoFetchVisible', () => { + it('allows auto fetch only when the document is visible and not hidden', () => { + expect(isAutoFetchVisible({ hidden: false, visibilityState: 'visible' })).toBe(true); + }); + + it('blocks auto fetch when the page is hidden even if visibilityState looks visible', () => { + expect(isAutoFetchVisible({ hidden: true, visibilityState: 'visible' })).toBe(false); + }); + + it('blocks auto fetch when visibilityState is not visible', () => { + expect(isAutoFetchVisible({ hidden: false, visibilityState: 'hidden' })).toBe(false); + }); + + it('defaults to allowing auto fetch when document visibility APIs are unavailable', () => { + expect(isAutoFetchVisible(undefined)).toBe(true); + expect(isAutoFetchVisible({})).toBe(true); + }); +}); diff --git a/frontend/src/utils/autoFetchVisibility.ts b/frontend/src/utils/autoFetchVisibility.ts new file mode 100644 index 0000000..00836ab --- /dev/null +++ b/frontend/src/utils/autoFetchVisibility.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +type AutoFetchVisibilitySource = Partial> | undefined; + +export const isAutoFetchVisible = (source?: AutoFetchVisibilitySource): boolean => { + if (!source) { + return true; + } + + if (source.hidden === true) { + return false; + } + + if (source.visibilityState && source.visibilityState !== 'visible') { + return false; + } + + return true; +}; + +const getDocumentAutoFetchVisibility = (): boolean => { + if (typeof document === 'undefined') { + return true; + } + + return isAutoFetchVisible(document); +}; + +export const useAutoFetchVisibility = (): boolean => { + const [isVisible, setIsVisible] = useState(() => getDocumentAutoFetchVisibility()); + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + + const syncVisibility = () => { + setIsVisible(getDocumentAutoFetchVisibility()); + }; + + syncVisibility(); + document.addEventListener('visibilitychange', syncVisibility); + window.addEventListener('focus', syncVisibility); + window.addEventListener('pageshow', syncVisibility); + + return () => { + document.removeEventListener('visibilitychange', syncVisibility); + window.removeEventListener('focus', syncVisibility); + window.removeEventListener('pageshow', syncVisibility); + }; + }, []); + + return isVisible; +}; diff --git a/internal/app/app.go b/internal/app/app.go index 3f17b86..2f96f64 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,7 @@ import ( ) const dbCachePingInterval = 30 * time.Second +const dbConnectFailureCooldown = 30 * time.Second const ( startupConnectRetryWindow = 20 * time.Second @@ -40,6 +41,11 @@ type cachedDatabase struct { lastPing time.Time } +type cachedConnectFailure struct { + occurredAt time.Time + err error +} + type queryContext struct { cancel context.CancelFunc started time.Time @@ -50,6 +56,7 @@ type App struct { ctx context.Context startedAt time.Time dbCache map[string]cachedDatabase // Cache for DB connections + connectFailures map[string]cachedConnectFailure mu sync.RWMutex // Mutex for cache access updateMu sync.Mutex updateState updateState @@ -70,6 +77,7 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App { } return &App{ dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), runningQueries: make(map[string]queryContext), configDir: resolveAppConfigDir(), secretStore: store, @@ -573,14 +581,28 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing if isFileDB { logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } + if failure, remaining, ok := a.getCachedConnectFailureByKey(key); ok { + message := fmt.Sprintf("连接最近失败,正在冷却中,请 %s 后重试;上次错误:%s", + formatConnectFailureCooldown(remaining), + normalizeErrorMessage(failure.err), + ) + logger.Warnf("命中数据库连接失败冷却:%s 缓存Key=%s 剩余=%s 原因=%s", + formatConnSummary(effectiveConfig), shortKey, formatConnectFailureCooldown(remaining), normalizeErrorMessage(failure.err)) + return nil, withLogHint{err: fmt.Errorf("%s", message), logPath: logger.Path()} + } + initialKey := key dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(resolvedConfig) if err != nil { + failedKey := getCacheKey(connectedConfig) + a.recordConnectFailureByKey(failedKey, err) return nil, err } + a.clearConnectFailureByKey(initialKey) effectiveConfig = connectedConfig key = getCacheKey(effectiveConfig) shortKey = shortenCacheKey(key) + a.clearConnectFailureByKey(key) now := time.Now() @@ -601,6 +623,62 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing return dbInst, nil } +func (a *App) getCachedConnectFailureByKey(key string) (cachedConnectFailure, time.Duration, bool) { + if a == nil || strings.TrimSpace(key) == "" { + return cachedConnectFailure{}, 0, false + } + + a.mu.RLock() + entry, exists := a.connectFailures[key] + a.mu.RUnlock() + if !exists || entry.err == nil || entry.occurredAt.IsZero() { + return cachedConnectFailure{}, 0, false + } + + remaining := dbConnectFailureCooldown - time.Since(entry.occurredAt) + if remaining <= 0 { + a.clearConnectFailureByKey(key) + return cachedConnectFailure{}, 0, false + } + + return entry, remaining, true +} + +func (a *App) recordConnectFailureByKey(key string, err error) { + if a == nil || strings.TrimSpace(key) == "" || err == nil { + return + } + + a.mu.Lock() + if a.connectFailures == nil { + a.connectFailures = make(map[string]cachedConnectFailure) + } + a.connectFailures[key] = cachedConnectFailure{ + occurredAt: time.Now(), + err: err, + } + a.mu.Unlock() +} + +func (a *App) clearConnectFailureByKey(key string) { + if a == nil || strings.TrimSpace(key) == "" { + return + } + + a.mu.Lock() + if a.connectFailures != nil { + delete(a.connectFailures, key) + } + a.mu.Unlock() +} + +func formatConnectFailureCooldown(remaining time.Duration) time.Duration { + if remaining <= time.Second { + return time.Second + } + return remaining.Truncate(time.Second) +} + func shortenCacheKey(key string) string { if len(key) > 12 { return key[:12] diff --git a/internal/app/app_startup_connect_retry_test.go b/internal/app/app_startup_connect_retry_test.go index b8fb027..8bd0ec1 100644 --- a/internal/app/app_startup_connect_retry_test.go +++ b/internal/app/app_startup_connect_retry_test.go @@ -303,3 +303,167 @@ func TestIsTransientStartupConnectError(t *testing.T) { t.Fatal("expected authentication failure to not be treated as transient startup connect error") } } + +func TestGetDatabaseWithPing_CoolsDownRepeatedFailures(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + return errors.New("dial tcp 10.1.131.86:5432: connect: connection refused") + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{ + startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second), + dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), + runningQueries: make(map[string]queryContext), + } + config := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, firstErr := a.getDatabaseWithPing(config, false) + if firstErr == nil { + t.Fatal("expected first connection attempt to fail") + } + if connectCalls != 2 { + t.Fatalf("expected first request to use 2 connect attempts, got %d", connectCalls) + } + + _, secondErr := a.getDatabaseWithPing(config, false) + if secondErr == nil { + t.Fatal("expected second connection attempt to fail during cooldown") + } + if connectCalls != 2 { + t.Fatalf("expected repeated request during cooldown to avoid reconnecting, got %d connect attempts", connectCalls) + } +} + +func TestGetDatabaseWithPing_AllowsRetryAfterFailureCooldown(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + if connectCalls <= 2 { + return errors.New("dial tcp 10.1.131.86:5432: connect: connection refused") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{ + startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second), + dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), + runningQueries: make(map[string]queryContext), + } + config := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, firstErr := a.getDatabaseWithPing(config, false) + if firstErr == nil { + t.Fatal("expected first connection attempt to fail") + } + if connectCalls != 2 { + t.Fatalf("expected first request to use 2 connect attempts, got %d", connectCalls) + } + + key := getCacheKey(config) + a.mu.Lock() + a.connectFailures[key] = cachedConnectFailure{ + occurredAt: time.Now().Add(-dbConnectFailureCooldown - time.Second), + err: errors.New("dial tcp 10.1.131.86:5432: connect: connection refused"), + } + a.mu.Unlock() + + inst, secondErr := a.getDatabaseWithPing(config, false) + if secondErr != nil { + t.Fatalf("expected retry after cooldown to be allowed, got error: %v", secondErr) + } + if inst == nil { + t.Fatal("expected database instance after cooldown retry") + } + if connectCalls != 3 { + t.Fatalf("expected reconnect after cooldown expiration, got %d connect attempts", connectCalls) + } +} + +func TestGetDatabaseWithPing_ClearsFailureCooldownAfterSuccess(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }() + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &fakeStartupRetryDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + if connectCalls <= 2 { + return errors.New("dial tcp 10.1.131.86:5432: connect: connection refused") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + a := &App{ + startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second), + dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), + runningQueries: make(map[string]queryContext), + } + config := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"} + + _, firstErr := a.getDatabaseWithPing(config, false) + if firstErr == nil { + t.Fatal("expected first connection attempt to fail") + } + + key := getCacheKey(config) + a.mu.Lock() + a.connectFailures[key] = cachedConnectFailure{ + occurredAt: time.Now().Add(-dbConnectFailureCooldown - time.Second), + err: errors.New("dial tcp 10.1.131.86:5432: connect: connection refused"), + } + a.mu.Unlock() + + _, secondErr := a.getDatabaseWithPing(config, false) + if secondErr != nil { + t.Fatalf("expected retry after cooldown to succeed, got error: %v", secondErr) + } + + a.mu.RLock() + _, exists := a.connectFailures[key] + a.mu.RUnlock() + if exists { + t.Fatal("expected successful connection to clear cached failure cooldown") + } +}