mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 09:21:38 +08:00
✨ feat(db-connection): 新增连接后台定时探活保活能力
- 为连接配置新增 keepAlive 开关与探活间隔 - 在后端缓存连接上增加后台定时 Ping 调度与失效剔除 - 补充前端表单、Wails 模型映射与定向测试覆盖 Close #579
This commit is contained in:
@@ -148,6 +148,7 @@ type ChoiceCardOption = {
|
||||
description?: string;
|
||||
};
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240;
|
||||
const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([
|
||||
"mongodb",
|
||||
"elasticsearch",
|
||||
@@ -1427,6 +1428,11 @@ const ConnectionModal: React.FC<{
|
||||
driver: config.driver,
|
||||
dsn: config.dsn,
|
||||
timeout: resolvedJvmTimeout,
|
||||
keepAliveEnabled: !!config.keepAliveEnabled,
|
||||
keepAliveIntervalMinutes:
|
||||
Number(config.keepAliveIntervalMinutes) > 0
|
||||
? Number(config.keepAliveIntervalMinutes)
|
||||
: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
|
||||
mysqlTopology: mysqlIsReplica ? "replica" : "single",
|
||||
mysqlReplicaHosts: mysqlReplicaHosts,
|
||||
rocketmqTopology: rocketmqIsCluster ? "cluster" : "single",
|
||||
@@ -2033,6 +2039,8 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelUser: "",
|
||||
httpTunnelPassword: "",
|
||||
timeout: 30,
|
||||
keepAliveEnabled: false,
|
||||
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
includeDatabases: undefined,
|
||||
@@ -2105,6 +2113,8 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelPort: 8080,
|
||||
httpTunnelUser: "",
|
||||
httpTunnelPassword: "",
|
||||
keepAliveEnabled: false,
|
||||
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
|
||||
mysqlTopology: "single",
|
||||
rocketmqTopology: "single",
|
||||
mqttTopology: "single",
|
||||
@@ -2153,6 +2163,8 @@ const ConnectionModal: React.FC<{
|
||||
httpTunnelPort: 8080,
|
||||
httpTunnelUser: "",
|
||||
httpTunnelPassword: "",
|
||||
keepAliveEnabled: false,
|
||||
keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
|
||||
mysqlTopology: "single",
|
||||
rocketmqTopology: "single",
|
||||
mqttTopology: "single",
|
||||
|
||||
@@ -6,6 +6,9 @@ import { getStoredSecretPlaceholder } from "../../utils/connectionModalPresentat
|
||||
import { noAutoCapInputProps } from "../../utils/inputAutoCap";
|
||||
|
||||
const { Text } = Typography;
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240;
|
||||
const MIN_KEEPALIVE_INTERVAL_MINUTES = 1;
|
||||
const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440;
|
||||
|
||||
type ConnectionModalNetworkSecuritySectionProps = Record<string, any>;
|
||||
|
||||
@@ -53,6 +56,7 @@ const ConnectionModalNetworkSecuritySection: React.FC<ConnectionModalNetworkSecu
|
||||
const effectiveUseProxy =
|
||||
!effectiveUseHttpTunnel &&
|
||||
(useProxy || !!form.getFieldValue("useProxy"));
|
||||
const keepAliveEnabled = !!Form.useWatch("keepAliveEnabled", form);
|
||||
const networkItems: Array<{
|
||||
key: "ssl" | "ssh" | "proxy" | "httpTunnel";
|
||||
title: string;
|
||||
@@ -993,6 +997,40 @@ const ConnectionModalNetworkSecuritySection: React.FC<ConnectionModalNetworkSecu
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="keepAliveEnabled"
|
||||
valuePropName="checked"
|
||||
help={t("connection.modal.network.keepAliveEnabled.help")}
|
||||
style={{ marginTop: 12, marginBottom: 12 }}
|
||||
>
|
||||
<Checkbox>
|
||||
{t("connection.modal.network.keepAliveEnabled.checkbox")}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="keepAliveIntervalMinutes"
|
||||
label={t("connection.modal.network.keepAliveInterval.label")}
|
||||
help={t("connection.modal.network.keepAliveInterval.help")}
|
||||
rules={[
|
||||
{
|
||||
type: "number",
|
||||
min: MIN_KEEPALIVE_INTERVAL_MINUTES,
|
||||
max: MAX_KEEPALIVE_INTERVAL_MINUTES,
|
||||
message: t("connection.modal.network.keepAliveInterval.range"),
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: "100%" }}
|
||||
min={MIN_KEEPALIVE_INTERVAL_MINUTES}
|
||||
max={MAX_KEEPALIVE_INTERVAL_MINUTES}
|
||||
disabled={!keepAliveEnabled}
|
||||
placeholder={t("connection.modal.example", {
|
||||
value: String(DEFAULT_KEEPALIVE_INTERVAL_MINUTES),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2301,6 +2301,8 @@ const renderStep2 = () => {
|
||||
useHttpTunnel: false,
|
||||
httpTunnelPort: 8080,
|
||||
timeout: 30,
|
||||
keepAliveEnabled: false,
|
||||
keepAliveIntervalMinutes: 240,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
oceanBaseProtocol: "mysql",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildConnectionConfig } from "./connectionModalConfig";
|
||||
|
||||
const translate = (key: string) => key;
|
||||
|
||||
const buildBaseValues = () => ({
|
||||
type: "mysql",
|
||||
host: "db.local",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "",
|
||||
useSSL: false,
|
||||
useSSH: false,
|
||||
useProxy: false,
|
||||
useHttpTunnel: false,
|
||||
timeout: 30,
|
||||
keepAliveEnabled: false,
|
||||
keepAliveIntervalMinutes: 240,
|
||||
savePassword: true,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
sslMode: "preferred",
|
||||
sslCAPath: "",
|
||||
sslCertPath: "",
|
||||
sslKeyPath: "",
|
||||
sshHost: "",
|
||||
sshPort: 22,
|
||||
sshUser: "",
|
||||
sshPassword: "",
|
||||
sshKeyPath: "",
|
||||
proxyType: "socks5",
|
||||
proxyHost: "",
|
||||
proxyPort: 1080,
|
||||
proxyUser: "",
|
||||
proxyPassword: "",
|
||||
httpTunnelHost: "",
|
||||
httpTunnelPort: 8080,
|
||||
httpTunnelUser: "",
|
||||
httpTunnelPassword: "",
|
||||
mysqlTopology: "single",
|
||||
rocketmqTopology: "single",
|
||||
mqttTopology: "single",
|
||||
kafkaTopology: "single",
|
||||
redisTopology: "single",
|
||||
mongoTopology: "single",
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: "primary",
|
||||
mongoAuthMechanism: "",
|
||||
mysqlReplicaHosts: [],
|
||||
rocketmqHosts: [],
|
||||
mqttHosts: [],
|
||||
kafkaHosts: [],
|
||||
redisHosts: [],
|
||||
redisSentinelMaster: "",
|
||||
redisSentinelUser: "",
|
||||
redisSentinelPassword: "",
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: "",
|
||||
mysqlReplicaPassword: "",
|
||||
mongoReplicaUser: "",
|
||||
mongoReplicaPassword: "",
|
||||
redisDB: 0,
|
||||
});
|
||||
|
||||
describe("connectionModalConfig keepalive", () => {
|
||||
it("keeps keepalive settings for network connections", async () => {
|
||||
const config = await buildConnectionConfig({
|
||||
values: {
|
||||
...buildBaseValues(),
|
||||
keepAliveEnabled: true,
|
||||
keepAliveIntervalMinutes: 15,
|
||||
},
|
||||
forPersist: true,
|
||||
translate,
|
||||
});
|
||||
|
||||
expect(config.keepAliveEnabled).toBe(true);
|
||||
expect(config.keepAliveIntervalMinutes).toBe(15);
|
||||
});
|
||||
|
||||
it("forces file database keepalive off", async () => {
|
||||
const config = await buildConnectionConfig({
|
||||
values: {
|
||||
...buildBaseValues(),
|
||||
type: "sqlite",
|
||||
host: "D:/tmp/demo.db",
|
||||
port: 0,
|
||||
keepAliveEnabled: true,
|
||||
keepAliveIntervalMinutes: 15,
|
||||
},
|
||||
forPersist: true,
|
||||
translate,
|
||||
});
|
||||
|
||||
expect(config.keepAliveEnabled).toBe(false);
|
||||
expect(config.keepAliveIntervalMinutes).toBe(15);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -417,6 +417,30 @@ describe('store appearance persistence', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes keepalive settings when replacing saved connections', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'postgres-keepalive',
|
||||
name: 'Postgres KeepAlive',
|
||||
config: {
|
||||
id: 'postgres-keepalive',
|
||||
type: 'postgres',
|
||||
host: 'db.local',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
keepAliveEnabled: true,
|
||||
keepAliveIntervalMinutes: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const config = useStore.getState().connections[0]?.config;
|
||||
expect(config?.keepAliveEnabled).toBe(true);
|
||||
expect(config?.keepAliveIntervalMinutes).toBe(240);
|
||||
});
|
||||
|
||||
it('keeps StarRocks saved connections as independent datasource type', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
|
||||
@@ -124,6 +124,9 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240;
|
||||
const MIN_KEEPALIVE_INTERVAL_MINUTES = 1;
|
||||
const MAX_KEEPALIVE_INTERVAL_MINUTES = 1440;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||||
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
|
||||
const PERSIST_VERSION = 12;
|
||||
@@ -827,6 +830,13 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
1,
|
||||
MAX_TIMEOUT_SECONDS,
|
||||
),
|
||||
keepAliveEnabled: Boolean(raw.keepAliveEnabled),
|
||||
keepAliveIntervalMinutes: normalizeIntegerInRange(
|
||||
raw.keepAliveIntervalMinutes,
|
||||
DEFAULT_KEEPALIVE_INTERVAL_MINUTES,
|
||||
MIN_KEEPALIVE_INTERVAL_MINUTES,
|
||||
MAX_KEEPALIVE_INTERVAL_MINUTES,
|
||||
),
|
||||
};
|
||||
|
||||
if (type === "redis") {
|
||||
|
||||
@@ -297,6 +297,8 @@ export interface ConnectionConfig {
|
||||
dsn?: string;
|
||||
connectionParams?: string;
|
||||
timeout?: number;
|
||||
keepAliveEnabled?: boolean;
|
||||
keepAliveIntervalMinutes?: number;
|
||||
redisDB?: number; // Redis database index
|
||||
uri?: string; // Connection URI for copy/paste
|
||||
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
|
||||
|
||||
@@ -927,6 +927,8 @@ export namespace connection {
|
||||
dsn?: string;
|
||||
connectionParams?: string;
|
||||
timeout?: number;
|
||||
keepAliveEnabled?: boolean;
|
||||
keepAliveIntervalMinutes?: number;
|
||||
redisDB?: number;
|
||||
redisSentinelMaster?: string;
|
||||
redisSentinelUser?: string;
|
||||
@@ -976,6 +978,8 @@ export namespace connection {
|
||||
this.dsn = source["dsn"];
|
||||
this.connectionParams = source["connectionParams"];
|
||||
this.timeout = source["timeout"];
|
||||
this.keepAliveEnabled = source["keepAliveEnabled"];
|
||||
this.keepAliveIntervalMinutes = source["keepAliveIntervalMinutes"];
|
||||
this.redisDB = source["redisDB"];
|
||||
this.redisSentinelMaster = source["redisSentinelMaster"];
|
||||
this.redisSentinelUser = source["redisSentinelUser"];
|
||||
|
||||
@@ -42,9 +42,12 @@ var (
|
||||
)
|
||||
|
||||
type cachedDatabase struct {
|
||||
inst db.Database
|
||||
lastPing time.Time
|
||||
config connection.ConnectionConfig
|
||||
inst db.Database
|
||||
lastPing time.Time
|
||||
config connection.ConnectionConfig
|
||||
keepAliveEnabled bool
|
||||
keepAliveInterval time.Duration
|
||||
keepAliveInFlight bool
|
||||
}
|
||||
|
||||
type cachedConnectFailure struct {
|
||||
@@ -88,6 +91,8 @@ type App struct {
|
||||
jvmPreviewTokenMu sync.Mutex
|
||||
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
|
||||
jvmPreviewTokenTTL time.Duration
|
||||
keepAliveCancel context.CancelFunc
|
||||
keepAliveDone chan struct{}
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
@@ -185,6 +190,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
installMacNativeWindowDiagnostics(logger.Path())
|
||||
}
|
||||
applyMacWindowTranslucencyFix()
|
||||
a.startConnectionKeepAliveLoop()
|
||||
logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts)
|
||||
}
|
||||
|
||||
@@ -233,6 +239,7 @@ func (a *App) LogWindowDiagnostic(stage string, payload string) {
|
||||
// Shutdown is called when the app terminates.
|
||||
func (a *App) Shutdown() {
|
||||
logger.Infof("应用开始关闭,准备释放资源")
|
||||
a.stopConnectionKeepAliveLoop()
|
||||
a.rollbackPendingSQLTransactionsOnShutdown()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -275,6 +282,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
}
|
||||
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
|
||||
normalized.Timeout = 0
|
||||
// keepalive 仅影响后台保活策略,不应参与物理连接复用键。
|
||||
normalized.KeepAliveEnabled = false
|
||||
normalized.KeepAliveIntervalMinutes = 0
|
||||
normalized.SavePassword = false
|
||||
|
||||
if !normalized.UseSSH {
|
||||
@@ -779,6 +789,20 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
entry, ok := a.dbCache[key]
|
||||
a.mu.RUnlock()
|
||||
if ok {
|
||||
keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig)
|
||||
if entry.keepAliveEnabled != keepAliveEnabled || entry.keepAliveInterval != keepAliveInterval {
|
||||
a.mu.Lock()
|
||||
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||||
cur.keepAliveEnabled = keepAliveEnabled
|
||||
cur.keepAliveInterval = keepAliveInterval
|
||||
if !keepAliveEnabled {
|
||||
cur.keepAliveInFlight = false
|
||||
}
|
||||
a.dbCache[key] = cur
|
||||
entry = cur
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
if isFileDB {
|
||||
logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey)
|
||||
}
|
||||
@@ -861,6 +885,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.clearConnectFailureByKey(key)
|
||||
|
||||
now := time.Now()
|
||||
keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig)
|
||||
|
||||
a.mu.Lock()
|
||||
if existing, exists := a.dbCache[key]; exists && existing.inst != nil {
|
||||
@@ -872,7 +897,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
}
|
||||
return existing.inst, nil
|
||||
}
|
||||
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now, config: normalizeCacheKeyConfig(effectiveConfig)}
|
||||
a.dbCache[key] = cachedDatabase{
|
||||
inst: dbInst,
|
||||
lastPing: now,
|
||||
config: normalizeCacheKeyConfig(effectiveConfig),
|
||||
keepAliveEnabled: keepAliveEnabled,
|
||||
keepAliveInterval: keepAliveInterval,
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
|
||||
@@ -43,6 +43,28 @@ func TestGetCacheKey_IgnoreConnectionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_IgnoreKeepAliveSettings(t *testing.T) {
|
||||
base := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "secret",
|
||||
Database: "app",
|
||||
KeepAliveEnabled: false,
|
||||
KeepAliveIntervalMinutes: 240,
|
||||
}
|
||||
modified := base
|
||||
modified.KeepAliveEnabled = true
|
||||
modified.KeepAliveIntervalMinutes = 15
|
||||
|
||||
left := getCacheKey(base)
|
||||
right := getCacheKey(modified)
|
||||
if left != right {
|
||||
t.Fatalf("expected same cache key when only keepalive settings differ, got %s vs %s", left, right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) {
|
||||
withHost := connection.ConnectionConfig{
|
||||
Type: "duckdb",
|
||||
|
||||
181
internal/app/app_keepalive.go
Normal file
181
internal/app/app_keepalive.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConnectionKeepAliveIntervalMinutes = 240
|
||||
minConnectionKeepAliveIntervalMinutes = 1
|
||||
maxConnectionKeepAliveIntervalMinutes = 1440
|
||||
connectionKeepAliveScanInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
type cachedDatabaseKeepAliveTarget struct {
|
||||
key string
|
||||
inst db.Database
|
||||
config connection.ConnectionConfig
|
||||
}
|
||||
|
||||
func resolveConnectionKeepAliveSettings(config connection.ConnectionConfig) (bool, time.Duration) {
|
||||
if !config.KeepAliveEnabled || isFileDatabaseType(config.Type) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
minutes := config.KeepAliveIntervalMinutes
|
||||
switch {
|
||||
case minutes <= 0:
|
||||
minutes = defaultConnectionKeepAliveIntervalMinutes
|
||||
case minutes < minConnectionKeepAliveIntervalMinutes:
|
||||
minutes = minConnectionKeepAliveIntervalMinutes
|
||||
case minutes > maxConnectionKeepAliveIntervalMinutes:
|
||||
minutes = maxConnectionKeepAliveIntervalMinutes
|
||||
}
|
||||
|
||||
return true, time.Duration(minutes) * time.Minute
|
||||
}
|
||||
|
||||
func (a *App) startConnectionKeepAliveLoop() {
|
||||
if a == nil || a.keepAliveCancel != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
a.keepAliveCancel = cancel
|
||||
a.keepAliveDone = done
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
ticker := time.NewTicker(connectionKeepAliveScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
a.runConnectionKeepAliveTick(now)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) stopConnectionKeepAliveLoop() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cancel := a.keepAliveCancel
|
||||
done := a.keepAliveDone
|
||||
a.keepAliveCancel = nil
|
||||
a.keepAliveDone = nil
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runConnectionKeepAliveTick(now time.Time) {
|
||||
for _, target := range a.collectDueConnectionKeepAliveTargets(now) {
|
||||
if target.inst == nil {
|
||||
continue
|
||||
}
|
||||
if err := target.inst.Ping(); err != nil {
|
||||
if closed, summary := a.evictCachedDatabaseAfterKeepAliveFailure(target); closed {
|
||||
logger.Warnf(
|
||||
"连接保活失败,已清理缓存连接:%s 缓存Key=%s 原因=%s",
|
||||
summary,
|
||||
shortCacheKey(target.key),
|
||||
normalizeErrorMessage(err),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
a.markCachedDatabaseKeepAliveSuccess(target.key, target.inst, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) collectDueConnectionKeepAliveTargets(now time.Time) []cachedDatabaseKeepAliveTarget {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
targets := make([]cachedDatabaseKeepAliveTarget, 0)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for key, entry := range a.dbCache {
|
||||
if entry.inst == nil || !entry.keepAliveEnabled || entry.keepAliveInterval <= 0 || entry.keepAliveInFlight {
|
||||
continue
|
||||
}
|
||||
if !entry.lastPing.IsZero() && now.Sub(entry.lastPing) < entry.keepAliveInterval {
|
||||
continue
|
||||
}
|
||||
|
||||
entry.keepAliveInFlight = true
|
||||
a.dbCache[key] = entry
|
||||
targets = append(targets, cachedDatabaseKeepAliveTarget{
|
||||
key: key,
|
||||
inst: entry.inst,
|
||||
config: entry.config,
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
func (a *App) markCachedDatabaseKeepAliveSuccess(key string, inst db.Database, pingedAt time.Time) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
entry, exists := a.dbCache[key]
|
||||
if !exists || entry.inst != inst {
|
||||
return
|
||||
}
|
||||
|
||||
entry.keepAliveInFlight = false
|
||||
entry.lastPing = pingedAt
|
||||
a.dbCache[key] = entry
|
||||
}
|
||||
|
||||
func (a *App) evictCachedDatabaseAfterKeepAliveFailure(target cachedDatabaseKeepAliveTarget) (bool, string) {
|
||||
if a == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
var (
|
||||
inst db.Database
|
||||
summary string
|
||||
)
|
||||
|
||||
a.mu.Lock()
|
||||
entry, exists := a.dbCache[target.key]
|
||||
if exists && entry.inst == target.inst {
|
||||
inst = entry.inst
|
||||
summary = formatConnSummary(entry.config)
|
||||
delete(a.dbCache, target.key)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if inst == nil {
|
||||
return false, ""
|
||||
}
|
||||
if closeErr := inst.Close(); closeErr != nil {
|
||||
logger.Error(closeErr, "关闭保活失败的缓存连接时出错:缓存Key=%s", shortCacheKey(target.key))
|
||||
}
|
||||
return true, summary
|
||||
}
|
||||
149
internal/app/app_keepalive_test.go
Normal file
149
internal/app/app_keepalive_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type keepAliveRecordingDB struct {
|
||||
closed int
|
||||
pings int
|
||||
pingErr error
|
||||
}
|
||||
|
||||
func (f *keepAliveRecordingDB) Connect(config connection.ConnectionConfig) error { 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) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -79,48 +79,50 @@ type JVMConfig struct {
|
||||
|
||||
// ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。
|
||||
type ConnectionConfig struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
||||
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
||||
SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path
|
||||
SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng)
|
||||
SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng)
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
|
||||
HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name
|
||||
RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user
|
||||
RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native
|
||||
OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
||||
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
||||
SSLCAPath string `json:"sslCAPath,omitempty"` // TLS root CA / server certificate path
|
||||
SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng)
|
||||
SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng)
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
|
||||
HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
ConnectionParams string `json:"connectionParams,omitempty"` // Extra URI query parameters for built-in drivers
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
KeepAliveEnabled bool `json:"keepAliveEnabled,omitempty"` // Enable background keep-alive ping for long-lived cached connections
|
||||
KeepAliveIntervalMinutes int `json:"keepAliveIntervalMinutes,omitempty"` // Keep-alive ping interval in minutes (default: 240)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
RedisSentinelMaster string `json:"redisSentinelMaster,omitempty"` // Redis Sentinel master name
|
||||
RedisSentinelUser string `json:"redisSentinelUser,omitempty"` // Redis Sentinel auth user
|
||||
RedisSentinelPassword string `json:"redisSentinelPassword,omitempty"` // Redis Sentinel auth password
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native
|
||||
OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"` // OceanBase tenant compatibility protocol: mysql | oracle
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica | cluster | sentinel
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
JVM JVMConfig `json:"jvm,omitempty"` // JVM connector config
|
||||
}
|
||||
|
||||
// ResultSetData 表示一个查询结果集(行 + 列名),用于多结果集场景。
|
||||
|
||||
@@ -644,6 +644,14 @@ export const messages: Record<SupportedLanguage, Record<MessageKey, string>> = {
|
||||
"connection.modal.network.timeout.label": "连接超时 (秒)",
|
||||
"connection.modal.network.timeout.help": "数据库连接超时时间,默认 30 秒",
|
||||
"connection.modal.network.timeout.range": "超时时间范围: 1-300 秒",
|
||||
"connection.modal.network.keepAliveEnabled.checkbox": "启用后台定时探活保活",
|
||||
"connection.modal.network.keepAliveEnabled.help":
|
||||
"仅在跳板机 token 或长连接会话需要定期续期时开启。",
|
||||
"connection.modal.network.keepAliveInterval.label": "探活间隔 (分钟)",
|
||||
"connection.modal.network.keepAliveInterval.help":
|
||||
"后台会按这个间隔对已建立的缓存连接执行 Ping,默认 240 分钟。",
|
||||
"connection.modal.network.keepAliveInterval.range":
|
||||
"探活间隔范围: 1-1440 分钟",
|
||||
"connection.modal.appearance.title": "外观",
|
||||
"connection.modal.appearance.description": "自定义图标与颜色",
|
||||
"connection.modal.appearance.icon": "图标",
|
||||
@@ -1534,6 +1542,16 @@ export const messages: Record<SupportedLanguage, Record<MessageKey, string>> = {
|
||||
"Database connection timeout. Default is 30 seconds.",
|
||||
"connection.modal.network.timeout.range":
|
||||
"Timeout must be between 1 and 300 seconds.",
|
||||
"connection.modal.network.keepAliveEnabled.checkbox":
|
||||
"Enable background keep-alive ping",
|
||||
"connection.modal.network.keepAliveEnabled.help":
|
||||
"Enable this only when a jump-host token or long-lived session needs periodic renewal.",
|
||||
"connection.modal.network.keepAliveInterval.label":
|
||||
"Keep-alive interval (minutes)",
|
||||
"connection.modal.network.keepAliveInterval.help":
|
||||
"GoNavi pings established cached connections at this interval. Default is 240 minutes.",
|
||||
"connection.modal.network.keepAliveInterval.range":
|
||||
"Keep-alive interval must be between 1 and 1440 minutes.",
|
||||
"connection.modal.appearance.title": "Appearance",
|
||||
"connection.modal.appearance.description": "Custom icon and color",
|
||||
"connection.modal.appearance.icon": "Icon",
|
||||
|
||||
Reference in New Issue
Block a user