Skip to content

Commit 25949aa

Browse files
author
Fabio Angius
committed
feat: reject_throttle — pace connection opens after establishment failures
Opt-in (default off; off is behaviourally identical to today). When a connection fails to ESTABLISH, the pool otherwise retries every connection immediately and in lockstep — stampeding a server that is already rejecting connections. With reject_throttle enabled the pool keeps a single failed connection as the lone prober (retrying with backoff), blocks the rest, and on each prober handshake success admits an exponentially growing batch of waiters (slow-start, capped at `max`), disengaging once the backlog drains. This complements the existing per-connection `backoff`: backoff only delays each retry, and since its counter is pool-shared every connection waits the same delay and then fires together — a delayed herd. reject_throttle instead keeps exactly one attempt in flight during an outage. It governs only the clean-close establishment-reconnect path (`if (initial) return reconnect()`); error-close (RST) and connect-timeout still reject as before. Tests in tests/reject_throttle.js (21 cases): option plumbing, throttle-vs-herd, single prober, ramp collapse, recovery and disengagement (including large backlogs and max:1), drop-path intact via max_lifetime, and boundaries (RST reject, connect_timeout, off == vanilla).
1 parent e7dfa14 commit 25949aa

4 files changed

Lines changed: 438 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"build:deno": "node transpile.deno.js",
2424
"build:cf": "node transpile.cf.js",
2525
"test": "npm run test:esm && npm run test:cjs && npm run test:deno",
26-
"test:esm": "node tests/index.js",
26+
"test:esm": "node tests/index.js && node tests/reject_throttle.js",
2727
"test:cjs": "npm run build:cjs && cd cjs/tests && node index.js && cd ../../",
2828
"test:deno": "npm run build:deno && cd deno/tests && deno run --no-lock --allow-all --unsafely-ignore-certificate-errors index.js && cd ../../",
2929
"lint": "eslint src && eslint tests",

src/connection.js

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const errorFields = {
4949
82 : 'routine' // R
5050
}
5151

