Files
MyGoNavi/internal/app/app_keepalive_test.go
Syngnat 94ad5ae2f6 feat(connection): 支持自定义 SQL 探活
- 新增连接级自定义探活 SQL 配置与多语言表单校验
- 限制单条 SELECT/WITH 并使用可取消的超时查询
- 隔离陈旧探活策略,避免配置错误驱逐健康连接

Fixes #611
2026-07-19 12:29:21 +08:00

582 lines
20 KiB
Go

package app
import (
"context"
"errors"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type keepAliveRecordingDB struct {
closed int
pings int
pingErr error
queries []string
queryErr error
queryContextCalls int
queryContextDeadline bool
queryContextHook func(context.Context, string) error
connectHook func()
}
func (f *keepAliveRecordingDB) Connect(config connection.ConnectionConfig) error {
if f.connectHook != nil {
f.connectHook()
}
return nil
}
func (f *keepAliveRecordingDB) Close() error {
f.closed++
return nil
}
func (f *keepAliveRecordingDB) Ping() error {
f.pings++
return f.pingErr
}
func (f *keepAliveRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) {
f.queries = append(f.queries, query)
return nil, nil, f.queryErr
}
func (f *keepAliveRecordingDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
f.queryContextCalls++
_, f.queryContextDeadline = ctx.Deadline()
f.queries = append(f.queries, query)
if f.queryContextHook != nil {
return nil, nil, f.queryContextHook(ctx, query)
}
return nil, nil, f.queryErr
}
func (f *keepAliveRecordingDB) Exec(query string) (int64, error) { return 0, nil }
func (f *keepAliveRecordingDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *keepAliveRecordingDB) GetTables(dbName string) ([]string, error) { return nil, nil }
func (f *keepAliveRecordingDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *keepAliveRecordingDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *keepAliveRecordingDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *keepAliveRecordingDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *keepAliveRecordingDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *keepAliveRecordingDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestRunConnectionKeepAliveTick_PingsDueCachedConnection(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "postgres"}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now().Add(-5 * time.Hour),
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
}
app.runConnectionKeepAliveTick(time.Now())
if dbInst.pings != 1 {
t.Fatalf("expected keepalive ping once, got %d", dbInst.pings)
}
entry := app.dbCache[key]
if entry.keepAliveInFlight {
t.Fatal("expected keepalive in-flight flag to be cleared")
}
if entry.lastPing.IsZero() {
t.Fatal("expected keepalive success to update lastPing")
}
if entry.lastKeepAliveAt.IsZero() {
t.Fatal("expected keepalive success to update lastKeepAliveAt")
}
}
func TestRunConnectionKeepAliveTick_ExecutesCustomReadOnlySQL(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{
Type: "mysql",
Host: "db.local",
Port: 3306,
User: "readonly",
KeepAliveEnabled: true,
KeepAliveSQL: " SELECT 1 ",
}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now().Add(-5 * time.Hour),
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "mysql",
}
app.runConnectionKeepAliveTick(time.Now())
if dbInst.pings != 0 {
t.Fatalf("expected custom SQL to replace Ping, got %d pings", dbInst.pings)
}
if len(dbInst.queries) != 1 || dbInst.queries[0] != "SELECT 1" {
t.Fatalf("expected trimmed custom SQL once, got %#v", dbInst.queries)
}
if dbInst.queryContextCalls != 1 || !dbInst.queryContextDeadline {
t.Fatalf("expected cancellable QueryContext with deadline, calls=%d deadline=%t", dbInst.queryContextCalls, dbInst.queryContextDeadline)
}
}
func TestResolveConnectionKeepAliveSQL_NormalizesSupportedSQLConnections(t *testing.T) {
sql, dbType := resolveConnectionKeepAliveSQL(connection.ConnectionConfig{
Type: "mysql",
KeepAliveEnabled: true,
KeepAliveSQL: " SELECT 1 ",
})
if sql != "SELECT 1" || dbType != "mysql" {
t.Fatalf("expected normalized MySQL keepalive SQL, sql=%q dbType=%q", sql, dbType)
}
sql, dbType = resolveConnectionKeepAliveSQL(connection.ConnectionConfig{
Type: "redis",
KeepAliveEnabled: true,
KeepAliveSQL: "SELECT 1",
})
if sql != "" || dbType != "" {
t.Fatalf("expected non-SQL datasource to ignore custom SQL, sql=%q dbType=%q", sql, dbType)
}
}
func TestExecuteConnectionKeepAlive_RejectsUnsafeSQL(t *testing.T) {
tests := []string{
"DELETE FROM accounts",
"SELECT 1; SELECT 2",
"SELECT 1; DELETE FROM accounts",
"/*!50000 DELETE FROM accounts */ SELECT 1",
"SELECT /*!50000 SQL_NO_CACHE */ 1",
"SELECT ';' AS probe",
"SELECT 1 /* ; */",
}
for _, query := range tests {
t.Run(query, func(t *testing.T) {
dbInst := &keepAliveRecordingDB{}
err := executeConnectionKeepAlive(context.Background(), cachedDatabaseKeepAliveTarget{
inst: dbInst,
sql: query,
dbType: "mysql",
})
if !errors.Is(err, errInvalidConnectionKeepAliveSQL) {
t.Fatalf("expected unsafe SQL rejection, got %v", err)
}
if len(dbInst.queries) != 0 || dbInst.pings != 0 {
t.Fatalf("expected unsafe SQL not to reach database, queries=%#v pings=%d", dbInst.queries, dbInst.pings)
}
})
}
}
func TestExecuteConnectionKeepAlive_RequiresCancellableQuery(t *testing.T) {
dbInst := &keepAliveRecordingDB{}
dbWithoutQueryContext := struct{ db.Database }{Database: dbInst}
err := executeConnectionKeepAlive(context.Background(), cachedDatabaseKeepAliveTarget{
inst: dbWithoutQueryContext,
sql: "SELECT 1",
dbType: "mysql",
})
if !errors.Is(err, errConnectionKeepAliveQueryContextUnsupported) {
t.Fatalf("expected drivers without QueryContext to be rejected, got %v", err)
}
if len(dbInst.queries) != 0 || dbInst.pings != 0 {
t.Fatalf("expected no unbounded query fallback, queries=%#v pings=%d", dbInst.queries, dbInst.pings)
}
}
func TestRunConnectionKeepAliveTick_KeepsHealthyConnectionOnPolicyError(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "mysql", Host: "db.local", Port: 3306, User: "readonly"}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "DELETE FROM accounts",
keepAliveDBType: "mysql",
}
app.runConnectionKeepAliveTick(time.Now())
entry, exists := app.dbCache[key]
if !exists || entry.inst != dbInst {
t.Fatal("expected policy error not to evict the healthy cached connection")
}
if entry.keepAliveInFlight || entry.lastKeepAliveAt.IsZero() {
t.Fatalf("expected skipped policy to finish and advance its schedule, inFlight=%t lastKeepAliveAt=%s", entry.keepAliveInFlight, entry.lastKeepAliveAt)
}
if dbInst.closed != 0 || len(dbInst.queries) != 0 || dbInst.pings != 0 {
t.Fatalf("expected invalid policy not to touch the database, closed=%d queries=%#v pings=%d", dbInst.closed, dbInst.queries, dbInst.pings)
}
}
func TestRunConnectionKeepAliveTickContext_CancelsCustomQuery(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "readonly"}
key := getCacheKey(config)
queryStarted := make(chan struct{})
dbInst := &keepAliveRecordingDB{
queryContextHook: func(ctx context.Context, _ string) error {
close(queryStarted)
<-ctx.Done()
return ctx.Err()
},
}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "postgres",
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
app.runConnectionKeepAliveTickContext(ctx, time.Now())
}()
<-queryStarted
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("expected custom keepalive query to stop after loop cancellation")
}
entry, exists := app.dbCache[key]
if !exists || entry.inst != dbInst || entry.keepAliveInFlight {
t.Fatalf("expected cancelled keepalive to preserve and release cached connection, exists=%t inFlight=%t", exists, entry.keepAliveInFlight)
}
if dbInst.closed != 0 {
t.Fatalf("expected shutdown cancellation not to evict the connection, closed=%d", dbInst.closed)
}
}
func TestCachedDatabaseKeepAliveTarget_SkipsQueuedStalePolicy(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "mysql", Host: "db.local", Port: 3306, User: "readonly"}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "mysql",
keepAliveRevision: 1,
}
targets := app.collectDueConnectionKeepAliveTargets(time.Now())
if len(targets) != 1 {
t.Fatalf("expected one due target, got %d", len(targets))
}
entry := app.dbCache[key]
entry.keepAliveRevision = nextConnectionKeepAliveRevision(entry.keepAliveRevision)
entry.keepAliveSQL = "SELECT 2"
app.dbCache[key] = entry
if app.isCachedDatabaseKeepAliveTargetCurrent(targets[0]) {
t.Fatal("expected queued target to become stale after policy update")
}
entry = app.dbCache[key]
if entry.keepAliveInFlight || entry.keepAliveSQL != "SELECT 2" || entry.keepAliveRevision != 2 {
t.Fatalf("expected stale target release without changing new policy, inFlight=%t sql=%q revision=%d", entry.keepAliveInFlight, entry.keepAliveSQL, entry.keepAliveRevision)
}
if len(dbInst.queries) != 0 {
t.Fatalf("expected stale queued SQL not to execute, queries=%#v", dbInst.queries)
}
}
func TestRunConnectionKeepAliveTick_DoesNotApplyStalePolicyResult(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "readonly"}
key := getCacheKey(config)
queryStarted := make(chan struct{})
finishQuery := make(chan struct{})
dbInst := &keepAliveRecordingDB{
queryContextHook: func(_ context.Context, _ string) error {
close(queryStarted)
<-finishQuery
return errors.New("old probe failed")
},
}
lastKeepAliveAt := time.Now().Add(-5 * time.Hour)
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastKeepAliveAt: lastKeepAliveAt,
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "postgres",
keepAliveRevision: 1,
}
done := make(chan struct{})
go func() {
defer close(done)
app.runConnectionKeepAliveTick(time.Now())
}()
<-queryStarted
app.mu.Lock()
entry := app.dbCache[key]
entry.keepAliveRevision = nextConnectionKeepAliveRevision(entry.keepAliveRevision)
entry.keepAliveSQL = "SELECT 2"
app.dbCache[key] = entry
app.mu.Unlock()
close(finishQuery)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("expected stale custom keepalive query to finish")
}
entry, exists := app.dbCache[key]
if !exists || entry.inst != dbInst {
t.Fatal("expected stale query failure not to evict the current cached connection")
}
if entry.keepAliveInFlight || entry.keepAliveSQL != "SELECT 2" || entry.keepAliveRevision != 2 {
t.Fatalf("expected current policy to remain unchanged, inFlight=%t sql=%q revision=%d", entry.keepAliveInFlight, entry.keepAliveSQL, entry.keepAliveRevision)
}
if !entry.lastKeepAliveAt.Equal(lastKeepAliveAt) {
t.Fatalf("expected stale result not to advance current policy schedule, before=%s after=%s", lastKeepAliveAt, entry.lastKeepAliveAt)
}
if dbInst.closed != 0 {
t.Fatalf("expected stale query failure not to close the current connection, closed=%d", dbInst.closed)
}
}
func TestRunConnectionKeepAliveTick_RemovesFailedCustomSQLConnection(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "mysql", Host: "db.local", Port: 3306, User: "readonly"}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{queryErr: errors.New("token expired")}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now().Add(-5 * time.Hour),
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "mysql",
}
app.runConnectionKeepAliveTick(time.Now())
if len(dbInst.queries) != 1 || dbInst.pings != 0 {
t.Fatalf("expected one custom query and no Ping, queries=%#v pings=%d", dbInst.queries, dbInst.pings)
}
if dbInst.closed != 1 || len(app.dbCache) != 0 {
t.Fatalf("expected failed custom keepalive to evict and close connection, closed=%d cache=%d", dbInst.closed, len(app.dbCache))
}
}
func TestRunConnectionKeepAliveTick_RemovesFailedCachedConnection(t *testing.T) {
app := NewApp()
config := connection.ConnectionConfig{Type: "postgres", Host: "db.local", Port: 5432, User: "postgres"}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{pingErr: errors.New("token expired")}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now().Add(-5 * time.Hour),
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 4 * time.Hour,
}
app.runConnectionKeepAliveTick(time.Now())
if dbInst.pings != 1 {
t.Fatalf("expected keepalive ping once, got %d", dbInst.pings)
}
if dbInst.closed != 1 {
t.Fatalf("expected failed cached connection to be closed once, got %d", dbInst.closed)
}
if len(app.dbCache) != 0 {
t.Fatalf("expected failed cached connection to be evicted, got %d entries", len(app.dbCache))
}
}
func TestGetDatabaseWithPing_UpdatesCachedKeepAliveSettings(t *testing.T) {
originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc
defer func() {
driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc
}()
driverRuntimeSupportStatusFunc = func(dbType string) (bool, string) {
return true, ""
}
app := NewApp()
config := connection.ConnectionConfig{
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
KeepAliveEnabled: true,
KeepAliveIntervalMinutes: 15,
KeepAliveSQL: " SELECT current_timestamp ",
}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now(),
config: normalizeCacheKeyConfig(config),
}
inst, err := app.getDatabaseWithPing(config, false)
if err != nil {
t.Fatalf("expected cached database lookup to succeed, got %v", err)
}
if inst != dbInst {
t.Fatal("expected cached database instance to be reused")
}
entry := app.dbCache[key]
if !entry.keepAliveEnabled {
t.Fatal("expected cached keepalive to be enabled from config")
}
if entry.keepAliveInterval != 15*time.Minute {
t.Fatalf("expected keepalive interval 15m, got %s", entry.keepAliveInterval)
}
if entry.keepAliveSQL != "SELECT current_timestamp" {
t.Fatalf("expected cached custom SQL to be updated, got %q", entry.keepAliveSQL)
}
if entry.keepAliveDBType != "postgres" {
t.Fatalf("expected cached custom SQL dialect postgres, got %q", entry.keepAliveDBType)
}
}
func TestGetDatabaseWithPing_ConcurrentCacheWinnerReceivesKeepAliveSettings(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc
originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc
verifyDriverAgentRevisionFunc = originalVerifyDriverAgentRevisionFunc
}()
driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" }
verifyDriverAgentRevisionFunc = func(connection.ConnectionConfig) error { return nil }
app := NewApp()
config := connection.ConnectionConfig{
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
KeepAliveEnabled: true,
KeepAliveIntervalMinutes: 15,
KeepAliveSQL: " SELECT current_timestamp ",
}
key := getCacheKey(config)
winner := &keepAliveRecordingDB{}
created := &keepAliveRecordingDB{}
created.connectHook = func() {
app.mu.Lock()
app.dbCache[key] = cachedDatabase{
inst: winner,
lastPing: time.Now(),
config: normalizeCacheKeyConfig(config),
}
app.mu.Unlock()
}
newDatabaseFunc = func(string) (db.Database, error) { return created, nil }
inst, err := app.getDatabaseWithPing(config, false)
if err != nil {
t.Fatalf("expected concurrent cache winner lookup to succeed, got %v", err)
}
if inst != winner {
t.Fatal("expected the concurrent cache winner to be reused")
}
if created.closed != 1 {
t.Fatalf("expected duplicate created connection to be closed once, got %d", created.closed)
}
entry := app.dbCache[key]
if !entry.keepAliveEnabled || entry.keepAliveInterval != 15*time.Minute {
t.Fatalf("expected winner keepalive policy to update, enabled=%t interval=%s", entry.keepAliveEnabled, entry.keepAliveInterval)
}
if entry.keepAliveSQL != "SELECT current_timestamp" || entry.keepAliveDBType != "postgres" {
t.Fatalf("expected winner custom SQL policy to update, sql=%q dbType=%q", entry.keepAliveSQL, entry.keepAliveDBType)
}
if entry.lastKeepAliveAt.IsZero() {
t.Fatal("expected winner keepalive schedule to start from the policy update")
}
}
func TestGetDatabaseWithPing_ForegroundPingDoesNotDelayCustomKeepAliveSQL(t *testing.T) {
originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc
defer func() {
driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc
}()
driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" }
app := NewApp()
config := connection.ConnectionConfig{
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
KeepAliveEnabled: true,
KeepAliveIntervalMinutes: 15,
KeepAliveSQL: "SELECT 1",
}
key := getCacheKey(config)
dbInst := &keepAliveRecordingDB{}
lastKeepAliveAt := time.Now().Add(-20 * time.Minute)
app.dbCache[key] = cachedDatabase{
inst: dbInst,
lastPing: time.Now().Add(-5 * time.Minute),
lastKeepAliveAt: lastKeepAliveAt,
config: normalizeCacheKeyConfig(config),
keepAliveEnabled: true,
keepAliveInterval: 15 * time.Minute,
keepAliveSQL: "SELECT 1",
keepAliveDBType: "postgres",
}
if _, err := app.getDatabaseWithPing(config, false); err != nil {
t.Fatalf("expected foreground cache health Ping to succeed, got %v", err)
}
entry := app.dbCache[key]
if !entry.lastKeepAliveAt.Equal(lastKeepAliveAt) {
t.Fatalf("expected foreground Ping not to move keepalive schedule, before=%s after=%s", lastKeepAliveAt, entry.lastKeepAliveAt)
}
app.runConnectionKeepAliveTick(time.Now())
if dbInst.pings != 1 || len(dbInst.queries) != 1 {
t.Fatalf("expected foreground Ping followed by due custom SQL, pings=%d queries=%#v", dbInst.pings, dbInst.queries)
}
}