When OracleProvider.connect() fails after the pool was created, the pool is never closed. node-oracledb's thin pool then keeps trying to reach poolMin connections in a background loop, forever, with no backoff. Nothing holds a reference to it any more, so nothing can stop it. One failed connect to an unreachable Oracle host pins a CPU core and floods that host with TCP connection attempts until the process exits.
Measured
Measured 2026-09-23, node-oracledb 6.10.0 (thin mode), nothing listening on 127.0.0.1:1521.
Plain Node process, no Next.js. oracledb.createPool({ poolMin: 2, poolMax: 10, ... }), then pool.getConnection() rejects with NJS-503: connection to host 127.0.0.1 port 1521 could not be established. The pool reference is then dropped, exactly as the provider does. net.Socket.prototype.connect was counted for the next 5 seconds:
| Window |
TCP connect attempts |
CPU time |
5 s after the failed getConnection() |
71,383 (about 14,000 per second) |
5,447 ms (one full core) |
1 s after pool.close(0) |
0 |
- |
So this is not a development-only problem: in production every orphaned pool is a spinning core and a connection flood toward the Oracle host (or a firewall in front of it).
Under next dev it also leaks memory. A bun dev server grew to 35 GB RSS in 13 minutes at about 137% CPU. A forced full GC freed only 0.3 GB of 23 GB of used heap, so it was retained. A 30 second sampling heap profile put the retained allocations in oracledb/lib/thin/pool.js bgThreadFunc, ThinConnectionImpl.connect and networkSession.connect, each captured by the async-hooks init hook of next/dist/compiled/next-server/app-page-turbo.runtime.dev.js, which keeps a stack trace per promise and socket (about 1 GB per 30 s). next dev sets --max-old-space-size to half of system RAM (32004 MB on a 64 GB machine), so nothing fails early and the machine runs out of memory first.
How it was hit
Log in with a seed config that contains an Oracle connection whose host is down, and open /admin (a logged-in visit to /login redirects there). POST /api/admin/fleet-health calls getOrCreateProvider() for every seed connection, so every unreachable Oracle seed orphans one pool per call. Two Oracle seeds (oracle-local, task30-oracle) were enough. Any other path that ends in getOrCreateProvider() for an unreachable Oracle connection does the same: health, query, object routes, the agent.
Cause
src/lib/db/providers/sql/oracle.ts, OracleProvider.connect():
this.pool = await oracledb.createPool({ poolMin: this.poolConfig.min, ... }) succeeds. Thin mode does not connect here, and poolMin defaults to 2 (DEFAULT_POOL_CONFIG in src/lib/db/types.ts).
- The test borrow
await this.pool.getConnection() rejects.
- The
catch sets the error and throws ConnectionError (or DatabaseConfigError for NJS-138) without closing this.pool.
getOrCreateProvider() in src/lib/db/factory.ts rethrows and never caches a provider whose connect() failed, so no later disconnect() can reach the pool.
bgThreadFunc() in node_modules/oracledb/lib/thin/pool.js loops "until a close request is received". On a failed connection it stores _bgErr and tries again after a setImmediate, with no delay.
There is a second, smaller defect from the same line: this.pool stays set after the failure, so a later connect() on the same instance hits the if (this.pool) return; guard and returns without connecting and without an error.
Precedent in this repository
PostgreSQL and SQL Server already fixed this exact shape, with the reason in a comment. PostgresProvider.connect() (src/lib/db/providers/sql/postgres.ts) does, in its catch:
const failedPool = this.pool;
this.pool = null;
await failedPool?.end().catch(() => {});
MSSQLProvider.connect() does the same with its ConnectionPool. Oracle should follow that pattern, with close(0) as the oracledb equivalent of end().
Scope
- In scope:
OracleProvider.connect() and its tests, plus the provider doc.
- Not affected, measured: MySQL.
mysql2's pool has no background creator; after a failed getConnection() it made 0 connection attempts and used 0.3 ms of CPU in 3 s. Do not change mysql.ts in this fix.
- Already fixed: PostgreSQL, SQL Server.
- Out of scope: adding backoff inside oracledb, changing
fleet-health, changing DEFAULT_POOL_CONFIG, or making the factory dispose providers. The provider must clean up after itself, the same contract PostgreSQL and SQL Server follow.
Acceptance criteria
- When
connect() fails at any step after createPool() resolved, the pool is closed with close(0) before the error leaves connect(). This covers both the ConnectionError path and the NJS-138 DatabaseConfigError path.
- After a failed
connect(), this.pool is null and isConnected() is false, so calling connect() again on the same instance runs createPool() again instead of returning early.
- If
close(0) itself rejects, the caller still receives the original connect error (same type, same message). The close failure must not replace or hide it.
- When
createPool() itself rejects, behaviour is unchanged (there is nothing to close).
- A successful
connect() is unchanged.
- The comment in the
catch states why the pool is closed there, the way the PostgreSQL provider's comment does.
docs/providers/oracle.md records that a failed connect closes its pool, per the provider triad rule in CLAUDE.md (code, docs and tests change together).
Test requirement
Write the failing tests first, in tests/integration/db/oracle-provider.test.ts, using its existing mockCreatePoolFn / mockPoolCloseFn doubles:
createPool resolves, getConnection rejects: assert ConnectionError, assert the pool's close was called once with 0, assert isConnected() is false.
- Same setup with an NJS-138 message: assert
DatabaseConfigError and that close(0) was called.
- Same setup, then a second
connect() where getConnection resolves: assert createPool was called twice and isConnected() is true.
getConnection rejects and close also rejects: assert the original error type and message reach the caller.
Each new test must fail on current main before the fix. The 100% line coverage gate applies (bun run test:coverage && bun run coverage:check).
Manual verification
With no Oracle running, run a script that calls new OracleProvider({ type: "oracle", host: "127.0.0.1", port: 1521, ... }).connect(), catch the error, then count net.Socket.prototype.connect calls and process.cpuUsage() for 5 seconds. Before the fix: tens of thousands of attempts and about one core of CPU. After the fix: 0 attempts and near-zero CPU.
When
OracleProvider.connect()fails after the pool was created, the pool is never closed. node-oracledb's thin pool then keeps trying to reachpoolMinconnections in a background loop, forever, with no backoff. Nothing holds a reference to it any more, so nothing can stop it. One failed connect to an unreachable Oracle host pins a CPU core and floods that host with TCP connection attempts until the process exits.Measured
Measured 2026-09-23, node-oracledb 6.10.0 (thin mode), nothing listening on
127.0.0.1:1521.Plain Node process, no Next.js.
oracledb.createPool({ poolMin: 2, poolMax: 10, ... }), thenpool.getConnection()rejects withNJS-503: connection to host 127.0.0.1 port 1521 could not be established. The pool reference is then dropped, exactly as the provider does.net.Socket.prototype.connectwas counted for the next 5 seconds:getConnection()pool.close(0)So this is not a development-only problem: in production every orphaned pool is a spinning core and a connection flood toward the Oracle host (or a firewall in front of it).
Under
next devit also leaks memory. Abun devserver grew to 35 GB RSS in 13 minutes at about 137% CPU. A forced full GC freed only 0.3 GB of 23 GB of used heap, so it was retained. A 30 second sampling heap profile put the retained allocations inoracledb/lib/thin/pool.jsbgThreadFunc,ThinConnectionImpl.connectandnetworkSession.connect, each captured by the async-hooksinithook ofnext/dist/compiled/next-server/app-page-turbo.runtime.dev.js, which keeps a stack trace per promise and socket (about 1 GB per 30 s).next devsets--max-old-space-sizeto half of system RAM (32004 MB on a 64 GB machine), so nothing fails early and the machine runs out of memory first.How it was hit
Log in with a seed config that contains an Oracle connection whose host is down, and open
/admin(a logged-in visit to/loginredirects there).POST /api/admin/fleet-healthcallsgetOrCreateProvider()for every seed connection, so every unreachable Oracle seed orphans one pool per call. Two Oracle seeds (oracle-local,task30-oracle) were enough. Any other path that ends ingetOrCreateProvider()for an unreachable Oracle connection does the same: health, query, object routes, the agent.Cause
src/lib/db/providers/sql/oracle.ts,OracleProvider.connect():this.pool = await oracledb.createPool({ poolMin: this.poolConfig.min, ... })succeeds. Thin mode does not connect here, andpoolMindefaults to 2 (DEFAULT_POOL_CONFIGinsrc/lib/db/types.ts).await this.pool.getConnection()rejects.catchsets the error and throwsConnectionError(orDatabaseConfigErrorfor NJS-138) without closingthis.pool.getOrCreateProvider()insrc/lib/db/factory.tsrethrows and never caches a provider whoseconnect()failed, so no laterdisconnect()can reach the pool.bgThreadFunc()innode_modules/oracledb/lib/thin/pool.jsloops "until a close request is received". On a failed connection it stores_bgErrand tries again after asetImmediate, with no delay.There is a second, smaller defect from the same line:
this.poolstays set after the failure, so a laterconnect()on the same instance hits theif (this.pool) return;guard and returns without connecting and without an error.Precedent in this repository
PostgreSQL and SQL Server already fixed this exact shape, with the reason in a comment.
PostgresProvider.connect()(src/lib/db/providers/sql/postgres.ts) does, in itscatch:MSSQLProvider.connect()does the same with itsConnectionPool. Oracle should follow that pattern, withclose(0)as the oracledb equivalent ofend().Scope
OracleProvider.connect()and its tests, plus the provider doc.mysql2's pool has no background creator; after a failedgetConnection()it made 0 connection attempts and used 0.3 ms of CPU in 3 s. Do not changemysql.tsin this fix.fleet-health, changingDEFAULT_POOL_CONFIG, or making the factory dispose providers. The provider must clean up after itself, the same contract PostgreSQL and SQL Server follow.Acceptance criteria
connect()fails at any step aftercreatePool()resolved, the pool is closed withclose(0)before the error leavesconnect(). This covers both theConnectionErrorpath and the NJS-138DatabaseConfigErrorpath.connect(),this.poolisnullandisConnected()isfalse, so callingconnect()again on the same instance runscreatePool()again instead of returning early.close(0)itself rejects, the caller still receives the original connect error (same type, same message). The close failure must not replace or hide it.createPool()itself rejects, behaviour is unchanged (there is nothing to close).connect()is unchanged.catchstates why the pool is closed there, the way the PostgreSQL provider's comment does.docs/providers/oracle.mdrecords that a failed connect closes its pool, per the provider triad rule inCLAUDE.md(code, docs and tests change together).Test requirement
Write the failing tests first, in
tests/integration/db/oracle-provider.test.ts, using its existingmockCreatePoolFn/mockPoolCloseFndoubles:createPoolresolves,getConnectionrejects: assertConnectionError, assert the pool'sclosewas called once with0, assertisConnected()isfalse.DatabaseConfigErrorand thatclose(0)was called.connect()wheregetConnectionresolves: assertcreatePoolwas called twice andisConnected()istrue.getConnectionrejects andclosealso rejects: assert the original error type and message reach the caller.Each new test must fail on current
mainbefore the fix. The 100% line coverage gate applies (bun run test:coverage && bun run coverage:check).Manual verification
With no Oracle running, run a script that calls
new OracleProvider({ type: "oracle", host: "127.0.0.1", port: 1521, ... }).connect(), catch the error, then countnet.Socket.prototype.connectcalls andprocess.cpuUsage()for 5 seconds. Before the fix: tens of thousands of attempts and about one core of CPU. After the fix: 0 attempts and near-zero CPU.