[#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 9 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
static final long DEFAULT_ALIVE_BYPASS_MS = 500; // opt-out, not opt-in
...
if (bypassNanos > 0 && System.nanoTime() - con.lastKnownAliveNanos < bypassNanos) {
return true; // isValid() never called
}
return con.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());
return true; // 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 both rollback() 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
6a2fa82
Compare
|
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:
static boolean isConnectionFailure(Throwable t) {
for (int hop=0; t!=null && hop<MAX_CAUSE_HOPS; t=t.getCause(), hop++) {
if (t instanceof SQLException) {
final String state=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 (t instanceof SQLRecoverableException || t instanceof SQLNonTransientConnectionException
|| t instanceof SQLTransientConnectionException) {
return true;
}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:
public void openTree(TreeName treeName, boolean createOnDemand) {
if (createOnDemand) {
if (!isExistsTable(treeName)) {
try (final PreparedStatement statement=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 single storage.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:
EntryContainer ec = this.entryContainers.get(baseDN);
if (ec != null) {
throw new InitializationException(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(final Connection con=getConnection()) {
return readOperation.run(new ReadableTransactionImpl(con));
} catch (Exception e) {
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 (Exception e) {
if (e!=failure) { throw e; } // (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 -> new AtomicLong()).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:163 open(AccessMode), :797 removeStorageFiles(), :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
7bc9294
Compare
|
Rebased on 1.
|
…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.
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, and 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.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 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, 104 methods, green, driven against mocked connections and the stub driver of #876: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.