🐛 fix(connect): 修复首次启动数据库连接偶发失败

This commit is contained in:
DurianPankek
2026-03-21 16:17:29 +08:00
parent 36a57f9601
commit 7bc358d612
5 changed files with 383 additions and 26 deletions

View File

@@ -14,6 +14,7 @@ import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import { getConnectionWorkbenchState } from './utils/startupReadiness';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
@@ -90,9 +91,11 @@ function App() {
const [runtimePlatform, setRuntimePlatform] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false);
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const globalProxyInvalidHintShownRef = React.useRef(false);
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
@@ -198,8 +201,16 @@ function App() {
content: '全局代理配置失败: ' + errMsg,
key: 'global-proxy-sync-error',
});
})
.finally(() => {
if (!cancelled) {
setHasAppliedInitialGlobalProxy(true);
}
});
} catch (e) {
if (!cancelled) {
setHasAppliedInitialGlobalProxy(true);
}
console.warn("Wails API: ConfigureGlobalProxy unavailable", e);
}
@@ -1638,8 +1649,44 @@ function App() {
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
<Sidebar onEditConnection={handleEditConnection} />
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, position: 'relative' }}>
<div style={{ height: '100%', opacity: connectionWorkbenchState.ready ? 1 : 0.72, pointerEvents: connectionWorkbenchState.ready ? 'auto' : 'none' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
{!connectionWorkbenchState.ready && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
background: darkMode ? 'rgba(7, 12, 20, 0.42)' : 'rgba(255, 255, 255, 0.58)',
backdropFilter: 'blur(4px)',
zIndex: 1,
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderRadius: 999,
background: darkMode ? 'rgba(15, 23, 36, 0.86)' : 'rgba(255, 255, 255, 0.94)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(22,32,51,0.08)',
boxShadow: darkMode ? '0 12px 24px rgba(0,0,0,0.26)' : '0 12px 24px rgba(15,23,42,0.08)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
fontSize: 12,
fontWeight: 500,
}}
>
<Spin size="small" />
<span>{connectionWorkbenchState.message}</span>
</div>
</div>
)}
</div>
{/* Floating SQL Log Toggle */}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getConnectionWorkbenchState } from './startupReadiness';
describe('startup readiness helpers', () => {
it('blocks sidebar interactions before local store hydration completes', () => {
expect(getConnectionWorkbenchState(false, false)).toEqual({
ready: false,
message: '正在加载本地配置...',
});
});
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
expect(getConnectionWorkbenchState(true, false)).toEqual({
ready: false,
message: '正在同步全局代理配置...',
});
});
it('unblocks sidebar after startup configuration is fully applied', () => {
expect(getConnectionWorkbenchState(true, true)).toEqual({
ready: true,
message: '',
});
});
});

View File

@@ -0,0 +1,26 @@
export interface ConnectionWorkbenchState {
ready: boolean;
message: string;
}
export function getConnectionWorkbenchState(
isStoreHydrated: boolean,
hasAppliedInitialGlobalProxy: boolean
): ConnectionWorkbenchState {
if (!isStoreHydrated) {
return {
ready: false,
message: '正在加载本地配置...',
};
}
if (!hasAppliedInitialGlobalProxy) {
return {
ready: false,
message: '正在同步全局代理配置...',
};
}
return {
ready: true,
message: '',
};
}

View File

