mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 19:11:39 +08:00
🐛 fix(connect): 修复首次启动数据库连接偶发失败
This commit is contained in:
@@ -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()
|
||||
|
||||
149
internal/app/app_startup_connect_retry_test.go
Normal file
149
internal/app/app_startup_connect_retry_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user