Skip to content

Commit 9e1f181

Browse files
feat: Add multihost support for native js driver
1 parent 1a38e1d commit 9e1f181

10 files changed

Lines changed: 1398 additions & 15 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ dist
1111
.vscode/
1212
manually-test-on-heroku.js
1313
tsconfig.tsbuildinfo
14+
.history

docs/pages/apis/client.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Every field of the `config` object is entirely optional. A `Client` instance wil
1212
type Config = {
1313
user?: string, // default process.env.PGUSER || process.env.USER
1414
password?: string or function, //default process.env.PGPASSWORD
15-
host?: string, // default process.env.PGHOST
16-
port?: number, // default process.env.PGPORT
15+
host?: string | string[], // default process.env.PGHOST; array enables multi-host failover
16+
port?: number | number[], // default process.env.PGPORT; one value or one per host
1717
database?: string, // default process.env.PGDATABASE || user
1818
connectionString?: string, // e.g. postgres://user:password@host:5432/database
1919
ssl?: any, // passed directly to node.TLSSocket, supports all tls.connect options
@@ -29,7 +29,8 @@ type Config = {
2929
idle_in_transaction_session_timeout?: number, // number of milliseconds before terminating any session with an open idle transaction, default is no timeout
3030
client_encoding?: string, // specifies the character set encoding that the database uses for sending data to the client
3131
fallback_application_name?: string, // provide an application name to use if application_name is not set
32-
options?: string // command-line options to be sent to the server
32+
options?: string, // command-line options to be sent to the server
33+
targetSessionAttrs?: 'any' | 'read-write' | 'read-only' | 'primary' | 'standby' | 'prefer-standby', // default 'any'
3334
}
3435
```
3536

docs/pages/features/connecting.mdx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,74 @@ client = new Client({
129129
})
130130
```
131131

132+
## Multiple hosts
133+
134+
node-postgres supports connecting to multiple PostgreSQL hosts. Pass arrays to `host` and `port` to enable automatic failover — the client tries each host in order and uses the first one it can reach.
135+
136+
```js
137+
import { Client } from 'pg'
138+
139+
const client = new Client({
140+
host: ['primary.db.com', 'replica1.db.com', 'replica2.db.com'],
141+
port: 5432, // single port reused for all hosts
142+
database: 'mydb',
143+
user: 'dbuser',
144+
password: 'secretpassword',
145+
})
146+
147+
await client.connect() // tries hosts left to right until one succeeds
148+
```
149+
150+
You can also specify a different port for each host:
151+
152+
```js
153+
const client = new Client({
154+
host: ['host-a.db.com', 'host-b.db.com'],
155+
port: [5432, 5433],
156+
database: 'mydb',
157+
})
158+
```
159+
160+
Host lists may mix TCP hosts and Unix socket directories. Each entry is interpreted independently:
161+
162+
```js
163+
const client = new Client({
164+
host: ['/var/run/postgresql', 'db.example.com'],
165+
port: 5432,
166+
database: 'mydb',
167+
})
168+
```
169+
170+
For an absolute host path, the port is used as the Unix socket filename extension (`.s.PGSQL.5432`). Other host values use TCP.
171+
172+
Port rules (same as libpq):
173+
- **one port** — reused for every host
174+
- **one port per host** — each port is paired with the corresponding host by index
175+
- any other combination throws at construction time
176+
177+
### target_session_attrs
178+
179+
Use `targetSessionAttrs` to control which host is accepted based on its role. This mirrors the [libpq `target_session_attrs`](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS) option.
180+
181+
```js
182+
const client = new Client({
183+
host: ['primary.db.com', 'replica.db.com'],
184+
port: 5432,
185+
targetSessionAttrs: 'read-write', // only connect to a writable primary
186+
})
187+
```
188+
189+
| Value | Accepted server |
190+
|---|---|
191+
| `any` (default) | any server |
192+
| `read-write` | server where `transaction_read_only = off` |
193+
| `read-only` | server where `transaction_read_only = on` |
194+
| `primary` | server that is not in hot standby |
195+
| `standby` | server that is in hot standby |
196+
| `prefer-standby` | standby if available, otherwise any |
197+
198+
When all hosts are exhausted without finding a matching server, the client emits an error.
199+
132200
## Connection URI
133201

134202
You can initialize both a pool and a client with a connection string URI as well. This is common in environments like Heroku where the database connection string is supplied to your application dyno through an environment variable. Connection string parsing brought to you by [pg-connection-string](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string).

packages/pg/lib/client.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const ConnectionParameters = require('./connection-parameters')
88
const Query = require('./query')
99
const defaults = require('./defaults')
1010
const Connection = require('./connection')
11+
const MultiConnection = require('./multi-connection')
1112
const crypto = require('./crypto/utils')
1213

1314
const activeQueryDeprecationNotice = nodeUtils.deprecate(
@@ -86,16 +87,23 @@ class Client extends EventEmitter {
8687

8788
this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
8889
this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS)
90+
const targetSessionAttrs = c.targetSessionAttrs || this.connectionParameters.targetSessionAttrs || null
91+
const connectionConfig = {
92+
stream: c.stream,
93+
ssl: this.connectionParameters.ssl,
94+
sslNegotiation: this.connectionParameters.sslnegotiation,
95+
keepAlive: c.keepAlive || false,
96+
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
97+
encoding: this.connectionParameters.client_encoding || 'utf8',
98+
targetSessionAttrs: targetSessionAttrs,
99+
}
100+
const needsMultiConnection =
101+
Array.isArray(this.host) ||
102+
Array.isArray(this.port) ||
103+
Boolean(targetSessionAttrs && targetSessionAttrs !== 'any')
104+
89105
this.connection =
90-
c.connection ||
91-
new Connection({
92-
stream: c.stream,
93-
ssl: this.connectionParameters.ssl,
94-
sslNegotiation: this.connectionParameters.sslnegotiation,
95-
keepAlive: c.keepAlive || false,
96-
keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0,
97-
encoding: this.connectionParameters.client_encoding || 'utf8',
98-
})
106+
c.connection || (needsMultiConnection ? new MultiConnection(connectionConfig) : new Connection(connectionConfig))
99107
this._queryQueue = []
100108
this.binary = c.binary || defaults.binary
101109
this.processID = null
@@ -170,7 +178,9 @@ class Client extends EventEmitter {
170178
}
171179
}
172180

173-
if (this.host && this.host.indexOf('/') === 0) {
181+
if (con instanceof MultiConnection || Array.isArray(this.host)) {
182+
con.connect(this.port, this.host)
183+
} else if (this.host && this.host.indexOf('/') === 0) {
174184
con.connect(this.host + '/.s.PGSQL.' + this.port)
175185
} else {
176186
con.connect(this.port, this.host)
@@ -566,7 +576,7 @@ class Client extends EventEmitter {
566576
if (client.activeQuery === query) {
567577
const con = this.connection
568578

569-
if (this.host && this.host.indexOf('/') === 0) {
579+
if (!Array.isArray(this.host) && this.host && this.host.indexOf('/') === 0) {
570580
con.connect(this.host + '/.s.PGSQL.' + this.port)
571581
} else {
572582
con.connect(this.port, this.host)

packages/pg/lib/connection-parameters.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,16 @@ class ConnectionParameters {
6767
this.database = this.user
6868
}
6969

70-
this.port = parseInt(val('port', config), 10)
70+
const rawPort = val('port', config)
71+
this.port = Array.isArray(rawPort) ? rawPort.map((p) => parseInt(p, 10)) : parseInt(rawPort, 10)
7172
this.host = val('host', config)
7273

74+
const hosts = Array.isArray(this.host) ? this.host : [this.host]
75+
const ports = Array.isArray(this.port) ? this.port : [this.port]
76+
if (ports.length !== 1 && ports.length !== hosts.length) {
77+
throw new Error(`ports must have either 1 entry or the same number of entries as hosts (${hosts.length})`)
78+
}
79+
7380
// "hiding" the password so it doesn't show up in stack traces
7481
// or if the client is console.logged
7582
Object.defineProperty(this, 'password', {
@@ -123,6 +130,17 @@ class ConnectionParameters {
123130
this.idle_in_transaction_session_timeout = val('idle_in_transaction_session_timeout', config, false)
124131
this.query_timeout = val('query_timeout', config, false)
125132

133+
this.targetSessionAttrs = val('targetSessionAttrs', config)
134+
135+
const validTargetSessionAttrs = ['any', 'read-write', 'read-only', 'primary', 'standby', 'prefer-standby']
136+
if (this.targetSessionAttrs && !validTargetSessionAttrs.includes(this.targetSessionAttrs)) {
137+
throw new Error(
138+
`invalid targetSessionAttrs value: "${this.targetSessionAttrs}". Must be one of: ${validTargetSessionAttrs.join(
139+
', '
140+
)}`
141+
)
142+
}
143+
126144
if (config.connectionTimeoutMillis === undefined) {
127145
this.connect_timeout = process.env.PGCONNECT_TIMEOUT || 0
128146
} else {

0 commit comments

Comments
 (0)