@@ -23,6 +23,17 @@ import (
const dbCachePingInterval = 30 * time.Second
const (
startupConnectRetryWindow = 20 * time.Second
startupConnectRetryDelay = 800 * time.Millisecond
startupConnectRetryAttempts = 4
)
var (
newDatabaseFunc = db.NewDatabase
resolveDialConfigWithProxyFunc = resolveDialConfigWithProxy
)
type cachedDatabase struct {
inst db.Database
lastPing time.Time
@@ -36,6 +47,7 @@ type queryContext struct {
// App struct
type App struct {
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
@@ -56,9 +68,10 @@ func NewApp() *App {
// so we can call the runtime methods
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
a.startedAt = time.Now()
logger.Init()
applyMacWindowTranslucencyFix()
logger.Infof("应用启动完成")
logger.Infof("应用启动完成(首次连接保护窗口=%s最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts)
}
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
@@ -429,12 +442,12 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
}
dbInst, err := db.NewDatabase(effectiveConfig.Type)
dbInst, err := newDatabaseFunc(effectiveConfig.Type)
if err != nil {
return nil, err
}
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
if proxyErr != nil {
_ = dbInst.Close()
return nil, wrapConnectError(effectiveConfig, proxyErr)
@@ -451,10 +464,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
isFileDB := isFileDatabaseType(effectiveConfig.Type)
key := getCacheKey(effectiveConfig)
shortKey := key
if len(shortKey) > 12 {
shortKey = shortKey[:12]
}
shortKey := shortenCacheKey(key)
if isFileDB {
rawDSN := resolveFileDatabaseDSN(effectiveConfig)
normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig))
@@ -531,26 +541,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
}
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
dbInst, err := db.NewDatabase(effectiveConfig.Type)
dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(config)
if err != nil {
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
return nil, err
}
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
if proxyErr != nil {
wrapped := wrapConnectError(effectiveConfig, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(effectiveConfig, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
effectiveConfig = connectedConfig
key = getCacheKey(effectiveConfig)
shortKey = shortenCacheKey(key)
now := time.Now()
@@ -571,6 +568,118 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
return dbInst, nil
}
func shortenCacheKey(key string) string {
if len(key) > 12 {
return key[:12]
}
return key
}
func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) {
var lastErr error
var lastEffectiveConfig connection.ConnectionConfig
for attempt := 1; attempt <= startupConnectRetryAttempts; attempt++ {
effectiveConfig := applyGlobalProxyToConnection(rawConfig)
lastEffectiveConfig = effectiveConfig
cacheKey := shortenCacheKey(getCacheKey(effectiveConfig))
logger.Infof("获取数据库连接:%s 缓存Key=%s 启动阶段=%s", formatConnSummary(effectiveConfig), cacheKey, a.startupPhaseLabel())
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s 尝试=%d/%d", effectiveConfig.Type, cacheKey, attempt, startupConnectRetryAttempts)
dbInst, err := newDatabaseFunc(effectiveConfig.Type)
if err != nil {
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, cacheKey)
return nil, effectiveConfig, err
}
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
if proxyErr != nil {
_ = dbInst.Close()
wrapped := wrapConnectError(effectiveConfig, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey)
return nil, effectiveConfig, wrapped
}
if err := dbInst.Connect(connectConfig); err == nil {
if attempt > 1 {
logger.Warnf("数据库连接在启动保护重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts)
}
return dbInst, effectiveConfig, nil
} else {
_ = dbInst.Close()
wrapped := wrapConnectError(effectiveConfig, err)
lastErr = wrapped
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey)
if !a.shouldRetryStartupConnect(err, attempt) {
return nil, effectiveConfig, wrapped
}
logger.Warnf("检测到启动期瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s",
formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err))
time.Sleep(startupConnectRetryDelay)
}
}
if lastErr == nil {
lastErr = fmt.Errorf("建立数据库连接失败")
}
return nil, lastEffectiveConfig, lastErr
}
func (a *App) startupPhaseLabel() string {
if a == nil || a.startedAt.IsZero() {
return "未知"
}
age := time.Since(a.startedAt).Round(time.Millisecond)
if age < 0 {
age = 0
}
if age <= startupConnectRetryWindow {
snapshot := currentGlobalProxyConfig()
state := "关闭"
if snapshot.Enabled {
state = fmt.Sprintf("启用(%s://%s:%d)", strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), strings.TrimSpace(snapshot.Proxy.Host), snapshot.Proxy.Port)
}
return fmt.Sprintf("启动期(age=%s,全局代理=%s)", age, state)
}
return fmt.Sprintf("稳定期(age=%s)", age)
}
func (a *App) shouldRetryStartupConnect(err error, attempt int) bool {
if attempt >= startupConnectRetryAttempts {
return false
}
if a == nil || a.startedAt.IsZero() {
return false
}
age := time.Since(a.startedAt)
if age < 0 || age > startupConnectRetryWindow {
return false
}
return isTransientStartupConnectError(err)
}
func isTransientStartupConnectError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(normalizeErrorMessage(err))
transientHints := []string{
"no route to host",
"network is unreachable",
"connection refused",
"connection timed out",
"i/o timeout",
"context deadline exceeded",
}
for _, hint := range transientHints {
if strings.Contains(message, hint) {
return true
}
}
return false
}
// generateQueryID generates a unique ID for a query using UUID v4
func generateQueryID() string {
return "query-" + uuid.New().String()

View File

@@ -0,0 +1,149 @@
package app
import (
"errors"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type fakeStartupRetryDB struct {
connect func(config connection.ConnectionConfig) error
}
func (f *fakeStartupRetryDB) Connect(config connection.ConnectionConfig) error {
if f.connect != nil {
return f.connect(config)
}
return nil
}
func (f *fakeStartupRetryDB) Close() error { return nil }
func (f *fakeStartupRetryDB) Ping() error { return nil }
func (f *fakeStartupRetryDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (f *fakeStartupRetryDB) Exec(query string) (int64, error) { return 0, nil }
func (f *fakeStartupRetryDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeStartupRetryDB) GetTables(dbName string) ([]string, error) { return nil, nil }
func (f *fakeStartupRetryDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeStartupRetryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeStartupRetryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeStartupRetryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeStartupRetryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeStartupRetryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlobalProxy(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
snapshot := currentGlobalProxyConfig()
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
if _, err := setGlobalProxyConfig(snapshot.Enabled, snapshot.Proxy); err != nil {
t.Fatalf("restore global proxy failed: %v", err)
}
}()
if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil {
t.Fatalf("disable global proxy failed: %v", err)
}
seenConfigs := make([]connection.ConnectionConfig, 0, 2)
connectCalls := 0
newDatabaseFunc = func(dbType string) (db.Database, error) {
return &fakeStartupRetryDB{
connect: func(config connection.ConnectionConfig) error {
connectCalls++
seenConfigs = append(seenConfigs, config)
if connectCalls == 1 {
_, _ = setGlobalProxyConfig(true, connection.ProxyConfig{Type: "socks5", Host: "127.0.0.1", Port: 1080})
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
}
return nil
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now()}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, effectiveConfig, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err != nil {
t.Fatalf("connectDatabaseWithStartupRetry returned error: %v", err)
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
}
if len(seenConfigs) != 2 {
t.Fatalf("expected 2 seen configs, got %d", len(seenConfigs))
}
if seenConfigs[0].UseProxy {
t.Fatalf("expected first attempt without proxy, got %+v", seenConfigs[0])
}
if !seenConfigs[1].UseProxy {
t.Fatalf("expected second attempt with proxy after startup retry, got %+v", seenConfigs[1])
}
if !effectiveConfig.UseProxy {
t.Fatalf("expected returned effective config to include proxy, got %+v", effectiveConfig)
}
}
func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(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: no route to host")
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err == nil {
t.Fatal("expected error, got nil")
}
if connectCalls != 1 {
t.Fatalf("expected 1 connect attempt outside startup window, got %d", connectCalls)
}
}
func TestIsTransientStartupConnectError(t *testing.T) {
if !isTransientStartupConnectError(errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")) {
t.Fatal("expected no route to host to be treated as transient startup connect error")
}
if isTransientStartupConnectError(errors.New("pq: password authentication failed")) {
t.Fatal("expected authentication failure to not be treated as transient startup connect error")
}
}