Skip to content

Commit 16bc7db

Browse files
porsagerMinigugus
andauthored
Multihost support for High Availability setups (porsager#158)
* Allow multiple hosts * Add support for target_session_attrs = 'read-write' * Support multi host in all host/port parameters Co-authored-by: Minigugus <43109623+Minigugus@users.noreply.github.com>
1 parent f90eb2c commit 16bc7db

File tree

7 files changed

+176
-57
lines changed

7 files changed

+176
-57
lines changed

README.md

+25-12
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ You can use either a `postgres://` url connection string or the options to defin
4545

4646
```js
4747
const sql = postgres('postgres://username:password@host:port/database', {
48-
host : '', // Postgres ip address or domain name
49-
port : 5432, // Postgres server port
48+
host : '', // Postgres ip address[s] or domain name[s]
49+
port : 5432, // Postgres server port[s]
5050
path : '', // unix socket path (usually '/tmp')
5151
database : '', // Name of database to connect to
5252
username : '', // Username of database user
@@ -68,10 +68,15 @@ const sql = postgres('postgres://username:password@host:port/database', {
6868
connection : {
6969
application_name : 'postgres.js', // Default application_name
7070
... // Other connection parameters
71-
}
71+
},
72+
target_session_attrs : null // Use 'read-write' with multiple hosts to
73+
// ensure only connecting to primary
7274
})
7375
```
7476

77+
### SSL
78+
More info for the `ssl` option can be found in the [Node.js docs for tls connect options](https://nodejs.org/dist/latest-v10.x/docs/api/tls.html#tls_new_tls_tlssocket_socket_options).
79+
7580
Although it is [vulnerable to MITM attacks](https://security.stackexchange.com/a/229297/174913), a common configuration for the `ssl` option for some cloud providers like Heroku is to set `rejectUnauthorized` to `false` (if `NODE_ENV` is `production`):
7681

7782
```js
@@ -83,23 +88,31 @@ const sql =
8388
: postgres();
8489
```
8590

86-
More info for the `ssl` option can be found in the [Node.js docs for tls connect options](https://nodejs.org/dist/latest-v10.x/docs/api/tls.html#tls_new_tls_tlssocket_socket_options).
91+
### Multi host connections - High Availability (HA)
92+
93+
Connection uri strings with multiple hosts works like in [`psql multiple host uris`](https://www.postgresql.org/docs/13/libpq-connect.html#LIBPQ-MULTIPLE-HOSTS)
94+
95+
Connecting to the specified hosts/ports will be tried in order, and on a successfull connection retries will be reset. This ensures that hosts can come up and down seamless to your application.
96+
97+
If you specify `target_session_attrs: 'read-write'` or `PGTARGETSESSIONATTRS=read-write` Postgres.js will only connect to a writeable host allowing for zero down time failovers.
8798

8899
### Environment Variables for Options
89100

90-
It is also possible to connect to the database without a connection string or options, which will read the options from the environment variables in the table below:
101+
It is also possible to connect to the database without a connection string or any options. Postgres.js will fall back to the common environment variables used by `psql` as in the table below:
91102

92103
```js
93104
const sql = postgres()
94105
```
95106

96-
| Option | Environment Variables |
97-
| ---------- | ------------------------ |
98-
| `host` | `PGHOST` |
99-
| `port` | `PGPORT` |
100-
| `database` | `PGDATABASE` |
101-
| `username` | `PGUSERNAME` or `PGUSER` |
102-
| `password` | `PGPASSWORD` |
107+
| Option | Environment Variables |
108+
| ----------------- | ------------------------ |
109+
| `host` | `PGHOST` |
110+
| `port` | `PGPORT` |
111+
| `database` | `PGDATABASE` |
112+
| `username` | `PGUSERNAME` or `PGUSER` |
113+
| `password` | `PGPASSWORD` |
114+
| `idle_timeout` | `PGIDLE_TIMEOUT` |
115+
' `connect_timeout` | `PGCONNECT_TIMEOUT` |
103116

104117
## Query ```sql` ` -> Promise```
105118

lib/connection.js

+65-16
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ function Connection(options = {}) {
2929
let ended
3030
let open = false
3131
let ready = false
32+
let write = false
33+
let next = false
3234
let statements = {}
3335
let connect_timer
3436

@@ -41,7 +43,8 @@ function Connection(options = {}) {
4143
ready,
4244
data,
4345
error,
44-
close
46+
close,
47+
cleanup
4548
})
4649

4750
const backend = Backend({
@@ -102,7 +105,7 @@ function Connection(options = {}) {
102105
}
103106

104107
function destroy() {
105-
error(errors.connection('CONNECTION_DESTROYED', options))
108+
error(errors.connection('CONNECTION_DESTROYED', options, socket))
106109
socket.destroy()
107110
}
108111

@@ -150,7 +153,7 @@ function Connection(options = {}) {
150153
}
151154

152155
function connectTimedOut() {
153-
error(errors.connection('CONNECT_TIMEOUT', options))
156+
error(errors.connection('CONNECT_TIMEOUT', options, socket))
154157
socket.destroy()
155158
}
156159

@@ -210,6 +213,9 @@ function Connection(options = {}) {
210213
idle()
211214

212215
if (!open) {
216+
if (multi())
217+
return
218+
213219
messages.forEach(socket.write)
214220
messages = []
215221
open = true
@@ -220,6 +226,25 @@ function Connection(options = {}) {
220226
ready && ended && ended()
221227
}
222228

229+
function multi() {
230+
if (next)
231+
return (next = false, true)
232+
233+
if (!write && options.target_session_attrs === 'read-write') {
234+
backend.query = {
235+
origin: '',
236+
result: [],
237+
statement: {},
238+
resolve: ([{ transaction_read_only }]) => transaction_read_only === 'on'
239+
? (next = true, socket.destroy())
240+
: (write = true, socket.success()),
241+
reject: error
242+
}
243+
socket.write(frontend.Query('show transaction_read_only'))
244+
return true
245+
}
246+
}
247+
223248
function data(x) {
224249
buffer = buffer.length === 0
225250
? x
@@ -237,54 +262,74 @@ function Connection(options = {}) {
237262

238263
function close() {
239264
clearTimeout(connect_timer)
240-
error(errors.connection('CONNECTION_CLOSED', options))
241-
statements = {}
265+
error(errors.connection('CONNECTION_CLOSED', options, socket))
242266
messages = []
243-
open = ready = false
244267
onclose && onclose()
245268
}
246269

270+
function cleanup() {
271+
statements = {}
272+
open = ready = write = false
273+
}
274+
247275
/* c8 ignore next */
248276
return connection
249277
}
250278

251279
function postgresSocket(options, {
252280
error,
253281
close,
282+
cleanup,
254283
data
255284
}) {
256285
let socket
257286
let closed = true
287+
let succeeded = false
258288
let next = null
259289
let buffer
290+
let i = 0
291+
292+
function onclose(err) {
293+
oncleanup()
294+
!succeeded && i < options.host.length
295+
? connect()
296+
: err instanceof Error
297+
? error(err)
298+
: close()
299+
i >= options.host.length && (i = 0)
300+
}
260301

261-
function onclose() {
302+
function oncleanup() {
262303
socket.removeListener('data', data)
263-
socket.removeListener('error', error)
304+
socket.removeListener('close', onclose)
305+
socket.removeListener('error', onclose)
264306
socket.removeListener('connect', ready)
265307
socket.removeListener('secureConnect', ready)
266308
closed = true
267-
close()
309+
cleanup()
268310
}
269311

270312
function connect() {
271313
if (!closed)
272314
return
273315

274-
closed = false
316+
closed = succeeded = false
275317

276318
socket = options.path
277319
? net.connect(options.path)
278-
: net.connect(options.port, options.host)
320+
: net.connect(
321+
x.port = options.port[i],
322+
x.host = options.host[i++]
323+
)
279324

280325
if (!options.ssl)
281326
return attach(socket)
282327

283-
socket.once('connect', () => socket.write(Buffer.from('0000000804d2162f', 'hex')))
284-
socket.once('error', error)
328+
socket.once('connect', () => socket.write(frontend.SSLRequest))
329+
socket.once('error', onclose)
285330
socket.once('close', onclose)
286331
socket.once('data', x => {
287-
socket.removeListener('error', error)
332+
socket.removeListener('error', onclose)
288333
socket.removeListener('close', onclose)
289334
x.toString() === 'S'
290335
? attach(tls.connect(Object.assign({ socket }, ssl(options.ssl))))
@@ -303,22 +348,26 @@ function postgresSocket(options, {
303348
function attach(x) {
304349
socket = x
305350
socket.on('data', data)
306-
socket.once('error', error)
351+
socket.once('error', onclose)
307352
socket.once('connect', ready)
308353
socket.once('secureConnect', ready)
309354
socket.once('close', onclose)
310355
}
311356

312357
function ready() {
313358
try {
314-
socket.write(frontend.connect(options))
359+
socket.write(frontend.StartupMessage(options))
315360
} catch (e) {
316361
error(e)
317362
socket.end()
318363
}
319364
}
320365

321366
const x = {
367+
success: () => {
368+
succeeded = true
369+
i >= options.host.length && (i = 0)
370+
},
322371
write: x => {
323372
buffer = buffer ? Buffer.concat([buffer, x]) : Buffer.from(x)
324373
if (buffer.length >= 1024)

lib/errors.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ module.exports.errors = {
1515
notSupported
1616
}
1717

18-
function connection(x, options) {
18+
function connection(x, options, socket) {
1919
const error = Object.assign(
20-
new Error(('write ' + x + ' ' + (options.path || (options.host + ':' + options.port)))),
20+
new Error(('write ' + x + ' ' + (options.path || (socket.host + ':' + socket.port)))),
2121
{
2222
code: x,
2323
errno: x,
24-
address: options.path || options.host
25-
}, options.path ? {} : { port: options.port }
24+
address: options.path || socket.host
25+
}, options.path ? {} : { port: socket.port }
2626
)
2727
Error.captureStackTrace(error, connection)
2828
return error

lib/frontend.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const execute = Buffer.concat([
1212
bytes.S().end()
1313
])
1414

15+
const SSLRequest = bytes.i32(8).i32(80877103).end(8)
16+
1517
const authNames = {
1618
2 : 'KerberosV5',
1719
3 : 'CleartextPassword',
@@ -33,9 +35,9 @@ const auths = {
3335
12: SASLFinal
3436
}
3537

36-
3738
module.exports = {
38-
connect,
39+
StartupMessage,
40+
SSLRequest,
3941
auth,
4042
Bind,
4143
Parse,
@@ -44,7 +46,7 @@ module.exports = {
4446
Execute
4547
}
4648

47-
function connect({ user, database, connection }) {
49+
function StartupMessage({ user, database, connection }) {
4850
return bytes
4951
.inc(4)
5052
.i16(3)

lib/index.js

+24-8
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ function Postgres(a, b) {
175175
query.resolve = resolve
176176
query.reject = reject
177177
ended !== null
178-
? reject(errors.connection('CONNECTION_ENDED', options))
178+
? reject(errors.connection('CONNECTION_ENDED', options, options))
179179
: ready
180180
? send(connection, query, xs, args)
181181
: fetchArrayTypes(connection).then(() => send(connection, query, xs, args)).catch(reject)
@@ -386,8 +386,8 @@ function Postgres(a, b) {
386386
},
387387
options
388388
))
389-
listener = { conn, result: {} };
390-
all.push(conn);
389+
listener = { conn, result: {} }
390+
all.push(conn)
391391
return listener
392392
}
393393

@@ -539,36 +539,52 @@ function Postgres(a, b) {
539539

540540
function parseOptions(a, b) {
541541
const env = process.env // eslint-disable-line
542-
, url = typeof a === 'string' ? Url.parse(a, true) : { query: {}, pathname: '' }
543542
, o = (typeof a === 'string' ? b : a) || {}
543+
, { url, multihost } = parseUrl(a, env)
544544
, auth = (url.auth || '').split(':')
545-
, host = o.hostname || o.host || url.hostname || env.PGHOST || 'localhost'
545+
, host = o.hostname || o.host || multihost || url.hostname || env.PGHOST || 'localhost'
546546
, port = o.port || url.port || env.PGPORT || 5432
547547
, user = o.user || o.username || auth[0] || env.PGUSERNAME || env.PGUSER || osUsername()
548548

549549
return Object.assign({
550-
host,
551-
port,
550+
host : host.split(',').map(x => x.split(':')[0]),
551+
port : host.split(',').map(x => x.split(':')[1] || port),
552552
path : o.path || host.indexOf('/') > -1 && host + '/.s.PGSQL.' + port,
553553
database : o.database || o.db || (url.pathname || '').slice(1) || env.PGDATABASE || user,
554554
user : user,
555555
pass : o.pass || o.password || auth[1] || env.PGPASSWORD || '',
556556
max : o.max || url.query.max || 10,
557557
types : o.types || {},
558-
ssl : o.ssl || url.sslmode || url.ssl || false,
558+
ssl : o.ssl || url.query.sslmode || url.query.ssl || false,
559559
idle_timeout : o.idle_timeout || url.query.idle_timeout || env.PGIDLE_TIMEOUT || warn(o.timeout),
560560
connect_timeout : o.connect_timeout || url.query.connect_timeout || env.PGCONNECT_TIMEOUT || 30,
561561
no_prepare : o.no_prepare,
562562
onnotice : o.onnotice,
563563
onparameter : o.onparameter,
564564
transform : Object.assign({}, o.transform),
565565
connection : Object.assign({ application_name: 'postgres.js' }, o.connection),
566+
target_session_attrs: o.target_session_attrs || url.query.target_session_attrs || env.PGTARGETSESSIONATTRS,
566567
debug : o.debug
567568
},
568569
mergeUserTypes(o.types)
569570
)
570571
}
571572

573+
function parseUrl(url) {
574+
if (typeof url !== 'string')
575+
return { url: { query: {} } }
576+
577+
let host = url
578+
host = host.slice(host.indexOf('://') + 3)
579+
host = host.split(/[?/]/)[0]
580+
host = host.slice(host.indexOf('@') + 1)
581+
582+
return {
583+
url: Url.parse(url.replace(host, host.split(',')[0]), true),
584+
multihost: host.indexOf(',') > -1 && host
585+
}
586+
}
587+
572588
function warn(x) {
573589
typeof x !== 'undefined' && console.log('The timeout option is deprecated, use idle_timeout instead') // eslint-disable-line
574590
return x

0 commit comments

Comments
 (0)