Skip to content

fix(relay): treat database pool connect failures as transient, not director faults - #21243

Merged
Jinwoo-H merged 3 commits into
mainfrom
relay-director-pool-connect-transient
Sep 17, 2026
Merged

Jinwoo-H merged 3 commits into
mainfrom
relay-director-pool-connect-transient

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​140 $\color{#cf222e}{\Huge{\mathbf{−}}}$​6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​134
Prod 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​109 $\color{#cf222e}{\Huge{\mathbf{−}}}$​22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​87

ELI5

The relay's traffic director keeps a small set of open database connections and hands one out for each request. When it cannot hand one out in time, it used to report "the server is broken" (HTTP 500) instead of "I am busy, try again shortly" (HTTP 503). Nothing was actually broken, and the wrong answer made our deployment safety check think the service was failing. This teaches it to say "busy, try again" for exactly the cases where no database work had started yet, and fixes the log field that was supposed to make this easy to spot and did not.

What Changed

Before. In the last 24 hours of production, the director returned real HTTP 500s in bursts roughly every 10 minutes. 271 database-connection failures occurred; only 10 of them were recognised as temporary. The other 261 fell through as unhandled errors:

Error message Count in 24 h
Connection terminated due to connection timeout 210
Connection terminated unexpectedly 51
timeout exceeded when trying to connect 10

The 210 all failed while acquiring a connection, at about 2002 ms elapsed, which is exactly the 2-second pool connect timeout, with nothing queued behind them. Users hit these on assignment and lookup requests. Our rollout safety gate counts 500s and ignores 503s, so these bursts also made every deployment look unsafe.

After. The same failures return 503 with the retry and backoff semantics those routes already have, and callers retry instead of seeing a hard failure.

Mechanism. The connection pool library raises acquire failures as a plain error with no SQLSTATE code, so the message is the only thing to match on. The classifier that decides "temporary" matched exactly one message. It now matches all three, with one important distinction:

  • Two of the messages can only ever come from the pool's connect path, so no database statement had started. They match on the message alone.
  • "Connection terminated unexpectedly" is raised whenever a socket ends early, both during the handshake and in the middle of a statement. Retrying a statement whose commit outcome is unknown is not safe. So the connection-acquire boundary now records which errors it produced, and that message counts as temporary only for those. Mid-statement, it stays a hard failure exactly as today.

That vocabulary now lives beside the acquire boundary that owns it, rather than inline in the classifier, because a second place was reading it.

Two more routes gained the handling the assignment routes already had. The region catalog endpoint had no error handling at all, and the admin evacuation-status endpoint was uncaught.

Diagnostics. The database failure log has a connectionTimeout boolean whose documented job is to flag exactly this condition. It matched only one of the two messages the 2-second timeout can produce, so all 210 dialling timeouts were logged as connectionTimeout: false. The field meant to find this burst was blind to 97% of it, which is why the messages had to be counted by hand. It now reads the same predicate the router does, so the two cannot drift.

The event also gained a transient boolean carrying the routing verdict the caller had already computed. Counting that field answers "how much of this burst reached users as a hard failure" directly. It is passed in rather than re-derived, so there is still one classifier.

Why

These are not director faults. A brief inability to open a database connection is the textbook case for "try again," and the rest of the relay already treats it that way. Reporting it as a 500 told clients the wrong thing and poisoned the deployment safety signal.

Alternatives considered:

  • Raise the 2-second pool connect timeout. This hides the symptom by making requests wait longer, and it eats into the budget that keeps a control renewal inside its own 30-second tick. It would also still produce 500s once the longer timeout expired.
  • Raise the rollout gate's error threshold. That makes the gate blind to the class of failure it exists to catch, and leaves users receiving 500s.
  • Match the message without the acquire distinction. Simpler, but it would classify a socket lost mid-statement as retryable, inviting a client to repeat a write whose commit outcome is unknown.
  • Duplicate the message list in the diagnostics module. Two copies of a predicate that must agree is how the original single-message gap survived. Both now read one source.

Linked Issue

No tracked issue. This was found by reading 24 hours of production director logs while investigating why the pre-roll safety gate kept tripping.

Fixes #

Visual Proof

N/A — server-side HTTP status code change with no user interface surface.

Testing

  • pnpm test in cloud/apps/relay, first with no database: 64 files / 661 tests pass, 21 files / 110 tests skipped (they need Postgres).
  • pnpm test in cloud/apps/relay against a fresh postgres:16-alpine container with ORCA_RELAY_TEST_POSTGRES_URL set, matching CI: 85 files / 772 tests pass, 1 skipped, nothing skipped at the file level.
  • pnpm test in cloud (the whole workspace, as CI runs it): all package suites pass plus 598 deployment-script tests, 0 failures.
  • pnpm typecheck and pnpm build in cloud, which is how CI compiles this workspace: both clean.
  • Reverted each change with the new tests in place. Reverting the classifier fails three tests; reverting the diagnostics predicate fails two. They are pinned to the behaviour, not to the implementation.

Reviewer steps: start Postgres, then run the relay suite.

docker run -d --name relay-pg -e POSTGRES_DB=orca_relay_test \
  -e POSTGRES_PASSWORD=relay_test -e POSTGRES_USER=relay_test \
  -p 55444:5432 postgres:16-alpine
cd cloud/apps/relay
ORCA_RELAY_TEST_POSTGRES_URL='postgres://relay_test:relay_test@127.0.0.1:55444/orca_relay_test' pnpm test
  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests cover the classifier for the pool connect timeout, the acquire-gated versus mid-statement split for the early-ended socket, a failed acquire that is not transient (a bad password stays a hard failure), the region catalog route returning 503 with Retry-After for a transient failure while still failing loudly for a programming error, and the two diagnostics booleans across a dialling timeout, an early-ended socket, a rejected password, and each execute-phase code.

One existing test was tightened rather than merely updated. It asserted three execute-phase codes together; it now carries the expected verdict per code, because ECONNRESET after a statement went out is deliberately not transient while 57014 and 55P03 are. That pins the mid-statement boundary this change must not cross.

AI Disclosure

Review

Retry safety

Widening a "this is temporary" classifier is only safe if nothing retries a non-idempotent write on the strength of it. Every call site was checked.

Four of the five call sites are request handlers in app.ts. None of them retries anything. They translate the error into a 503 response and the client decides. For the assignment write path, a 503 for the very same operation is already returned on the capacity branch, so the client-side retry semantics are unchanged in kind.

The one place that does retry server-side is startup cell reconciliation, which retries up to 20 times inside a 45-second window. That work is a single transaction of INSERT ... ON CONFLICT DO UPDATE over the configured cell list, so re-running it converges to the same state. The practical effect of the widening there is that a pool connect failure during startup now retries instead of throwing and killing the container.

The retry loop that could genuinely repeat a write is the in-transaction retry inside the database layer. It is keyed on a separate function that matches SQLSTATE codes only, and this change does not touch it.

The acquire-boundary gate is what keeps the widening honest: an error that reaches a caller with a statement already in flight is never newly classified as temporary.

Compatibility

The region catalog is fetched by the desktop client, which throws on any non-OK status, so 500 and 503 are equivalent to it. The evacuation-status endpoint is called by three deployment scripts; two throw on any non-OK status, and the third already retries both 500 and 503 for this path. No caller regresses.

Cells are unaffected: every /v1 route returns 404 for the cell role.

The diagnostics event gains a field and corrects one. Adding transient is additive for any log reader. Correcting connectionTimeout makes it match its documented meaning, so a saved query counting it will start seeing the dialling timeouts it was always supposed to include. The doc is updated in the same commit.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Security: no change to authentication, authorization, or what any response body reveals. The 503 bodies name a condition, not an internal error string. The diagnostics event stays bounded to categories and booleans; no message, query text, parameter, or identifier is emitted, and a test asserts that private strings never reach the log.

Cross-platform, SSH, and mobile: server-side only. Mobile and desktop clients see a status code they already handle.

Backwards compatibility: no wire or schema change. Clients that treated 500 as fatal now get a retryable status for the same condition, which is strictly better for them.

Performance: one WeakSet insert on the connection-failure path only. Nothing on the success path.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…ults

pg-pool raises connection-acquire failures as a plain Error with no SQLSTATE,
so the transient classifier matched only one of the three messages it can
produce. The other two reached the routes unclassified and became HTTP 500s,
which is what the rollout safety gate counts.

The acquire boundary now marks the errors it produces, so "Connection
terminated unexpectedly" counts as transient when the socket died during the
handshake and stays a hard failure mid-statement, where a retry could repeat a
commit whose outcome is unknown.

/v1/regions and /v1/admin/evacuation-status gain the transient handling
/v1/assign and /v1/resolve already had.
The query-failure event's connectionTimeout boolean matched one of the two
messages connectionTimeoutMillis can produce, so the 210 dialling timeouts in
the last day logged as false and were invisible to the field meant to find them.

The pool-connect vocabulary now lives beside the acquire boundary that owns it,
and both the router's classifier and the diagnostics read it from there, so the
two cannot drift. The event also carries the routing verdict the caller already
computed, making "how much of this burst reached users as a 500" one field.
The classifier now runs inside the query catch, where a thrown null or
undefined would have turned a database failure into a TypeError that buried it.

The diagnostics doc claimed transient maps to a 503 or a 500. Sweeps, startup
reconciliation, and admin routes that answer 409 all emit the same event, so
counting the false ones over-states user-facing hard failures.
@Jinwoo-H
Jinwoo-H marked this pull request as ready for review September 17, 2026 16:21
@pullfrog

pullfrog Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

This run was cancelled 🛑

The workflow was cancelled before completion. Please check the link below for details.

Pullfrog  | View workflow run | via Pullfrog | 𝕏

@Jinwoo-H
Jinwoo-H merged commit a3046cd into main Sep 17, 2026
29 of 46 checks passed
@Jinwoo-H
Jinwoo-H deleted the relay-director-pool-connect-transient branch September 17, 2026 16:26
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The change adds pool-acquire failure tracking and centralized PostgreSQL transient-error classification. Query diagnostics now include the caller-provided transient verdict. The regions and evacuation-status routes return 503 responses for transient database failures while rethrowing non-transient errors. Tests and diagnostics documentation cover the new classifications and responses.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to abca6

Diagnostics can misidentify queue timeouts as connection dials, and the new evacuation retry response is not protected by a route-level failure test. These are bounded issues but should be addressed before relying on the change operationally.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 8 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: treating relay database pool connection failures as transient instead of director faults.
Description check ✅ Passed The description is detailed and covers the required explanation, motivation, testing, visual proof, review notes, compatibility, and checklist items. The Linked Issue section states that no tracked is…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 8 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 2b082c1e-12e9-4764-8879-123cfc0e0b2c

📥 Commits

Reviewing files that changed from the base of the PR and between 0d23ea6 and abca659.

📒 Files selected for processing (9)
  • cloud/apps/relay/src/app.ts
  • cloud/apps/relay/src/database-transient-error.test.ts
  • cloud/apps/relay/src/database.ts
  • cloud/apps/relay/src/postgres-pool-pressure.ts
  • cloud/apps/relay/src/postgres-query-failure-postgres.test.ts
  • cloud/apps/relay/src/postgres-query-failure.test.ts
  • cloud/apps/relay/src/postgres-query-failure.ts
  • cloud/apps/relay/src/relay-region-app.test.ts
  • cloud/docs/relay-database-failure-diagnostics.md

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines +1318 to +1328
try {
const status = await operations.assignments.cellEvacuationStatus(
body.data.sourceCellId,
body.data.targetCellId,
body.data.completeReady
)
return context.json({ v: 1, ...status })
} catch (error) {
if (!isRelayDatabaseTransientError(error)) throw error
return context.json({ error: 'database_temporarily_unavailable' }, 503)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

rg -n -C 5 'evacuation-status|cellEvacuationStatus|database_temporarily_unavailable|verifyReadOnlyAdminToken' cloud/apps/relay/src --glob '*test.ts'
sed -n '1305,1335p' cloud/apps/relay/src/app.ts
sed -n '250,315p' cloud/apps/relay/src/relay-region-app.test.ts

Repository: stablyai/orca

Length of output: 40202


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- route-focused blackbox tests ---'
sed -n '2040,2195p' cloud/apps/relay/src/relay.blackbox.test.ts
sed -n '2480,2655p' cloud/apps/relay/src/relay.blackbox.test.ts
printf '%s\n' '--- relay-region test structure and region route ---'
sed -n '1,120p' cloud/apps/relay/src/relay-region-app.test.ts
sed -n '210,325p' cloud/apps/relay/src/relay-region-app.test.ts
printf '%s\n' '--- transient classifier and route registration ---'
rg -n -C 8 'function isRelayDatabaseTransientError|isRelayDatabaseTransientError|app\.post.*/v1/admin/evacuation-status|cellEvacuationStatus' cloud/apps/relay/src --glob '*.ts'

Repository: stablyai/orca

Length of output: 50369


Test the transient mapping on POST /v1/admin/evacuation-status. Existing tests cover successful requests and authentication responses, but none make cellEvacuationStatus() reject with a classified transient error. Add an authenticated request and assert status 503 with { error: 'database_temporarily_unavailable' }. The /v1/regions test stubs assignments.regionCatalog, so it cannot detect a regression in this handler.

Comment on lines +13 to +15
time includes acquisition. An acquisition timeout can mean either waiting in the
queue or establishing a new connection; `connectionTimeout` covers both, and the
pool counts separate them: a queue wait has waiters, a dial does not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '60,150p' cloud/apps/relay/src/postgres-pool-pressure.ts
sed -n '945,985p' cloud/apps/relay/src/database.ts
sed -n '1,90p' cloud/apps/relay/src/postgres-query-failure.ts
sed -n '8,20p' cloud/docs/relay-database-failure-diagnostics.md
rg -n -C 3 'databasePoolWaiting|waitingCount|databasePoolWaitersMax|databasePoolWaitMsMax|reportPostgresQueryFailure' cloud/apps/relay/src

Repository: stablyai/orca

Length of output: 50369


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- package bindings ---'
rg -n -C 2 '"(pg|pg-pool)"|from ['"'"']pg|from ['"'"']pg-pool' package.json package-lock.json pnpm-lock.yaml yarn.lock cloud package.json 2>/dev/null | head -120
printf '%s\n' '--- failure reporter tests ---'
sed -n '1,220p' cloud/apps/relay/src/postgres-query-failure.test.ts
printf '%s\n' '--- pressure tests ---'
sed -n '1,180p' cloud/apps/relay/src/postgres-pool-pressure.test.ts
printf '%s\n' '--- local package source candidates ---'
find . -path '*/node_modules/pg-pool/*' -o -path '*/node_modules/pg/lib/*' 2>/dev/null | head -80

Repository: stablyai/orca

Length of output: 13280


🏁 Script executed:

printf '%s\n' '--- bindings ---'
rg -n -C 2 '"pg(-pool)?"|from ['"'"']pg' package.json package-lock.json pnpm-lock.yaml yarn.lock cloud/package.json cloud/apps/relay/package.json 2>/dev/null | head -160
printf '%s\n' '--- reporter test ---'
sed -n '1,180p' cloud/apps/relay/src/postgres-query-failure.test.ts
printf '%s\n' '--- pressure test ---'
sed -n '1,100p' cloud/apps/relay/src/postgres-pool-pressure.test.ts

Repository: stablyai/orca

Length of output: 7718


🌐 Web query:

pg-pool source connectionTimeoutMillis pendingQueue waitingCount timeout response.callback

💡 Result:

<search_synthesis>
In the context of the node-postgres (pg-pool) library, these terms relate to pool configuration, internal queue management, and health monitoring [1][2][3]. connectionTimeoutMillis This is a configuration parameter for the Pool constructor [1][2]. It defines the number of milliseconds the pool will wait to establish a new connection to the database before timing out [1][2]. By default, it is set to 0, which means there is no timeout [2]. If a connection cannot be established within this time, the request will fail, typically with a timeout error [4]. pendingQueue Internally, pg-pool maintains a _pendingQueue (an array of PendingItem objects) [3]. This queue stores callbacks for requests that are waiting to check out a client from the pool when all available connections are currently in use [3]. waitingCount This is a public property exposed by the Pool instance (pool.waitingCount) [2][5]. It represents the current number of requests waiting in the _pendingQueue for an available client [2][3]. Monitoring this metric is a standard best practice for detecting pool saturation; if waitingCount is greater than zero, it indicates that the pool is at its maximum capacity (max) and incoming requests are queued [6][5]. response.callback In the context of internal pool operation, when a user calls pool.connect or pool.query, the library creates a task or "PendingItem" [3]. This object encapsulates the user&#39;s callback function (the "response" callback) which is executed once a connection is successfully acquired from the pool or rejected due to a timeout or other error [3]. Summary of Pool Health Metrics Developers typically monitor three primary properties to assess pool health [6][5]: 1. totalCount: The total number of client instances (idle + in use) [3][5]. 2. idleCount: The number of clients currently available in the pool [3][5]. 3. waitingCount: The number of queued requests waiting for a client [3][5]. If you observe waitingCount > 0 while idleCount is 0, your application is under load and waiting for database connections to become available [6]. Consistent saturation may indicate a need to increase the pool max size or optimize query execution time [2][6].
</search_synthesis>

<source_evidence>

<title>node-postgres/packages/pg-pool at master · brianc/node-postgres · GitHub</title> https://github.com/brianc/node-postgres/tree/master/packages/pg-pool // you can pass properties to the pool // these properties are passed unchanged to both the node-postgres Client constructor // and the pool constructor, allowing you to fully configure the behavior of both const pool2 = new Pool({ database: &`#39`;postgres&`#39`;, user: &`#39`;brianc&`#39`;, password: &`#39`;secret!&`#39`;, port: 5432, ssl: true, max: 20, // set pool max size to 20 idleTimeoutMillis: 1000, // close idle clients after 1 second connectionTimeoutMillis: 1000, // return an error after 1 second if connection could not be established maxUses: 7500, // close (and replace) a connection after it has been used 7500 times (see below for discussion) }) ... pg-pool supports a fully promise-based api for acquiring clients ... pool.connect().then((client) => { client .query(&`#39`;select $1::text as name&`#39`;, [&`#39`;pg-pool&`#39`;]) .then((res) => { client.release() console.log(&`#39`;hello from&`#39`;, res.rows[0].name) }) .catch((e) => { client.release() console.error(&`#39`;query error&`#39`;, e.message, e.stack) }) }) ... you can also use a callback here if you&`#39`;d like: ... ``` const pool = new Pool() ... pool.query(&`#39`;SELECT $1::text as name&`#39`;, [&`#39`;brianc&`#39`;], function (err, res) { console.log(res.rows[0].name) // brianc }) ... pro tip: unless you need to run a transaction (which requires a single client for multiple queries) or you have some other edge case like streaming rows or using a cursor you should almost always just use`pool.query`. Its ... , it does the right ... ️, and wont ever forget to return clients back to the pool after ... is done. ... pg-pool still and will always support the traditional callback api for acquiring a client. This is the exact API node-postgres has shipped with for years: ... ``` const pool = new Pool() ... pool.connect((err, client, done) => { if (err) return done(err) client.query(&`#39`;SELECT $1::text as name&`#39`;, [&`#39`;pg-pool&`#39`;], (err, res) => { done() if (err) { return console.error(&`#39`;query error&`#39`;, err.message, err.stack) } console.log(&`#39`;hello from&`#39`;, res.rows[0].name) }) }) ``` ... When you are finished with the pool if all the clients are idle the pool will close them after`config.idleTimeoutMillis` and your app will shutdown gracefully. If you don&`#39`;t want to wait for the timeout you can end the pool as follows: ... Every instance of a`Pool` is an event emitter. These instances emit the following events: ... server shuts down ... Fired whenever the pool creates a new`pg.Client` instance and successfully connects it to the backend. ... pool .connect() .then((client) => { return client .query(&`#39`;SELECT $1::int AS "clientCount"&`#39`;, [client.count]) .then((res) => console.log(res.rows[0].clientCount)) // outputs 0 .then(() => client) }) .then((client) => client.release()) <title>GitHub</title> https://node-postgres.com/apis/pool ``` type Config = { // All valid client config options are also valid here. // In addition here are the pool specific configuration parameters: // Number of milliseconds to wait before timing out when connecting a new client. // By default this is 0 which means no timeout. connectionTimeoutMillis?: number // Number of milliseconds a client must sit idle in the pool and not be checked out // before it is disconnected from the backend and discarded. // Default is 10000 (10 seconds) - set to 0 to disable auto-disconnection of idle clients. idleTimeoutMillis?: number // Maximum number of clients the pool should contain. // By default this is set to 10. There is some nuance to setting the maximum size of your pool. // See https://node-postgres.com/guides/pool-sizing for more information. max?: number // Minimum number of clients the pool should hold on to and _not_ destroy with the idleTimeoutMillis. // This can be useful if you get very bursty traffic and want to keep a few clients around. // Note: currently the pool will not automatically create and connect new clients up to the min, it will // only not evict and close clients except those which exceed the min count. // The default is 0 which disables this behavior. min?: number // Default behavior is the pool will keep clients open & connected to the backend // until idleTimeoutMillis expire for each client and node will maintain a ref // to the socket on the client, keeping the event loop alive until all clients are closed // after being idle or the pool is manually shutdown with `pool.end()`. // // Setting `allowExitOnIdle: true` in the config will allow the node event loop to exit // as soon as all clients in the pool are idle, even if their socket is still open // to the postgres server. This can be handy in scripts & tests // where you don&`#39`;t want to wait for your clients to go idle before your process exits. allowExitOnIdle?: boolean // Number of times a client can be checked out from the pool before it is // disconnected and a new client is created in its place. // The default is Infinity which means a client will never be automatically destroyed // outside of other lifecycle events like manually removing it, it timing out due to idleness, etc. maxUses?: number // Sets a max overall life for the connection. // A value of 60 would evict connections that have been around for over 60 seconds, // regardless of whether they are idle. It&`#39`;s useful to force rotation of connection pools through // middleware so that you can rotate the underlying servers. The default is disabled (value of zero). maxLifetimeSeconds?: number // Called once when a new client is created, before it is made available to the pool. // The client is fully connected and queryable at this point. // Can be a regular function or an async function. // If the function throws or returns a promise that rejects, the client is destroyed // and the error is returned to the caller requesting the connection. onConnect?: (client: Client) => void | Promise<void> } ... `pool.connect() => Promise<pg.Client>` ... - If there are idle clients in the pool one will be returned to the callback on `process.nextTick`. - If the pool is not full but all current clients are checked out a new client will be created & returned to this callback. - If the pool is ‘full’ and all clients are currently checked out, requests will wait in a FIFO queue until a client becomes available by being released back to the pool. ... If you forget to release the client then your application will quickly exhaust available, idle clients in the pool and all further calls to `pool.connect` will timeout with an error or hang indefinitely if you have ` connectionTimeoutMillis ` configured to 0. ... `pool.idleCount: number` ... in the pool ... `pool.waitingCount: number` The number of queued requests waiting on a client when all clients are checked out. It can be helpful to monitor this number to see if you need …[truncated] <title>Connection Pooling | brianc/node-postgres | DeepWiki</title> https://deepwiki.com/brianc/node-postgres/4-connection-pooling - `_clients` packages/pg-pool/index.js102: Array of all`Client` instances, both idle and in-use - `_idle` packages/pg-pool/index.js103: Array of`IdleItem` objects, each wrapping a client with its`idleListener` and`timeoutId` - `_pendingQueue` packages/pg-pool/index.js105: FIFO queue of`PendingItem` objects containing callbacks waiting for clients - `_expired` packages/pg-pool/index.js104:`WeakSet` marking clients that exceeded`maxLifetimeSeconds` ... - `IdleItem` packages/pg-pool/index.js12-18: Container for`(client, idleListener, timeoutId)` - `PendingItem` packages/pg-pool/index.js20-24: Container for`(callback)` with optional`timedOut` flag ... 1. Calls`this.connect()` to acquire a client 2. Attaches one-time error listener to handle errors during query execution 3. Invokes`client.query()` with parameters 4. Always releases client in callback, even on error 5. Returns result or error to application callback ... When`pool.connect()` packages/pg-pool/index ... 190-238 ... index.js127-170 ... - `pool.connect(cb)` packages/pg-pool/index.js190-238: Entry point, creates`PendingItem`, delegates to`_pulseQueue()` - `_pulseQueue()` packages/pg-pool/index.js127-170: Matches pending requests with idle clients or creates new clients ... - `newClient(pendingItem)` packages/pg-pool/index.js240-309: Creates and connects new`Client`, handles`onConnect` hook ... - `_afterConnect(client, pendingItem, idleListener)` packages/pg-pool/index.js311-332: Sets up`maxLifetimeSeconds` timeout ... - `_acquireClient(client, pendingItem, idleListener, isNew)` packages/pg-pool/index.js334-366: Emits events, attaches`release()`, invokes callback ... - `_release(client, idleListener ... 384-429: Decides whether to ... Idle Timeout Logic packages/pg-pool/index.js407-428: ... - If`idleTimeoutMillis` configured and`_isAboveMin()` returns true, sets timeout - Timeout callback checks`_isAboveMin()` again before removing - If`allowExitOnIdle` is true, calls`tid.unref()` and`client.unref()` to allow process exit ... -10 ... .connect(cb)` ... 190-238 ... , returns`Promise ... query(text ... 431-486 ... - `pool. ... (cb)` ... -pool/ ... 488- ... - `pool.totalCount` packages/pg-pool/index.js514-516: Returns`this._clients.length`(idle + in-use) - `pool.idleCount` packages/pg-pool/index.js505-507: Returns`this._idle.length` - `pool.waitingCount` packages/pg-pool/index.js501-503: Returns`this._pendingQueue.length` - `pool.expiredCount` packages/pg-pool/index.js509-511: Counts clients in`this._expired` WeakSet ... - Use`Pool` instead of`Client` for web servers and long-running applications - Use`pool.query()` for simple, one-off queries - Use`pool.connect()` for transactions or multiple related queries - Always release clients acquired with`pool.connect()`, preferably in a`finally` block - Monitor pool health using`totalCount`,`idleCount`, and`waitingCount` - Add an`error` event listener to handle background connection failures ... For detailed configuration options (max, min, idleTimeoutMillis, maxUses, etc.), see Pool Configuration. For implementation details of the client lifecycle (how the pool decides when to create, reuse, or remove clients), see Client Lifecycle Management. <title>[pg-pool] Connection timeouts cause connection leaks</title> GitHub issue 3543 in brianc/node-postgres (link omitted to avoid creating a cross-reference) We found that when a new connection is opened and `connectionTimeoutMillis` is hit due to a race condition the following things can happen: ... 1. A connection is opened on the Postgres side, and is never actually utilized 2. `pg-pool` continues tracking the connection but never marks it as idle 3. The connection isn’t cleaned up until the pool itself is closed (or the NodeJS process dies) ... What&`#39`;s unusual is that the connection "times out" but continues to maintain an idle connection on the Postgres side, without ever marking it as idle in the pool. Might suggest a race condition here between the timeout and the client connection. ... const pool = new Pool({ port: 5432, // Setting this to 1 will cause everything to crash. max: 2, connectionTimeoutMillis: 1000, // Comment this out to try with `pg` // Client, log: (msg) => { console.log(msg); } }); ... const baseConnect = pool.Client.prototype.connect; ... pool.Client.prototype.connect = function (cb) { if (connection === 0) { connection += 1; return setTimeout(() => baseConnect.call(this, cb), 1100 ) } connection += 1; return baseConnect.call(this, cb) } ... await pool.connect(); ... let zombiesCount = 0; ... const res = await pool.query( &`#39`;select count(*) from pg_stat_activity where query_start is NULL and state = $1::text;&`#39`;, [&`#39`;idle&`#39`;] ); if (res.rows) { zombiesCount = res.rows[0][&`#39`;count&`#39`;] } else { zombiesCount = res[0][&`#39`;count&`#39`;] } ... pool.idle ... Here you can see the issue in action, where a connection is held open after the initial failed `connect` call and we end with a pool of two connections, only one of which is idle. ... You can test this by setting `max: 1` in the reproduction script’s config. This illustrates that leaked connections don’t get cleaned up when a new connection is requested, meaning it’s possible to exhaust the pool with enough connection timeouts. ... /Users/nick/node-postgres/packages/pg-pool/index.js:45 Error.captureStackTrace(err) ^ Error: timeout exceeded when trying to connect at /Users/nick/node-postgres/packages/pg-pool/index.js:45:11 at async file:///Users/nick/node-postgres/packages/pg-pool/test.mjs:67:13 ... `pg-native` has even more unusual behavior here, as it totally crashes the script and exits with code `13`. No error is thrown. ... If there is another Promise being awaited then no crash occurs and we resolve as usual (you can test this by creating an unresolved promise before the leaked connection query and awaiting it after the query is fired off). ... ``` const promise = new Promise((resolve) => setTimeout(resolve, ... 2000)); ... const res = await pool.query( &`#39`;select count(*) from pg_stat_activity where query_start is NULL and state = $1::text;&`#39`;, [&`#39`;idle&`#39`;] ); await promise ... ## Possible fix for `pg` ... I was able to resolve this for `pg` specifically with the following change ... ```diff diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index 3e505f79..59c258c6 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -244,7 +244,7 @@ class Pool extends EventEmitter { this.log(&`#39`;ending client due to timeout&`#39`;) timeoutHit = true // force kill the node driver, and let libpq do its teardown - client.connection ? client.connection.stream.destroy() : client.end() + client.connection ? client.connection.end() : client.end() }, this.options.connectionTimeoutMillis) } ``` ... https://github.com/hex-inc/typeorm/blob/96ce9c27b59297224aba171bb7ac0bd0ccfb04ee/src/driver/postgres/PostgresDriver.ts#L821 ... TypeORM&`#39`;s Postgres driver uses the `connect` API here. During a timeout `pg-pool` doesn&`#39`;t return any sort of functional release callback (which makes sense given that the connection "timed out"), so there&`#39`;s no way for TypeORM or other consumers, to clean up these leaked connections. ... > For confirming the pool state divergence, having visibility into active/idle/waiting counts over time helps — …[truncated] <title>Database Connection Pooling In Node.js, Nazar Boyko</title> https://www.nazarboyko.com/articles/database-connection-pooling-in-nodejs const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 ... , idleTimeoutMillis: 30_000, connection ... Millis: 2_000, }); ... `connectionTimeoutMillis` (called `acquireTimeout` in some libraries) is how long a request will wait for a free connection before giving up. If the pool is full and every connection is busy, this is the timer that decides when the caller sees an error vs. when it gets a connection. Default in `pg` is zero, which means "wait forever", almost never what you want in a web service. Set this to something between 1 and 5 seconds. The right number is "less than your upstream timeout, with room to spare." ... The acquire timeout (`connectionTimeoutMillis` in `pg`) is how long the request waits inside Node for a pool connection. If this fires, you get an error before any query runs. ... The query timeout is how long an individual statement is allowed to run. In `pg`, you set this on the client with `statement_timeout` (which the pool will apply to each new connection), or you pass `query_timeout` to the Pool config (which is enforced from the Node side). In Postgres, you can also `SET statement_timeout = &`#39`;5s&`#39`;` per session or per transaction. If this fires, the statement is cancelled and the query rejects with an error, but the connection is fine and goes back to the pool. ... The transaction timeout (`idle_in_transaction_session_timeout` in Postgres) is how long Postgres will let a session sit in `idle in transaction` before forcibly closing the connection. This is the backstop for the orphan-transaction leak. Set it to something like 60 seconds on the database side, it&`#39`;s a server-level safety net that costs you nothing. ... The upstream timeout is whatever timeout your HTTP client or load balancer applies. If your ALB times out requests at 30 seconds and your acquire+query timeouts can sum to 60, then the client always sees a 504 and your service spends 30 more seconds processing a query nobody is waiting for. Acquire timeout + query timeout + a small buffer should always be less than the upstream timeout. ... ```ts const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10, connectionTimeoutMillis: 3_000, // wait up to 3s for a pool slot idleTimeoutMillis: 30_000, maxLifetimeSeconds: 3600, // statement_timeout is most reliably set via DATABASE_URL options or per-query: // postgres://...?options=-c statement_timeout=20000 }); ``` ... `pg.Pool` exposes three numeric counters that tell you almost everything you need: ... ```ts pool.totalCount // open physical connections (idle + in use) pool.idleCount // open but unused pool.waitingCount // requests queued for a connection ``` ... A request waiting for the pool is the single most important signal in this whole system. If `waitingCount > 0` for more than a moment, you&`#39`;re at capacity. Export this as a Prometheus gauge: TypeScript src/metrics.ts Copy⧉ ```ts import { collectDefaultMetrics, Gauge } from &`#39`;prom-client&`#39`;; collectDefaultMetrics(); ... const totalGauge = new Gauge({ name: &`#39`;pg_pool_total&`#39`;, help: &`#39`;Total pg pool connections&`#39`; }); const idleGauge = new Gauge({ name: &`#39`;pg_pool_idle&`#39`;, help: &`#39`;Idle pg pool connections&`#39`; }); const waitingGauge = new Gauge({ name: &`#39`;pg_pool_waiting&`#39`;, help: &`#39`;Requests waiting for a pg connection&`#39`; }); ... export function reportPoolMetrics(pool: import(&`#39`;pg&`#39`;).Pool) { setInterval(() => { totalGauge.set(pool.totalCount); idleGauge.set(pool.idleCount); waitingGauge.set(pool.waitingCount); }, 1000).unref(); } ... - `pg_pool_waiting > 0` sustained for more ... 10 seconds, ... capacity. - ... pg_pool_ ... / max > ... &`#39`;re close to capacity ... `pg_pool_total` collapsing ... zero with traffic still flowing, you&`#39`;re leaking; somebody isn&`#39`;t releasing. ... The pool also emits events you can hook for richer logging: ... ```ts pool.on(&`#39`;connect&`#39`;, (client) => console.log(&`#39`;…[truncated]

Citations:


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- lockfiles ---'
find . -maxdepth 3 -type f \( -name 'package-lock.json' -o -name 'pnpm-lock.yaml' -o -name 'yarn.lock' -o -name 'bun.lockb' -o -name 'bun.lock' \) -print
printf '%s\n' '--- pg dependency references ---'
rg -n -C 3 'pg-pool|pg@|/pg/|pg:|version: 8\.22|8\.22\.0' . --glob 'package-lock.json' --glob 'pnpm-lock.yaml' --glob 'yarn.lock' --glob 'package.json' --glob 'bun.lock' 2>/dev/null | head -240

Repository: stablyai/orca

Length of output: 5074


🌐 Web query:

node-postgres pg-pool v3.10.1 source PendingItem timedOut connectionTimeoutMillis waitingCount

💡 Result:

<search_synthesis>
In pg-pool, PendingItem is an internal class used to track requests for a connection that are currently waiting in the pool&#39;s queue [1]. When you call pool.connect, if no idle connections are available and the pool is at its capacity limit, a new PendingItem is created and added to the pool&#39;s _pendingQueue [1]. The property waitingCount reflects the length of this _pendingQueue [2][3]. If your waitingCount is high, it indicates that many requests are blocked waiting for an available connection [4]. Regarding connectionTimeoutMillis and timedOut: 1. When connectionTimeoutMillis is configured, a timer is set for each acquisition attempt [1]. 2. If this timer expires before a connection is acquired, the PendingItem is marked with timedOut = true [1]. 3. Historically, this mechanism had significant flaws, particularly in version 3.10.1 and earlier, leading to connection leaks and "hanging" pool connections [5][6][7]. Specifically: - Race conditions allowed connections to be established and added to the pool even after the timeout had already triggered [8][7]. - If no specific client condition was met when the timer fired (e.g., during a stalled DNS lookup), the timer would do nothing, causing pool.connect to hang indefinitely [6]. These issues have been addressed in later versions of the library, where the timeout now explicitly rejects the checkout, cleans up the pending state, and ensures that late-completing connections are discarded rather than leaked [8][6][7]. If you are experiencing issues with connection timeouts or leaks, updating to a version newer than 3.10.1 is strongly recommended [9].
</search_synthesis>

<source_evidence>

<title>node-postgres/packages/pg-pool/index.js at 9d2c977ce9b13f8f3b024759b1deaec165564a6a · brianc/node-postgres</title> https://github.com/brianc/node-postgres/blob/9d2c977ce9b13f8f3b024759b1deaec165564a6a/packages/pg-pool/index.js class PendingItem { constructor(callback) { this.callback = callback } } ... this.\_clients = [] ... idle = [] ... this.\_pendingQueue = [] ... // if we don&`#39`;t have any waiting, do nothing if (!this.\_pendingQueue.length) { ... const pendingItem = this.\_pendingQueue.shift() ... if (this.\_ ... .length) { ... connect(cb) { ... () || this ... if (!this.options.connectionTimeoutMillis) { ... this.\_pendingQueue.push(new PendingItem(response.callback)) ... return result } ... const queueCallback = (err, res, done) => { ... const pendingItem = new PendingItem(queueCallback) ... // set connection timeout on checking out an existing client const tid = setTimeout(() => { ... // remove the callback from pending waiters because // we&`#39`;re going to call it with a timeout error removeWhere(this.\_pendingQueue, (i) => i.callback === queueCallback) ... pendingItem.timedOut = true response.callback(new Error(&`#39`;timeout exceeded when trying to connect&`#39`;)) }, this.options.connectionTimeoutMillis) ... this.\_pendingQueue.push(pendingItem) ... this.newClient(new PendingItem(response.callback)) ... newClient(pendingItem) { ... // connection timeout logic ... let tid let timeoutHit = false ... if (this.options.connectionTimeoutMillis) { ... tid = setTimeout(() => { ... this.log(&`#39`;ending client due to timeout&`#39`;) ... timeoutHit = true ... // force kill the node driver, and let libpq do its teardown client.connection ? client.connection.stream.destroy() : client.end() }, this.options.connectionTimeoutMillis) } ... if (!pendingItem.timedOut) { ... pendingItem.callback(err, undefined, NOOP) } } else { ... return this.\ ... acquireClient(client, pendingItem, idleListener, true) } ... // acquire a client for a pending work item ... \_acquireClient(client, pendingItem, idleListener, isNew) { ... if (!pendingItem.timedOut) { ... if (isNew && this.options.verify) { ... this.options.verify(client, (err) => { ... if (err) { ... client.release(err) ... return pendingItem.callback(err, undefined, NOOP) } ... pendingItem.callback(undefined, client, client.release) ... } else { ... pendingItem.callback(undefined, client, client ... get waitingCount() { return this.\_pendingQueue.length } get idleCount() { return this.\_idle.length } get totalCount() { return this.\_clients.length } } <title>Connection Pooling | brianc/node-postgres | DeepWiki</title> https://deepwiki.com/brianc/node-postgres/4-connection-pooling - `_clients` packages/pg-pool/index.js102: Array of all`Client` instances, both idle and in-use - `_idle` packages/pg-pool/index.js103: Array of`IdleItem` objects, each wrapping a client with its`idleListener` and`timeoutId` - `_pendingQueue` packages/pg-pool/index.js105: FIFO queue of`PendingItem` objects containing callbacks waiting for clients - `_expired` packages/pg-pool/index.js104:`WeakSet` marking clients that exceeded`maxLifetimeSeconds` ... - `IdleItem` packages/pg-pool/index.js12-18: Container for`(client, idleListener, timeoutId)` - `PendingItem` packages/pg-pool/index.js20-24: Container for`(callback)` with optional`timedOut` flag ... -170 ... - `pool.connect(cb)` packages/pg-pool/index.js190-238: Entry point, creates`PendingItem`, delegates to`_pulseQueue()` - `_pulseQueue()` packages/pg-pool/index.js127-170: Matches pending requests with idle clients or creates new clients - `newClient(pendingItem)` packages/pg-pool/index.js240-309: Creates and connects new`Client`, handles`onConnect` hook ... - `_afterConnect(client, pendingItem, idleListener)` packages/pg-pool/index.js311-332: Sets up`maxLifetimeSeconds` timeout ... - `_acquireClient(client, pendingItem, idleListener, isNew)` packages/pg-pool/index.js334-366: Emits events, attaches`release()`, invokes callback ... pg-pool/index. ... 407-428: ... - If`idleTimeoutMillis` configured and`_isAboveMin()` returns true, sets timeout - Timeout callback checks`_isAboveMin()` again before removing - If`allowExitOnIdle` is true, calls`tid.unref()` and`client.unref()` to allow process exit ... - `pool.totalCount` packages/pg-pool/index.js514-516: Returns`this._clients.length`(idle + in-use) - `pool.idleCount` packages/pg-pool/index.js505-507: Returns`this._idle.length` - `pool.waitingCount` packages/pg-pool/index.js501-503: Returns`this._pendingQueue.length` - `pool.expiredCount` packages/pg-pool/index.js509-511: Counts clients in`this._expired` WeakSet ... - Use`Pool` instead of`Client` for web servers and long-running applications - Use`pool.query()` for simple, one-off queries - Use`pool.connect()` for transactions or multiple related queries - Always release clients acquired with`pool.connect()`, preferably in a`finally` block - Monitor pool health using`totalCount`,`idleCount`, and`waitingCount` - Add an`error` event listener to handle background connection failures ... For detailed configuration options (max, min, idleTimeoutMillis, maxUses, etc.), see Pool Configuration. For implementation details of the client lifecycle (how the pool decides when to create, reuse, or remove clients), see Client Lifecycle Management. <title>Result 3</title> https://cdn.jsdelivr.net/npm/@agentuity/postgres@3.1.18/src/pool.ts export class PostgresPool extends EventEmitter implements Registrable { private _pool: pg.Pool | null = null; private _config: PoolConfig; private _poolConfig: pg.PoolConfig = {}; private _connected = false; private _reconnecting = false; private _closed = false; private _shuttingDown = false; private _signalHandlers: { signal: string; handler: () => void }[] = []; private _reconnectPromise: Promise | null = null; private _connectPromise: Promise | null = null; private _stats: Omit< PoolStats, &`#39`;connected&`#39`; | &`#39`;reconnecting&`#39`; | &`#39`;totalCount&`#39`; | &`#39`;idleCount&`#39`; | &`#39`;waitingCount&`#39`; > = { totalConnections: 0, reconnectAttempts: 0, failedReconnects: 0, lastConnectedAt: null, lastDisconnectedAt: null, lastReconnectAttemptAt: null, }; /** * Creates a new PostgresPool. * * Note: By default, the actual connection is established lazily on first query. * Set `preconnect: true` in config to verify connectivity immediately. * * `@param` config - Connection configuration. Can be a connection URL string or a config object. * If not provided, uses `process.env.DATABASE_URL`. */ constructor(config?: string | PoolConfig) { super(); if (typeof config === &`#39`;string&`#39`;) { this._config = { connectionString: config }; } else { this._config = config ?? {}; } ... /** * Pool statistics. */ get stats(): Readonly { return { ...this._stats, connected: this._connected, reconnecting: this._reconnecting, totalCount: this._pool?.totalCount ?? 0, idleCount: this._pool?.idleCount ?? 0, waitingCount: this._pool?.waitingCount ?? 0, }; } /** * Total number of clients in the pool. */ get totalCount(): number { return this._pool?.totalCount ?? 0; } /** * Number of idle clients in the pool. */ get idleCount(): number { return this._pool?.idleCount ?? 0; } /** * Number of clients waiting to be acquired. */ get waitingCount(): number { return this._pool?.waitingCount ?? 0; } ... ?: pg. ... | pg. ... | null, result?: pg. ... /** * Wait for the connection to be established. * If the connection hasn&`#39`;t been established yet (lazy connection), this will * warm the connection by acquiring and releasing a client. * If reconnection is in progress, waits for it to complete. * * `@param` timeoutMs - Optional timeout in milliseconds * `@throws` {ConnectionClosedError} If the pool has been closed or connection fails */ async waitForConnection(timeoutMs?: number): Promise { if (this._connected && this._pool) { return; } if (this._closed) { throw new ConnectionClosedError({ message: &`#39`;Pool has been closed&`#39`;, }); } const connectOperation = async () => { // Wait for preconnect if in progress if (this._connectPromise) { try { await this._connectPromise; } catch (err) { this._connectPromise = null; throw err; } this._connectPromise = null; } // Wait for reconnection if in progress if (this._reconnecting && this._reconnectPromise) { await this._reconnectPromise; } // If still not connected, warm the connection if (!this._connected && this._pool) { await this._warmConnection(); } if (!this._connected || !this._pool) { throw new ConnectionClosedError({ message: &`#39`;Failed to establish connection&`#39`;, }); } }; if (timeoutMs !== undefined) { let timerId: ReturnType | undefined; const timeoutPromise = new Promise ((_, reject) => { timerId = setTimeout( () => reject( new QueryTimeoutError({ timeoutMs, }) ), timeoutMs ); }); try { await Promise.race([connectOperation(), timeoutPromise]); } finally { if (timerId !== undefined) { clearTimeout(timerId); } } } else { await connectOperation(); } } ... || this._ ... Acquire a client to verify connectivity ... client. ... this._ ... ; this._ ... this._ ... .lastConnected ... = new Date(); } /** * Handles idle-client errors from the underlying pg.Pool. * * pg.Pool emits `error` when a connected but idle client is dropped * (proxy idle timeout, server restart, network blip). The pool already * removes that client; remaining clients stay usable and the next * check…[truncated] <title>Losing connections in pg_pool? · Issue `#2366` · brianc/node-postgres</title> GitHub issue 2366 in brianc/node-postgres (link omitted to avoid creating a cross-reference) # Issue: brianc/node-postgres `#2366` - Repository: brianc/node-postgres | PostgreSQL client for node.js. | 13K stars | JavaScript ## Losing connections in pg_pool? - Author: [`@pcbentz`](https://github.com/pcbentz) - State: closed (completed) - Created: 2020-10-07T14:22:19Z - Updated: 2020-10-09T12:27:49Z - Closed: 2020-10-09T12:27:48Z - Closed by: [`@brianc`](https://github.com/brianc) Greetings, I am running 30 nodejs instances in AWS talking to a postgreSQL rds instance. Each nodejs instance is using pg and a pg_pool for all database interaction. All calls are using simply pg_pool.query to interact with the pool. Everything runs along well for several hours - we watch the waitingCount, idleCount and runningCount during execution and the values rise and fall nicely during the run. As some point in time however things go off the rails. The totalCount rises to the limit of 90, the idleCount remains at zero and the waitingCount continues to rise. (up to and over 100,000). The netstat command on the ec2 instances show they have 90 sessions established to the database server - however pg_stat_activity on the postgreSQL server indicates no queries are active from the instance. It appears the connections are idle - however the pool does not seem to be placing them back into the pool for the waiting entries? NodeJS Version: 4.2.6 pg Version: 6.4.2 pg-pool: 2.0.10 PostgreSQL Version 9.5.15 RDS instance size: db.r4.16xlarge We are creating a single global pool - and are handling error, remove, connect and aquire messages. Have the pool configured with an connectionTimeoutMillis: 60000 and idleTimeoutMillis: 120000 Using the bluebird promise. Am I actually losing connections in the pool? As an aside - eventually the postgreSQL rds instance eventually runs out of freeableMemory and crashes. AWS claims the crash is caused by excessive load. --- ### Timeline **`@charmander`** commented · Oct 7, 2020 at 11:36pm > You should upgrade to a supported version of Node and a supported version of pg. **`@brianc`** commented · Oct 9, 2020 at 12:27pm > `@pcbentz` - charmander is right you&`#39`;re running pretty old stuff. It&`#39`;s hard to say if the bugs are related to your code, version of node, or older version of PG....a few things I can recommend, some of which you probably already thought of. > > Make sure you have a single place in your app to do queries & make sure all the clients are returned to the pool...the most common issue of starved connection pools is not returning the client to the pool. [At the very bottom of this page](https://node-postgres.com/guides/project-structure#example) I put a little bit of info on how you could possibly track down unreturned clients. > > > As an aside - eventually the postgreSQL rds instance eventually runs out of freeableMemory and crashes. AWS claims the crash is caused by excessive load. > > If your instance is running out of memory but your sessions connected to the database is remaining steady at 90 then it&`#39`;s likely something fishy going on with your actual database. Connections to postgres backend use a non-trivial amount of ram because postgres uses a threaded model - but it&`#39`;s something like 4 megabytes per connection. In the distant past I&`#39`;ve crashed a postgres instance on an 4xl instance in ec2 but we connected like 3,000+ connections to it at once (oops!). Anyways, if you only have 90 connections steadily open to your database and it&`#39`;s running out of freeable memory, something else is going on there...possibly some runaway query or something else on the box. > > The final thing you can try doing, if upgrading pg & node isn&`#39`;t an option for you (I run apps in prod too, I know it can be a pain) is using something like https://www.npmjs.com/package/generic-pool and implement your own pool if you suspect the issue is in pg-pool. You can have fine-grained control over acquire and release semantics there & do any sort of instrumentation y…[truncated] <title>[pg-pool] Connection timeouts cause connection leaks</title> GitHub issue 3543 in brianc/node-postgres (link omitted to avoid creating a cross-reference) # [pg-pool] Connection timeouts cause connection leaks ... We found that when a new connection is opened and `connectionTimeoutMillis` is hit due to a race condition the following things can happen: ... 1. A connection is opened on the Postgres side, and is never actually utilized 2. `pg-pool` continues tracking the connection but never marks it as idle 3. The connection isn’t cleaned up until the pool itself is closed (or the NodeJS process dies) ... What&`#39`;s unusual is that the connection "times out" but continues to maintain an idle connection on the Postgres side, without ever marking it as idle in the pool. Might suggest a race condition here between the timeout and the client connection. ... const pool = new Pool({ port: 5432, // Setting this to 1 will cause everything to crash. max: 2, connectionTimeoutMillis: 1000, // Comment this out to try with `pg` // Client, log: (msg) => { console.log(msg); } }); ... = pool. ... pool.Client.prototype.connect = function (cb) { if (connection === 0) { connection += 1; return setTimeout(() => baseConnect.call(this, cb), 1100 ) } connection += 1; return baseConnect.call(this, cb) } ... await pool.connect(); ... let zombiesCount = 0; ... const res = await pool.query( &`#39`;select count(*) from pg_stat_activity where query_start is NULL and state = $1::text;&`#39`;, [&`#39`;idle&`#39`;] ); if (res.rows) { zombiesCount = res.rows[0][&`#39`;count&`#39`;] } else { zombiesCount = res[0][&`#39`;count&`#39`;] } ... ("Total errors ... ); console ... log("Total leaked ... ", zombiesCount); ... ("Total current pool size: ", pool.totalCount); ... console.log("Total current idle pool size: ", pool.idleCount); ... Here you can see the issue in action, where a connection is held open after the initial failed `connect` call and we end with a pool of two connections, only one of which is idle. ... You can test this by setting `max: 1` in the reproduction script’s config. This illustrates that leaked connections don’t get cleaned up when a new connection is requested, meaning it’s possible to exhaust the pool with enough connection timeouts. ... connected /Users/nick/node-postgres/packages/pg-pool/index.js:45 Error.captureStackTrace(err) ^ Error: timeout exceeded when trying to connect at /Users/nick/node-postgres/packages/pg-pool/index.js:45:11 at async file:///Users/nick/node-postgres/packages/pg-pool/test.mjs:67:13 ... `pg-native` has even more unusual behavior here, as it totally crashes the script and exits with code `13`. No error is thrown. ... If there is another Promise being awaited then no crash occurs and we resolve as usual (you can test this by creating an unresolved promise before the leaked connection query and awaiting it after the query is fired off). ... ## Possible fix for `pg` ... I was able to resolve this for `pg` specifically with the following change ... ```diff diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index 3e505f79..59c258c6 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -244,7 +244,7 @@ class Pool extends EventEmitter { this.log(&`#39`;ending client due to timeout&`#39`;) timeoutHit = true // force kill the node driver, and let libpq do its teardown - client.connection ? client.connection.stream.destroy() : client.end() + client.connection ? client.connection.end() : client.end() }, this.options.connectionTimeoutMillis) } ``` ... Which resulted in the following output: ... ending client due to timeout client failed ... no queued requests ----------------- Total connections 1 Total acquisitions: 1 Total releases: 1 Total removes: 0 Total errors: 0 Total leaked connections: 0 Total current pool size: 1 Total current idle pool size: 1 ----------------- ending pulse queue pulse queue on ending ... # How did we run into this? ... https://github.com/hex-inc/typeorm/blob/96ce9c27b59297224aba171bb7ac0bd0ccfb04ee/src/driver/postgres/PostgresDriver.ts#L821 TypeORM&`#39`;s Postgres driver u…[truncated]

Citations:


Preserve acquire-time waiter state for diagnostics. PostgresDatabase.query() reports the failure after PostgresPoolPressure.connect() rejects. The custom waiter is removed in finally, and pg-pool@3.14.0 removes the timed-out request from its pending queue before rejecting it. reportPostgresQueryFailure() then reads pool.waitingCount, which can be zero for a queue timeout, just like a dial timeout. The event does not include the retained pressure high-water metrics. Preserve acquire-time queue state in the event, or remove this queue-versus-dial distinction from the documentation.

ivanvykopal pushed a commit to ivanvykopal/orca that referenced this pull request Sep 20, 2026
…rector faults (stablyai#21243)

* fix(relay): treat pool connect failures as transient, not director faults

pg-pool raises connection-acquire failures as a plain Error with no SQLSTATE,
so the transient classifier matched only one of the three messages it can
produce. The other two reached the routes unclassified and became HTTP 500s,
which is what the rollout safety gate counts.

The acquire boundary now marks the errors it produces, so "Connection
terminated unexpectedly" counts as transient when the socket died during the
handshake and stays a hard failure mid-statement, where a retry could repeat a
commit whose outcome is unknown.

/v1/regions and /v1/admin/evacuation-status gain the transient handling
/v1/assign and /v1/resolve already had.

* fix(relay): mirror the pool-connect verdict in failure diagnostics

The query-failure event's connectionTimeout boolean matched one of the two
messages connectionTimeoutMillis can produce, so the 210 dialling timeouts in
the last day logged as false and were invisible to the field meant to find them.

The pool-connect vocabulary now lives beside the acquire boundary that owns it,
and both the router's classifier and the diagnostics read it from there, so the
two cannot drift. The event also carries the routing verdict the caller already
computed, making "how much of this burst reached users as a 500" one field.

* fix(relay): null-safe transient classification and honest transient docs

The classifier now runs inside the query catch, where a thrown null or
undefined would have turned a database failure into a TypeError that buried it.

The diagnostics doc claimed transient maps to a 503 or a 500. Sweeps, startup
reconciliation, and admin routes that answer 409 all emit the same event, so
counting the false ones over-states user-facing hard failures.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant