From 94ad5ae2f648ec17ae1d85daf0dcdb6c30e71e69 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 19 Jul 2026 12:29:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(connection):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=20SQL=20=E6=8E=A2=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增连接级自定义探活 SQL 配置与多语言表单校验 - 限制单条 SELECT/WITH 并使用可取消的超时查询 - 隔离陈旧探活策略,避免配置错误驱逐健康连接 Fixes #611 --- .../components/ConnectionModal.i18n.test.tsx | 1 + frontend/src/components/ConnectionModal.tsx | 4 + .../ConnectionModalNetworkSecuritySection.tsx | 57 +++ .../connectionModal/ConnectionModalStep2.tsx | 1 + .../connectionModalConfig.keepalive.test.ts | 44 ++ .../connectionModal/connectionModalConfig.ts | 35 ++ frontend/src/store.test.ts | 2 + frontend/src/store.ts | 13 + frontend/src/types.ts | 1 + frontend/src/utils/connectionReadOnly.test.ts | 18 + frontend/src/utils/connectionReadOnly.ts | 163 +++++++ .../src/utils/connectionRpcConfig.test.ts | 6 + frontend/wailsjs/go/models.ts | 2 + internal/app/app.go | 71 ++- internal/app/app_cache_key_test.go | 2 + internal/app/app_keepalive.go | 232 ++++++++- internal/app/app_keepalive_test.go | 442 +++++++++++++++++- internal/connection/types.go | 1 + shared/i18n/de-DE.json | 4 + shared/i18n/en-US.json | 4 + shared/i18n/ja-JP.json | 4 + shared/i18n/messages.ts | 18 +- shared/i18n/ru-RU.json | 4 + shared/i18n/zh-CN.json | 4 + shared/i18n/zh-TW.json | 4 + 25 files changed, 1099 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index 5d90f90e..f9525803 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -433,6 +433,7 @@ describe("ConnectionModal i18n", () => { expect(pageText).toContain("首选"); expect(pageText).toContain("必需"); expect(pageText).toContain("跳过验证"); + expect(pageText).toContain("自定义探活 SQL"); }, ); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 52175322..890c0574 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1496,6 +1496,7 @@ const ConnectionModal: React.FC<{ Number(config.keepAliveIntervalMinutes) > 0 ? Number(config.keepAliveIntervalMinutes) : DEFAULT_KEEPALIVE_INTERVAL_MINUTES, + keepAliveSQL: config.keepAliveSQL || "", mysqlTopology: mysqlIsReplica ? "replica" : "single", mysqlReplicaHosts: mysqlReplicaHosts, rocketmqTopology: rocketmqIsCluster ? "cluster" : "single", @@ -2104,6 +2105,7 @@ const ConnectionModal: React.FC<{ timeout: 30, keepAliveEnabled: false, keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, + keepAliveSQL: "", uri: "", connectionParams: "", includeDatabases: undefined, @@ -2178,6 +2180,7 @@ const ConnectionModal: React.FC<{ httpTunnelPassword: "", keepAliveEnabled: false, keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, + keepAliveSQL: "", mysqlTopology: "single", rocketmqTopology: "single", mqttTopology: "single", @@ -2228,6 +2231,7 @@ const ConnectionModal: React.FC<{ httpTunnelPassword: "", keepAliveEnabled: false, keepAliveIntervalMinutes: DEFAULT_KEEPALIVE_INTERVAL_MINUTES, + keepAliveSQL: "", mysqlTopology: "single", rocketmqTopology: "single", mqttTopology: "single", diff --git a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx index ce292da7..3893f349 100644 --- a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx @@ -4,6 +4,11 @@ import { Button, Checkbox, Form, Input, InputNumber, Space, Typography } from "a import { t } from "../../i18n"; import { getStoredSecretPlaceholder } from "../../utils/connectionModalPresentation"; import { noAutoCapInputProps } from "../../utils/inputAutoCap"; +import { + isSingleReadOnlyConnectionQuery, + MAX_CONNECTION_KEEPALIVE_SQL_LENGTH, + supportsConnectionKeepAliveSQL, +} from "../../utils/connectionReadOnly"; const { Text } = Typography; const DEFAULT_KEEPALIVE_INTERVAL_MINUTES = 240; @@ -57,6 +62,13 @@ const ConnectionModalNetworkSecuritySection: React.FC + {keepAliveSQLSupported ? ( + { + const sql = String(value || "").trim(); + if ( + !sql || + !keepAliveEnabled || + isSingleReadOnlyConnectionQuery( + { + type: dbType, + driver: connectionDriver, + oceanBaseProtocol: oceanBaseProtocol, + }, + sql, + ) + ) { + return Promise.resolve(); + } + return Promise.reject( + new Error(t("connection.modal.network.keepAliveSQL.readOnly")), + ); + }, + }, + ]} + style={{ marginTop: 12, marginBottom: 0 }} + > + + + ) : null} ); diff --git a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx index df157236..49ddf914 100644 --- a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx @@ -2590,6 +2590,7 @@ const ConnectionModalStep2: React.FC = (props) => { timeout: 30, keepAliveEnabled: false, keepAliveIntervalMinutes: 240, + keepAliveSQL: "", uri: "", connectionParams: "", restrictDataEdit: false, diff --git a/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts index edb7dfa7..214422c8 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.keepalive.test.ts @@ -18,6 +18,7 @@ const buildBaseValues = () => ({ timeout: 30, keepAliveEnabled: false, keepAliveIntervalMinutes: 240, + keepAliveSQL: "", savePassword: true, uri: "", connectionParams: "", @@ -71,6 +72,7 @@ describe("connectionModalConfig keepalive", () => { ...buildBaseValues(), keepAliveEnabled: true, keepAliveIntervalMinutes: 15, + keepAliveSQL: " SELECT 1 ", }, forPersist: true, translate, @@ -78,6 +80,7 @@ describe("connectionModalConfig keepalive", () => { expect(config.keepAliveEnabled).toBe(true); expect(config.keepAliveIntervalMinutes).toBe(15); + expect(config.keepAliveSQL).toBe("SELECT 1"); }); it("forces file database keepalive off", async () => { @@ -96,6 +99,47 @@ describe("connectionModalConfig keepalive", () => { expect(config.keepAliveEnabled).toBe(false); expect(config.keepAliveIntervalMinutes).toBe(15); + expect(config.keepAliveSQL).toBe(""); + }); + + it("keeps custom SQL while disabled and rejects unsafe SQL when enabled", async () => { + const disabledConfig = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + keepAliveEnabled: false, + keepAliveSQL: " SELECT 1 ", + }, + forPersist: true, + translate, + }); + + expect(disabledConfig.keepAliveSQL).toBe("SELECT 1"); + await expect(buildConnectionConfig({ + values: { + ...buildBaseValues(), + keepAliveEnabled: true, + keepAliveSQL: "DELETE FROM accounts", + }, + forPersist: true, + translate, + })).rejects.toThrow("connection.modal.network.keepAliveSQL.readOnly"); + }); + + it("drops stale custom SQL for unsupported datasource types without blocking save", async () => { + const config = await buildConnectionConfig({ + values: { + ...buildBaseValues(), + type: "redis", + port: 6379, + keepAliveEnabled: true, + keepAliveSQL: "DELETE FROM accounts", + }, + forPersist: true, + translate, + }); + + expect(config.keepAliveEnabled).toBe(true); + expect(config.keepAliveSQL).toBe(""); }); it("persists readOnly only for datasource types that support production guard", async () => { diff --git a/frontend/src/components/connectionModal/connectionModalConfig.ts b/frontend/src/components/connectionModal/connectionModalConfig.ts index 6e3d6286..08206f1e 100644 --- a/frontend/src/components/connectionModal/connectionModalConfig.ts +++ b/frontend/src/components/connectionModal/connectionModalConfig.ts @@ -1,7 +1,10 @@ import type { ConnectionConfig, SavedConnection } from "../../types"; import { deriveLegacyConnectionReadOnlyFlag, + isSingleReadOnlyConnectionQuery, + MAX_CONNECTION_KEEPALIVE_SQL_LENGTH, normalizeConnectionProtectionConfig, + supportsConnectionKeepAliveSQL, supportsConnectionReadOnlyMode, } from "../../utils/connectionReadOnly"; import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft"; @@ -794,6 +797,37 @@ export const buildConnectionConfig = async ({ MAX_KEEPALIVE_INTERVAL_MINUTES, ) : DEFAULT_KEEPALIVE_INTERVAL_MINUTES; + const keepAliveSQLInput = String(mergedValues.keepAliveSQL || "").trim(); + const keepAliveSQLSupported = supportsConnectionKeepAliveSQL({ + type, + driver: mergedValues.driver, + oceanBaseProtocol: selectedOceanBaseProtocol, + }); + if ( + keepAliveEnabled && + keepAliveSQLSupported && + keepAliveSQLInput.length > MAX_CONNECTION_KEEPALIVE_SQL_LENGTH + ) { + throw new Error(t("connection.modal.network.keepAliveSQL.maxLength")); + } + if ( + keepAliveEnabled && + keepAliveSQLSupported && + keepAliveSQLInput && + !isSingleReadOnlyConnectionQuery( + { + type, + driver: mergedValues.driver, + oceanBaseProtocol: selectedOceanBaseProtocol, + }, + keepAliveSQLInput, + ) + ) { + throw new Error(t("connection.modal.network.keepAliveSQL.readOnly")); + } + const keepAliveSQL = keepAliveSQLSupported + ? keepAliveSQLInput.slice(0, MAX_CONNECTION_KEEPALIVE_SQL_LENGTH) + : ""; const normalizedConnectionParams = supportsConnectionParamsForType(type) ? type === "oceanbase" ? normalizeOceanBaseConnectionParamsText( @@ -845,6 +879,7 @@ export const buildConnectionConfig = async ({ timeout: Number(mergedValues.timeout || 30), keepAliveEnabled: keepAliveEnabled, keepAliveIntervalMinutes: keepAliveIntervalMinutes, + keepAliveSQL: keepAliveSQL, redisDB: Number.isFinite(Number(mergedValues.redisDB)) ? Math.max(0, Math.trunc(Number(mergedValues.redisDB))) : 0, diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index a5c6c931..e57dd26a 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -689,6 +689,7 @@ describe('store appearance persistence', () => { user: 'postgres', keepAliveEnabled: true, keepAliveIntervalMinutes: 0, + keepAliveSQL: ' SELECT 1 ', }, }, ]); @@ -696,6 +697,7 @@ describe('store appearance persistence', () => { const config = useStore.getState().connections[0]?.config; expect(config?.keepAliveEnabled).toBe(true); expect(config?.keepAliveIntervalMinutes).toBe(240); + expect(config?.keepAliveSQL).toBe('SELECT 1'); }); it('keeps StarRocks saved connections as independent datasource type', async () => { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 4b801758..966b3dbd 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -107,8 +107,10 @@ import { } from "./utils/sqlFileTabDrafts"; import { deriveLegacyConnectionReadOnlyFlag, + MAX_CONNECTION_KEEPALIVE_SQL_LENGTH, normalizeConnectionProtectionConfig, resolveConnectionProtectionConfig, + supportsConnectionKeepAliveSQL, } from "./utils/connectionReadOnly"; import { DEFAULT_QUERY_EDITOR_EDITOR_HEIGHT_RATIO, @@ -914,6 +916,17 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => { MIN_KEEPALIVE_INTERVAL_MINUTES, MAX_KEEPALIVE_INTERVAL_MINUTES, ), + keepAliveSQL: supportsConnectionKeepAliveSQL({ + type, + driver: toTrimmedString(raw.driver), + oceanBaseProtocol: + raw.oceanBaseProtocol as ConnectionConfig["oceanBaseProtocol"], + }) + ? toTrimmedString(raw.keepAliveSQL).slice( + 0, + MAX_CONNECTION_KEEPALIVE_SQL_LENGTH, + ) + : "", }; const resolvedProtection = resolveConnectionProtectionConfig(safeConfig); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b2a6649c..ffb5a766 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -308,6 +308,7 @@ export interface ConnectionConfig { timeout?: number; keepAliveEnabled?: boolean; keepAliveIntervalMinutes?: number; + keepAliveSQL?: string; redisDB?: number; // Redis database index uri?: string; // Connection URI for copy/paste clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override diff --git a/frontend/src/utils/connectionReadOnly.test.ts b/frontend/src/utils/connectionReadOnly.test.ts index 3e88d487..2a105cc3 100644 --- a/frontend/src/utils/connectionReadOnly.test.ts +++ b/frontend/src/utils/connectionReadOnly.test.ts @@ -6,10 +6,28 @@ import { isConnectionDataImportRestricted, isConnectionScriptExecutionRestricted, isConnectionStructureEditRestricted, + isSingleReadOnlyConnectionQuery, resolveConnectionProtectionConfig, + supportsConnectionKeepAliveSQL, } from './connectionReadOnly'; describe('connectionReadOnly', () => { + it('accepts only one read-only SQL query for custom keepalive', () => { + const config = { type: 'mysql' } as any; + + expect(supportsConnectionKeepAliveSQL(config)).toBe(true); + expect(isSingleReadOnlyConnectionQuery(config, 'SELECT 1')).toBe(true); + expect(isSingleReadOnlyConnectionQuery(config, 'WITH probe AS (SELECT 1) SELECT * FROM probe')).toBe(true); + expect(isSingleReadOnlyConnectionQuery(config, 'SELECT 1; SELECT 2')).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, 'DELETE FROM accounts')).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, '/*!50000 DELETE FROM accounts */ SELECT 1')).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, 'SELECT /*!50000 SQL_NO_CACHE */ 1')).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, "SELECT ';' AS probe")).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, 'SELECT 1 /* ; */')).toBe(false); + expect(isSingleReadOnlyConnectionQuery(config, 'SELECT 1; -- probe')).toBe(true); + expect(supportsConnectionKeepAliveSQL({ type: 'redis' } as any)).toBe(false); + }); + it('maps legacy readOnly connections to the full production protection set', () => { expect(resolveConnectionProtectionConfig({ type: 'postgres', diff --git a/frontend/src/utils/connectionReadOnly.ts b/frontend/src/utils/connectionReadOnly.ts index 4e44e062..50e869b4 100644 --- a/frontend/src/utils/connectionReadOnly.ts +++ b/frontend/src/utils/connectionReadOnly.ts @@ -65,6 +65,8 @@ const CONNECTION_READ_ONLY_TYPES = new Set([ "mongodb", ]); +export const MAX_CONNECTION_KEEPALIVE_SQL_LENGTH = 4096; + const SQL_READ_ONLY_KEYWORDS = new Set([ "select", "with", @@ -280,6 +282,167 @@ const isConnectionReadOnlyStatement = ( return isReadOnlySqlStatement(statement, dialect); }; +const KEEPALIVE_EXECUTABLE_COMMENT_DIALECTS = new Set([ + "mysql", + "mariadb", + "oceanbase", + "diros", + "starrocks", +]); + +const skipKeepAliveLineComment = (text: string, start: number): number => { + let index = start; + while (index < text.length && text[index] !== "\n" && text[index] !== "\r") { + index += 1; + } + return index; +}; + +const skipKeepAliveQuotedText = ( + text: string, + start: number, + quote: string, +): number => { + for (let index = start + 1; index < text.length; index += 1) { + if (text[index] === "\\" && index + 1 < text.length) { + index += 1; + continue; + } + if (text[index] !== quote) continue; + if (index + 1 < text.length && text[index + 1] === quote) { + index += 1; + continue; + } + return index + 1; + } + return text.length; +}; + +const hasExecutableKeepAliveComment = ( + text: string, + dbType: string, +): boolean => { + if (!KEEPALIVE_EXECUTABLE_COMMENT_DIALECTS.has(dbType)) return false; + for (let index = 0; index < text.length;) { + const remaining = text.slice(index); + if ( + remaining.startsWith("/*!") || + remaining.slice(0, 4).toLowerCase() === "/*m!" + ) { + return true; + } + const current = text[index]; + if (current === "'" || current === '"' || current === "`") { + index = skipKeepAliveQuotedText(text, index, current); + continue; + } + if (current === "#") { + index = skipKeepAliveLineComment(text, index + 1); + continue; + } + if (remaining.startsWith("--") && isDashLineCommentStart(remaining, dbType)) { + index = skipKeepAliveLineComment(text, index + 2); + continue; + } + if (remaining.startsWith("/*")) { + const end = remaining.indexOf("*/", 2); + index = end >= 0 ? index + end + 2 : text.length; + continue; + } + index += 1; + } + return false; +}; + +const isKeepAliveWhitespace = (character: string): boolean => + character === " " || + character === "\t" || + character === "\n" || + character === "\r" || + character === "\f" || + character === "\v"; + +const containsOnlyTrailingKeepAliveTrivia = ( + text: string, + dbType: string, +): boolean => { + for (let index = 0; index < text.length;) { + const remaining = text.slice(index); + if (isKeepAliveWhitespace(text[index])) { + index += 1; + continue; + } + if (remaining.startsWith("--") && isDashLineCommentStart(remaining, dbType)) { + index = skipKeepAliveLineComment(text, index + 2); + continue; + } + if (text[index] === "#" && supportsHashLineComment(dbType)) { + index = skipKeepAliveLineComment(text, index + 1); + continue; + } + if (remaining.startsWith("/*")) { + const end = remaining.indexOf("*/", 2); + if (end < 0) return false; + index += end + 2; + continue; + } + return false; + } + return true; +}; + +const hasSafeKeepAliveStatementDelimiter = ( + text: string, + dbType: string, +): boolean => { + const asciiCount = text.split(";").length - 1; + const fullWidthCount = text.split(";").length - 1; + if (asciiCount + fullWidthCount === 0) return true; + if (asciiCount + fullWidthCount !== 1) return false; + const delimiter = asciiCount === 1 ? ";" : ";"; + const index = text.indexOf(delimiter); + return containsOnlyTrailingKeepAliveTrivia( + text.slice(index + delimiter.length), + dbType, + ); +}; + +export const supportsConnectionKeepAliveSQL = ( + config: ConnectionReadOnlyLike, +): boolean => { + const type = resolveConnectionReadOnlyType(config); + return ( + type !== "mongodb" && + type !== "sqlite" && + type !== "duckdb" && + CONNECTION_READ_ONLY_TYPES.has(type) + ); +}; + +export const isSingleReadOnlyConnectionQuery = ( + config: ConnectionReadOnlyLike, + sql: string, +): boolean => { + if (!supportsConnectionKeepAliveSQL(config)) return false; + const dialect = resolveConnectionReadOnlyType(config); + const text = String(sql || ""); + if ( + hasExecutableKeepAliveComment(text, dialect) || + !hasSafeKeepAliveStatementDelimiter(text, dialect) + ) { + return false; + } + const statements = findSqlStatementRanges(text, dialect) + .map((range) => range.text.trim()) + .filter(Boolean); + if (statements.length !== 1) return false; + const keyword = extractLeadingSqlKeyword(statements[0], dialect); + return ( + (keyword === "select" || keyword === "with") && + isConnectionReadOnlyStatement(config, statements[0]) + ); +}; + export const supportsConnectionReadOnlyMode = ( config: ConnectionReadOnlyLike, ): boolean => { diff --git a/frontend/src/utils/connectionRpcConfig.test.ts b/frontend/src/utils/connectionRpcConfig.test.ts index 4972d1ab..666426fe 100644 --- a/frontend/src/utils/connectionRpcConfig.test.ts +++ b/frontend/src/utils/connectionRpcConfig.test.ts @@ -12,6 +12,9 @@ describe('buildRpcConnectionConfig', () => { host: 'db.local', port: '5432' as unknown as number, user: 'postgres', + keepAliveEnabled: true, + keepAliveIntervalMinutes: 15, + keepAliveSQL: 'SELECT 1', useSSH: true, ssh: { host: 'bastion.local', @@ -38,6 +41,9 @@ describe('buildRpcConnectionConfig', () => { expect(result.timeout).toBe(120); expect(result.redisDB).toBe(6); expect(result.database).toBe('app'); + expect(result.keepAliveEnabled).toBe(true); + expect(result.keepAliveIntervalMinutes).toBe(15); + expect(result.keepAliveSQL).toBe('SELECT 1'); }); it('preserves ClickHouse protocol override for RPC calls', () => { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 717e805d..2842a207 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1121,6 +1121,7 @@ export namespace connection { timeout?: number; keepAliveEnabled?: boolean; keepAliveIntervalMinutes?: number; + keepAliveSQL?: string; redisDB?: number; redisSentinelMaster?: string; redisSentinelUser?: string; @@ -1174,6 +1175,7 @@ export namespace connection { this.timeout = source["timeout"]; this.keepAliveEnabled = source["keepAliveEnabled"]; this.keepAliveIntervalMinutes = source["keepAliveIntervalMinutes"]; + this.keepAliveSQL = source["keepAliveSQL"]; this.redisDB = source["redisDB"]; this.redisSentinelMaster = source["redisSentinelMaster"]; this.redisSentinelUser = source["redisSentinelUser"]; diff --git a/internal/app/app.go b/internal/app/app.go index d31450fa..bfc17213 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -50,12 +50,17 @@ var ( ) type cachedDatabase struct { - inst db.Database - lastPing time.Time - config connection.ConnectionConfig - keepAliveEnabled bool - keepAliveInterval time.Duration - keepAliveInFlight bool + inst db.Database + lastPing time.Time + lastKeepAliveAt time.Time + config connection.ConnectionConfig + keepAliveEnabled bool + keepAliveInterval time.Duration + keepAliveSQL string + keepAliveDBType string + keepAliveRevision uint64 + keepAliveInFlight bool + keepAliveInFlightRevision uint64 } type cachedConnectFailure struct { @@ -374,6 +379,7 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn // keepalive 仅影响后台保活策略,不应参与物理连接复用键。 normalized.KeepAliveEnabled = false normalized.KeepAliveIntervalMinutes = 0 + normalized.KeepAliveSQL = "" normalized.SavePassword = false if !normalized.UseSSH { @@ -918,13 +924,31 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.mu.RUnlock() if ok { keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig) - if entry.keepAliveEnabled != keepAliveEnabled || entry.keepAliveInterval != keepAliveInterval { + keepAliveSQL, keepAliveDBType := resolveConnectionKeepAliveSQL(effectiveConfig) + if entry.keepAliveEnabled != keepAliveEnabled || + entry.keepAliveInterval != keepAliveInterval || + entry.keepAliveSQL != keepAliveSQL || + entry.keepAliveDBType != keepAliveDBType { 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 + policyChanged := cur.keepAliveEnabled != keepAliveEnabled || + cur.keepAliveInterval != keepAliveInterval || + cur.keepAliveSQL != keepAliveSQL || + cur.keepAliveDBType != keepAliveDBType + if policyChanged { + wasKeepAliveEnabled := cur.keepAliveEnabled + cur.keepAliveRevision = nextConnectionKeepAliveRevision(cur.keepAliveRevision) + cur.keepAliveEnabled = keepAliveEnabled + cur.keepAliveInterval = keepAliveInterval + cur.keepAliveSQL = keepAliveSQL + cur.keepAliveDBType = keepAliveDBType + if !keepAliveEnabled { + cur.keepAliveInFlight = false + cur.keepAliveInFlightRevision = 0 + cur.lastKeepAliveAt = time.Time{} + } else if !wasKeepAliveEnabled || cur.lastKeepAliveAt.IsZero() { + cur.lastKeepAliveAt = time.Now() + } } a.dbCache[key] = cur entry = cur @@ -1014,9 +1038,30 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing now := time.Now() keepAliveEnabled, keepAliveInterval := resolveConnectionKeepAliveSettings(effectiveConfig) + keepAliveSQL, keepAliveDBType := resolveConnectionKeepAliveSQL(effectiveConfig) a.mu.Lock() if existing, exists := a.dbCache[key]; exists && existing.inst != nil { + policyChanged := existing.keepAliveEnabled != keepAliveEnabled || + existing.keepAliveInterval != keepAliveInterval || + existing.keepAliveSQL != keepAliveSQL || + existing.keepAliveDBType != keepAliveDBType + if policyChanged { + wasKeepAliveEnabled := existing.keepAliveEnabled + existing.keepAliveRevision = nextConnectionKeepAliveRevision(existing.keepAliveRevision) + existing.keepAliveEnabled = keepAliveEnabled + existing.keepAliveInterval = keepAliveInterval + existing.keepAliveSQL = keepAliveSQL + existing.keepAliveDBType = keepAliveDBType + if !keepAliveEnabled { + existing.keepAliveInFlight = false + existing.keepAliveInFlightRevision = 0 + existing.lastKeepAliveAt = time.Time{} + } else if !wasKeepAliveEnabled || existing.lastKeepAliveAt.IsZero() { + existing.lastKeepAliveAt = now + } + } + a.dbCache[key] = existing a.mu.Unlock() // Prefer existing cached connection to avoid cache racing duplicates. _ = dbInst.Close() @@ -1028,9 +1073,13 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing a.dbCache[key] = cachedDatabase{ inst: dbInst, lastPing: now, + lastKeepAliveAt: now, config: normalizeCacheKeyConfig(effectiveConfig), keepAliveEnabled: keepAliveEnabled, keepAliveInterval: keepAliveInterval, + keepAliveSQL: keepAliveSQL, + keepAliveDBType: keepAliveDBType, + keepAliveRevision: 1, } a.mu.Unlock() diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go index d9c01ade..f70c9065 100644 --- a/internal/app/app_cache_key_test.go +++ b/internal/app/app_cache_key_test.go @@ -53,10 +53,12 @@ func TestGetCacheKey_IgnoreKeepAliveSettings(t *testing.T) { Database: "app", KeepAliveEnabled: false, KeepAliveIntervalMinutes: 240, + KeepAliveSQL: "SELECT 1", } modified := base modified.KeepAliveEnabled = true modified.KeepAliveIntervalMinutes = 15 + modified.KeepAliveSQL = "SELECT current_timestamp" left := getCacheKey(base) right := getCacheKey(modified) diff --git a/internal/app/app_keepalive.go b/internal/app/app_keepalive.go index 14476a50..d8dcd6be 100644 --- a/internal/app/app_keepalive.go +++ b/internal/app/app_keepalive.go @@ -2,11 +2,15 @@ package app import ( "context" + "errors" + "strings" "time" + "unicode/utf8" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/sqlaudit" ) const ( @@ -14,12 +18,75 @@ const ( minConnectionKeepAliveIntervalMinutes = 1 maxConnectionKeepAliveIntervalMinutes = 1440 connectionKeepAliveScanInterval = 30 * time.Second + connectionKeepAliveQueryTimeout = 30 * time.Second + maxConnectionKeepAliveSQLLength = 4096 +) + +var ( + errInvalidConnectionKeepAliveSQL = errors.New("custom keep-alive SQL must be one SELECT or WITH statement without write operations") + errConnectionKeepAliveQueryContextUnsupported = errors.New("database driver does not support cancellable custom keep-alive SQL") ) type cachedDatabaseKeepAliveTarget struct { - key string - inst db.Database - config connection.ConnectionConfig + key string + inst db.Database + config connection.ConnectionConfig + sql string + dbType string + revision uint64 +} + +func nextConnectionKeepAliveRevision(current uint64) uint64 { + next := current + 1 + if next == 0 { + return 1 + } + return next +} + +func supportsConnectionKeepAliveSQL(config connection.ConnectionConfig) bool { + dbType := resolveDDLDBType(config) + if dbType == "mongodb" || isFileDatabaseType(dbType) { + return false + } + _, supported := connectionReadOnlySupportedTypes[dbType] + return supported +} + +func resolveConnectionKeepAliveSQL(config connection.ConnectionConfig) (string, string) { + if !config.KeepAliveEnabled || !supportsConnectionKeepAliveSQL(config) { + return "", "" + } + return strings.TrimSpace(config.KeepAliveSQL), resolveDDLDBType(config) +} + +func executeConnectionKeepAlive(ctx context.Context, target cachedDatabaseKeepAliveTarget) error { + if strings.TrimSpace(target.sql) == "" { + return target.inst.Ping() + } + if utf8.RuneCountInString(target.sql) > maxConnectionKeepAliveSQLLength || + !isSafeExplainQuery(target.dbType, target.sql) { + return errInvalidConnectionKeepAliveSQL + } + + if ctx == nil { + ctx = context.Background() + } + queryCtx, cancel := context.WithTimeout(ctx, connectionKeepAliveQueryTimeout) + defer cancel() + queryer, ok := target.inst.(interface { + QueryContext(context.Context, string) ([]map[string]interface{}, []string, error) + }) + if !ok { + return errConnectionKeepAliveQueryContextUnsupported + } + _, _, err := queryer.QueryContext(queryCtx, target.sql) + return err +} + +func isConnectionKeepAlivePolicyError(err error) bool { + return errors.Is(err, errInvalidConnectionKeepAliveSQL) || + errors.Is(err, errConnectionKeepAliveQueryContextUnsupported) } func resolveConnectionKeepAliveSettings(config connection.ConnectionConfig) (bool, time.Duration) { @@ -61,7 +128,7 @@ func (a *App) startConnectionKeepAliveLoop() { case <-ctx.Done(): return case now := <-ticker.C: - a.runConnectionKeepAliveTick(now) + a.runConnectionKeepAliveTickContext(ctx, now) } } }() @@ -86,22 +153,103 @@ func (a *App) stopConnectionKeepAliveLoop() { } func (a *App) runConnectionKeepAliveTick(now time.Time) { - for _, target := range a.collectDueConnectionKeepAliveTargets(now) { - if target.inst == nil { + a.runConnectionKeepAliveTickContext(context.Background(), now) +} + +func (a *App) runConnectionKeepAliveTickContext(ctx context.Context, now time.Time) { + if ctx == nil { + ctx = context.Background() + } + targets := a.collectDueConnectionKeepAliveTargets(now) + for index, target := range targets { + if ctx.Err() != nil { + a.releaseCachedDatabaseKeepAliveTargets(targets[index:]) + return + } + if target.inst == nil || !a.isCachedDatabaseKeepAliveTargetCurrent(target) { continue } - if err := target.inst.Ping(); err != nil { + if err := executeConnectionKeepAlive(ctx, target); err != nil { + if ctx.Err() != nil { + a.releaseCachedDatabaseKeepAliveTargets(targets[index:]) + return + } + if isConnectionKeepAlivePolicyError(err) { + if a.markCachedDatabaseKeepAliveSkipped(target, time.Now()) { + logger.Warnf( + "连接自定义保活配置无效,已跳过本次探活:%s 缓存Key=%s 原因=%s", + formatConnSummary(target.config), + shortCacheKey(target.key), + sqlaudit.RedactError(normalizeErrorMessage(err)), + ) + } + continue + } if closed, summary := a.evictCachedDatabaseAfterKeepAliveFailure(target); closed { logger.Warnf( "连接保活失败,已清理缓存连接:%s 缓存Key=%s 原因=%s", summary, shortCacheKey(target.key), - normalizeErrorMessage(err), + sqlaudit.RedactError(normalizeErrorMessage(err)), ) } continue } - a.markCachedDatabaseKeepAliveSuccess(target.key, target.inst, time.Now()) + a.markCachedDatabaseKeepAliveSuccess(target, time.Now()) + } +} + +func cachedDatabaseKeepAliveTargetOwnsInFlight(entry cachedDatabase, target cachedDatabaseKeepAliveTarget) bool { + return entry.inst == target.inst && + entry.keepAliveInFlight && + entry.keepAliveInFlightRevision == target.revision +} + +func cachedDatabaseKeepAliveTargetMatches(entry cachedDatabase, target cachedDatabaseKeepAliveTarget) bool { + return cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) && + entry.keepAliveEnabled && + entry.keepAliveRevision == target.revision && + entry.keepAliveSQL == target.sql && + entry.keepAliveDBType == target.dbType +} + +func (a *App) isCachedDatabaseKeepAliveTargetCurrent(target cachedDatabaseKeepAliveTarget) bool { + if a == nil { + return false + } + + a.mu.Lock() + defer a.mu.Unlock() + entry, exists := a.dbCache[target.key] + if !exists { + return false + } + if cachedDatabaseKeepAliveTargetMatches(entry, target) { + return true + } + if cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + a.dbCache[target.key] = entry + } + return false +} + +func (a *App) releaseCachedDatabaseKeepAliveTargets(targets []cachedDatabaseKeepAliveTarget) { + if a == nil || len(targets) == 0 { + return + } + + a.mu.Lock() + defer a.mu.Unlock() + for _, target := range targets { + entry, exists := a.dbCache[target.key] + if !exists || !cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) { + continue + } + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + a.dbCache[target.key] = entry } } @@ -118,23 +266,27 @@ func (a *App) collectDueConnectionKeepAliveTargets(now time.Time) []cachedDataba if entry.inst == nil || !entry.keepAliveEnabled || entry.keepAliveInterval <= 0 || entry.keepAliveInFlight { continue } - if !entry.lastPing.IsZero() && now.Sub(entry.lastPing) < entry.keepAliveInterval { + if !entry.lastKeepAliveAt.IsZero() && now.Sub(entry.lastKeepAliveAt) < entry.keepAliveInterval { continue } entry.keepAliveInFlight = true + entry.keepAliveInFlightRevision = entry.keepAliveRevision a.dbCache[key] = entry targets = append(targets, cachedDatabaseKeepAliveTarget{ - key: key, - inst: entry.inst, - config: entry.config, + key: key, + inst: entry.inst, + config: entry.config, + sql: entry.keepAliveSQL, + dbType: entry.keepAliveDBType, + revision: entry.keepAliveRevision, }) } return targets } -func (a *App) markCachedDatabaseKeepAliveSuccess(key string, inst db.Database, pingedAt time.Time) { +func (a *App) markCachedDatabaseKeepAliveSuccess(target cachedDatabaseKeepAliveTarget, pingedAt time.Time) { if a == nil { return } @@ -142,14 +294,50 @@ func (a *App) markCachedDatabaseKeepAliveSuccess(key string, inst db.Database, p a.mu.Lock() defer a.mu.Unlock() - entry, exists := a.dbCache[key] - if !exists || entry.inst != inst { + entry, exists := a.dbCache[target.key] + if !exists { return } + if cachedDatabaseKeepAliveTargetMatches(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + entry.lastPing = pingedAt + entry.lastKeepAliveAt = pingedAt + a.dbCache[target.key] = entry + return + } + if cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + a.dbCache[target.key] = entry + } +} - entry.keepAliveInFlight = false - entry.lastPing = pingedAt - a.dbCache[key] = entry +func (a *App) markCachedDatabaseKeepAliveSkipped(target cachedDatabaseKeepAliveTarget, attemptedAt time.Time) bool { + if a == nil { + return false + } + + a.mu.Lock() + defer a.mu.Unlock() + + entry, exists := a.dbCache[target.key] + if !exists { + return false + } + if cachedDatabaseKeepAliveTargetMatches(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + entry.lastKeepAliveAt = attemptedAt + a.dbCache[target.key] = entry + return true + } + if cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + a.dbCache[target.key] = entry + } + return false } func (a *App) evictCachedDatabaseAfterKeepAliveFailure(target cachedDatabaseKeepAliveTarget) (bool, string) { @@ -164,10 +352,14 @@ func (a *App) evictCachedDatabaseAfterKeepAliveFailure(target cachedDatabaseKeep a.mu.Lock() entry, exists := a.dbCache[target.key] - if exists && entry.inst == target.inst { + if exists && cachedDatabaseKeepAliveTargetMatches(entry, target) { inst = entry.inst summary = formatConnSummary(entry.config) delete(a.dbCache, target.key) + } else if exists && cachedDatabaseKeepAliveTargetOwnsInFlight(entry, target) { + entry.keepAliveInFlight = false + entry.keepAliveInFlightRevision = 0 + a.dbCache[target.key] = entry } a.mu.Unlock() diff --git a/internal/app/app_keepalive_test.go b/internal/app/app_keepalive_test.go index b030e382..7b094cee 100644 --- a/internal/app/app_keepalive_test.go +++ b/internal/app/app_keepalive_test.go @@ -1,20 +1,33 @@ package app import ( + "context" "errors" "testing" "time" "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" ) type keepAliveRecordingDB struct { - closed int - pings int - pingErr error + 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 { return nil } +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 @@ -24,7 +37,17 @@ func (f *keepAliveRecordingDB) Ping() error { return f.pingErr } func (f *keepAliveRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) { - return nil, nil, nil + 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 } @@ -75,6 +98,305 @@ func TestRunConnectionKeepAliveTick_PingsDueCachedConnection(t *testing.T) { 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) { @@ -121,6 +443,7 @@ func TestGetDatabaseWithPing_UpdatesCachedKeepAliveSettings(t *testing.T) { User: "postgres", KeepAliveEnabled: true, KeepAliveIntervalMinutes: 15, + KeepAliveSQL: " SELECT current_timestamp ", } key := getCacheKey(config) dbInst := &keepAliveRecordingDB{} @@ -146,4 +469,113 @@ func TestGetDatabaseWithPing_UpdatesCachedKeepAliveSettings(t *testing.T) { 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) + } } diff --git a/internal/connection/types.go b/internal/connection/types.go index e91bc82d..42014dd3 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -114,6 +114,7 @@ type ConnectionConfig struct { 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) + KeepAliveSQL string `json:"keepAliveSQL,omitempty"` // Optional single SELECT/WITH probe used instead of the driver ping 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 diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index b9e481e4..f34954eb 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "HTTP Tunnel aktivieren, um Tunnel-Host, Port und Zugangsdaten zu konfigurieren.", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel kann nicht zusammen mit SSH Tunnel oder Proxy verwendet werden.", "connection_modal.network.http_tunnel_panel_description": "Ein HTTP Tunnel-Gateway für diese Verbindung konfigurieren.", + "connection_modal.network.keepAliveSQL.help": "Leer lassen, um den Treiber-Ping zu verwenden. Es ist nur eine SELECT-/WITH-Anweisung zulässig; verwenden Sie eine einfache Abfrage, die nur wenige Daten zurückgibt, und ein Datenbankkonto mit Leseberechtigung. Dieser Wert wird im Klartext mit der Verbindung gespeichert; keine Zugangsdaten eingeben.", + "connection_modal.network.keepAliveSQL.label": "Benutzerdefiniertes Keepalive-SQL", + "connection_modal.network.keepAliveSQL.maxLength": "Benutzerdefiniertes Keepalive-SQL darf höchstens 4096 Zeichen lang sein.", + "connection_modal.network.keepAliveSQL.readOnly": "Benutzerdefiniertes Keepalive-SQL muss aus genau einer SELECT- oder WITH-Anweisung bestehen.", "connection_modal.network.proxy": "Proxy", "connection_modal.network.proxy_disabled_hint": "Proxy aktivieren, um diese Verbindung über SOCKS5 oder HTTP CONNECT zu leiten.", "connection_modal.network.proxy_panel_description": "Proxy-Einstellungen für diese Verbindung konfigurieren.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 5b7b2e75..2cbf5818 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "Enable HTTP Tunnel to configure tunnel host, port, and credentials.", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel cannot be used together with SSH Tunnel or proxy.", "connection_modal.network.http_tunnel_panel_description": "Configure an HTTP Tunnel gateway for this connection.", + "connection_modal.network.keepAliveSQL.help": "Leave blank to use the driver Ping. Only one SELECT/WITH statement is allowed; use a lightweight query that returns little data and a database account with read-only permissions. This value is stored in plain text with the connection; do not include credentials.", + "connection_modal.network.keepAliveSQL.label": "Custom keep-alive SQL", + "connection_modal.network.keepAliveSQL.maxLength": "Custom keep-alive SQL cannot exceed 4096 characters.", + "connection_modal.network.keepAliveSQL.readOnly": "Custom keep-alive SQL must be one SELECT or WITH statement.", "connection_modal.network.proxy": "Proxy", "connection_modal.network.proxy_disabled_hint": "Enable proxy to route this connection through SOCKS5 or HTTP CONNECT.", "connection_modal.network.proxy_panel_description": "Configure per-connection proxy settings.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 4e23b0b8..fa5d0c0f 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "HTTP Tunnel を有効にすると Tunnel ホスト、ポート、認証情報を設定できます。", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel は SSH Tunnel またはプロキシと同時に使用できません。", "connection_modal.network.http_tunnel_panel_description": "この接続の HTTP Tunnel ゲートウェイを設定します。", + "connection_modal.network.keepAliveSQL.help": "空欄の場合はドライバーの Ping を使用します。SELECT/WITH 文を 1 つだけ指定し、返すデータ量が少ない軽量なクエリと読み取り専用のデータベースアカウントを使用してください。この値は接続情報とともに平文で保存されるため、認証情報を入力しないでください。", + "connection_modal.network.keepAliveSQL.label": "カスタムキープアライブ SQL", + "connection_modal.network.keepAliveSQL.maxLength": "カスタムキープアライブ SQL は 4096 文字以内で指定してください。", + "connection_modal.network.keepAliveSQL.readOnly": "カスタムキープアライブ SQL は SELECT または WITH 文 1 つである必要があります。", "connection_modal.network.proxy": "プロキシ", "connection_modal.network.proxy_disabled_hint": "プロキシを有効にすると、この接続は SOCKS5 または HTTP CONNECT 経由で転送されます。", "connection_modal.network.proxy_panel_description": "この接続専用のプロキシ設定を行います。", diff --git a/shared/i18n/messages.ts b/shared/i18n/messages.ts index ac7a8a39..d6cebaa0 100644 --- a/shared/i18n/messages.ts +++ b/shared/i18n/messages.ts @@ -649,9 +649,16 @@ export const messages: Record> = { "仅在跳板机 token 或长连接会话需要定期续期时开启。", "connection.modal.network.keepAliveInterval.label": "探活间隔 (分钟)", "connection.modal.network.keepAliveInterval.help": - "后台会按这个间隔对已建立的缓存连接执行 Ping,默认 240 分钟。", + "后台会按这个间隔对已建立的缓存连接执行 Ping 或自定义探活 SQL,默认 240 分钟。", "connection.modal.network.keepAliveInterval.range": "探活间隔范围: 1-1440 分钟", + "connection.modal.network.keepAliveSQL.label": "自定义探活 SQL", + "connection.modal.network.keepAliveSQL.help": + "留空时使用驱动 Ping;仅允许一条 SELECT/WITH,请使用只返回少量数据的轻量查询和数据库只读账号。配置会随连接明文保存,请勿填写凭证。", + "connection.modal.network.keepAliveSQL.maxLength": + "自定义探活 SQL 不能超过 4096 个字符", + "connection.modal.network.keepAliveSQL.readOnly": + "自定义探活 SQL 仅允许一条 SELECT 或 WITH 语句", "connection.modal.appearance.title": "外观", "connection.modal.appearance.description": "自定义图标与颜色", "connection.modal.appearance.icon": "图标", @@ -1549,9 +1556,16 @@ export const messages: Record> = { "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.", + "GoNavi runs Ping or the custom keep-alive SQL on 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.network.keepAliveSQL.label": "Custom keep-alive SQL", + "connection.modal.network.keepAliveSQL.help": + "Leave blank to use the driver Ping. Only one SELECT/WITH statement is allowed; use a lightweight query that returns little data and a database account with read-only permissions. This value is stored in plain text with the connection; do not include credentials.", + "connection.modal.network.keepAliveSQL.maxLength": + "Custom keep-alive SQL cannot exceed 4096 characters.", + "connection.modal.network.keepAliveSQL.readOnly": + "Custom keep-alive SQL must be one SELECT or WITH statement.", "connection.modal.appearance.title": "Appearance", "connection.modal.appearance.description": "Custom icon and color", "connection.modal.appearance.icon": "Icon", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index ff8d3094..5f740e76 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "Включите HTTP Tunnel, чтобы настроить хост, порт и учетные данные Tunnel.", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel нельзя использовать вместе с SSH Tunnel или прокси.", "connection_modal.network.http_tunnel_panel_description": "Настройте шлюз HTTP Tunnel для этого подключения.", + "connection_modal.network.keepAliveSQL.help": "Оставьте поле пустым, чтобы использовать Ping драйвера. Допускается только один запрос SELECT/WITH; используйте лёгкий запрос с небольшим результатом и учётную запись базы данных только для чтения. Значение сохраняется в открытом виде вместе с подключением; не указывайте учётные данные.", + "connection_modal.network.keepAliveSQL.label": "Пользовательский SQL для проверки соединения", + "connection_modal.network.keepAliveSQL.maxLength": "Пользовательский SQL для проверки соединения не должен превышать 4096 символов.", + "connection_modal.network.keepAliveSQL.readOnly": "Пользовательский SQL должен содержать только один запрос SELECT или WITH.", "connection_modal.network.proxy": "Прокси", "connection_modal.network.proxy_disabled_hint": "Включите прокси, чтобы направить это подключение через SOCKS5 или HTTP CONNECT.", "connection_modal.network.proxy_panel_description": "Настройте параметры прокси для этого подключения.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index a0699db2..b651021d 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "启用 HTTP Tunnel 后可配置 Tunnel 主机、端口和凭据。", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel 不能与 SSH Tunnel 或代理同时使用。", "connection_modal.network.http_tunnel_panel_description": "为此连接配置 HTTP Tunnel 网关。", + "connection_modal.network.keepAliveSQL.help": "留空时使用驱动 Ping;仅允许一条 SELECT/WITH,请使用只返回少量数据的轻量查询和数据库只读账号。配置会随连接明文保存,请勿填写凭证。", + "connection_modal.network.keepAliveSQL.label": "自定义探活 SQL", + "connection_modal.network.keepAliveSQL.maxLength": "自定义探活 SQL 不能超过 4096 个字符", + "connection_modal.network.keepAliveSQL.readOnly": "自定义探活 SQL 仅允许一条 SELECT 或 WITH 语句", "connection_modal.network.proxy": "代理", "connection_modal.network.proxy_disabled_hint": "启用代理后,此连接会通过 SOCKS5 或 HTTP CONNECT 转发。", "connection_modal.network.proxy_panel_description": "配置此连接的专用代理设置。", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index d598cbc1..1686422e 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3527,6 +3527,10 @@ "connection_modal.network.http_tunnel_disabled_hint": "啟用 HTTP Tunnel 後可設定 Tunnel 主機、連接埠和憑據。", "connection_modal.network.http_tunnel_mutex_hint": "HTTP Tunnel 不能與 SSH Tunnel 或代理同時使用。", "connection_modal.network.http_tunnel_panel_description": "為此連線設定 HTTP Tunnel 閘道。", + "connection_modal.network.keepAliveSQL.help": "留空時使用驅動程式 Ping;僅允許一條 SELECT/WITH,請使用只回傳少量資料的輕量查詢和資料庫唯讀帳號。此設定會隨連線以純文字儲存,請勿填入憑證。", + "connection_modal.network.keepAliveSQL.label": "自訂探活 SQL", + "connection_modal.network.keepAliveSQL.maxLength": "自訂探活 SQL 不得超過 4096 個字元。", + "connection_modal.network.keepAliveSQL.readOnly": "自訂探活 SQL 僅允許單一 SELECT 或 WITH 陳述式。", "connection_modal.network.proxy": "代理", "connection_modal.network.proxy_disabled_hint": "啟用代理後,此連線會透過 SOCKS5 或 HTTP CONNECT 轉發。", "connection_modal.network.proxy_panel_description": "設定此連線的專用代理設定。",