From fedb3e018914c513dc62f727f9903c5bdf9800b1 Mon Sep 17 00:00:00 2001 From: happygame Date: Sat, 24 Jan 2026 14:18:17 +0800 Subject: [PATCH 01/10] feat(gaussdb-connection-string): add multi-host support --- packages/gaussdb-connection-string/index.js | 95 +++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/gaussdb-connection-string/index.js b/packages/gaussdb-connection-string/index.js index 0b6995577..b175dbc89 100644 --- a/packages/gaussdb-connection-string/index.js +++ b/packages/gaussdb-connection-string/index.js @@ -5,6 +5,41 @@ //Copyright (c) 2025 happy-game //MIT License +/** + * Parse multiple hosts from hostname string + * @param {string} hostnameString - Comma-separated host:port string + * @param {number} defaultPort - Default port to use if not specified + * @returns {Array<{host: string, port: number}>} Array of host specs + */ +function parseMultipleHosts(hostnameString, defaultPort) { + const hostTokens = (hostnameString || '') + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0) + + if (hostTokens.length === 0) return [] + + return hostTokens.map((token) => { + const colonIndex = token.indexOf(':') + + // Check if there's a port specification (host:port) + if (colonIndex > 0) { + const host = token.substring(0, colonIndex) + const port = parseInt(token.substring(colonIndex + 1), 10) + return { + host: decodeURIComponent(host), + port: port || defaultPort, + } + } + + // No port specified + return { + host: decodeURIComponent(token), + port: defaultPort, + } + }) +} + //parses a connection string function parse(str, options = {}) { //unix socket @@ -18,6 +53,43 @@ function parse(str, options = {}) { const config = {} let result let dummyHost = false + let multiHostString = null + let extractedPort = null + + // Extract multi-host format BEFORE URL encoding + // Match pattern: gaussdb://[user:pass@]host1[:port1],host2[:port2],.../database + const multiHostMatch = str.match(/^(gaussdb:\/\/(?:[^@]+@)?)([^/?]+)(\/.*)?$/) + if (multiHostMatch && multiHostMatch[2] && multiHostMatch[2].includes(',')) { + multiHostString = multiHostMatch[2] + + // Determine the default port by checking if only the last host has a port + // e.g., "node1,node2,node3:5433" -> default port is 5433 + // e.g., "node1:5432,node2:5433" -> default port is 5432 (standard) + const hostParts = multiHostString.split(',') + let hasMultiplePortSpecifications = false + let lastPartPort = null + + for (let i = 0; i < hostParts.length; i++) { + const part = hostParts[i].trim() + const colonIndex = part.indexOf(':') + const hasPort = colonIndex > 0 + + if (hasPort && i < hostParts.length - 1) { + hasMultiplePortSpecifications = true + break + } + if (hasPort && i === hostParts.length - 1) { + lastPartPort = part.substring(colonIndex + 1) + } + } + + // If only last part has port, use it as default; otherwise use 5432 + extractedPort = !hasMultiplePortSpecifications && lastPartPort ? lastPartPort : '5432' + + // Replace with placeholder host + str = multiHostMatch[1] + '__MULTI_HOST_PLACEHOLDER__' + (multiHostMatch[3] || '') + } + if (/ |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(str)) { // Ensure spaces are encoded as %20 str = encodeURI(str).replace(/%25(\d\d)/g, '%$1') @@ -61,6 +133,28 @@ function parse(str, options = {}) { const pathname = result.pathname.slice(1) || null config.database = pathname ? decodeURI(pathname) : null + // Parse multiple hosts if we extracted multi-host string + if (multiHostString) { + const defaultPort = parseInt(extractedPort, 10) + const hosts = parseMultipleHosts(multiHostString, defaultPort) + if (hosts.length > 0) { + config.hosts = hosts + // Set first host as default for backward compatibility + config.host = hosts[0].host + config.port = hosts[0].port + } else if (config.host === '__MULTI_HOST_PLACEHOLDER__') { + config.host = '' + config.port = defaultPort + } + } + + // Parse loadBalanceHosts parameter + if (config.loadBalanceHosts === 'true' || config.loadBalanceHosts === '1') { + config.loadBalanceHosts = true + } else if (config.loadBalanceHosts === 'false' || config.loadBalanceHosts === '0') { + config.loadBalanceHosts = false + } + if (config.ssl === 'true' || config.ssl === '1') { config.ssl = true } @@ -205,5 +299,6 @@ function parseIntoClientConfig(str) { module.exports = parse parse.parse = parse +parse.parseMultipleHosts = parseMultipleHosts parse.toClientConfig = toClientConfig parse.parseIntoClientConfig = parseIntoClientConfig From dc7be04ff9931fa127fe2d5a7d899700b558fb94 Mon Sep 17 00:00:00 2001 From: happygame Date: Sat, 24 Jan 2026 16:07:11 +0800 Subject: [PATCH 02/10] feat(gaussdb-node): add load balancing and target server type support --- packages/gaussdb-connection-string/index.d.ts | 26 +++++ .../gaussdb-node/lib/connection-parameters.js | 109 ++++++++++++++++++ packages/gaussdb-node/lib/defaults.js | 18 +++ packages/gaussdb-node/lib/host-spec.js | 37 ++++++ 4 files changed, 190 insertions(+) create mode 100644 packages/gaussdb-node/lib/host-spec.js diff --git a/packages/gaussdb-connection-string/index.d.ts b/packages/gaussdb-connection-string/index.d.ts index f9f73ecb0..c98fc1656 100644 --- a/packages/gaussdb-connection-string/index.d.ts +++ b/packages/gaussdb-connection-string/index.d.ts @@ -14,6 +14,11 @@ interface SSLConfig { rejectUnauthorized?: boolean } +export interface HostSpec { + host: string + port: number +} + export interface ConnectionOptions { host: string | null password?: string @@ -28,6 +33,27 @@ export interface ConnectionOptions { options?: string keepalives?: number + // Multi-host configuration + hosts?: HostSpec[] + + // Load balancing mode (corresponds to JDBC autoBalance) + // false: no load balancing (default) + // true/'roundrobin'/'balance': round-robin mode + // 'shuffle': random mode + // 'leastconn': least connection mode (phase 2) + // 'priority[n]': priority round-robin mode (phase 2) + loadBalanceHosts?: boolean | string + + // Target server type for connections (corresponds to JDBC targetServerType) + // 'any': connect to any node (default) + // 'master': connect to master node only + // 'slave': connect to slave node only + // 'preferSlave': prefer slave node, fallback to master if no slave available + targetServerType?: 'any' | 'master' | 'slave' | 'preferSlave' + + // Host status recheck interval in seconds + hostRecheckSeconds?: number + // We allow any other options to be passed through [key: string]: unknown } diff --git a/packages/gaussdb-node/lib/connection-parameters.js b/packages/gaussdb-node/lib/connection-parameters.js index e09d81922..53381499d 100644 --- a/packages/gaussdb-node/lib/connection-parameters.js +++ b/packages/gaussdb-node/lib/connection-parameters.js @@ -6,6 +6,8 @@ const defaults = require('./defaults') const parse = require('gaussdb-connection-string').parse // parses a connection string +const HostSpec = require('./host-spec') + const val = function (key, config, envVar) { if (envVar === undefined) { envVar = process.env['GAUSS' + key.toUpperCase()] @@ -122,6 +124,113 @@ class ConnectionParameters { if (typeof config.keepAliveInitialDelayMillis === 'number') { this.keepalives_idle = Math.floor(config.keepAliveInitialDelayMillis / 1000) } + + // Parse multi-host configuration + this.hosts = this._parseHosts(config) + + // Parse loadBalanceHosts parameter and map to mode + this.loadBalanceMode = this._parseLoadBalanceMode(config) + + // Parse targetServerType parameter + this.targetServerType = val('targetServerType', config) + + // Parse hostRecheckSeconds parameter + const hostRecheckSeconds = val('hostRecheckSeconds', config) + this.hostRecheckSeconds = + typeof hostRecheckSeconds === 'number' ? hostRecheckSeconds : parseInt(hostRecheckSeconds, 10) + } + + /** + * Parse hosts configuration from multiple sources + * Priority: config.hosts > connectionString parsed hosts > config.host/port arrays > config.host/port single + * @param {object} config - Configuration object + * @returns {HostSpec[]} Array of HostSpec instances + * @private + */ + _parseHosts(config) { + const hosts = [] + + // Priority 1: config.hosts (array of {host, port} objects) + if (config.hosts && Array.isArray(config.hosts)) { + for (const hostInfo of config.hosts) { + const host = hostInfo.host || 'localhost' + const port = hostInfo.port || this.port || defaults.port + hosts.push(new HostSpec(host, port)) + } + return hosts + } + + // Priority 2: connectionString parsed hosts (already in config from parse()) + // This is already handled by the connection string parser + + // Priority 3: config.host and config.port as arrays + if (Array.isArray(config.host)) { + const hostArray = config.host + const portArray = Array.isArray(config.port) ? config.port : [] + const defaultPort = this.port || defaults.port + + for (let i = 0; i < hostArray.length; i++) { + const host = hostArray[i] + const port = portArray[i] || defaultPort + hosts.push(new HostSpec(host, port)) + } + return hosts + } + + // Priority 4: Single host/port (backward compatibility) + if (this.host) { + hosts.push(new HostSpec(this.host, this.port)) + } + + return hosts + } + + /** + * Parse and map loadBalanceHosts parameter value + * Maps JDBC autoBalance values to Node.js loadBalanceMode + * @param {object} config - Configuration object + * @returns {string|boolean} Parsed load balance mode + * @private + */ + _parseLoadBalanceMode(config) { + const rawValue = val('loadBalanceHosts', config) + + // Handle boolean values + if (rawValue === true || rawValue === 'true' || rawValue === '1') { + return 'roundrobin' // true maps to roundrobin mode + } + + if (rawValue === false || rawValue === 'false' || rawValue === '0' || rawValue === undefined) { + return false // false means no load balancing + } + + // Handle string values + if (typeof rawValue === 'string') { + const normalizedValue = rawValue.toLowerCase() + + // roundrobin mode + if (normalizedValue === 'roundrobin' || normalizedValue === 'balance') { + return 'roundrobin' + } + + // shuffle mode + if (normalizedValue === 'shuffle') { + return 'shuffle' + } + + // leastconn mode + if (normalizedValue === 'leastconn') { + return 'leastconn' + } + + // priority mode + if (normalizedValue.startsWith('priority')) { + return normalizedValue // Return as-is, e.g., 'priority2' + } + } + + // Default: no load balancing + return false } getLibpqConnectionString(cb) { diff --git a/packages/gaussdb-node/lib/defaults.js b/packages/gaussdb-node/lib/defaults.js index 015909dcd..cc77540a0 100644 --- a/packages/gaussdb-node/lib/defaults.js +++ b/packages/gaussdb-node/lib/defaults.js @@ -70,6 +70,24 @@ module.exports = { keepalives: 1, keepalives_idle: 0, + + // load balancing mode for multi-host connections + // false: no load balancing (default) + // true/'roundrobin'/'balance': round-robin mode + // 'shuffle': random mode + // 'leastconn': least connection mode + // 'priority[n]': priority round-robin mode + loadBalanceHosts: false, + + // target server type for connections + // 'any': connect to any node (default) + // 'master': connect to master node only + // 'slave': connect to slave node only + // 'preferSlave': prefer slave node, fallback to master if no slave available + targetServerType: 'any', + + // host status recheck interval in seconds + hostRecheckSeconds: 10, } const pgTypes = require('pg-types') diff --git a/packages/gaussdb-node/lib/host-spec.js b/packages/gaussdb-node/lib/host-spec.js new file mode 100644 index 000000000..afde22fe8 --- /dev/null +++ b/packages/gaussdb-node/lib/host-spec.js @@ -0,0 +1,37 @@ +'use strict' + +/** + * HostSpec - Represents a database host specification + * Encapsulates host and port information for multi-host connections + */ +class HostSpec { + /** + * Constructor + * @param {string} host - The hostname or IP address + * @param {number} port - The port number + */ + constructor(host, port) { + this.host = host + this.port = port + } + + /** + * Convert to string representation (host:port) + * @returns {string} String representation of the host spec + */ + toString() { + return `${this.host}:${this.port}` + } + + /** + * Check equality with another HostSpec + * @param {HostSpec} other - Another HostSpec instance + * @returns {boolean} True if both host and port are equal + */ + equals(other) { + if (!other) return false + return this.host === other.host && this.port === other.port + } +} + +module.exports = HostSpec From 2a3f7f14d88120bccbd3eb8fb511c89f7a6419ec Mon Sep 17 00:00:00 2001 From: happygame Date: Sat, 24 Jan 2026 16:47:15 +0800 Subject: [PATCH 03/10] feat(gaussdb-node): add host status tracker --- .../gaussdb-node/lib/host-status-tracker.js | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/gaussdb-node/lib/host-status-tracker.js diff --git a/packages/gaussdb-node/lib/host-status-tracker.js b/packages/gaussdb-node/lib/host-status-tracker.js new file mode 100644 index 000000000..9f285800f --- /dev/null +++ b/packages/gaussdb-node/lib/host-status-tracker.js @@ -0,0 +1,110 @@ +'use strict' + +/** + * HostStatusTracker - Global singleton to track host connection states + */ + +/** + * Host status enumeration + */ +const HostStatus = { + CONNECT_OK: 'CONNECT_OK', // Host is reachable + CONNECT_FAIL: 'CONNECT_FAIL', // Host connection failed + MASTER: 'MASTER', // Host is a primary server + SLAVE: 'SLAVE' // Host is a standby server +} + +/** + * HostStatusEntry - Stores host status with timestamp + */ +class HostStatusEntry { + /** + * Constructor + * @param {string} status - Host status (from HostStatus enum) + * @param {number} timestamp - Timestamp when status was recorded (milliseconds) + */ + constructor(status, timestamp) { + this.status = status + this.timestamp = timestamp + } + + /** + * Check if this status entry has expired + * @param {number} currentTime - Current timestamp in milliseconds + * @param {number} recheckMillis - Recheck interval in milliseconds + * @returns {boolean} True if expired + */ + isExpired(currentTime, recheckMillis) { + return (currentTime - this.timestamp) >= recheckMillis + } +} + +/** + * HostStatusTracker - Singleton class for tracking host connection states + */ +class HostStatusTracker { + constructor() { + if (HostStatusTracker.instance) { + return HostStatusTracker.instance + } + + // Map: hostKey (host:port) -> HostStatusEntry + this.statusMap = new Map() + + HostStatusTracker.instance = this + } + + /** + * Generate a unique key for a HostSpec + * @param {Object} hostSpec - HostSpec object {host, port} + * @returns {string} Unique key in format "host:port" + * @private + */ + _getHostKey(hostSpec) { + return `${hostSpec.host}:${hostSpec.port}` + } + + /** + * Update host status + * @param {Object} hostSpec - HostSpec object {host, port} + * @param {string} status - Host status (from HostStatus enum) + */ + updateHostStatus(hostSpec, status) { + if (!hostSpec) { + return + } + + const hostKey = this._getHostKey(hostSpec) + const timestamp = Date.now() + const entry = new HostStatusEntry(status, timestamp) + + this.statusMap.set(hostKey, entry) + } + + /** + * Get host status + * @param {Object} hostSpec - HostSpec object {host, port} + * @returns {HostStatusEntry|null} Status entry or null if not found + */ + getHostStatus(hostSpec) { + if (!hostSpec) { + return null + } + + const hostKey = this._getHostKey(hostSpec) + return this.statusMap.get(hostKey) || null + } + + /** + * Clear all host status (for testing purposes) + */ + clear() { + this.statusMap.clear() + } +} + +const instance = new HostStatusTracker() + +module.exports = instance +module.exports.HostStatus = HostStatus +module.exports.HostStatusTracker = HostStatusTracker From 8341f5a5a37a47a4e2e8cfd12d7417bad683577d Mon Sep 17 00:00:00 2001 From: happygame Date: Sat, 24 Jan 2026 19:39:55 +0800 Subject: [PATCH 04/10] feat(gaussdb-node): add host chooser for load balancing --- packages/gaussdb-node/lib/host-chooser.js | 128 ++++++++++++++++++ .../gaussdb-node/lib/host-status-tracker.js | 104 +++++++++++++- 2 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 packages/gaussdb-node/lib/host-chooser.js diff --git a/packages/gaussdb-node/lib/host-chooser.js b/packages/gaussdb-node/lib/host-chooser.js new file mode 100644 index 000000000..aaaa76b9d --- /dev/null +++ b/packages/gaussdb-node/lib/host-chooser.js @@ -0,0 +1,128 @@ +'use strict' + +const HostStatusTracker = require('./host-status-tracker') + +/** + * HostChooser - Manages host selection strategy for load balancing + */ +class HostChooser { + /** + * Constructor + * @param {Array} hosts - Array of HostSpec objects + * @param {string|boolean} loadBalanceMode - Load balance mode (false/roundrobin/shuffle/leastconn/priority[n]) + * @param {string} targetServerType - Target server type (any/master/slave/preferSlave) + * @param {number} hostRecheckSeconds - Host status recheck interval in seconds + */ + constructor(hosts, loadBalanceMode, targetServerType, hostRecheckSeconds) { + this.hosts = hosts || [] + this.loadBalanceMode = loadBalanceMode + this.targetServerType = targetServerType || 'any' + this.recheckMillis = (hostRecheckSeconds || 10) * 1000 + } + + /** + * Get candidate hosts from HostStatusTracker + * Filters hosts based on status and targetServerType + * @returns {Array} Array of candidate HostSpec objects + * @private + */ + _getCandidateHosts() { + const candidates = HostStatusTracker.getCandidateHosts(this.hosts, this.targetServerType, this.recheckMillis) + + // Fallback to all hosts if no candidates available + if (candidates.length === 0) { + return this.hosts.slice() + } + + return candidates + } + + /** + * Apply roundrobin load balancing + * @param {Array} hosts - Candidate hosts + * @returns {Array} Reordered hosts for round-robin + * @private + */ + _roundRobin(hosts) { + // TODO + throw new Error('HostChooser: roundrobin load balance mode is not implemented') + } + + /** + * Apply shuffle (random) load balancing + * @param {Array} hosts - Candidate hosts + * @returns {Array} Randomly shuffled hosts + * @private + */ + _shuffle(hosts) { + // TODO + throw new Error('HostChooser: shuffle load balance mode is not implemented') + } + + /** + * Apply leastconn (least connections) load balancing + * @param {Array} hosts - Candidate hosts + * @returns {Array} Hosts sorted by connection count (ascending) + * @private + */ + _leastConn(hosts) { + // TODO + throw new Error('HostChooser: leastconn load balance mode is not implemented') + } + + /** + * Apply priority roundrobin load balancing + * @param {Array} hosts - Candidate hosts + * @param {number} n - Number of priority hosts + * @returns {Array} Priority hosts first, then fallback hosts + * @private + */ + _priority(hosts, n) { + // TODO + throw new Error('HostChooser: priority load balance mode is not implemented') + } + + /** + * Get host iterator based on load balancing mode + * Yields hosts one by one for connection attempts + * @returns {Generator} Host iterator + */ + *getHostIterator() { + // Get candidate hosts filtered by status and targetServerType + const candidateHosts = this._getCandidateHosts() + + // If no load balancing, return hosts in original order + if (!this.loadBalanceMode) { + for (const host of candidateHosts) { + yield host + } + return + } + + // Apply load balancing strategy + // TODO: What if the status of hosts changes between calls? + let orderedHosts = candidateHosts + + if (this.loadBalanceMode === 'roundrobin' || this.loadBalanceMode === 'balance') { + orderedHosts = this._roundRobin(candidateHosts) + } else if (this.loadBalanceMode === 'shuffle') { + orderedHosts = this._shuffle(candidateHosts) + } else if (this.loadBalanceMode === 'leastconn') { + orderedHosts = this._leastConn(candidateHosts) + } else if (typeof this.loadBalanceMode === 'string' && this.loadBalanceMode.startsWith('priority')) { + // TODO: Extract priority number from 'priority2', 'priority3', etc. + const match = this.loadBalanceMode.match(/^priority(\d+)$/) + if (match) { + const n = parseInt(match[1], 10) + orderedHosts = this._priority(candidateHosts, n) + } + } + + // Yield hosts in order + for (const host of orderedHosts) { + yield host + } + } +} + +module.exports = HostChooser diff --git a/packages/gaussdb-node/lib/host-status-tracker.js b/packages/gaussdb-node/lib/host-status-tracker.js index 9f285800f..b3e6cbff1 100644 --- a/packages/gaussdb-node/lib/host-status-tracker.js +++ b/packages/gaussdb-node/lib/host-status-tracker.js @@ -8,10 +8,10 @@ * Host status enumeration */ const HostStatus = { - CONNECT_OK: 'CONNECT_OK', // Host is reachable - CONNECT_FAIL: 'CONNECT_FAIL', // Host connection failed - MASTER: 'MASTER', // Host is a primary server - SLAVE: 'SLAVE' // Host is a standby server + CONNECT_OK: 'CONNECT_OK', // Host is reachable + CONNECT_FAIL: 'CONNECT_FAIL', // Host connection failed + MASTER: 'MASTER', // Host is a primary server + SLAVE: 'SLAVE', // Host is a standby server } /** @@ -35,7 +35,7 @@ class HostStatusEntry { * @returns {boolean} True if expired */ isExpired(currentTime, recheckMillis) { - return (currentTime - this.timestamp) >= recheckMillis + return currentTime - this.timestamp >= recheckMillis } } @@ -95,6 +95,100 @@ class HostStatusTracker { return this.statusMap.get(hostKey) || null } + /** + * Check if a host is suitable based on status and targetServerType + * @param {Object} hostSpec - HostSpec object {host, port} + * @param {string} targetServerType - Target server type (any/master/slave/preferSlave) + * @param {number} recheckMillis - Recheck interval in milliseconds + * @returns {boolean} True if host is suitable + * @private + */ + _isHostSuitable(hostSpec, targetServerType, recheckMillis) { + const entry = this.getHostStatus(hostSpec) + + // No status recorded, consider it suitable (will be tested) + if (!entry) { + return true + } + + const currentTime = Date.now() + + // If status has expired, consider it suitable (needs recheck) + if (entry.isExpired(currentTime, recheckMillis)) { + return true + } + + const status = entry.status + + // Connection failed recently, not suitable + if (status === HostStatus.CONNECT_FAIL) { + return false + } + + // For targetServerType='any', any connected host is suitable + if (targetServerType === 'any') { + return true + } + + // For targetServerType='master', only MASTER or CONNECT_OK is suitable + if (targetServerType === 'master') { + return status === HostStatus.MASTER || status === HostStatus.CONNECT_OK + } + + // For targetServerType='slave', only SLAVE is suitable + if (targetServerType === 'slave') { + return status === HostStatus.SLAVE + } + + // For targetServerType='preferSlave', SLAVE is preferred, but MASTER/CONNECT_OK is acceptable + if (targetServerType === 'preferSlave') { + return status === HostStatus.SLAVE || status === HostStatus.MASTER || status === HostStatus.CONNECT_OK + } + + // Default: consider suitable + return true + } + + /** + * Get candidate hosts filtered by targetServerType and status expiration + * @param {Array} hostSpecs - Array of HostSpec objects + * @param {string} targetServerType - Target server type (any/master/slave/preferSlave) + * @param {number} recheckMillis - Recheck interval in milliseconds + * @returns {Array} Array of suitable HostSpec objects + */ + getCandidateHosts(hostSpecs, targetServerType, recheckMillis) { + if (!hostSpecs || hostSpecs.length === 0) { + return [] + } + + // For targetServerType='preferSlave', prioritize SLAVE hosts + if (targetServerType === 'preferSlave') { + const slaveHosts = [] + const otherHosts = [] + + for (const hostSpec of hostSpecs) { + if (!this._isHostSuitable(hostSpec, targetServerType, recheckMillis)) { + continue + } + + const entry = this.getHostStatus(hostSpec) + if (entry && entry.status === HostStatus.SLAVE && !entry.isExpired(Date.now(), recheckMillis)) { + slaveHosts.push(hostSpec) + } else { + otherHosts.push(hostSpec) + } + } + + // Prefer slaves, fallback to others + return slaveHosts.length > 0 ? slaveHosts : otherHosts + } + + // For other targetServerType, filter suitable hosts + return hostSpecs.filter((hostSpec) => { + return this._isHostSuitable(hostSpec, targetServerType, recheckMillis) + }) + } + /** * Clear all host status (for testing purposes) */ From 74a949e7c34bc174c7da49abf6472fcb361dcaeb Mon Sep 17 00:00:00 2001 From: happygame Date: Sat, 24 Jan 2026 20:40:25 +0800 Subject: [PATCH 05/10] feat(gaussdb-node): implement round-robin for load balancing --- packages/gaussdb-node/lib/host-chooser.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/gaussdb-node/lib/host-chooser.js b/packages/gaussdb-node/lib/host-chooser.js index aaaa76b9d..cb4adcd2c 100644 --- a/packages/gaussdb-node/lib/host-chooser.js +++ b/packages/gaussdb-node/lib/host-chooser.js @@ -18,6 +18,8 @@ class HostChooser { this.loadBalanceMode = loadBalanceMode this.targetServerType = targetServerType || 'any' this.recheckMillis = (hostRecheckSeconds || 10) * 1000 + + this.roundRobinIndex = 0 } /** @@ -44,8 +46,14 @@ class HostChooser { * @private */ _roundRobin(hosts) { - // TODO - throw new Error('HostChooser: roundrobin load balance mode is not implemented') + if (hosts.length === 0) { + return [] + } + + const index = this.roundRobinIndex % hosts.length + const rotated = hosts.slice(index).concat(hosts.slice(0, index)) + this.roundRobinIndex++ + return rotated } /** From 72c4c342a1a90f5d1ec477869d4cdbeebd917cdf Mon Sep 17 00:00:00 2001 From: happygame Date: Sun, 25 Jan 2026 20:25:52 +0800 Subject: [PATCH 06/10] feat(gaussdb-node): use driver-level round-robin counter --- packages/gaussdb-node/lib/host-chooser.js | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/gaussdb-node/lib/host-chooser.js b/packages/gaussdb-node/lib/host-chooser.js index cb4adcd2c..233d328e8 100644 --- a/packages/gaussdb-node/lib/host-chooser.js +++ b/packages/gaussdb-node/lib/host-chooser.js @@ -2,6 +2,26 @@ const HostStatusTracker = require('./host-status-tracker') +const roundRobinRegistry = new Map() + +const buildClusterKey = (hosts, loadBalanceMode, targetServerType) => { + const hostList = (hosts || []).map((hostSpec) => `${hostSpec.host}:${hostSpec.port}`) + return JSON.stringify({ + hosts: hostList, + loadBalanceMode: loadBalanceMode || '', + targetServerType: targetServerType || '', + }) +} + +const getRoundRobinCounter = (clusterKey) => { + let counter = roundRobinRegistry.get(clusterKey) + if (!counter) { + counter = { value: 0 } + roundRobinRegistry.set(clusterKey, counter) + } + return counter +} + /** * HostChooser - Manages host selection strategy for load balancing */ @@ -19,7 +39,11 @@ class HostChooser { this.targetServerType = targetServerType || 'any' this.recheckMillis = (hostRecheckSeconds || 10) * 1000 - this.roundRobinIndex = 0 + // Always use driver-level round-robin counter for consistent load balancing + if (this.loadBalanceMode === 'roundrobin' || this.loadBalanceMode === 'balance') { + const clusterKey = buildClusterKey(this.hosts, this.loadBalanceMode, this.targetServerType) + this.roundRobinCounter = getRoundRobinCounter(clusterKey) + } } /** @@ -50,9 +74,12 @@ class HostChooser { return [] } - const index = this.roundRobinIndex % hosts.length + if (!this.roundRobinCounter) { + throw new Error('HostChooser: roundRobinCounter not initialized') + } + + const index = this.roundRobinCounter.value++ % hosts.length const rotated = hosts.slice(index).concat(hosts.slice(0, index)) - this.roundRobinIndex++ return rotated } From d51e3e2b78e0623992732391933414b4bc56773e Mon Sep 17 00:00:00 2001 From: happygame Date: Sun, 25 Jan 2026 20:58:33 +0800 Subject: [PATCH 07/10] feat(gaussdb-node): implement shuffle load balance mode --- packages/gaussdb-node/lib/host-chooser.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/gaussdb-node/lib/host-chooser.js b/packages/gaussdb-node/lib/host-chooser.js index 233d328e8..c2af540f2 100644 --- a/packages/gaussdb-node/lib/host-chooser.js +++ b/packages/gaussdb-node/lib/host-chooser.js @@ -90,8 +90,14 @@ class HostChooser { * @private */ _shuffle(hosts) { - // TODO - throw new Error('HostChooser: shuffle load balance mode is not implemented') + const shuffled = hosts.slice() + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + const tmp = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = tmp + } + return shuffled } /** From 09500efd31d943d800583f74abc230ec6828309d Mon Sep 17 00:00:00 2001 From: happygame Date: Mon, 26 Jan 2026 15:45:18 +0800 Subject: [PATCH 08/10] feat(gaussdb-node): add load balancing support to client - Implement multi-host connection retry with automatic failover - Integrate HostChooser and HostStatusTracker for host selection - Track host connection status and update on success/failure --- packages/gaussdb-node/lib/client.js | 180 +++++++++++++++++++++++++--- 1 file changed, 161 insertions(+), 19 deletions(-) diff --git a/packages/gaussdb-node/lib/client.js b/packages/gaussdb-node/lib/client.js index 64ad14aa9..333d2de16 100644 --- a/packages/gaussdb-node/lib/client.js +++ b/packages/gaussdb-node/lib/client.js @@ -10,6 +10,9 @@ const Query = require('./query') const defaults = require('./defaults') const Connection = require('./connection') const crypto = require('./crypto/utils') +const HostChooser = require('./host-chooser') +const HostStatusTracker = require('./host-status-tracker') +const HostStatus = HostStatusTracker.HostStatus class Client extends EventEmitter { constructor(config) { @@ -44,15 +47,15 @@ class Client extends EventEmitter { this._queryable = true this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered - this.connection = - c.connection || - new Connection({ - stream: c.stream, - ssl: this.connectionParameters.ssl, - keepAlive: c.keepAlive || false, - keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0, - encoding: this.connectionParameters.client_encoding || 'utf8', - }) + this._connectionOptions = { + stream: c.stream, + ssl: this.connectionParameters.ssl, + keepAlive: c.keepAlive || false, + keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0, + encoding: this.connectionParameters.client_encoding || 'utf8', + } + this._clientProvidedConnection = Boolean(c.connection) + this.connection = c.connection || new Connection(this._connectionOptions) this.queryQueue = [] this.binary = c.binary || defaults.binary this.processID = null @@ -68,6 +71,8 @@ class Client extends EventEmitter { } this._connectionTimeoutMillis = c.connectionTimeoutMillis || 0 + this._connectErrorHandler = null + this._activeHostSpec = null } _errorAllQueries(err) { @@ -87,8 +92,6 @@ class Client extends EventEmitter { } _connect(callback) { - const self = this - const con = this.connection this._connectionCallback = callback if (this._connecting || this._connected) { @@ -102,8 +105,13 @@ class Client extends EventEmitter { if (this._connectionTimeoutMillis > 0) { this.connectionTimeoutHandle = setTimeout(() => { - con._ending = true - con.stream.destroy(new Error('timeout expired')) + const activeConnection = this.connection + if (activeConnection) { + activeConnection._ending = true + if (activeConnection.stream) { + activeConnection.stream.destroy(new Error('timeout expired')) + } + } }, this._connectionTimeoutMillis) if (this.connectionTimeoutHandle.unref) { @@ -111,6 +119,25 @@ class Client extends EventEmitter { } } + const params = this.connectionParameters + const hasHosts = Array.isArray(params.hosts) && params.hosts.length > 0 + const loadBalanceEnabled = Boolean(params.loadBalanceMode) + const isDomainSocket = this.host && this.host.indexOf('/') === 0 + + if (hasHosts && loadBalanceEnabled && !isDomainSocket) { + this._connectWithLoadBalance() + } else if (hasHosts && !loadBalanceEnabled && !isDomainSocket) { + this._setActiveHost(params.hosts[0]) + this._startConnection() + } else { + this._startConnection() + } + } + + _startConnection() { + const self = this + const con = this.connection + if (this.host && this.host.indexOf('/') === 0) { // TODO: is GaussDB using domain sockets? con.connect(this.host + '/.s.PGSQL.' + this.port) @@ -133,9 +160,17 @@ class Client extends EventEmitter { this._attachListeners(con) + const activeConnection = con con.once('end', () => { + if (activeConnection !== this.connection) { + return + } const error = this._ending ? new Error('Connection terminated') : new Error('Connection terminated unexpectedly') + if (this._connecting && this._connectErrorHandler) { + return this._connectErrorHandler(error) + } + clearTimeout(this.connectionTimeoutHandle) this._errorAllQueries(error) this._ended = true @@ -162,6 +197,92 @@ class Client extends EventEmitter { }) } + _setActiveHost(hostSpec) { + if (!hostSpec) { + return + } + this.host = hostSpec.host + this.port = hostSpec.port + this._activeHostSpec = hostSpec + + if (this.connectionParameters) { + this.connectionParameters.host = hostSpec.host + this.connectionParameters.port = hostSpec.port + } + } + + _connectWithLoadBalance() { + const params = this.connectionParameters + const chooser = new HostChooser( + params.hosts, + params.loadBalanceMode, + params.targetServerType, + params.hostRecheckSeconds + ) + const orderedHosts = Array.from(chooser.getHostIterator()) + + if (orderedHosts.length === 0) { + return this._finalizeConnectFailure(new Error('No hosts to connect')) + } + + if (this._clientProvidedConnection) { + orderedHosts.splice(1) + } + + let lastError = null + + const tryNextHost = () => { + const hostSpec = orderedHosts.shift() + if (!hostSpec) { + return this._finalizeConnectFailure(lastError || new Error('No hosts to connect')) + } + + this._connectionError = false + this._setActiveHost(hostSpec) + + if (!this._clientProvidedConnection) { + this.connection = new Connection(this._connectionOptions) + } + + this._connectErrorHandler = (err) => { + if (this._connectionError) { + return + } + this._connectionError = true + lastError = err + HostStatusTracker.updateHostStatus(hostSpec, HostStatus.CONNECT_FAIL) + + if (this.connection && this.connection.stream) { + this.connection.stream.destroy() + } + + if (this._clientProvidedConnection) { + return this._finalizeConnectFailure(lastError) + } + + process.nextTick(tryNextHost) + } + + this._startConnection() + } + + tryNextHost() + } + + _finalizeConnectFailure(err) { + this._connectErrorHandler = null + this._connecting = false + this._connectionError = true + clearTimeout(this.connectionTimeoutHandle) + + if (this._connectionCallback) { + const callback = this._connectionCallback + this._connectionCallback = null + return callback(err) + } + this.emit('error', err) + } + connect(callback) { if (callback) { this._connect(callback) @@ -180,6 +301,10 @@ class Client extends EventEmitter { } _attachListeners(con) { + const handleError = (err) => this._handleErrorEvent(err, con) + const handleErrorMessage = (msg) => this._handleErrorMessage(msg, con) + const handleReadyForQuery = (msg) => this._handleReadyForQuery(msg, con) + // password request handling con.on('authenticationCleartextPassword', this._handleAuthCleartextPassword.bind(this)) // password request handling @@ -191,9 +316,9 @@ class Client extends EventEmitter { con.on('authenticationSASLContinue', this._handleAuthSASLContinue.bind(this)) con.on('authenticationSASLFinal', this._handleAuthSASLFinal.bind(this)) con.on('backendKeyData', this._handleBackendKeyData.bind(this)) - con.on('error', this._handleErrorEvent.bind(this)) - con.on('errorMessage', this._handleErrorMessage.bind(this)) - con.on('readyForQuery', this._handleReadyForQuery.bind(this)) + con.on('error', handleError) + con.on('errorMessage', handleErrorMessage) + con.on('readyForQuery', handleReadyForQuery) con.on('notice', this._handleNotice.bind(this)) con.on('rowDescription', this._handleRowDescription.bind(this)) con.on('dataRow', this._handleDataRow.bind(this)) @@ -313,11 +438,19 @@ class Client extends EventEmitter { this.secretKey = msg.secretKey } - _handleReadyForQuery(msg) { + _handleReadyForQuery(msg, connection) { + if (connection && connection !== this.connection) { + return + } if (this._connecting) { this._connecting = false this._connected = true clearTimeout(this.connectionTimeoutHandle) + this._connectErrorHandler = null + + if (this._activeHostSpec) { + HostStatusTracker.updateHostStatus(this._activeHostSpec, HostStatus.CONNECT_OK) + } // process possible callback argument to Client#connect if (this._connectionCallback) { @@ -340,6 +473,9 @@ class Client extends EventEmitter { // if we receieve an error event or error message // during the connection process we handle it here _handleErrorWhileConnecting(err) { + if (this._connectErrorHandler) { + return this._connectErrorHandler(err) + } if (this._connectionError) { // TODO(bmc): this is swallowing errors - we shouldn't do this return @@ -363,7 +499,10 @@ class Client extends EventEmitter { // if we're connected and we receive an error event from the connection // this means the socket is dead - do a hard abort of all queries and emit // the socket error on the client as well - _handleErrorEvent(err) { + _handleErrorEvent(err, connection) { + if (connection && connection !== this.connection) { + return + } if (this._connecting) { return this._handleErrorWhileConnecting(err) } @@ -373,7 +512,10 @@ class Client extends EventEmitter { } // handle error messages from the postgres backend - _handleErrorMessage(msg) { + _handleErrorMessage(msg, connection) { + if (connection && connection !== this.connection) { + return + } if (this._connecting) { return this._handleErrorWhileConnecting(msg) } From e0f4e9d042a76848f225f6c3e7d0d934de8861ba Mon Sep 17 00:00:00 2001 From: happygame Date: Wed, 28 Jan 2026 19:51:49 +0800 Subject: [PATCH 09/10] feat(gaussdb-pool): add multi-host load-balanced pooling --- packages/gaussdb-pool/index.js | 159 ++++++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 10 deletions(-) diff --git a/packages/gaussdb-pool/index.js b/packages/gaussdb-pool/index.js index 5809f4625..a519ce01a 100644 --- a/packages/gaussdb-pool/index.js +++ b/packages/gaussdb-pool/index.js @@ -1,5 +1,7 @@ 'use strict' const EventEmitter = require('events').EventEmitter +const ConnectionParameters = require('../gaussdb-node/lib/connection-parameters') +const HostChooser = require('../gaussdb-node/lib/host-chooser') const NOOP = function () {} @@ -106,6 +108,27 @@ class Pool extends EventEmitter { this._endCallback = undefined this.ending = false this.ended = false + + // Multi-host load balancing support + // Track which host each client is connected to + this._clientHostMap = new WeakMap() + this._hostChooser = null + this._isMultiHost = false + this._loadBalanceMode = false + + // Initialize HostChooser for multi-host load balancing + const params = new ConnectionParameters(this.options) + this._isMultiHost = Array.isArray(params.hosts) && params.hosts.length > 1 + this._loadBalanceMode = params.loadBalanceMode + + if (this._isMultiHost && this._loadBalanceMode) { + this._hostChooser = new HostChooser( + params.hosts, + params.loadBalanceMode, + params.targetServerType, + params.hostRecheckSeconds + ) + } } _isFull() { @@ -145,20 +168,55 @@ class Pool extends EventEmitter { if (!this._idle.length && this._isFull()) { return } + + // Select target host for multi-host load balancing + let targetHostSpec = null + if (this._isMultiHost && this._hostChooser) { + const iterator = this._hostChooser.getHostIterator() + const first = iterator.next() + if (!first.done) { + targetHostSpec = first.value + this.log('selected target host:', targetHostSpec.toString()) + } + } + const pendingItem = this._pendingQueue.shift() + + // Try to find idle client for target host + let idleItem = null if (this._idle.length) { - const idleItem = this._idle.pop() - clearTimeout(idleItem.timeoutId) - const client = idleItem.client + idleItem = this._findIdleClientForHost(targetHostSpec) + if (idleItem) { + clearTimeout(idleItem.timeoutId) + const client = idleItem.client + client.ref && client.ref() + const idleListener = idleItem.idleListener + + return this._acquireClient(client, pendingItem, idleListener, false) + } + } + + // If no suitable idle client but pool not full, create new connection + if (!this._isFull()) { + return this.newClient(pendingItem, targetHostSpec) + } + + // Pool full and no idle client for target host + // Fallback: use any available idle client to avoid request starvation + if (this._idle.length > 0) { + this.log('Pool full, no idle client for target host, using fallback client') + const fallbackItem = this._idle.pop() + clearTimeout(fallbackItem.timeoutId) + const client = fallbackItem.client client.ref && client.ref() - const idleListener = idleItem.idleListener + const idleListener = fallbackItem.idleListener return this._acquireClient(client, pendingItem, idleListener, false) } - if (!this._isFull()) { - return this.newClient(pendingItem) - } - throw new Error('unexpected condition') + + // No idle clients at all, wait for any connection to be released + this._pendingQueue.unshift(pendingItem) + this.log('Pool full, no idle clients available, waiting...') } _remove(client) { @@ -169,6 +227,10 @@ class Pool extends EventEmitter { } this._clients = this._clients.filter((c) => c !== client) + + // Remove from host tracking + this._clientHostMap.delete(client) + client.end() this.emit('remove', client) } @@ -223,11 +285,19 @@ class Pool extends EventEmitter { return result } - newClient(pendingItem) { - const client = new this.Client(this.options) + newClient(pendingItem, targetHostSpec = null) { + // If targetHostSpec provided, create single-host config + const clientConfig = targetHostSpec ? this._configWithHost(targetHostSpec) : this.options + + const client = new this.Client(clientConfig) this._clients.push(client) const idleListener = makeIdleListener(this, client) + // Track which host this client will connect to + if (targetHostSpec) { + this._clientHostMap.set(client, targetHostSpec) + } + this.log('checking client timeout') // connection timeout logic @@ -252,6 +322,10 @@ class Pool extends EventEmitter { this.log('client failed to connect', err) // remove the dead client from our list of clients this._clients = this._clients.filter((c) => c !== client) + + // Remove from host map + this._clientHostMap.delete(client) + if (timeoutHit) { err = new Error('Connection terminated due to connection timeout', { cause: err }) } @@ -265,6 +339,11 @@ class Pool extends EventEmitter { } else { this.log('new client connected') + // Update host map with actual connected host + if (!targetHostSpec && client._activeHostSpec) { + this._clientHostMap.set(client, client._activeHostSpec) + } + if (this.options.maxLifetimeSeconds !== 0) { const maxLifetimeTimeout = setTimeout(() => { this.log('ending client due to expired lifetime') @@ -444,6 +523,66 @@ class Pool extends EventEmitter { return response.result } + /** + * Find an idle client connected to the specified host + * @param {HostSpec|null} targetHostSpec - Target host specification + * @returns {IdleItem|null} Idle item for the target host, or null if not found + * @private + */ + _findIdleClientForHost(targetHostSpec) { + if (!targetHostSpec) { + // No target host specified (single-host or no load balancing) + // Use existing LIFO behavior (pop from end) + return this._idle.length > 0 ? this._idle.pop() : null + } + + // Multi-host load balancing: find idle client for specific host + for (let i = this._idle.length - 1; i >= 0; i--) { + const idleItem = this._idle[i] + const clientHost = this._clientHostMap.get(idleItem.client) + + if (clientHost && this._hostsEqual(clientHost, targetHostSpec)) { + // Found idle client for target host - remove from idle array + return this._idle.splice(i, 1)[0] + } + } + + return null + } + + /** + * Compare two HostSpec objects for equality + * @param {HostSpec} hostSpec1 - First host spec + * @param {HostSpec} hostSpec2 - Second host spec + * @returns {boolean} True if both host and port are equal + * @private + */ + _hostsEqual(hostSpec1, hostSpec2) { + if (!hostSpec1 || !hostSpec2) return false + return hostSpec1.host === hostSpec2.host && hostSpec1.port === hostSpec2.port + } + + /** + * Create a config object for a single-host connection + * @param {HostSpec} targetHostSpec - Target host specification + * @returns {object} Config object with host overridden + * @private + */ + _configWithHost(targetHostSpec) { + // Create shallow copy preserving non-enumerable properties + const config = Object.create(Object.getPrototypeOf(this.options)) + Object.defineProperties(config, Object.getOwnPropertyDescriptors(this.options)) + + // Override host and port for this specific connection + config.host = targetHostSpec.host + config.port = targetHostSpec.port + + config.loadBalanceHosts = false + config.hosts = undefined + + return config + } + end(cb) { this.log('ending') if (this.ending) { From 532a85100fa23e909a369521d067458ca431c0e7 Mon Sep 17 00:00:00 2001 From: happygame Date: Wed, 4 Mar 2026 21:21:52 +0800 Subject: [PATCH 10/10] test(connection-string): add coverage for multi-host --- .../gaussdb-connection-string/test/parse.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/gaussdb-connection-string/test/parse.ts b/packages/gaussdb-connection-string/test/parse.ts index 6bcfd3835..9592f90c7 100644 --- a/packages/gaussdb-connection-string/test/parse.ts +++ b/packages/gaussdb-connection-string/test/parse.ts @@ -447,4 +447,66 @@ describe('parse', function () { const subject = parse(connectionString) subject.port?.should.equal('1234') }) + + it('parses multiple hosts with per-host ports and fallback default', function () { + const subject = parse('gaussdb://node1:5433,node2,node3:5434/mydb') + subject.host?.should.equal('node1') + subject.port?.should.equal(5433) + subject.database?.should.equal('mydb') + subject.hosts?.should.deep.equal([ + { host: 'node1', port: 5433 }, + { host: 'node2', port: 5432 }, + { host: 'node3', port: 5434 }, + ]) + }) + + it('parses multiple hosts and uses the last host port as default', function () { + const subject = parse('gaussdb://node1,node2,node3:5439/mydb') + subject.host?.should.equal('node1') + subject.port?.should.equal(5439) + subject.database?.should.equal('mydb') + subject.hosts?.should.deep.equal([ + { host: 'node1', port: 5439 }, + { host: 'node2', port: 5439 }, + { host: 'node3', port: 5439 }, + ]) + }) + + it('handles empty multi-host list by falling back to default port', function () { + const subject = parse('gaussdb://,,/mydb') + subject.host?.should.equal('') + subject.port?.should.equal(5432) + subject.database?.should.equal('mydb') + }) + + it('converts loadBalanceHosts query parameter to boolean true', function () { + parse('gaussdb://localhost/mydb?loadBalanceHosts=true').loadBalanceHosts?.should.equal(true) + parse('gaussdb://localhost/mydb?loadBalanceHosts=1').loadBalanceHosts?.should.equal(true) + }) + + it('converts loadBalanceHosts query parameter to boolean false', function () { + parse('gaussdb://localhost/mydb?loadBalanceHosts=false').loadBalanceHosts?.should.equal(false) + parse('gaussdb://localhost/mydb?loadBalanceHosts=0').loadBalanceHosts?.should.equal(false) + }) + + it('supports multi-host URL without pathname suffix', function () { + const subject = parse('gaussdb://node1,node2') + subject.host?.should.equal('node1') + subject.port?.should.equal(5432) + ;(subject.database === null).should.equal(true) + }) + + it('keeps query host when parsed multi-host list is empty', function () { + const subject = parse('gaussdb://,,/mydb?host=override-host') + subject.host?.should.equal('override-host') + subject.database?.should.equal('mydb') + }) + + it('parseMultipleHosts returns empty list for empty input', function () { + parse.parseMultipleHosts(undefined, 5432).should.deep.equal([]) + }) + + it('parseMultipleHosts uses default port for invalid host port', function () { + parse.parseMultipleHosts('node1:not-a-port', 5432).should.deep.equal([{ host: 'node1', port: 5432 }]) + }) })