52-
function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose = noop } = {}) {
52+
function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose = noop, requeue = noop } = {}) {
5353
const {
5454
sslnegotiation,
5555
ssl,
@@ -362,6 +362,58 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
362362
setTimeout(connect, closedTime ? Math.max(0, closedTime + delay - performance.now()) : 0)
363363
}
364364

365+
function backOffOnError() {
366+
// Escalate the shared backoff on a real error — the increment closed() does on a
367+
// drop. Callers recompute `delay` from it unconditionally (as the original does),
368+
// so only this escalation is gated: called `hadError && ...` on the drop path, and
369+
// unconditionally from our establishment reconnect (a close there is always an error).
370+
options.shared.retries++
371+
}
372+
373+
function reconnect_with_reject_throttle() {
374+
const t = options.shared.throttle
375+
if (t.prober === null || t.prober === connection)
376+
prober_reconnect()
377+
else
378+
stand_down()
379+
}
380+
381+
function prober_reconnect() {
382+
// Become / stay the lone prober and pace the retry exactly as closed() paces a
383+
// drop; every other establishing connection blocks behind us until we hand off.
384+
// closedTime is set by closed() (as for a drop) — reconnect() only reads it.
385+
options.shared.throttle.prober = connection
386+
backOffOnError()
387+
delay = (typeof backoff === 'function' ? backoff(options.shared.retries) : backoff) * 1000
388+
reconnect()
389+
}
390+
391+
function stand_down() {
392+
// A non-prober open failed (a slow-start admit): collapse the ramp and give up
393+
// this slot — hand the query back to the pool instead of retrying (which would
394+
// re-form the herd). The prober keeps probing, now paced by the higher retries.
395+
const t = options.shared.throttle
396+
t.ramp = 1
397+
t.allowance = 0
398+
requeue(initial)
399+
initial = null
400+
onclose(connection, Errors.connection('CONNECTION_CLOSED', options, socket))
401+
}
402+
403+
function throttle_on_success() {
404+
// Only the prober's success clocks a recovery round: refill the admit allowance
405+
// with the current batch and double the ramp (capped at the pool max). We do NOT
406+
// null the prober here — it stays engaged (as this now-healthy connection) until
407+
// throttleAdmit hands the baton to the next waiter, or to null once the backlog
408+
// is drained. Admit successes are silent, else N concurrent successes compound it.
409+
const t = options.shared.throttle
410+
if (!options.reject_throttle || t.prober !== connection)
411+
return
412+
413+
t.allowance += t.ramp
414+
t.ramp = Math.min(t.ramp * 2, options.max)
415+
}
416+
365417
function connected() {
366418
try {
367419
statements = {}
@@ -447,12 +499,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
447499
socket.removeAllListeners()
448500
socket = null
449501

450-
if (initial)
451-
return reconnect()
502+
if (initial) {
503+
if (!options.reject_throttle)
504+
return reconnect()
505+
closedTime = performance.now()
506+
return reconnect_with_reject_throttle()
507+
}
452508

453509
!hadError && (query || sent.length) && error(Errors.connection('CONNECTION_CLOSED', options, socket))
454510
closedTime = performance.now()
455-
hadError && options.shared.retries++
511+
hadError && backOffOnError()
456512
delay = (typeof backoff === 'function' ? backoff(options.shared.retries) : backoff) * 1000
457513
onclose(connection, Errors.connection('CONNECTION_CLOSED', options, socket))
458514
}
@@ -566,6 +622,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
566622

567623
initial && !initial.reserve && execute(initial)
568624
options.shared.retries = retries = 0
625+
throttle_on_success()
569626
initial = null
570627
return
571628
}

src/index.js

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function Postgres(a, b) {
6262
, full = Queue()
6363
, queues = { connecting, reserved, closed, ended, open, busy, full }
6464

65-
const connections = [...Array(options.max)].map(() => Connection(options, queues, { onopen, onend, onclose }))
65+
const connections = [...Array(options.max)].map(() => Connection(options, queues, { onopen, onend, onclose, requeue }))
6666

6767
const sql = Sql(handler)
6868

@@ -207,7 +207,7 @@ function Postgres(a, b) {
207207
: await new Promise((resolve, reject) => {
208208
const query = { reserve: resolve, reject }
209209
queries.push(query)
210-
closed.length && connect(closed.shift(), query)
210+
closed.length && throttleAllows() && connect(closed.shift(), query)
211211
})
212212

213213
move(c, reserved)
@@ -333,7 +333,7 @@ function Postgres(a, b) {
333333
if (open.length)
334334
return go(open.shift(), query)
335335

336-
if (closed.length)
336+
if (closed.length && throttleAllows())
337337
return connect(closed.shift(), query)
338338

339339
busy.length
@@ -388,19 +388,69 @@ function Postgres(a, b) {
388388
resolve()
389389
}
390390

391+
// reject_throttle (opt-in): after a connection fails to ESTABLISH, block new
392+
// opens (closed -> connecting) so the pool doesn't stampede a server that is
393+
// rejecting connections. Complements `backoff` — backoff only *delays* each
394+
// retry, and since its counter is pool-shared every connection waits the same
395+
// delay and then fires together (a delayed herd); this instead keeps one failed
396+
// connection as the lone prober while the rest block, then admits opens on an
397+
// exponentially-growing `allowance` that each prober handshake-success refills
398+
// (slow-start), spending one per admitted open. Returns true when a new open may
399+
// proceed now (always, when the feature is off or the breaker is idle).
400+
function throttleAllows() {
401+
const t = options.shared.throttle
402+
if (!options.reject_throttle || t.prober === null)
403+
return true
404+
if (t.allowance > 0)
405+
return t.allowance--, true
406+
return false
407+
}
408+
391409
function connect(c, query) {
392410
move(c, connecting)
393411
c.connect(query)
394412
return c
395413
}
396414

415+
// reject_throttle: hand a query pinned to a stood-down connection back to the pool
416+
// (an idle connection serves it, otherwise it waits in the backlog) rather than
417+
// having that connection retry — which is what would re-form the herd.
418+
function requeue(query) {
419+
query.reserve ? queries.push(query) : handler(query)
420+
}
421+
422+
// reject_throttle: a prober's handshake success refilled `allowance` with the next
423+
// (doubled) slow-start batch. Open up to that many for the backlog and make the last
424+
// one opened the new prober, so exactly one connection keeps probing while the
425+
// breaker stays engaged. Backlog dry => nothing opened, prober stays null => the
426+
// breaker disengages.
427+
function throttleAdmit() {
428+
const t = options.shared.throttle
429+
if (!options.reject_throttle || t.allowance === 0)
430+
return
431+
let last = null
432+
while (t.allowance > 0 && queries.length && closed.length) {
433+
t.allowance--
434+
last = connect(closed.shift(), queries.shift())
435+
}
436+
if (last) {
437+
t.prober = last // hand the baton to the last waiter opened; breaker stays engaged
438+
} else {
439+
t.prober = null // nothing left to admit => recovered => disengage, reset to baseline
440+
t.ramp = 1
441+
t.allowance = 0
442+
}
443+
}
444+
397445
function onend(c) {
398446
move(c, ended)
399447
}
400448

401449
function onopen(c) {
402-
if (queries.length === 0)
450+
if (queries.length === 0) {
451+
throttleAdmit() // no backlog: disengage the breaker if a prober success granted allowance
403452
return move(c, open)
453+
}
404454

405455
let max = Math.ceil(queries.length / (connecting.length + 1))
406456
, ready = true
@@ -416,14 +466,16 @@ function Postgres(a, b) {
416466
ready
417467
? move(c, busy)
418468
: move(c, full)
469+
470+
throttleAdmit()
419471
}
420472

421473
function onclose(c, e) {
422474
move(c, closed)
423475
c.reserved = null
424476
c.onclose && (c.onclose(e), c.onclose = null)
425477
options.onclose && options.onclose(c.id)
426-
queries.length && connect(c, queries.shift())
478+
queries.length && throttleAllows() && connect(c, queries.shift())
427479
}
428480
}
429481

@@ -455,6 +507,7 @@ function parseOptions(a, b) {
455507
max_pipeline : 100,
456508
backoff : backoff,
457509
keep_alive : 60,
510+
reject_throttle : false,
458511
prepare : true,
459512
debug : false,
460513
fetch_types : true,
@@ -495,7 +548,7 @@ function parseOptions(a, b) {
495548
socket : o.socket,
496549
transform : parseTransform(o.transform || { undefined: undefined }),
497550
parameters : {},
498-
shared : { retries: 0, typeArrayMap: {} },
551+
shared : { retries: 0, typeArrayMap: {}, throttle: { prober: null, ramp: 1, allowance: 0 } },
499552
...mergeUserTypes(o.types)
500553
}
501554
}

0 commit comments

Comments
 (0)