[#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 3 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.
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 ( |
|
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.
|
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
…its connections one by one Nothing limited how many connections a backend opened. The pool was an unbounded queue behind a cache with 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 itself. The TTL sat on the pool rather than on a connection - expireAfterAccess keyed by the connection string, reset by every borrow and every return - so under continuous traffic nothing ever expired and the peak of a burst stayed open for as long as the backend saw any traffic at all. Caffeine was left without a scheduler besides, so an entry was only ever expired by a later cache operation, and a backend gone idle - the one case the TTL exists for - performs none. The cache entry is replaced by a Pool per connection string: a permit per live connection sized once from the new pool.max property, an idle deque handed out from the end it is returned to, and a sweep of its own that closes what nothing has borrowed for the TTL. The pool is reference counted by the storages using it, since it belongs to a database rather than to a backend and two backends may address one. Rebased onto master, which has since taken OpenIdentityPlatform#876, OpenIdentityPlatform#880 and OpenIdentityPlatform#883. Four points of contact this had to decide rather than merge: - pollIdle() is told whether the borrow trusts the alive window of OpenIdentityPlatform#879 and asks isUsable() as master wrote it. The pool of this change and that window are one layer, not two. - A connection carries the poolable flag of OpenIdentityPlatform#872, the proof of life of OpenIdentityPlatform#879 and the pool, the meter and the permit of this change together. close() sends a connection that may not be pooled through Pool.destroy() rather than closing it, or the bound would lose a place for every one of them. - getValidatedConnection() and distrustPool() of OpenIdentityPlatform#879 read db-directory again where this change had already established that the string open() registered with is the one to use: a db-directory changed on a running backend sent each of them to a pool holding none of this storage's connections. All three go through poolKey() now. - A nested borrow takes a permit when the pool has room, and goes on unmetered only when the pool stands at its bound. The exemption a nested borrow carries is from the wait rather than from the pool, which is what Pool.held has always documented; the code exempted it from both, so a connection borrowed inside another was never pooled again and the pool stopped handing out the connection returned last. Not carried over: the validation of a pooled connection is not clamped to what is left of the borrow. Master bounds it at the socket instead, and the pollIdle loop checks the deadline after each connection it discards, so a drain overruns by at most the one validation it is inside - which is what master does today. CachedConnectionTestCase: 83/83.
642729f to
2371bc9
Compare
Rebased onto master — re-requesting review, since the approval predates the rebase#876, #880 and #883 have landed, so this branch no longer stands on The three commits are squashed into one. Their base was the first commit of #872, and
One of the four is a fix to this PR, not to the merge. A nested borrow now takes a permit when the One behaviour is deliberately not carried over: the validation of a pooled connection is no longer
Your approval of 24 Aug was given on |
maximthomas
left a comment
There was a problem hiding this comment.
Blocking
issue (blocking): close() loses a permit for good when rollback() throws unchecked.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:1806-1813
if (!returned.compareAndSet(false, true)) { return; }
if (owner == Thread.currentThread()) { pool.leave(); }
try { rollback(); } catch (SQLException e) { pool.destroy(this); throw e; }There is no finally, and SQLException is the only catch. An unchecked throw out of parent.rollback() escapes after the CAS and after leave(), so the connection reaches neither give() nor destroy(). releasePermit() has exactly one caller — Pool.destroy() — and pools are never removed from the static map, so the bound drops by one for the life of the JVM; enough of them and every borrow fails with SQLTimeoutException. Give close() a finally with a handed-off flag so every exit that is not a successful give() goes through destroy().
Checked and clean: the retried close() returns at the CAS; pool and releasePermit are package-private and callers hold only java.sql.Connection, so no caller can compensate; #876's testConnectionThatCannotBeRolledBackIsClosed uses doThrow(new SQLException(...)), which exercises the catch, not the escape.
Non-blocking
issue (non-blocking): The bound is sized from a formula that is only a fallback.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:126
static final int DEFAULT_POOL_MAX = Math.max(16, Runtime.getRuntime().availableProcessors() * 2);The comment justifies this as "sized like the worker thread pool ... Platform.computeNumberOfThreads(16, 2)". But that is the else branch of opendj-server-legacy/src/main/java/org/opends/server/api/WorkQueue.java:150-159:
if (configuredNumWorkerThreads != null) { return configuredNumWorkerThreads; }
int value = Platform.computeNumberOfThreads(16, 2.0f);An 8-core server with ds-cfg-num-worker-threads tuned to 64 gets 64 borrowers and 16 permits; under sustained load the surplus waits out pool.timeout and fails, on a config the operator never touched. Derive the default from the configured value, or warn at startup when it exceeds the bound.
Checked and clean: the configured value reaches computeNumWorkerThreads from all four call sites (TraditionalWorkQueue:159/:623, ParallelWorkQueue:114/:431); nothing outside CachedConnection.java:122/:126 and the test class references POOL_MAX_PROPERTY or DEFAULT_POOL_MAX.
issue (non-blocking): A failed open() reports working() with its registration already released.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:222-234
try (final Connection con = getValidatedConnection()) {
this.accessMode = accessMode;
storageStatus = StorageStatus.working();
} catch (Exception e) { if (registeredHere) { releasePool(); } throw e; }The status is set inside the resource block, so a throw from the implicit con.close() runs releasePool() (users 0, closed true) and rethrows with the status still working(). RootContainer.open wraps and rethrows without storage.close(), and both re-open guards (:853, :1922) test isWorking() — so no user is registered again, and Pool.give() destroys every returned connection. Pooling is off for that connection string, silently. Hoist the assignment out of the try-with-resources.
Checked and clean: no permit is leaked here — destroy() releases them; the loss is the pooling.
issue (non-blocking): catch (Exception) where the PR's own standard needs Throwable.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1940-1950 and :229
} catch (Exception e) {
if (borrowed != null) { try { borrowed.close(); } catch (SQLException e2) {} }In the ImporterImpl constructor the borrow is already handed off, and new WriteableTransactionTransactionImpl(borrowed) runs new StampSession() in a field initializer. An Error there — OOM in a bulk import — escapes, and nothing releases the permit. In open() a NoClassDefFoundError from driver static init escapes with the pool holding a user that never leaves. ImporterImpl.close() sixty lines below catches Throwable for exactly this reason. Widen both to match.
Checked and clean: the constructor's comment "nothing holds what this constructor takes until it returns" is why this is worse, not better — after construction close() compensates; inside it nothing does.
issue (non-blocking): liveCount() cannot see the connections that exceed the bound.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:409-412
/** The connections this pool holds, borrowed and idle together. */
int liveCount() { return max - permits.availablePermits(); }It counts permits. A nested borrow at the bound goes unmetered — final boolean metered = pool.tryReserve() fails and, with reentrant true, the borrow falls through holding nothing. So the connections it misses are exactly the ones over the bound, and the eight assertions that rely on it (several == 0) pass with unmetered connections outstanding. Either correct the javadoc or count unmetered connections separately. This is the one deviation the description does not mention.
…and open a storage that says so The findings of the review, none of them changing what the pool does. close() lost the permit of a connection whose rollback threw unchecked. The CAS that makes one close the only one runs first, so an escape past it left the connection closed by nothing at all and its permit released by nothing either - destroy() is the only caller of releasePermit(), and a pool is never removed from the static map, so that place in the bound was gone for the life of the server. The hand-off is decided in a finally now: whatever does not reach give() reaches destroy(), and the flag is raised before give() rather than after, so a connection already in the idle deque is not closed a second time. ImporterImpl.releaseConnection() is widened the same way: its close() is that same return, and an unchecked failure of it left with the failure of the commit - or with the Error the Throwable branch above exists to preserve - dropped. The depth that exempts a nested borrow from the wait came down only where the returning thread was the borrowing one, and the connection forgot its borrower either way, so a return made on another thread left that borrower standing at a depth nothing could lower: it was taken for a nested borrow for the life of the server, exempt from the bound, opening an unmetered connection per operation that the return then closed. The connection carries the counter of its borrower rather than the borrower itself, and lowers it wherever the return happens. JDBCStorage.open() set the status of the storage inside the try-with-resources of its validating borrow. A throw from the implicit close() - the return rolls back, and the rollback goes to the database - left the storage reporting working() while open() failed and gave its registration of the pool back; write() and ImporterImpl both skip the re-open when the status says working, so the pool stayed without a user and destroyed every connection returned to it, pooling off for that database for as long as the server ran. The status is set after the block, and the registration is taken inside it. What answers for that registration is now openPool() having returned rather than the claim of the flag: a throw between the two would otherwise take a user off a pool this storage never added one to, and the pool of a database two backends share would lose the user of the other one. newStampConnection() goes through poolKey() for the same reason - it is the last connection of this backend that followed a db-directory changed under it, into a database the rest of the backend had stopped using. The constructor of ImporterImpl caught Exception where its own close() sixty lines below catches Throwable, and for the same reason: nothing holds the borrow until the constructor returns, and WriteableTransactionTransactionImpl runs a StampSession in a field initializer. An Error out of a bulk import took the permit with it. open() is widened the same way, for a driver whose static initializer fails. DEFAULT_POOL_MAX is Platform.computeNumberOfThreads(16, 2), which is only what WorkQueue.computeNumWorkerThreads falls back to - a configured ds-cfg-num-worker-threads replaces that count outright. No default can follow it, since the bound belongs to a database two backends may share while the count belongs to the server, so openPool() names both in the log when the borrowers outnumber the places. One difference, not the whole demand: the replay threads of replication default to that same count again and borrow on top of the workers. The remedy the pool-full failure names says to restart as well, since the bound is read once per pool; and that failure is throttled per connection string now, the way the stall warning beside it already was, or the backend that reported first would silence the one whose operations are the ones failing. The sweep books its next run out of the one before it rather than at a delay computed once, so a ttl changed on a running server changes when connections are actually reaped; and a connection borrowed and given back between the peek that found it expired and the removal that takes it out goes back to the deque instead of being closed a moment after it was returned. Pool.liveCount() counted permits while its javadoc promised connections, and the ones it misses are exactly those past the bound, which a nested borrow may hold. Renamed to meteredCount() and documented as the count the bound is about. CachedConnection.invalidate() went with it: nothing that ships called it, and the drain it wrapped is reachable through the pool. The public wrapper constructor says poolable=false, which is what the accounting made of it anyway. Two comments the removal of Caffeine left behind: isKnownAlive() explained its isClosed() check by a removalListener that iterated the deque under a borrow - every path that destroys an idle connection now takes it out of the deque first, so the check stands for a driver that gave up on the connection instead - and getAliveBypassMillis() claimed the ttl it clamps against is read once, which getCacheTtlMillis() has stopped doing. CachedConnectionTestCase: 86/86, three of them new - a rollback that fails unchecked, an open that fails on the return of its validating borrow, and a return made on a thread other than the borrower.
|
Thanks — the blocking one holds up exactly as you traced it, and so do the three non-blocking ones. All four are fixed in
|
| suite | result |
|---|---|
CachedConnectionTestCase |
86/86 |
Three cases added. The depth one was run against the mechanism reverted and fails there on its own assertion, so it is not passing either way.
The engine suites have not been re-run since the rebase and are left to CI.
…bounded OpenIdentityPlatform#882 landed, and the two branches had moved the same borrow in different directions. Resolved so that the design of this one stands and what OpenIdentityPlatform#877 gave that path comes with it: * The importer keeps the borrow this branch moved into its constructor, and startImport() is collapsed to match. git flagged only the constructor - it had auto-merged startImport() to master's version, which borrows before building the importer - so taking the conflicted side alone would have borrowed twice. * Both transactions of the importer keep StatementBound.BULK: every statement an import issues is bulk by construction, which is the contract of OpenIdentityPlatform#877. * The ReadOnlyStorageException of OpenIdentityPlatform#882 stays, inside the try rather than in front of it, so a storage this constructor opened is given back when the refusal fires. * One seam for the borrow (OpenIdentityPlatform#882) naming the pool this storage registered with (OpenIdentityPlatform#878): getConnection(boolean) goes through poolKey(). The refusal now stands in front of the borrow, so an import of a read-only storage takes no connection at all rather than taking one and returning it. The two tests of OpenIdentityPlatform#882 that pinned the return are rewritten to pin that nothing is borrowed - the same leak, covered at the state that cannot reach it.
|
@maximthomas #877 (#882) has landed and this branch is merged with it, git flagged the wrong half#882 borrows in The merge marked only the constructor. It had auto-merged Resolved by keeping this branch's design and collapsing What of #882 comes with it
One behaviour changed, and it is not cosmeticThe refusal now stands in front of the borrow, so an import of a read-only storage takes no connection at all rather than taking one and returning it. That makes two of your tests describe something that no longer happens. I am flagging this rather than presenting it as a straight merge: it walks back the assertion of a test you asked for, and if you would rather the refusal sat behind the borrow so that the original assertions stand as written, say so.
State
The five points of your last round are answered in |
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. It does go throughpoolKey()now, so adb-directorychanged under a running backend no longer stamps the trees of this backend in a database the rest of it has stopped using.pool.maxis read when a pool is built rather than at every use, unlikeconnect.timeout,pool.timeoutandttl: a pool is never removed from the map and the permits of one already built are not resized, so this property takes a restart of the server — the failure that recommends raising it says so now, and so does its javadoc. None of the four is documented anywhere outside the source.DEFAULT_POOL_MAXstill counts the worker threads only, while the replay threads of replication — which default to that samecomputeNumberOfThreads(16, 2)— and the import, backup and admin paths borrow on top of them;openPool()now reports the one difference it can measure, and picking a different default remains 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 losttestAConnectFailingUncheckedCostsThePoolNothingmeteredCount() == 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.Rebased onto master
#876, #880 and #883 landed while this branch stood, and it is now one commit on top of
masterrather than a branch stacked on
issues/872-jdbc-connect-timeout. The three commits it carried aresquashed into that one: their base was the first commit of #872, so every one of them would have had
to be replayed onto a file two rounds of review had rewritten, and resolving the same hunks three
times over would have been three guesses rather than one.
mastergrew the alive window of #879 and the read bound of #872 in the very code this PR replaces,so four points of contact are decisions rather than merges:
pollIdle()is told whether the borrow trusts the alive window, and asksisUsable()asmasterwrote it. The pool of this PR and that window are one layer, not two: the deque thischange hands out from the hot end is what gives the window anything to bypass, and the validation
it skips is the one this change would otherwise pay per connection it discards.
poolableflag of JDBC backend: the connection pool connects without any timeout and retries a failed connect forever #872, the proof oflife of JDBC backend validates the pooled connection on every borrow, costing a database round trip per operation #879, and the pool, the meter and the permit of this PR.
close()sends one that may notbe pooled through
Pool.destroy()rather than closing it outright, or the bound would lose a placefor every connection kept out of the pool, permanently.
poolKey().getValidatedConnection()anddistrustPool()of JDBC backend validates the pooled connection on every borrow, costing a database round trip per operation #879 readdb-directoryagain, where this PR had already established that thestring
open()registered with is the one to use: adb-directorychanged on a running backendsent the validated borrow to a pool this storage never registered with, and filed the distrust
against a pool holding none of its connections.
stands at its bound. This is a fix to this PR rather than to the merge:
Pool.helddocuments theexemption as being from the wait rather than from the pool, while the code exempted a nested
borrow from both — so a connection borrowed inside another was never pooled again, and the pool
stopped handing out the connection returned last.
testTheConnectionReturnedLastIsBorrowedFirstof [#879] Skip the validation of a pooled JDBC connection returned a moment ago #883 is what caught it.
Not carried over: the validation of a pooled connection is no longer clamped to what is left of
the borrow (
isUsable(con, deadline)).masterbounds that validation at the socket instead, andthe
pollIdleloop checks the deadline after every connection it discards, so a drain of a pool thedatabase no longer answers overruns by at most the single validation it is inside — which is what
masterdoes today. Say the word and it comes back as a commit of its own.CachedConnectionTestCaseThat run covers the cases of this PR and the ones #872 and #879 added to the same class. The engine
suites have not been re-run since the rebase and are left to CI.
Review of the rebase, answered
a1bdc2ffollows the review of 2 September and the reading of the branch it prompted. Nothing here changes what the pool does; the shape of the fix, the bound and the TTL are as reviewed.The blocking one.
close()lost the permit of a connection whoserollback()threw unchecked. The CAS that makes oneclose()the only one runs first, so an escape past it left the connection closed by nothing at all and its permit released by nothing either —destroy()is the only caller ofreleasePermit(), and a pool is never removed from the static map, so that place in the bound was gone for the life of the server. The hand-off is decided in afinallynow: whatever does not reachgive()reachesdestroy(). The flag is raised beforegive()rather than after, so a connection that already reached the idle deque is not closed a second time under the borrower it is about to be handed to.ImporterImpl.releaseConnection()needed the same widening for the same reason: itsclose()is that return, and an unchecked failure of it left with the failure of the commit — or, in theThrowablebranch above it, with theErrorthat branch exists to preserve — dropped on the floor.The three non-blocking ones.
open(). It was set inside the try-with-resources of the validating borrow, so a throw from the implicitclose()left the storage reportingworking()whileopen()failed and gave its registration of the pool back.write()andImporterImplboth skip the re-open when the status says working, so the pool stayed with no user and destroyed every connection returned to it. Set after the block now, and the registration is taken inside it.catch (Exception)where the file's own standard isThrowable. Widened in theImporterImplconstructor and inopen(). AnErroris rethrown as it is rather than wrapped.liveCount(). It counted permits while its javadoc promised connections, and the ones it misses are exactly those past the bound. RenamedmeteredCount()and documented as the count the bound is about.The bound against the worker threads. Correct, and the comment that claimed otherwise is fixed:
Platform.computeNumberOfThreads(16, 2)is only whatWorkQueue.computeNumWorkerThreadsfalls back to, and a configuredds-cfg-num-worker-threadsreplaces it outright. No default can follow that count — the bound belongs to a database two backends may share, while the count belongs to the server — soopenPool()names both in the log when the borrowers outnumber the places, rather than leaving the wait to be found in a latency graph. It is one difference and not the whole demand, and says so: the replay threads of replication default to that same count again and borrow on top of the workers.Read again, and what that turned up
Re-reading the branch against those findings turned up more of the same family. All of it is in
a1bdc2f.releasePool()answered for a registration that was never madeopenPool()having returned. A throw between the two would take a user off a pool this storage never added one to — and the pool of a database two backends share would lose the user of the other one, draining connections it is still borrowing.newStampConnection()was the one connection left followingdb-directorypoolKey()now, like every other.AtomicLong, while the stall warning twenty lines above is a map per connection string with a comment saying why. Two backends standing full at once: the one that reported first silenced the one whose operations are the ones failing.peekLast()and removed with a separateremoveLastOccurrence(). A borrow that took the connection and gave it back between the two left the decision standing on a reading it no longer carried. The reading is taken again after the removal; a fresh connection goes back to the deque.raise pool.maxwas advice that could not be takenCachedConnection.invalidate()had no caller that ships — the drain it wrapped is reachable through the pool, and the tests call that. The public wrapper constructor passespoolable=false, which is what the accounting made of it anyway. Two comments the removal of Caffeine left behind — aremovalListenerthat no longer exists, and a TTL described as read once — say what the code does now.Verification of this round
CachedConnectionTestCaseThree cases added:
testAConnectionWhoseRollbackFailsUncheckedIsClosedrollback()is reported, the connection is closed, and the pool keeps neither it nor its permittestAnOpenThatFailsOnTheReturnLeavesTheStorageClosedworking(), and the open that follows is not skippedtestAReturnOnAnotherThreadLowersTheDepthOfTheBorrowerThe third was run against the mechanism reverted and fails there on its own assertion, so it is not passing either way.
The engine suites have not been re-run since the rebase and are left to CI.
Merged with master (#882)
#877 (#882) has landed and this branch is merged with it. One file conflicts,
JDBCStorage.java, and the whole of it is one question: both branches moved theborrow of an import, in different directions.
startImport()is collapsed to match. [#877] Bound a statement of the JDBC backend by the class of the work it belongs to #882 borrows instartImport()and givesthe connection back through a
finallywhen the importer cannot be built; thisbranch moved the open and the borrow into
ImporterImpl()so that one owner givesback what one owner took. git flagged only the constructor: it had auto-merged
startImport()to master's version, which borrows before building the importer, sotaking the conflicted side alone would have borrowed twice - once in
startImport(), once in the constructor - and leaked one of the two on everyimport. That is the merge deciding something it did not look like it was deciding,
and it is the reason this section is worth reading rather than taking on the
summary's word.
StatementBound.BULK. That is thecontract of JDBC backend: no statement is given a query timeout, and the read bound of an established connection is gone too #877 - every statement an import issues is bulk by construction - and
this branch was written before it, so its constructor carried neither.
ReadOnlyStorageExceptionof [#877] Bound a statement of the JDBC backend by the class of the work it belongs to #882 stays, inside thetryrather than infront of it. [#877] Bound a statement of the JDBC backend by the class of the work it belongs to #882 put the refusal where the importer is built, which is right and
is kept. With the open now inside the same constructor, a refusal in front of the
trywould leave a storage this constructor opened with nobody to close it.through
getConnection(boolean trusted)so that a stand-in for the pool interceptsevery path; this branch routes every borrow through
poolKey()so that adb-directorychanged on a running backend cannot send them to different pools.The seam is one method and it goes through
poolKey().One behaviour changed, and it is not cosmetic. The refusal now stands in front of
the borrow, so an import of a read-only storage takes no connection at all rather than
taking one and returning it. The two tests of #882 that pinned the return -
testStartImportGivesTheConnectionBackWhenTheImporterCannotBeBuiltandtestStartImportClosesTheStorageItOpenedWhenTheImporterCannotBeBuilt- now pin thatnothing is borrowed (
borrows == 0,verify(con, never()).close()). The leak theycover is the same one; what changed is that this state cannot reach it. The second
still pins the storage: the constructor opened it, so the refusal gives it back.
testEveryStatementOfAnImportIsBulkbuilds its importer through the borrow seaminstead of handing a connection to the constructor, on the same physical connection the
entry read beside it runs on - the backstop is keyed on the connection, not on the
storage, which is what that test was always about.
mvn -pl opendj-server-legacy test-compileis green, and so areJDBCStatementBoundTestCase(37/37),CachedConnectionTestCase(86/86),JDBCStorageRetryTest(66/66),StampConnectionTestCase(5/5),BulkCursorTest(12/12) and
PersistentCompressedSchemaTest(8/8) - 214/214 together. The four enginesuites have not been re-run since this merge and are left to CI.