Uh oh!
There was an error while loading. Please reload this page.
[#879] Skip the validation of a pooled JDBC connection returned a moment ago - #883
[#879] Skip the validation of a pooled JDBC connection returned a moment ago#883vharseko wants to merge 10 commits into
Conversation
…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.
…d what the review found open A database that is starting up, recovering or shutting down answers a connect with a state of its own - 57P03 on postgresql, ORA-01033/01034/01089, 1053 on mysql, 921/922/927 and 40613 on sql server - and clears it in seconds. Only pool exhaustion was waited out, so a backend whose database restarted together with the server stayed locked down until the next restart of it: nothing above JDBCStorage.open() attempts the open a second time. Those states are retried alongside pool exhaustion now, under the same pool deadline. ORA-12514 is left out of them: it is what a service name of a typo answers as well. The deadline is applied where it was missing. Draining the pool costs a round trip per connection and the pool has no bound on the number it holds, so the drain stops at the deadline of the borrow; and one connect attempt is bounded by what is left of that deadline, so a borrow can no longer outlive its pool timeout by a whole connect timeout - which is what the property promised. The validation of a pooled connection is bounded at the socket rather than through isValid(n) alone: the sql server driver turns that argument into a query timeout (setQueryTimeout, then "SELECT 1"), which needs an answer from the server to fire at all, and the read bound of the login was lifted the moment the connection was established. A tighter bound of the connection string is left alone, and a connection whose bound cannot be put back is discarded instead of being handed out carrying it. Also from the review: a setup failing with an unchecked exception no longer leaks the connection; pool exhaustion is 53300 rather than the whole insufficient_resources class, and getNextException() is walked along with the causes; an url is stripped of its credentials before its parameters and with the separator of its own dialect, so a password holding a ";" no longer reaches the log; a parameter is recognized the way its driver recognizes it, case-sensitively for pgjdbc alone; the read bound of an oracle descriptor (RECV_TIMEOUT, oracle.net.READ_TIMEOUT) counts as one of the administrator, so ours is neither set on top of it nor lifted with it; loginTimeout stays inside the [0, 65535] the sql server driver validates it against; and the warning for a read bound that cannot be set is throttled rather than given once per JVM, as is the stall warning, now kept per connection string. CachedConnectionTestCase covers each of these without a database: 23 tests.
maximthomas
left a comment
There was a problem hiding this comment.
The rewrite of getConnection() is a faithful port of the old loop (the con = null in the old catch was already dead code) and the static-init order is safe. The problem is that the bypass is on by default, and both comments justifying it are false on real paths.
Unvalidated connections can silently diverge replicas (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
staticfinallongDEFAULT_ALIVE_BYPASS_MS = 500; // opt-out, not opt-in
...
if (bypassNanos > 0 && System.nanoTime() - con.lastKnownAliveNanos < bypassNanos) {
returntrue; // isValid() never called
}
returncon.isValid(0);An idle-connection reaper — SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state='idle' AND state_change < now() - interval '...' — is harmless on master: PgConnection.isValid(int) returns false on a dead socket (its only throw path is timeout < 0), the old loop drains the pool, and DriverManager reconnects because the DB is still reachable. With this patch those same connections are handed out dead and their first statement fails.
On a replica that failure is not visible. StorageRuntimeException → BackendImpl.createDirectoryException → ResultCode 80 (OTHER). LDAPReplicationDomain.replay retries only NO_OPERATION / BUSY / UNAVAILABLE; OTHER falls into solveNamingConflict, which ends in:
// The other type of errors can not be caused by naming conflicts.// Log a message for the repair tool.logger.error(ERR_ERROR_REPLAYING_OPERATION, op, ctx.getCSN(), result, op.getErrorMessage());
returntrue; // replayDonereplayDone → updateError(csn) → RemotePendingChanges.commit(csn) advances the ServerState unconditionally, and the RS resume cursor uses AFTER_MATCHING_KEY, so the change is never resent. replayErrorMsg stays null, so SAFE_READ acks the originating master as if it applied.
Net: silently lost changes, replica reporting fully caught up, unresolved-naming-conflicts at 0 (ModifyDN even increments the resolved counter), one log line. Recovery is a manual dsreplication initialize.
The replay bug itself is pre-existing and storage-agnostic — but this PR turns routine DB maintenance into a trigger for it.
Either fix closes this:
- default the window to
0(opt-in), or - evict the pool generation on SQLSTATE
08xxx. That is the piece of HikariCP's machinery this pool lacks: Hikari's window is safe because it hands theSQLExceptionto application code that decides whether to retry — here the caller may be a replay path that records the failure as applied.
Liveness stamp is fabricated on zero-statement borrows (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
// Stamped after the rollback rather than before it: a transaction the operation opened// ends in a round trip of its own, so a connection reaching the pool has just answered.rollback();
lastKnownAliveNanos = System.nanoTime();
cached.get(connectionString).addFirst(this);pgjdbc short-circuits bothrollback() and commit() when the transaction state is IDLE — no bytes reach the server, nothing throws. So on borrows that issue no SQL the stamp proves nothing, and a dead connection goes back to the head of the deque marked alive (verified against postgres:16 + pgjdbc 42.7.12; reproduced repeatedly on the same connection).
Three such paths:
JDBCStorage.open()— borrows and issues nothingBackendImpl.applyConfigurationChange()— itsstorage.write()body no-ops when the base-DN set is unchanged, so anydsconfig set-backend-propon a live backend hits itImporterImpl.close()with nothing imported
pgjdbc is the only one of the four bundled drivers that does this, and PostgreSQL is the default dialect. Fix: stamp only when the rollback/commit actually round-tripped, or have open() validate explicitly.
Virtual attribute reads fail silently (minor)
The other justifying comment — "A connection that broke inside the window surfaces as the failure of the statement itself" — does not hold for hasSubordinates / numSubordinates. Each is its own storage.read(), so it borrows its own connection while the search still holds one:
EntryContainer.hasSubordinates/getNumberOfChildren→StorageRuntimeException- →
BackendImpl.createDirectoryException - → swallowed in
HasSubordinatesVirtualAttributeProvider/NumSubordinatesVirtualAttributeProvider, returningAttributes.empty(...)
The client gets the entry with the attribute missing and resultCode: 0. Filters on them evaluate FALSE, not UNDEFINED. Both providers are ds-cfg-enabled: true in the shipped opendj-server-legacy/resource/config/config.ldif.
Narrow — a plain ldapsearch with no attribute list never reaches this — but tree browsers and monitoring queries request exactly these.
LIFO handoff strands cold connections (minor)
expireAfterAccess sits on the pool entry, and both getConnection() and close() call cached.get(connectionString), so the 15 s TTL only fires when the backend is fully idle for 15 s. With LIFO, connections below the working set are never borrowed, never validated, never closed — a burst that opens 50 connections leaves 49 holding sockets and server-side sessions indefinitely. FIFO used to rotate them through, and isValid() reaped the dead ones.
The PR body defers this to #878, but #884 isn't merged — merging this first introduces the leak on its own. (Note the unbounded pool does not amplify the bypass window: cold connections carry stale stamps and are still validated.)
Nits
aliveBypassNanosshould bevolatile: it is a non-finalstatic longread from every backend worker and replay thread; a 64-bit non-volatile write is neither atomic (JLS 17.7) nor visible. The test writes it from the TestNG thread.- Timing-dependent tests:
connectionReturnedWithinTheWindowIsNotValidatedandmostRecentlyReturnedConnectionIsBorrowedFirstset a 500 ms window and assertvalidations() == 0. Whichever runs first also pays for cold class loading, so a loaded CI fork can exceed the window and fail. UseTimeUnit.HOURS.toNanos(1)— the 1 ms window in the "beyond the window" tests is already the right shape. StubDrivercan't model the failure:breakConnections()flipsaliveon the driver, not per connection, so the replacement instaleConnectionBeyondTheWindowIsReplacedis also "dead" and the test never checks it is usable.rollback()also proxies to a never-throwing default, which happens to mimic pgjdbc's IDLE no-op — so no test covers "borrow inside the window, connection is dead, close, borrow again", the case that exposes the fabricated stamp.
Two pre-existing bugs found while reviewing, both worth their own issues and neither blocking here:
- The replay path dropping changes on
OTHERwhile advancing the ServerState and sending a clean assured ack. Storage-agnostic — JE and PersistIt hit it on anyStorageRuntimeException. Settingserver-error-result-codeto 52 does not fix it; the state advance is unconditional on every terminal path inreplay(). isValid(0)never sets a network timeout and the default connection string sets nosocketTimeout, so against a black-holed socket the drain loop can block for minutes per connection.
# Conflicts: # opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
…nd the rest of what the review found open
pgjdbc puts an SO_TIMEOUT on the login socket only where socketTimeout is set,
and it defaults to none, so every read of the login - the prelogin handshake,
TLS, authentication - was left to loginTimeout alone. That one is not a bound
of the socket at all: Driver.connect hands the login to a daemon thread of its
own and gives up on the thread rather than on the login, leaving it parked in
the read for as long as the read lasts. Against the database this change exists
for - one that completes the TCP handshake and then says nothing - each borrow
to postgresql returned on time and left a daemon thread and an ESTABLISHED
socket behind it, where the code before this branch parked the operation thread
alone; tcpKeepAlive is off by default, so nothing reaped them. socketTimeout is
set now, as on the other three dialects, and lifted once the login is through;
loginTimeout is kept on top of it for a url naming more than one host, where
each host costs a connect and a login of its own.
Also from the review:
- the deadline of a borrow stops the drain of the pool rather than destroying
the connection in hand: a database at its connection limit has no source of
connections other than the ones coming back, and one returned to the pool a
moment before the deadline is the connection this borrow was waiting for;
- nothing is put back on a connection whose validation failed - Connector/J
aborts such a connection and the sql server driver terminates it, so the
restore failed as well and warned about the statements of a connection that
is being closed, over an idle connection the server had merely reaped;
- a connection whose read bound could not be lifted serves the borrower waiting
for it and is closed rather than pooled: the result of relaxReadBound() used
to be dropped, and the bound of the login went into every borrow the pool
handed that connection to - an import batch among them;
- the deadline of the borrow bounds a connect attempt even where the
...jdbc.connect.timeout property gives it no bound of its own: turning the
per-attempt bound off must not turn the bound of the whole borrow off with it;
- safeUrl() looks for the credentials where the url of the dialect holds them -
between the subprotocol and the first "@" on oracle, inside the authority
elsewhere - so a password holding the parameter separator of its own dialect
("scott/pa?ss@//host") no longer reaches the log;
- the message of the timeout no longer reports a database on its way up as one
at its connection limit, and carries the last error it saw.
CachedConnectionTestCase is at 28 tests, still without a database and ~22 s: the
login thread pgjdbc abandons, the pooled connection the deadline used to close
unvalidated, the bound that is not put back on a reaped connection, the
connection that must not be pooled, and a connect attempt the deadline bounds on
its own. Each of them fails against the code it fixes - the last one by hanging
for the whole 600 s of the run, which is the shape of OpenIdentityPlatform#872 itself.10698d1 to
6a2fa82Comparevharseko
commented
Aug 20, 2026
Thanks — the whole chain you traced holds, I walked it line by line. The branch is rewritten on top of #876, since the window belongs inside the 1. Unvalidated connections and the replay pathAnswered, but not by switching the window off. Two changes in
One correction on the trigger, though. An idle-connection reaper does not reach the window. For an idle session What genuinely reaches the window is an event that kills a connection within the window of its return — a restart, a failover, a network partition hitting a busy pool — and your finding #2, which was the one path that put a known-dead connection back at the head of the deque with a fresh stamp. That one is fixed below, and the two together are what made the scenario reachable at all. The replay path itself — 2. Liveness stamp fabricated on zero-statement borrowsFixed, and it drove the design of the rest. The stamp is now set only by an answer the connection actually gave: when it is established, and whenever it validates. Never on the way back into the pool. So the window means "validated at most once per window" rather than "returned recently", which is a claim the pool can always back — and the pgjdbc IDLE short-circuit of The cost is that a connection in constant use is validated once per window instead of never — one round trip per 500 ms per connection, against one per operation before. 3. Virtual attribute readsThe comment that claimed a broken connection "surfaces as the failure of the statement itself" is rewritten: it now says the failure surfaces on the statement of the caller, that not every caller reports it to the client, and that the trade is therefore taken off them by the replay and the distrust above. In practice a 4. LIFO and the cold endKept, since without it the window rarely applies, but the dependency is now written down in the code and in the PR description: this must not land before #884. I would put it slightly differently, though — the pool held every one of those 50 connections under FIFO as well; what LIFO removes is not the connections but the only thing that pruned the dead ones, since nothing borrows them and nothing validates them any more. The per-connection idle expiry of #878 is the fix, not something this PR should duplicate. 5. Nits
6. |
…rest of what the review found open
safeUrl() took the credentials off the first host of a url and assumed they end
at the first "/", and the stall report of a database that takes no connection
carried whatever was left into the server log. Both assumptions break on shapes
Connector/J accepts: a failover or replication url gives every host credentials
of its own ("//u:p@h1:3306,u2:p2@h2:3306"), and its key-value host syntax holds
them inside the authority itself ("//address=(host=h)(user=u)(password=p)"),
where neither a userinfo nor a parameter stands - so the password of the second
host, or the whole one of a key-value url, reached logger.warn and the message
of the SQLTimeoutException. Every userinfo of an authority is taken off now, a
"password=" left standing anywhere is blanked out, and a url that none of this
took apart is not logged past its subprotocol: the host of a stall report is
worth less than a password in the server log. Both safeUrl() and the warning
belong to this branch, so nothing of this reached a release.
Also from the review, each one measured against the driver it is about:
- Connector/J looks its properties up by their exact name, exactly as pgjdbc
does - PropertyKey.fromValue("SocketTimeout") answers null, and the driver
then reads no bound out of the url either. Taken for a bound of the
administrator, a mis-cased parameter left a mysql backend with no read bound
at all, which is the hang OpenIdentityPlatform#872 is about;
- a dotted property of the oracle driver is read out of the system properties as
well, the way a whole jvm is bounded with -Doracle.jdbc.ReadTimeout: against a
listener that completes the handshake and never speaks, -D alone gives up at
2.5 s and a Properties value of ours on top of it takes the timing over. That
bound was then lifted after the login as if it were ours, leaving a connection
with no read bound where the administrator had set one - so the system
properties are looked up as well now;
- RECV_TIMEOUT is a parameter of sqlnet.ora and of the listener that ojdbc8
never reads - the name appears in none of its classes - so a descriptor
carrying one took our read bound off a connection that had none of its own,
leaving an administrator who wrote a timeout with less than one who wrote
nothing;
- a property set to 0 is not a bound of the administrator either: every one of
these drivers reads 0 as "wait as long as it takes". On postgresql a
"?socketTimeout=0" was worse than no bound at all - loginTimeout alone hands
the login to the daemon thread pgjdbc abandons at the timeout, and an
unbounded read leaves it parked there with the socket it holds;
- the bound handed to a driver stays inside the range an int of milliseconds
takes: with ...jdbc.connect.timeout at 0 an attempt takes what is left of the
deadline, ...jdbc.pool.timeout has no upper bound of its own, and mssql-jdbc
rejects a socketTimeout past Integer.MAX_VALUE outright ("The socketTimeout
3000000000 is not valid"), failing every connect of that backend with the name
of a property nobody typed;
- the validation of a pooled connection catches an unchecked failure of a driver
as well: it would unwind through poll(), which stands outside every try of the
borrow, and leave the connection dequeued and closed by nobody.
And three comments that described the right behaviour after the wrong code: the
SO_TIMEOUT of a pgjdbc login is put on in tryConnect rather than in
openConnectionImpl; the connect of a multi-host url is one budget for all of its
hosts, taken from the single System.nanoTime() in front of the loop over them,
rather than one per host; and ...jdbc.pool.timeout bounds a borrow, but not to
the millisecond - the connection in hand is validated whatever the deadline says
and an attempt is never given less than a second.
CachedConnectionTestCase is at 32 tests, still without a database and ~20 s.
Each of the fixes above was put back one at a time, and the assertion that
covers it failed.
maximthomas
left a comment
There was a problem hiding this comment.
The design holds and every round-1 point is answered in code — the stamp-on-proof-only rule, the pre-commit-only replay and the distrust marker are all implemented as described. Two things still need changing: the compensation is absent on SQL Server, and the replay it leans on can re-run an operation that is idempotent in the database but not in Java. The rest are nits.
Note: this branch is stacked on e77c8f72, and #876 has since moved to 625e2f23 (+346/-83). It needs a rebase.
isConnectionFailure misses SQL Server session kills (Major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:966 classifies a lost connection by SQLState alone:
staticbooleanisConnectionFailure(Throwablet) {
for (inthop=0; t!=null && hop<MAX_CAUSE_HOPS; t=t.getCause(), hop++) {
if (tinstanceofSQLException) {
finalStringstate=String.valueOf(((SQLException) t).getSQLState());
if (state.startsWith(CONNECTION_FAILURE_CLASS) || CONNECTION_FAILURE_STATES.contains(state)) {Measured against the pinned mssql-jdbc-13.4.0.jre11:
SQLServerExceptionisfinal ... extends java.sql.SQLException— notSQLRecoverableException, notSQLNonTransientConnectionException.xopenStatesdefaults to false (SQLServerDriverBooleanProperty.<clinit>).- Socket path:
terminate()picks 08006/08001,mapFromXopenturns both into08S01— class 08, caught. - Server-error-token path:
generateStateCode's default branch maps only 220/515/547/1205/2601/2627/2714/8152/208 and otherwise returns"S"+dbState. MeasuredS0001for 596 (session in kill state), 3980, 10054, 18456, 4060.
So a KILL, a resource-governor kill or an AG transition gives neither the replay nor the distrust. Every in-window connection is handed out unvalidated, each first statement fails, the pool is never told — one failed client operation per pooled connection, which is what the distrust exists to prevent. On master every borrow validated and none of them failed. scopeOf at :623 would not catch it either.
Fix — reuse what this file already knows, which also makes the Oracle case robust rather than lucky (ojdbc8 happens to map ORA-03113/00028/01089 to 08006):
if (tinstanceofSQLRecoverableException || tinstanceofSQLNonTransientConnectionException
|| tinstanceofSQLTransientConnectionException) {
returntrue;
}committing == false does not mean nothing was committed (Major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1186 commits on the transaction's own connection, inside writeOperation.run(txn), while committing is still false:
publicvoidopenTree(TreeNametreeName, booleancreateOnDemand) {
if (createOnDemand) {
if (!isExistsTable(treeName)) {
try (finalPreparedStatementstatement=con.prepareStatement("create table "+...)){
execute(statement);
con.commit(); // <-- and again at :1197 for the postgres indexopendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java:135 wraps PersistentCompressedSchema.load plus openAndRegisterEntryContainers — roughly 25 openTree(..., true) calls per suffix — in a singlestorage.write.
Two base DNs, fresh schema: the trees for base DN #1 are created, committed and registered; the connection drops while creating a tree for base DN #2. committing is false, so replayReason returns "a connection the database dropped" and the whole WriteOperation is replayed. openAndRegisterEntryContainers restarts at base DN #1 and RootContainer.java:193 throws:
EntryContainerec = this.entryContainers.get(baseDN);
if (ec != null) {
thrownewInitializationException(ERR_ENTRY_CONTAINER_ALREADY_REGISTERED.get(...));
}That is neither a conflict nor class 08, so it propagates: the backend fails to open and the real drop is masked. Each replay also leaves the previous attempt's AttributeIndex/VLVIndex in place without close() (EntryContainer.open:536/:550), leaking the config listeners their constructors register.
Database-side idempotence genuinely holds — isExistsTable guards the create, postgres uses if not exists, the data writes are upserts, and nextEntryID is an in-memory AtomicLong recomputed from getHighestEntryID each attempt. The non-idempotence is purely Java-side.
Fix: have openTree record that it committed and suppress the drop replay for that attempt, or do not commit mid-transaction. At minimum the replayReason javadoc should not claim a guarantee the code does not have. (#867's conflict replay could already reach this via a deadlock on DDL; this PR widens the trigger to any dropped connection.)
read() distrusts the pool when a new connect is rejected (Minor)
JDBCStorage.java:844 — the try-with-resources initializer is inside the try, so a borrow failure reaches the distrust call:
try(finalConnectioncon=getConnection()) {
returnreadOperation.run(newReadableTransactionImpl(con));
} catch (Exceptione) {
distrustPoolOnConnectionFailure(e);Connector/J 9.2.0 maps 1040 ER_CON_COUNT_ERROR ("Too many connections") to 08004, and CachedConnection either rethrows it raw (:412) or wraps it as SQLTimeoutException(msg, e) (:416) with the 08004 one cause hop down — matched either way. With MySQL at max_connections, every failed borrow re-stamps poolDistrustedAt (no latch), so every returning connection validates on its next borrow: an extra round trip against a server already refusing connections. 08004 is a rejected new connect and says nothing about the pooled ones — which is the rule CachedConnection.java:106-109 states and this breaks.
Not a regression against master (which validated every borrow anyway); the window just switches itself off under the load it exists for. Fix: scope the distrust to failures raised by the operation, not by the borrow.
A drop seen only on release never reaches the pool (Minor)
Two gaps in JDBCStorage.java:908:
} catch (Exceptione) {
if (e!=failure) { throwe; } // (a) returns before the distrust call below
}
distrustPoolOnConnectionFailure(failure);- (a)
commit()succeeds,returnruns, the implicitclose()then raises 08006 from itsrollback().failureis still null, soe != failureand it is rethrown before the distrust.read()has no such guard and does distrust. - (b) the operation throws,
close()then raises 08006 — added viaaddSuppressed(JLS 14.20.3.1). Nowe == failureso the distrust is called, butisConnectionFailurewalksgetCause()only. It also never walksgetNextException(), unlikefailureScope()at:614in the same file, andCachedConnection.java:88documents both chains.
Both are narrow — a connection dropped while idle throws on its first statement, which lands in the inner catch and gets both the distrust and the replay, and on pgjdbc (a) cannot happen at all since rollback() short-circuits while IDLE after a commit. Worth walking getSuppressed()/getNextException() anyway.
distrustPool's update is not atomic (Minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:535:
poolDistrustedAt.computeIfAbsent(connectionString, url -> newAtomicLong()).set(System.nanoTime());- Lost update — A reads T1, B reads T2>T1, B sets T2, A sets T1. The distrust point moves backwards, so a connection proven at T1<p<T2 satisfies
provenAt - distrusted.get() > 0and is trusted although it predates B's drop. - Torn publication —
computeIfAbsentinstallsnew AtomicLong()(value 0) beforeset()runs; a racing borrow reads 0 andprovenAt - 0 > 0holds.
Both collapse into one change:
poolDistrustedAt.merge(connectionString, System.nanoTime(), Math::max);Three pool borrows get neither replay nor distrust (Minor)
JDBCStorage.java:163open(AccessMode), :797removeStorageFiles(), :1596 the ImporterImpl constructor. These are the only such sites — StampSession.newStampConnection (:380) goes to DriverManager, not the pool. A dead in-window connection at :163 issues no statement, and close() → rollback() reaches the server on mysql/oracle/mssql, throwing with no catch in open(). Pre-change, all three validated on borrow and discarded a dead connection.
Nits
- Deque lock swap:
LinkedBlockingQueuehas separatetakeLock/putLock, so a borrow and a return proceed concurrently;LinkedBlockingDequehas a singleReentrantLock, sopollFirst/addFirstnow serialise on the handoff path this PR set out to make cheaper. Dwarfed by the round trip removed, but the comment atCachedConnection.java:114-121justifies LIFO without mentioning it. - Unclamped window:
CachedConnection.java:60—getNonNegativePropertyaccepts any non-negative long andtoNanossaturates, so a large value disables validation permanently. Both sibling timeouts (:387,:391) are clamped. Nothing warns when the window exceedsTTL_PROPERTY(15 s). - No
isClosed()on the trusted path:CachedConnection.java:470—isValid()used to be that check implicitly. The CaffeineremovalListenercloses connections it finds in the deque and the iterator is weakly consistent. Unreachable at defaults, live if the window is configured >= the TTL. - Stamp taken after the round trip:
CachedConnection.java:497setslastKnownAliveNanosafterisValid()returns, so the effective window is the configured one plus validation latency. Optimistic, never conservative. poolDistrustedAtis never pruned, not even by theremovalListenerthat disposes the pool for that key.- Seeded-pool tests use the wrong end:
CachedConnectionTestCase.java:348/366/390/409/651/652/678still calladd(), which on a Deque isaddLast— the opposite end from theaddFirstproduction returns to. OnlytestTheConnectionReturnedLastIsBorrowedFirstexercises the real path. testAWindowOfZeroValidatesEveryBorrowis non-discriminating: identical in body and outcome to the pre-change always-validate path. The other six new tests do flip when the change is reverted.committingis never tested as computed: it is only ever passed toreplayReasonas a literal, and that is the load-bearing half of the "only before commit" claim — see the second issue above.- The pool wiring is untested:
distrustPoolOnConnectionFailureis private, and the pool-key identity (:156getConnection(config.getDBDirectory())vs:986distrustPool(...)) is asserted by nothing. - Neither
isKnownAliveedge is tested: age exactly== window, andprovenAt == distrusted. - Wrapper coverage claim: both wrapper rows in
JDBCStorageRetryTestcarry class-08 states (08006, 08003); no 57P0x is exercised through a wrapper though the description says so. - The property is undocumented: nothing outside the source file names
org.openidentityplatform.opendj.jdbc.alive.bypass, so an operator hitting stale-connection errors has no documented way to find=0.TTL_PROPERTYhas the same gap. - HikariCP comment: says "minus its two Sybase states"; three are dropped —
01002as well. - Read once at class init: unlike
connect.timeout/pool.timeout, which are read per borrow — inconsistent within the same property family. - Prose assertions: the three new
replayReasontests assert on English returned by production code.
…nd bound what the review found unbounded The password reached the server log through every exit but the two that called safeUrl(). The jdk builds "No suitable driver found for " + url - the ordinary oracle misconfiguration, a driver jar left out of lib/extensions - JDBCStorage .open() hands it to RootContainer, which makes the message of the cause its own, and BackendConfigManager logs that at ERROR and answers a config change with it. What leaves this class is redacted whole now: the message of every link of the chain, the chain rebuilt rather than wrapped, since everything that prints a failure prints its causes along with it. Three bounds that were not bounds: - -Doracle.net.READ_TIMEOUT was taken for a bound of the administrator, but ojdbc8 reads that name out of the connection properties alone - the classes carrying the literal hand it to Properties.get, none of them to System .getProperty. The names a driver does read out of the system properties are listed now instead of told from the dot in them, so a -D of it no longer leaves the login with no read bound at all. - the connect properties of a dialect are one budget rather than independent knobs: filling in the one the administrator left out capped the one they set, and a postgresql "?connectTimeout=300" answered with a loginTimeout of ours was a login pgjdbc gave up on at 30 s. - a parameter of a postgresql url outranks the property supplied to the driver, so a "socketTimeout=0" there cannot be replaced. It is reported now rather than written over in a map the driver goes on to ignore. Also: 08001 on the timeout of a borrow, a report for a connection string whose driver is not one of the four this class knows the properties of, a validation that could not be bounded discarded rather than run unbounded, and the messages, the throttles and the ranges the review listed. CachedConnectionTestCase is at 41 (from 32), still without a database, ~23 s. Each fix was put back one at a time and the assertion covering it failed. The four container suites pass 54/54, and testLoginBoundDoesNotOutliveTheLogin now asserts the read bound is in force before asserting it is lifted - against a relaxReadBound() that lifts nothing it fails with "expected [0] but found [2000]", where before it passed either way.
… as its last answer holds Every borrow from the pool validated the connection it took out, and Connection.isValid() is a round trip of its own - an empty query on postgresql, a ping on mysql, a round trip on oracle and sql server. Every operation of this backend borrows, so a read of one entry cost three exchanges with the database - the validation, the select and the rollback that ends the transaction - of which one was the statement the operation came for. A connection is now handed out unvalidated while the last answer it gave is younger than org.openidentityplatform.opendj.jdbc.alive.bypass - 500 ms by default, 0 to validate every borrow as before - the way the aliveBypassWindow of HikariCP does it. The pool hands connections out from the end it takes them back at, a LinkedBlockingDeque rather than a LinkedBlockingQueue: with FIFO the connection borrowed next is the one reached after a whole cycle of the pool, which has been idle far longer than the window. What proves a connection alive is an answer it actually gave: it is stamped when established and whenever it validates, never on its way back into the pool. pgjdbc short-circuits both rollback() and commit() when the transaction state is IDLE, so a borrow that issued no statement - JDBCStorage.open(), a configuration change that leaves the base DNs alone, an import of nothing - returns a connection without a byte reaching the server, and stamping that return would mark a connection the database had dropped as the freshest one in the pool. A connection that breaks inside the window no longer costs the operation: - JDBCStorage.write() replays it on a connection the next attempt borrows of its own, on SQLState class 08 and on the 57P0x states postgresql announces a connection it is about to drop with - but only while the transaction has not been committed yet, since a drop reported by commit() leaves the outcome unknown and replaying a write that in fact committed applies it twice; - read() and write() both mark the pool distrusted on such a failure, so every connection proven alive before the drop is validated once before it is trusted again. Whatever dropped one connection dropped the whole generation, and a borrow inside the window asks the database nothing - the statement that broke is the only place a drop is ever seen. A failed validation does not mark the pool: an idle connection the server reaped is a routine event. That second point is what makes the window safe to leave on by default. A write of the replication replay that fails is recorded as applied - the ServerState advances past the change and the assured ack reports success, see OpenIdentityPlatform#889 - so a dropped connection there would cost a silently diverged replica rather than an error.
…er, not only by its SQLState, and the rest of what the review found open isConnectionFailure classified a lost connection by SQLState alone, and mssql-jdbc carries none: SQLServerException extends SQLException directly, xopenStates is off by default, and generateStateCode maps neither 596 (session in kill state) nor 3980, 10054, 18456 or 4060 - every one of them comes out as "S"+errorState, measured as S0001. A KILL, a resource governor kill or an availability group transition therefore gave neither the replay nor the distrust, and the window handed out the rest of that generation unvalidated, one failed operation per pooled connection. What the driver does do is close the connection for any error of severity 20 and above, before it throws, so the connection is now asked as well as the failure - while the operation that failed still owns it, since a released one may already be another borrow's. The types the JDBC contract gives a driver to say so are matched too, which is what makes the oracle case robust rather than lucky, and the next-exception and suppressed chains are walked with the causes, the way failureScope already walked both. An attempt that committed part of its own work is no longer replayed at all. openTree, clearTree and deleteTree commit inside WriteOperation.run - and mysql and oracle commit before a DDL statement whether asked to or not - so the attempt no longer rolls back as a whole, while a WriteOperation is only idempotent in the database. RootContainer.open opens and registers the entry containers of every base DN in a single write: replayed after the trees of the first base DN were created and committed, it registers that base DN a second time, fails with ERR_ENTRY_CONTAINER_ALREADY_REGISTERED, masks the failure that caused the replay and leaves the indexes of the previous attempt behind with the configuration listeners their constructors registered. The conflict replay of OpenIdentityPlatform#867 could already reach this, so the rule covers any replay rather than only the drop added here. read() and write() borrow outside their try, so that a connect the pool could not make no longer distrusts the pool: Connector/J reports a server at its connection limit as 08004, which is class 08 like a connection that broke, and every failed borrow re-stamped the distrust - an extra round trip per returning connection against a server already refusing connections. A drop reported by the release of a connection now reaches the pool from write() as well, which returned before the distrust call. The three borrows nothing compensates - open(), removeStorageFiles() and the importer - ask for a connection the pool validates whatever the window says: they issue their statements far from the borrow, and the open issues none at all, so a connection dropped inside the window surfaced out of the rollback that released it, with nothing to replay it and nothing to tell the pool. One round trip on a path taken once per open, per import or per removal. distrustPool merges its reading with max instead of setting an AtomicLong published holding its initial 0, so that two operations reporting a drop at once cannot move the distrust point backwards. The window is clamped to the ttl an idle pooled connection is kept for, and says so once when it is asked for more: a value the unit conversion saturates on would leave every connection trusted for the life of the server. A connection the pool closed under the borrow - the removal listener iterates a weakly consistent view - is no longer handed out on the strength of its last answer. Also: the comment crediting HikariCP's list undercounted what it leaves out, the seeded pool of the tests filled the end production does not return to, and the bound of the walk covers all three chains.
6a2fa82 to
7bc9294CompareRebased on 1. |
Uh oh!
There was an error while loading. Please reload this page.
…its own name CodeQL java/confusing-method-signature: redactedCopy(SQLException..) and redactedCopy(Throwable..) picked the SQL-specific rebuild - the one that keeps the SQLState and the vendor code - by the static type of the argument. The instanceof routing made every current call land right; the name no longer lets a future one land wrong.
maximthomas
left a comment
There was a problem hiding this comment.
issue (blocking): warnedOnce is declared below the initializer that reaches it
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:68
:68staticvolatilelongaliveBypassNanos = TimeUnit.MILLISECONDS.toNanos(getAliveBypassMillis());
// ...65 lines...
:133staticfinalSet<String> warnedOnce = ConcurrentHashMap.newKeySet();getAliveBypassMillis():190 reaches warnOnce() two ways — :194 when the window exceeds the ttl, and:221 via getNonNegativeProperty, called for alive.bypass at :191 and for the ttl at:192 → getCacheTtlMillis():180. warnOnce():229 does warnedOnce.add(key). JLS 12.4.2 runs
class-variable initializers in textual order, so warnedOnce is still null at :68. It compiles only
because the reference sits inside a method body, not in an initializer by simple name.
-Dorg.openidentityplatform.opendj.jdbc.alive.bypass=60000 — larger than the 15000 ms default ttl, which
is exactly the tuning the new javadoc invites — NPEs in <clinit>: ExceptionInInitializerError on first
touch, NoClassDefFoundError with no cause on every later one. No connection can be borrowed, so the
backend cannot open. Same for any non-numeric or negative value of either property.
The ttl half is a regression: at 52ca42bfwarnedOnce was at :111 and the first getCacheTtlMillis()
call was the cache field at :116 — after it. -D...jdbc.ttl=30s merely logged the warning the comment at:219 anticipates.
Fix: move the warnedOnce declaration above :68, or initialize aliveBypassNanos lazily.testTheWindowIsClampedToTheIdleTimeOfThePool cannot catch this — it calls getAliveBypassMillis() long
after the class is initialized. Pinning it needs a fresh JVM/classloader with the property set.
issue (blocking): partlyCommitted is raised before any statement runs
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1329
if (createOnDemand) {
partlyCommitted=true; // :1329 — above the guard, before anything is issuedif (!isExistsTable(treeName)) { // :1331
... createtable ...; con.commit();
}
...
}Steady state, table and index already present:
| dialect | what openTree(name,true) issues | flag |
|---|---|---|
| postgres | create index if not exists + con.commit(), unconditional (:1340) | accurate |
| mysql | isExistsIndex() first (:1348) → nothing | wrong |
| oracle | same shape (:1358) → nothing | wrong |
| mssql | no index branch at all (:1367) | wrong |
commentTable():1371 runs on StampSession's own connection, so it cannot commit on con.
replayReason returns null on the flag at :1019, before it tests isRetryableConflict at :1021,
so the flag suppresses #867's conflict replay as well as this PR's drop replay.
Restart of an existing backend on mssql/oracle/mysql: RootContainer:135 opens one storage.write whose
first act is PersistentCompressedSchema:143 openTree(ad, true) — flag set, nothing issued. The cursor
walk at PersistentCompressedSchema:148 deadlocks (1205 / ORA-00060); write():946 reads true,replayReason returns null, and the backend fails to open on a transaction the engine had rolled back
whole with nothing registered. At 52ca42bf the only gate was !isRetryableConflict(failure, driver)
(:883), so that attempt was replayed — and could succeed, because :143..:186 runs entirely before the
first registerEntryContainer.
Fix: move the assignment down to each site that actually commits — inside if (!isExistsTable(...)) before
the create-table commit, and before each create-index statement (unconditional on postgres, inside theisExistsIndex guard on mysql/oracle). The stated rationale — mysql and oracle commit implicitly before a
DDL statement — justifies setting it before the DDL, not before a catalog read that issues nothing.
Decoration revised to blocking after the verdict: this is a regression against the base branch, not a
missed improvement, and the fix lands in the file already being reopened for the issue above.
issue (non-blocking): write() replays a release drop it never distrusts
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:939
:939dropped=isConnectionFailure(e,con); // computed BEFORE the implicit close()
:953if (e!=failure) { // skipped when close()'s 08 was SUPPRESSED into failure
:954if (isConnectionFailure(e)) { distrustPool(); }
:956throwe;
}
:965if (dropped) { distrustPool(); } // stale flagreplayReason → isConnectionFailure(failure) does walk getSuppressed(), so the attempt is replayed on
evidence it never hands to the pool. read():881 is if (dropped||isConnectionFailure(e)) and has no such
hole. Round-2 finding 4(b) is closed in the classifier, not on write()'s call path — the answer claims
both halves. Self-heals on the next borrow.
Fix: if (dropped || isConnectionFailure(failure)) at :965.
issue (non-blocking): partlyCommitted's computation is covered by nothing
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java:213
git grep partlyCommitted -- src/test returns zero hits. Every use is a boolean literal inreplayReason()'s 4th argument, so only the consumer is pinned; the three assignment sites (:1329,:1392, :1404) and the partlyCommitted=txn.partlyCommitted read at :946 are not. Deleting the :1329
assignment keeps all 104 tests green — which is also why neither placement in the issue above would be
noticed. Round-2 [15] is not closed, though the answer says it is; round-2 [16] (nothing entersread()/write()) is not closed either, and this round widened it.
suggestion (non-blocking): the clamp's javadoc argues something the code does not do
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:184
"a connection in constant use … validated not once per window but never" — expireAfterAccess is refreshed
by every borrow and every return of any connection in the entry, and isKnownAlive validates on age
alone, so a hot connection is still validated once per window. Also aliveBypassNanos is assigned once at:68, so the clamp does not track a ttl set later — the third assertion of the clamp test describes a
re-read the field never does.
suggestion (non-blocking): MAX_CAUSE_HOPS=16 now bounds three chains together
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1074 —while (!pending.isEmpty() && seen.size()<MAX_CAUSE_HOPS). The sibling walk failureScope():629 has no
bound at all. mssql-jdbc chains every error of one message via setNextException, and that chain is pushed
last so it is popped first; a >16-link chain would spend the budget before reaching getCause(). Mechanism
only — no measured real chain length, so this may well be unreachable.
suggestion (non-blocking): the drop-replay log names the wrong SQLState
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:976 →conflictSummary():1176 walks getCause() only. In the shape above the line reads "replaying the
transaction after a connection the database dropped … SQLState 23000" — the statement's state, not the 08
on getSuppressed() that caused the replay. That line is the only observable record of a drop replay.
nitpick (non-blocking): Math::max on raw nanoTime
CachedConnection.java:808poolDistrustedAt.merge(cs, System.nanoTime(), Math::max), against the file's
own "the overflow safe form of the comparison" at :780 and :783. ~292 years of uptime; an internal
inconsistency rather than a bug.
nitpick (non-blocking): !isClosed(con.parent) inherits isValid()'s TOCTOU
CachedConnection.java:785 — checked before the hand-out, with nothing serialising the removalListener
against pollFirst. No regression, but it is described as answering what the validation it replaces
answered.
nitpick (non-blocking): clearTree's partlyCommitted has no caller inside write()
JDBCStorage.java:1404. The only in-repo clearTree on this impl is ImporterImpl:2014, which borrows once
at :1745 and never enters the replay loop. Harmless; the comment asserts a protection nothing exercises.
nitpick (non-blocking): the test fixture does not reset poolDistrustedAt or the pool cache
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java:97 — safe
today only because every test builds a unique url.
note (non-blocking): isClosed(con) makes a pool eviction read as a database drop
JDBCStorage.java:875/:1042. The removalListener closes what it finds in a weakly consistent view of the
deque, so an eviction racing an in-flight operation yields dropped=true → a spurious distrustPool() plus
a full replay. Benign now that partlyCommitted guards the non-idempotent case.
note (non-blocking): neither edge of isKnownAlive is tested
CachedConnection.java:774 (>= window) and :778 (provenAt - distrusted <= 0), both rewritten this
round into the overflow-safe form. Flipping either comparison passes all 104 tests. Round-2 [17], untouched
and unmentioned in the answer.
note (non-blocking): a green test row documents a known gap as expected behaviour
JDBCStorageRetryTest.java:172 — { "mssql killed session", sql(596,"S0001"), false } pins the 1-arg
classifier, which is what read():881 and write():957 use on the release path, where the connection is
deliberately not asked. A SQL Server session killed by error token and first seen on the release therefore
still gets neither replay nor distrust.
note (non-blocking): what was verified closed
- Round-2 [13] is genuinely fixed —
seedPoolfills withaddFirstin reverse order and all seven oldadd()sites plus one new go through it. - The "104 methods" count is accurate: 51 + (11 methods, 2 data-driven, 23+21 rows) = 53.
- All six named new tests exist and are discriminating.
openTree(createOnDemand=true)is genuinely never reached from an entry write — entry writes goBackendImpl → EntryContainer → txn.put/delete/update(:1418,:1487) and resolve tables from the
tree2table catalog. The assertion in the answer holds, which is what keeps the second issue above out of
the hot path.- No SPI, format or upgrade impact:
partlyCommittedis a field of the private finalWriteableTransactionTransactionImpl; JE/PDB/Cassandra cannot see it. - Neither new overload pair is the CodeQL confusing-overload shape — both differ in arity, not in parameter
type — and theredactedCopy→redactedSqlCopyrename rebinds no call site.
…zer that reaches it, and commit the flag with the statement Three findings of the third review round. warnedOnce sat below aliveBypassNanos, whose initializer reaches warnOnce() through both properties it reads: class variable initializers run in textual order (JLS 12.4.2), so any value worth a log line - a window longer than the ttl, which is the tuning the javadoc of the property invites, or a non-numeric or negative value of either property - left the class uninitializable. The first borrow got an ExceptionInInitializerError and every one after it a causeless NoClassDefFoundError, so no connection could be borrowed and the backend could not open at all. The ttl half of that was a regression against the base branch. openTree() raised partlyCommitted for the whole method, before the catalog read that most often decides no statement is needed. On an existing backend on mysql, oracle and mssql it issues nothing, so the flag took a transaction the engine had rolled back whole out of the replay - the conflict replay of OpenIdentityPlatform#867 included, since replayReason() reads it before it asks anything else. It is raised at each site that actually commits instead, and the create index of postgresql is now guarded by the catalog read the other engines already used: unguarded it commits on every openTree(), which took every write that opens a tree out of the conflict replay on the engine of every default deployment. write() computed the drop flag before the implicit close(), so a drop the release reported - suppressed into the failure being unwound rather than replacing it - was replayed on evidence the pool was never told about. Both loops now report the drop from the inner catch, before the release returns the connection to the head of the pool where a borrow racing the report would be handed it unvalidated; and the rollback that unwinds a failed attempt joins its own failure to the one being unwound rather than dropping it, since on a driver that reports a killed session as a plain vendor error that rollback is the only place the drop is ever stated. Alongside: the classifiers share one walk of the failure, which reads the suppressed exceptions for a question about the connection and not for a question about what the engine did with the transaction - the release runs after the outcome was decided and cannot speak for it, and a class 40 raised there would otherwise re-authorise the replay of a commit left in doubt. The walk of failureScope() is left unbounded, since its verdict weakens under truncation rather than merely going unnoticed. The replay log names the failure the replay was decided on. The distrust point is merged with the overflow safe comparison the rest of the file uses, and the clamp javadoc argues what the code does. CachedConnectionTestCase and JDBCStorageRetryTest, 115 methods, green - the writes now run through JDBCStorage.write() against a stub driver rather than against the classifiers alone. PgSqlTestCase against postgres in docker, 54 methods, green.
vharseko
commented
Aug 25, 2026
Round 3 answered in [1] |
Fixes#879
Every borrow from the pool of the JDBC backend validated the connection it took out, and
Connection.isValid()is a round trip to the database — an empty query on postgresql, a ping on mysql, a round trip of its own on oracle and sql server. Every operation of the backend borrows:read(),write(), the cursor of a search, the import. A read of one entry therefore cost three exchanges with the database — the validation, the select, and the rollback that ends the transaction — of which one was the statement the operation came for.The alive window
A connection is handed out unvalidated while the last answer it gave is younger than
org.openidentityplatform.opendj.jdbc.alive.bypass— 500 ms by default,0to validate every borrow as before — the way thealiveBypassWindowof HikariCP does it. Beyond the window, a connection that has been sitting in the pool is validated and, if it no longer answers, discarded and replaced exactly as before. The window is clamped toorg.openidentityplatform.opendj.jdbc.ttl, the idle time the pool keeps a connection for, and says so once when it is configured higher: a connection trusted for longer than the pool holds it would never be validated at all.What counts as an answer. The stamp is set when a connection is established — the login and the two round trips that set it up have just answered — and whenever it validates. It is deliberately not set on the way back into the pool. pgjdbc short-circuits both
rollback()andcommit()when the transaction state is IDLE (PgConnection.rollback:if (getTransactionState() != IDLE)), so a borrow that issued no statement puts a connection back without a byte reaching the server. Stamping that return would mark a connection the database had dropped meanwhile as the freshest one in the pool. Stamping proof rather than use makes the window mean "validated at most once per window", which is a claim the pool can always back.LIFO handoff. The pool hands connections out from the end it takes them back at — a
LinkedBlockingDequeinstead of aLinkedBlockingQueue. Without it the window would rarely apply: a FIFO queue reaches a returned connection only after a whole cycle of the pool, and with a pool larger than the load that cycle is far longer than the window.What happens to a connection that breaks inside the window
It is handed out, and the failure surfaces on the statement rather than on the borrow. That is where a connection breaking mid-operation surfaces anyway — but not every caller of this backend reports such a failure to the client, so the trade is not the caller's alone to bear. Three things take it off them:
write()already replays a transaction conflict; it now replays a connection the database dropped as well. The next attempt borrows a connection of its own. Only while the transaction has not been committed yet, though: a drop reported bycommit()leaves the outcome unknown — the server may have committed and died before the answer reached us — and replaying a write that in fact committed applies it twice. That is the same reason 40003 is excluded from the conflicts.read()andwrite()mark the pool distrusted on such a failure, from the catch that still owns the connection rather than after the release: a rollback that never reaches the server — pgjdbc with an IDLE transaction, the very case the stamp rule below is built around — leaves the connection poolable, so the release returns it to the head of the deque, and a borrow racing the report would be handed it unvalidated. Every connection proven alive before that moment is validated once before it is trusted again. Whatever dropped one connection — a restart, a failover, a network that went away — dropped every connection established before it, and a borrow inside the window asks the database nothing, so the statement that broke is the only place a drop is ever seen. A validation that fails does not mark the pool: an idle connection the server reaped is a routine event and says nothing about the connection in use. The borrow itself is outside that rule — a connect the pool could not make (Connector/J reports a server at its connection limit as08004, class 08 like a connection that broke) says nothing about the connections it holds, so it leaves the loop without distrusting anything.open(AccessMode),removeStorageFiles()and theImporterImplconstructor ask for a connection the pool validates whatever the window says. They issue their statements far from the borrow, and the open issues none at all — a connection dropped inside the window would surface there out of therollback()that releases it, with no statement to replay and nothing to tell the pool. Each is one borrow of a cold path.Recognizing a dropped connection
A SQLState is not enough. mssql-jdbc reports a session killed by
KILL, by the resource governor or by an availability group transition as error 596, 3980, 10054, 18456 or 4060, andgenerateStateCodemaps none of them: withxopenStatesoff, which is its default, every one comes out as"S"+errorState— measured asS0001, indistinguishable from a rejected statement.SQLServerExceptionisfinal ... extends SQLException, so no exception type tells them apart either.What the driver does do is close the connection for any error of severity 20 and above before it throws, and Msg 596 is Level 21. So the connection is asked as well as the failure — while the operation that failed still owns it, since a released one is back in the pool and may already be another borrow's. Alongside that,
SQLRecoverableExceptionand the two connection exception types of the JDBC contract are matched (which is what makes the oracle mapping of ORA-03113/00028/01089 robust rather than lucky), and the walk coversgetNextException()andgetSuppressed()next togetCause()— a driver reports what happened as the next exception of a generic failure as readily as it reports it as the cause, and the drop of aclose()arrives suppressed into the failure of the operation. The rollback that unwinds a failed attempt joins its own failure to the one being unwound rather than discarding it: on a driver that reports a killed session as a plain vendor error, that rollback is the only place a class 08 is ever stated.Which chains answer which question. The suppressed exceptions are read for a question about the connection and not for one about what the engine did with the transaction.
replayReason()asks for a conflict before it asks thecommittingguard, so a class 40 contributed by the release —40000is not among the two states excluded from the conflicts — would re-authorise the replay of acommit()whose outcome nobody knows, and apply the write twice. The release runs after the outcome was decided and cannot speak for it.What is never replayed
An attempt that committed part of its own work, whatever the failure says.
openTree,clearTreeanddeleteTreecommit insideWriteOperation.run— and mysql and oracle commit before a DDL statement whether asked to or not — so the attempt no longer rolls back as a whole, while aWriteOperationis only idempotent in the database.RootContainer.openopens and registers the entry containers of every base DN in a singlestorage.write: replayed after the trees of the first base DN were created and committed, it registers that base DN a second time, fails withERR_ENTRY_CONTAINER_ALREADY_REGISTERED— masking the failure that caused the replay — and leaves the indexes of the previous attempt behind with the configuration listeners their constructors registered.The flag is raised immediately before each statement that commits, not once for the method that may issue one. Every one of those statements is guarded by a catalog read — including the
create index if not existsof postgresql, which commits whether it creates anything or not — so on an existing backendopenTree(name, true)issues nothing at all and the attempt stays replayable. Raising it on the catalog read instead would take a transaction the engine had rolled back whole out of the replay, andRootContainer.open()callsopenTree~25 times per suffix before anything is registered.The rule covers any replay rather than only the drop replay added here: the conflict replay of #867 could already reach the same wall through a deadlock on DDL. That
RootContainer.openpasses aWriteOperationwhich is not idempotent, against what the contract asks of it, is a bug of its own and storage-agnostic — #896; this keeps the JDBC backend out of it. It costs nothing on the hot path —openTree(..., createOnDemand=true)is reached from the open of a backend, from an import and from adsconfigthat adds an index, never from an entry write.That matters beyond one failed operation. A write of the replication replay that fails is recorded as applied — the ServerState advances past the change and the assured ack reports success — so a dropped connection there would cost a silently diverged replica rather than an error. That path is a pre-existing, storage-agnostic bug of its own — #889 — and this PR makes sure the JDBC backend does not walk into it.
LIFO and the cold end of the pool
expireAfterAccesssits on the pool entry, and every borrow and every return touches it, so the 15 s TTL only fires when the backend is fully idle for 15 s. With LIFO the connections below the working set are never borrowed, so nothing validates them and nothing reaps the dead ones — FIFO used to rotate them through. The pool held them all before as well, LIFO does not add connections; what it removes is the only mechanism that pruned dead ones. The per-connection idle expiry of #878 (#884) is what closes this, so this PR should not land before it.Tests
CachedConnectionTestCaseandJDBCStorageRetryTest, 115 methods, green, driven against mocked connections and the stub driver of #876 — and, for the retry loop, throughJDBCStorage.write()end to end rather than against its classifiers alone:0validates every borrow;SQLRecoverableException, next-exception and suppressed cases are recognized through the wrappers they arrive in, while53300, a deadlock and a bareS0001are not;PgSqlTestCaseagainst postgres in docker: 54 methods, green.