[#878] Bound the JDBC connection pool and expire its connections one by one - #884
[#878] Bound the JDBC connection pool and expire its connections one by one#884vharseko wants to merge 4 commits into
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
Reviewed 368cb104f2 only (aa64e76142 is shared with #876). The design is sound and a clear improvement over master — the pool is bounded, the TTL is per connection, expiry no longer needs a later borrow, and a closed backend releases its connections. One issue should be fixed before merge; the rest are nits.
Permit accounting is not exception-safe (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java has no finally anywhere in 950 lines, and every permit release is wired to the happy path or to catch (SQLException). Three sites, one root cause:
// getConnection — reservation taken at :513, released only on SQLException
try {
return borrowed(connect(connectionString, dialect, connectTimeoutSeconds, pool, !reentrant));
} catch (SQLException e) {
if (!reentrant) {
pool.cancelReservation();
}// connect — closeQuietly is inside the SQLException-only catch,
// so an unchecked throw here leaks the physical DB session too
} catch (SQLException e) { // nothing holds this connection yet: it would leak
closeQuietly(conNew);
throw e;
}// Pool.destroy — closeQuietly catches only SQLException, so releasePermit() is skipped
void destroy(CachedConnection con) {
closeQuietly(con.parent);
con.releasePermit();
}The permit's owner (the CachedConnection) is not constructed until the last line of connect(), so anything unchecked thrown before that orphans it with no object left to return it.
Reachable and reproduced: a % in a MySQL URL throws IllegalArgumentException from URLDecoder inside ConnectionUrlParser — the driver only wraps CJException, and DriverManager catches only SQLException around driver.connect. This project puts credentials in the URL (opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/MySqlTestCase.java:48), so a generated password containing % hits it. OutOfMemoryError reaches the same path.
1: IllegalArgumentException: URLDecoder: Illegal hex characters ... | liveCount=1
2: ... | liveCount=2
3: ... | liveCount=3
4: SQLTimeoutException: ... all 3 connections of the pool are in use (raise ...pool.max to allow more)
pools is static and never pruned, and only a live CachedConnection can return a permit, so nothing recovers it — not the database coming back, not closePool, not disabling and re-enabling the backend. After pool.max occurrences every borrow blocks the full 60 s and then reports that all connections are in use while holding none, pointing the operator at a property that would only feed the leak.
Suggested fix: try { ... } finally { if (!reentrant && !handedOff) pool.cancelReservation(); } around the borrow; widen the connect() guard to catch (Throwable) with a rethrow so closeQuietly always runs; wrap the close in destroy() in try { ... } finally { con.releasePermit(); }.
StubDriver (opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java) only ever throws SQLException, so CI does not defend this invariant — worth a driver mode that throws unchecked, asserting liveCount() == 0 afterwards.
A blocking close can park the sweeper for every pool (minor)
Pool.destroy() (opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java) closes inline on the single sweeper thread, and scheduleWithFixedDelay never overlaps runs, so one close that does not return stops TTL expiry JVM-wide — silently, since sweep() logs only thrown exceptions. Oracle's logoff() is a real round trip and relaxReadBound has lifted its read bound (Postgres/MySQL/MSSQL close() never read, so they are safe). abort(Executor) is already delegated and unused.
The borrow path does not compensate: it polls the head (pollFirst, freshest) while the sweep reaps the tail, so the stale tail is only reached once demand drains everything fresher.
held is global rather than per-pool (minor)
held (opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java) is one ThreadLocal for all pools, so a thread holding a connection to backend A is judged reentrant when borrowing from backend B: B's connection takes no permit, bypasses B's bound, and is destroyed instead of pooled on return. The deadlock the exemption guards against only exists within one pool, so the counter wants to be keyed by pool.
Nits
pool.maxis read once: it is the only property in the class not re-read at use (connect.timeout,pool.timeoutandttlall are), so it is fixed for a connection string from its first use. Worth one clause in the javadoc, since the neighbouring TTL javadoc advertises the opposite of itself.- The
heldjavadoc names a path it cannot cover:EntryContainer.importEntry's only caller passes a chunk-backed transaction that holds no connection, soheld == 0there and the exemption never fires.modifyDNis correct and should stay. ImporterImplconstructor is not exception-safe (opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java):conis borrowed and then two transactions are constructed on bare lines; a throw between them loses the borrow. Unreachable today (bothstartImport()sites close the storage first, so the mode is always re-openedREAD_WRITE), but one refactor from becoming live.catch (RuntimeException e) { closeQuietly(con); throw e; }settles it.close()unregisters with a connection stringopen()may not have used (opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java):applyConfigurationChangereplacesconfigin between, so a livedb-directorychange makesclosePooltarget a different URL thanopenPooldid. Capture the string used to register.open()'s catch unregisters unconditionally: thecompareAndSet(false, true)may have skipped registration, but the failure path still runscompareAndSet(true, false)+closePool. Latent — no caller issues twoopen()s on one instance — but the two flags are only safe by accident.ImporterImpl.close()masks the commit failure: the newfinally { con.close(); }lets the rollback error replace theSQLExceptionfromcommit(), which is exactly the case where the commit failed on a broken connection.addSuppressedkeeps both.- Sweep interval is computed once: derived from the TTL in force when the first pool is created and never revisited, so a TTL lowered at runtime is still swept on the old period.
DEFAULT_POOL_MAXcounts only worker threads:MultimasterReplication.getNumberOfReplayThreadsOrDefaultdefaults to the samecomputeNumberOfThreads(16, 2.0f), and import/backup/admin paths borrow too. Where borrowers outnumber the bound, each excess borrow also occupies its worker for the fullpool.timeoutbefore failing.close()is not idempotent: a second call would put the same connection into the idle deque twice. No caller reaches it today, but JDBC's contract saysclose()on a closed connection is a no-op.- No test for close-then-reopen:
addUser()'sclosed = falseis what keeps rebuild,removeStorageFiles()and import from leaving a pool that never pools again, and it is the least covered line in the change. Relatedly,clearProperties()is a no-op for pools already built — the suite is correct only because each test uses a unique URL. lib/extensionscomment is inaccurate:opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.javasays a driver "needs one dropped intolib/extensionsby hand", but all four drivers ship inlib/andopendj-server-legacy/resource/bin/_script-util.shsetsCLASSPATH=${INSTALL_ROOT}/lib/*, solib/extensionsis not on the server classpath at all.
Three pre-existing issues surfaced while reviewing this; none are caused by the PR and all deserve their own issues: RootContainer.open() calls storage.write() unconditionally even for READ_ONLY, which JDBCStorage.write() rejects, so offline export-ldif/verify-index/backendstat appear broken for JDBC backends; CompressedSchema.getAttributeId publishes a token before persisting it and never withdraws it on failure, leaving entries undecodable after a restart; and ImporterImpl's single Connection is written through concurrently by the phase-one and phase-two pools.
…ort a connect it cannot make CachedConnection.getConnection() established connections with no bound of its own and treated every SQLException from the connect as "the server is at max_connections", retrying it recursively with a wait doubling from 1 ms and no end to it. A database that listens but does not answer, a password that is not accepted, a driver that is not in lib/extensions - each hung the caller instead of failing it, silently: every backend operation, the open of a backend and dsconfig create-backend-index on a running server included, borrows through this path. The borrow is now bounded in both phases. One connect attempt is bounded by the properties of its dialect, recognized by the prefix of the connection string, through org.openidentityplatform.opendj.jdbc.connect.timeout (30 s by default, 0 for no bound); a property the connection string sets itself keeps precedence, so the loginTimeout/socketTimeout an administrator put into db-directory by hand still governs. Not one of the four drivers bounds the attempt with a single property - the second covers the reads of the prelogin handshake, of TLS and of authentication - and that includes the SQL Server driver, whose loginTimeout leaves the prelogin read open. Where that second property is a socket read timeout for the life of the connection (mysql, oracle, sql server), it is lifted once the login is through, so a statement slower than the bound is unaffected. Only a database that accepts no further connection is retried now, under the deadline of org.openidentityplatform.opendj.jdbc.pool.timeout (60 s by default), with the backoff capped at 1 s and a throttled warning so the stall is visible in the server log; every other failure is reported to the caller. A connect whose setup fails no longer leaks the connection, a connection that cannot be rolled back is closed instead of being pooled or dropped, and a pooled connection is validated with a bound rather than with isValid(0), which means "no timeout" in the JDBC contract. CachedConnectionTestCase covers all of it without a database - every dialect against a socket that never answers and a driver of the test for the retry - and the container suites assert that the read bound of the login does not outlive it.
…its connections one by one The pool held its connections in an unbounded queue behind a cache entry keyed by the connection string, and four things followed from that shape. Nothing limited how many connections a backend opened. The queue was unbounded and the cache had no maximum size, so a burst of concurrent operations opened as many connections as there were threads asking, and the only ceiling left was the max_connections of the database - which the backend then treats as a condition to wait out, turning a burst into a burst of connect attempts against a server already at its limit. The TTL sat on the entry rather than on a connection. expireAfterAccess is reset by every read of the entry and both the borrow and the return read it, so under continuous traffic the entry never expired and neither did anything in it: the peak count of a burst stayed open for as long as the backend saw any traffic at all. Expiry was lazy, and the case it exists for is the one it missed. The cache was built without a scheduler, so an entry was only expired by a later cache operation, and a backend that has gone idle - the only situation in which it could expire at all - performs none. A closed backend released nothing: close() only flipped the storage status. The entry is replaced by a pool of its own. A semaphore bounds it at org.openidentityplatform.opendj.jdbc.pool.max, defaulting to the shape of the server's worker thread pool - max(16, 2 x CPU) - since an operation holds one connection for its duration; a borrow above the bound waits for a returned connection until the deadline of pool.timeout and then reports which bound it hit, rather than opening one more. Idle connections are kept most recently returned first, each carrying the time it was returned, so the TTL is a property of a connection: the ones a burst opened sink to the bottom, where the borrow and a sweeper thread running every half TTL find them - the sweep needs no borrow behind it. The return goes straight to the pool the connection came from instead of through a lookup of its connection string, which could hand back an entry that was evicted between the two, leaving the connection in a queue nothing referred to any more. A borrow made while the same thread already holds a connection passes the bound. PersistentCompressedSchema.store() opens a write of its own - the definition has to commit independently of the entry - and is reached from inside a transaction by EntryContainer.importEntry and EntryContainer.modifyDN, both of which encode the entry inside it; making the second borrow wait for the first would wait for this very thread. Such a connection holds no permit and is closed rather than pooled on return, so the pool does not grow past its bound. JDBCStorage registers as a user of the pool of its connection string when it opens and gives it up when it closes, the connections being released once the last user is gone. Reference counted because a pool belongs to a database rather than to a backend: two backends may address one database, and closing one of them must not take the connections of the other with it. ImporterImpl.close() now returns its connection from a finally block. A commit that failed skipped the close, so the connection was leaked along with the failure.
368cb10 to
ae2964a
Compare
|
Rebased onto master (0b9c0f6), which had moved on under the JDBC backend since this branch was cut (#886 catalog lookup, #866 table stamping and statistics, #867 SQL Server upsert). Conflicts and how they were resolved:
Note this branch still carries #876 ( |
…he rest of what the review found The pool kept no finally anywhere, and every release of a permit hung on the happy path or on catch (SQLException). DriverManager catches SQLException alone, so an unchecked failure of a driver - Connector/J hands a url with a "%" in it to URLDecoder, and this backend keeps its credentials in the url - left the reservation of the attempt behind. Nothing gives such a permit back: only a live connection carries one, and pools are static and never pruned, so after as many failures as the bound every borrow waited out pool.timeout and then reported that all connections were in use while the pool held none. The attempt now gives back what it took on every way out of it, connect() closes the connection it established whatever is thrown, and destroy() releases the permit from a finally. The reentrancy counter moved from one static ThreadLocal into the pool it belongs to. The deadlock its exemption guards against only exists within one pool, so a shared count judged a thread holding a connection to one database reentrant while it borrowed from another - passing the bound of a pool it held nothing of, and destroying the connection instead of pooling it, on every operation. The sweep hands its closes to an executor instead of running them. Every pool is swept on one thread and scheduleWithFixedDelay never overlaps its runs, so one close that did not return stopped the expiry of every pool in the JVM, silently: oracle logs off over the network, and the read bound of the login is lifted by then. close() became a no-op the second time, as the JDBC contract says, rather than putting the same connection into the pool twice. JDBCStorage keeps the connection string it registered with, since applyConfigurationChange replaces config and close() would otherwise release the pool of another database and leave its own with a user it never loses; the catch of open() now unregisters only what that call registered. The ImporterImpl constructor gives back the connection it borrowed and closes the storage it opened when a transaction below it throws - WriteableTransactionTransactionImpl rejects a storage opened READ_ONLY - since close() belongs to an object that was built. Its close() no longer lets the failure of the return replace the failure of the commit, which is the case where the commit failed on a broken connection. Five cases added, none of which needs a database.
|
Thanks - the permit accounting was the real one, and it is fixed together with both minors and most of the nits in Permit accounting is not exception-safeFixed at all three sites, and the reachability holds up:
The driver mode you asked for is there: I checked that the new cases actually defend the invariant by reverting the three mechanisms and running the suite against the old behaviour: A blocking close can park the sweeper for every poolFixed.
|
| suite | result |
|---|---|
CachedConnectionTestCase |
25/25 (20 + 5) |
PgSqlTestCase |
54/54 |
MySqlTestCase |
54/54 |
MsSqlTestCase and OracleTestCase are left to CI - nothing dialect-specific was touched.
|
Filed the pre-existing ones. A correction to what I wrote above: only two of the three needed an issue.
The |
maximthomas
left a comment
There was a problem hiding this comment.
The design holds and does what it says — the bound, the per-connection TTL, the sweeper, the refcount and the permit finallys are all there, and the round-1 fixes land. Three majors below, no blockers. One of them is not new: it is the poll() defect from the #876 review, carried through the rewrite.
ImporterImpl.close() returns the connection outside a finally (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1581-1615
The PR body says the connection goes back "from a finally block". It goes back below a catch:
try { con.commit(); ... updateTableStatistics(con, writtenTrees); }
catch (SQLException e) { failure=e; } // SQLException only
try { con.close(); } catch (SQLException e) { ... }
finally { txw.stampSession.close(); }Any throwable that is not an SQLException out of con.commit() (CachedConnection:765 hands straight to parent.commit()) or out of updateTableStatistics() skips both con.close() and stampSession.close(). Only CachedConnection.close() → give()/destroy() releases the permit, so it is gone.
It does not heal on re-enable: nothing removes a Pool from the static pools map, so poolOf() hands back the same Semaphore, one permit short. ImportTask:684 catches Exception → STOPPED_BY_ERROR and the JVM lives on; repeats walk the bound to zero, after which every borrow blocks the full pool.timeout.
Reachable sources are Error (OOM in a bulk import) and a driver's commit() — not a RuntimeException from this file. getTableName is a pure SHA-224 hash and #886's catalog lookup is in openTree, so the obvious candidate does not fire.
try {
con.commit();
...
} finally {
try { con.close(); } catch (SQLException e) { /* addSuppressed */ }
finally { txw.stampSession.close(); }
}pollIdle() never consults the borrow deadline (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:290-308
Same defect the #876 review raised against the then-named poll(); the rewrite carried it over.
remainingWait -= System.currentTimeMillis() - polledAt;
if (remainingWait <= 0) { remainingWait = 0; } // does not end the looppollFirst(0, MS) is a non-blocking poll, so the clamp only makes the rest of the polls non-blocking — the loop drains the whole deque, paying isValid(VALIDATION_TIMEOUT_SECONDS=5) per entry. The only exit is an empty deque. deadline is read at :569 (pool full) and :594 (connection limit) — never before pollIdle, never after it returns null.
A moved VIP or a firewall idle-timeout leaves the pooled sockets half-open, and the sweeper only reaps past the TTL (15s), so a burst's connections all pass the TTL check at :298 and each blocks 5s. Worst case in one pollIdle = idleCount × 5s, idleCount ≤ pool.max: 80s by default, 320s on 32 cores, against a 60s pool.timeout. Then tryReserve() succeeds (every destroy released its permit) and the borrow falls into connect() for another 30s — ~110s for a borrow the operator bounded at 60s, on every worker at once.
Worth noting alongside: relaxReadBound() (:659) sets networkTimeout 0 on pooled connections, so closeQuietly(parent) inside destroy() (:342) is itself unbounded on a driver that talks on close (Oracle logoff) — and it runs on the borrowing thread.
Pass the deadline into pollIdle and break on it; bound the validation with min(VALIDATION_TIMEOUT_SECONDS, remaining).
A live db-directory change borrows from a pool with no users (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:107-118, with :143-145
open() pins the string; the borrow re-reads it:
poolConnectionString = config.getDBDirectory(); // open(), pinned once
CachedConnection.openPool(poolConnectionString);
...
return CachedConnection.getConnection(config.getDBDirectory()); // getConnection(), liveapplyConfigurationChange does only this.config = cfg;, and isConfigurationChangeAcceptable returns true unconditionally. Change X → Y on a running backend and pool X keeps users=1 with nothing borrowing from it, while every operation goes to poolOf(Y) with users=0. close() then drains X and never Y — the leak #878 exists to remove, back through the config path. Worse, if a second backend had registered Y and is later disabled, removeUser takes Y to zero, sets closed=true and drains it while this backend is still borrowing from it; give() destroys every returned connection instead of pooling it, so the live backend reconnects per operation, permanently.
The <adm:requires-admin-action><adm:component-restart/> on db-directory does not prevent this — AdministratorAction only renders a dsconfig/doc message, and BackendConfigManager.applyConfigurationChange acts on enabled and java-class alone; everything else reaches the backend's own listener.
Simplest fix: borrow from poolConnectionString instead of re-reading config. Otherwise handle the swap in applyConfigurationChange (openPool(new), closePool(old)), or reject the change in isConfigurationChangeAcceptable if a live change is not meant to be supported.
Nits
- Nested-borrow javadoc is false for the idle-hit case (
CachedConnection.java:201):pollIdle(:560) runs before the reentrancy branch (:564), so a nested borrow served from the deque is metered and is pooled on return. The bound still holds —give()destroys unmetered connections — but "never pooled on return" and the PR's "holds no permit" are wrong as written.liveCount()also cannot see in-flight unmetered borrows. - The sweeper hand-off test cannot fail (
CachedConnectionTestCase.java:365):pool.sweep(1000, handedOff::add)supplies its own executor, so the productioncloserfield (CachedConnection:92/121/143) is never read. RevertingclosertoDIRECT_EXECUTORleaves it green. ThescheduleWithFixedDelaywiring (:132) has no test at all. - The
JDBCStoragehalf is untested: every new case drivesCachedConnection.openPool/closePooldirectly.poolRegistered,poolConnectionStringandreleasePool()— four of the round-1 fixes — are asserted by nothing; deleting both calls leaves the suite green. - Two sweep tests race the live sweeper (
CachedConnectionTestCase.java:199,:361): both back-datereturnedAtMillistonow-60000, and the scheduled sweeper runs everymax(1000, 15000/2)=7500msoverpools.values()with a 15s TTL. A sweep landing before the assertion empties the deque. The class runs well past 7.5s, so this is a real CI flake. - The pool-full failure logs nothing (
CachedConnection.java:564): the one new failure mode of this PR throwsSQLTimeoutExceptionafter 60s with no server-log line;warnStall()is only on the connection-limit path (:602). On upgrade, a deployment over the new default bound sees LDAP errors with nothing attributing them to it. sweep()abandons the whole cycle on one lost race (:367):!idle.removeLastOccurrence(con)is treated like "nothing expired" andreturns. One concurrent borrow of the tail leaves every other expired connection open until the next sweep.continuerather thanreturn.close()never removes the config change listener (JDBCStorage.java:199): the constructor callsaddJDBCChangeListener(this);PDBStorage.close()removes its own. A disabled backend keeps mutatingthis.config— the input to major 3.isClosed()delegates to the parent (CachedConnection.java:800): a connection already returned to the pool answersfalse. No in-tree caller since the CaffeineremovalListenerwent, so this is SPI surface only.- Nothing is ever torn down (
:86,:209): nopools.remove, noThreadLocal.remove, no counterpart tostartSweeper(). OnePoolper connection string ever used, retained for the JVM's life, and the sweeper keeps waking for pools that will never hold a connection again. - Boundary contracts untested: no case sets
pool.max=0(→Integer.MAX_VALUE),pool.timeout=0(→Long.MAX_VALUE),ttl=0, or a negative/non-numeric value. "0 means unbounded" is operator-facing; inverting either mapping would pass. - Wall-clock time for TTL and deadlines (
:336,:364):System.currentTimeMillis(), whileJDBCStorage.write()(:892) usesnanoTime. An NTP step back stops the TTL firing; a step forward expires the pool at once. - The properties are documented nowhere:
pool.max,pool.timeout,connect.timeoutandttlappear only in source. The one place an operator learns the bound exists is the exception text. - Stamp connections are outside the bound (
JDBCStorage.java:404):newStampConnection()callsDriverManager.getConnectiondirectly, so the peak ispool.maxplus one per concurrent stamp, and they carry none of #876's connect bounds. Pre-existing (#866), newly relevant now that a bound exists — worth a line in the PR text either way.
…e its connection back whatever fails ImporterImpl.close() guarded its commit against SQLException alone, and the return of the connection sat below that catch rather than in a finally. An Error out of a bulk import, or a driver failing unchecked, skipped both the return and the close of the stamp session - and only that return gives back the permit the borrow took. A pool is never removed from the static map, so the permit was gone for the life of the server, and enough imports walk the bound of the pool down to nothing, after which every borrow waits out pool.timeout and then reports that all connections are in use. The commit is guarded against everything now, and the failure of the return rides along with what escaped instead of replacing it. A borrow uses the connection string open() registered with, rather than the one config carries at the time. db-directory reaches the listener of a running backend - applyConfigurationChange takes it, isConfigurationChangeAcceptable refuses nothing, and the component-restart admin action renders a message rather than holding the change back - so a borrow after such a change drew from a pool no storage had registered with, while the pool this one did register with kept a user that never borrows: the leak of OpenIdentityPlatform#878 back through the configuration. And that unregistered pool is drained the moment another backend which did register with it closes, leaving this one to reconnect for every operation. pollIdle() stops at the deadline of the borrow. pollFirst(0, MS) is a poll of no duration but still hands out whatever the deque holds, so the clamp to zero only made the rest of the polls non-blocking and the loop drained the deque whatever the deadline said. A connection whose socket is half-open - a moved VIP, a firewall that dropped the idle sockets - costs the validation timeout to discard, and the pool holds as many of those as its bound allows: 80s of validation against a 60s pool.timeout by default, 320s on 32 cores, before the connect that follows had even started. The validation is bounded by what is left of the borrow as well. A sweep gives up on the one connection a borrow took from under it rather than on the whole cycle, which used to leave everything else that had expired open until the next one. An Error out of a sweep no longer reaches scheduleWithFixedDelay, which never runs a task that threw again: the expiry of every pool in the JVM stopped with it, and silently, which is the failure mode the hand-off of the close exists to avoid. The bound of the pool reaches the server log, throttled like the stall warning, rather than only the error the client is given. The javadoc of the reentrancy exemption said a nested borrow is never pooled on return. pollIdle runs before the reentrancy branch, so one served out of the idle deque carries the permit that connection already holds and is pooled like any other; only one that had to establish a connection of its own is not. The exemption is from the wait rather than from the pool. Seven cases added, none of which needs a database, and the two that back-date a connection are taken out of the reach of the sweeper running beside them.
|
Thanks — all three majors hold up against the code, and they are fixed together with most of the nits in
|
| suite | result |
|---|---|
CachedConnectionTestCase |
32/32 (13 of #876 + 19) |
PgSqlTestCase |
54/54 |
MySqlTestCase |
54/54 |
MsSqlTestCase and OracleTestCase are left to CI — nothing dialect-specific was touched.
Each of the four mechanisms was reverted and the suite run against the old behaviour, to check the new cases fail on it rather than passing either way:
testABorrowStopsAtItsDeadlineRatherThanDrainingThePool the borrow drained the pool past its deadline: 6051 ms
testAnImportGivesItsConnectionBackWhenTheCommitFailsUnchecked the import kept the connection of the pool expected [1] but found [0]
testTheStorageBorrowsFromThePoolItRegisteredWith expected [...storage-registered] but found [...storage-changed]
testTheScheduledSweepClosesOnAThreadOfItsOwn the close ran on the test thread, not on the closer pool
Fixes #878
Problem
The pool held its connections in an unbounded queue behind a Caffeine entry keyed by the connection string:
Nothing limited how many connections a backend opened. Not the queue itself —
getConnectionestablishes a new connection whenever the queue comes back empty, and no count of live connections existed anywhere. The peak is therefore the number of threads borrowing at once, i.e. the worker threads:WorkQueue.computeNumWorkerThreadsfalls back toPlatform.computeNumberOfThreads(16, 2.0f), somax(16, 2 x CPU)by default. The only ceiling left was themax_connectionsof the database — which the backend treats as a condition to wait out, so a burst turned into a burst of connect attempts against a server already at its limit.The TTL sat on the entry, not on a connection.
expireAfterAccessis reset by every read of the entry, and both the borrow (cached.get(...)) and the return (cached.get(connectionString).add(this)) read it. Under continuous traffic the entry never expired and neither did anything in it: a burst that opened 200 connections kept all 200 for as long as the backend saw any traffic at all. The documented meaning oforg.openidentityplatform.opendj.jdbc.ttl— "the time after which an idle pooled connection is closed" — only held when the whole backend was idle.Expiry was lazy, and the case it exists for is the one it missed. No
scheduler()on the builder, so an entry was only ever expired by a later cache operation — and a backend that has gone idle, the only situation in which it could expire at all, performs none.A closed backend released nothing.
JDBCStorage.close()only flipped the storage status, so disabling or removing a JDBC backend left its connections open, possibly for good by the point above.And the return path raced:
cached.get(connectionString)and.add(this)are two operations, so a connection could land in a queue evicted between them — never handed out again, never closed.Fix
The cache entry is replaced by a pool of its own,
ConcurrentMap<String, Pool>.org.openidentityplatform.opendj.jdbc.pool.max, defaulting tomax(16, 2 x CPU)— the shape of the server's worker thread pool, since an operation holds one connection for its duration. The bound is a ceiling on connections held, not on operations served: a borrow above it waits for a returned connection until the deadline ofpool.timeout([#872] Bound the connect of the JDBC pool and report a connect it cannot make #876) and only then fails, naming the bound and the property it comes from, in the error the client sees and in a throttled warning in the server log — this is the one failure the change introduces, and a deployment whose peak sits above the default has to be able to attribute it.0means no bound.Errorincluded — escapes it intoscheduleWithFixedDelay, which never runs a task that threw again.pollFirst(0, MS)is a poll of no duration that still hands out whatever the deque holds, and a connection whose socket is half-open — a moved VIP, a firewall that dropped the idle sockets — costs the validation timeout to discard. The pool holds as many of those as its bound allows, so draining it overran the deadline the operator set: 80s of validation against a 60spool.timeoutby default, 320s on 32 cores, before the connect that follows had even started. The validation of a pooled connection is bounded by what is left of the borrow too.close()hands it back directly and the race is gone with the intermediateget.PersistentCompressedSchema.store()opens astorage.writeof its own — the definition has to commit independently of the entry — andEntryContainer.modifyDN(EntryContainer.java:2165) reaches it from inside a transaction, having encoded the entry there. Making the second borrow wait for the first would wait for this very thread. The exemption is from the wait rather than from the pool: a nested borrow served out of the idle deque carries the permit that connection already holds and is pooled again on return like any other, and only one that had to establish a connection of its own, because the pool stood at its bound, holds no permit — that one is closed rather than pooled when it comes back, so the pool does not grow past its bound. (addEntryandmodifyEntryencode before the write and do not nest.) The count of what a thread holds is kept per pool, not per thread: that deadlock exists only within one pool, and a shared count would judge a thread holding a connection to one database reentrant while it borrows from another.JDBCStorageregisters as a user of the pool of its connection string onopen(), borrows from that same string for as long as it is open, and gives it up onclose(); the idle ones are closed when the last user goes, and one still on loan is closed when it comes back rather than pooled for a borrower that is not going to come. Reference counted because a pool belongs to a database rather than to a backend: two backends may address one database, and closing one must not take the connections of the other. The string is pinned rather than re-read, becausedb-directoryreaches the listener of a running backend —applyConfigurationChangetakes it,isConfigurationChangeAcceptablerefuses nothing, and<adm:component-restart/>renders a message rather than holding the change back — so a borrow that followed the change would draw from a pool no storage had registered with, while the pool this one did register with kept a user that never borrows, and the unregistered one is drained the moment another backend that did register with it closes.Nothing is left behind by a borrow that failed. The reservation an attempt takes is given back on every way out of it rather than on
catch (SQLException)alone:DriverManagercatchesSQLExceptionalone too, so an unchecked failure of a driver — Connector/J hands a url with a%in it toURLDecoder, and this backend keeps its credentials in the url — used to leave a permit behind, and nothing gives such a permit back.connect()closes the session it established whatever is thrown, anddestroy()releases the permit from afinally.The same holds for the import:
ImporterImpl.close()guards itscommit()against everything rather than againstSQLException, and hands the connection back — and closes the stamp session — on every way out. Only that return gives back the permit the borrow took, and a pool is never removed from the static map, so anErrorout of a bulk import used to cost the bound one permit for the life of the server. The failure of the return rides along with what escaped, byaddSuppressed, instead of replacing it: the commit is what went wrong, and the rollback of the return fails on exactly the connection whose commit just did. The constructor gives back the connection it borrowed — and closes the storage it opened — when a transaction below it throws, sinceclose()belongs to an object that was built.close()on a connection already returned is a no-op, as the JDBC contract says, rather than putting it into the pool a second time.Not in this
The leak of a connection whose
rollback()fails inclose()is already fixed by #876 (testConnectionThatCannotBeRolledBackIsClosed), so nothing here repeats it — it is listed in the issue analysis againstmaster, where #876 has not landed.Closing the pool at server shutdown, rather than only at backend close, is left out: the map is static, so an in-process shutdown of the whole server still leaves the pools behind. Every path that closes a backend now releases them, which covers the deployments the issue describes; a shutdown hook is a change to
DirectoryServerrather than to this backend. Nothing removes aPoolfrom the map either, and the sweeper is never stopped, so a connection string used once is a pool for the life of the JVM.The stamp connections of #866 are outside the bound:
newStampConnection()goes toDriverManagerdirectly, so the peak ispool.maxplus one per concurrent stamp, and they carry none of the connect bounds of #876. Pre-existing, and newly worth stating now that a bound exists at all.pool.maxis read when a pool is built rather than at every use, unlikeconnect.timeout,pool.timeoutandttl, and the sweep interval is derived from the TTL in force when the first pool is created. Neither of the four properties is documented anywhere outside the source.DEFAULT_POOL_MAXcounts the worker threads only, while the replay threads of replication and the import, backup and admin paths borrow too; picking a different default is a decision about capacity rather than a fix.Verification
Nineteen cases added to
CachedConnectionTestCase, none of which needs a database — the stub driver of #876 serves them, so a regression fails the build wherever it runs:testThePoolDoesNotGrowPastItsBoundpool.max=2, two borrows on threads of their own fill the pool, a third fails with the bound named, and a returned connection then serves the next borrowtestABorrowNestedInAnotherMayPassTheBoundpool.max=1, a second borrow on the same thread succeeds, is closed rather than pooled on return, and the pool keeps exactly one idle connection afterwardstestABorrowStopsAtItsDeadlineRatherThanDrainingThePoolpool.timeoutof one, the borrow gives up on the deque instead of draining it, and is served in about a second rather than in sixtestABorrowWithNoDeadlineWaitsForAReturnedConnectionpool.timeout=0waits without limit rather than giving up at oncetestTheBoundOfThePoolReadsItsBoundaryValuespool.max=0is no bound, and a negative or non-numeric value falls back to the defaulttestAnIdleConnectionIsClosedAfterItsTtltestAZeroTtlKeepsNoIdleConnectionttl=0keeps nothingtestTheSweepClosesAnIdleConnectionWithNoBorrowBehindIttestTheSweepDoesNotCloseOnTheSweeperThreadtestTheScheduledSweepClosesOnAThreadOfItsOwntestClosingTheLastUserReleasesTheConnectionstestConnectionsSurviveWhileAnotherBackendStillUsesTheDatabasetestAConnectionReturnedAfterTheLastUserLeftIsClosedtestABackendClosedAndOpenedAgainPoolsItsConnectionstestTheStorageBorrowsFromThePoolItRegisteredWithdb-directorychanged under a running storage does not move its borrows, and itsclose()releases the pool it registered withtestAnImportGivesItsConnectionBackWhenTheCommitFailsUncheckedErrorout ofcommit()is reported to the caller and the connection is back in the pool, with no permit losttestAConnectFailingUncheckedCostsThePoolNothingliveCount() == 0, and the pool still servestestHoldingAConnectionToOneDatabaseDoesNotExemptABorrowFromAnothertestASecondCloseDoesNotPoolTheConnectionTwiceThe bound case borrows one connection per thread on purpose: two borrows on one thread are nested by definition, and a nested one is allowed past the bound.
Test runs, all green:
CachedConnectionTestCasePgSqlTestCaseMySqlTestCaseEvery case that covers a mechanism of this PR was also run against that mechanism reverted, to check that it fails on the old behaviour rather than passing either way.
MsSqlTestCaseandOracleTestCasewere not run locally — nothing dialect-specific was added, the path is shared with the two engines above — and are left to CI.