[#879] Skip the validation of a pooled JDBC connection returned a moment ago - #883
Conversation
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.
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.
|
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.
6a2fa82 to
7bc9294
Compare
|
Rebased on 1.
|
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
:68 static volatile long aliveBypassNanos = TimeUnit.MILLISECONDS.toNanos(getAliveBypassMillis());
// ...65 lines...
:133 static final Set<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 52ca42bf warnedOnce 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 issued
if (!isExistsTable(treeName)) { // :1331
... create table ...; 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 the
isExistsIndex 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
:939 dropped=isConnectionFailure(e,con); // computed BEFORE the implicit close()
:953 if (e!=failure) { // skipped when close()'s 08 was SUPPRESSED into failure
:954 if (isConnectionFailure(e)) { distrustPool(); }
:956 throw e;
}
:965 if (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 in
replayReason()'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 enters
read()/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:808 poolDistrustedAt.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 old
add()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 go
BackendImpl → 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 final
WriteableTransactionTransactionImpl; 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.
|
Round 3 answered in [1]
|
maximthomas
left a comment
There was a problem hiding this comment.
Two blocking issues below, both introduced
or left by this commit; the rest are non-blocking.
Two candidates I chased and refuted — recorded so they don't come back:
- A class-40 at
commit()replaying past thecommittingguard. The branch is reachable
(partlyCommittedis false there), but a class-40 reaching the client fromcommit()means the
server processed it and answered "rolled back" — stated, not in doubt.40003is already excluded
at:97, and every genuinely in-doubt outcome arrives as class 08 /SQLRecoverableException/
driver-closed, which!committingcovers. Not a hazard. firstLinkMatching's LIFO drain spendingMAX_CHAIN_LINKSon the next-exception chain before the
cause. The ordering and the arithmetic are exactly as they look. The shape isn't producible:
StorageRuntimeExceptionis not anSQLException, so it has no next-chain to drain ahead of its
cause, and every wrap in the file isnew StorageRuntimeException(e). Long next-chains come from
driver leaves, where the verdict is in the chain. NoaddBatchanywhere underbackends/jdbc.
issue (blocking): the alive stamp can outlive the distrustPool() meant to invalidate it
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:754
The stamp is taken after the validation round trip, and compared against the distrust as a
timestamp ordering:
usable = con.isValid(VALIDATION_TIMEOUT_SECONDS); // :737
...
con.lastKnownAliveNanos = System.nanoTime(); // :754final long provenAt = con.lastKnownAliveNanos; // :782
final Long distrusted = poolDistrustedAt.get(con.connectionString);
if (distrusted != null && provenAt - distrusted <= 0) { // :787Thread A validates at T1; the database fails over at Te > T1; thread B's in-flight statement fails
and calls distrustPool() at T2; A writes the stamp at T3 > T2. The distrust is now older than the
proof and :787 does not fire. Nothing in the class serializes this — no synchronized, the borrow
holds no lock, and distrustPool() is a bare ConcurrentHashMap.merge called from worker failure
paths. Because the stamp is never rewritten on release, every borrow bypasses validation for the
full window until a real use fails.
Record when the proof started, not when it was filed:
final long provenAt = System.nanoTime();
usable = con.isValid(VALIDATION_TIMEOUT_SECONDS);
...
con.lastKnownAliveNanos = provenAt;issue (blocking): on postgres partlyCommitted is raised before a statement that commits nothing when it fails
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1423
if (!isExistsIndex(tableName,"k_"+tableName.substring("opendj_".length()))) {
partlyCommitted=true; // :1423
try (final PreparedStatement statement=con.prepareStatement("create index if not exists ...")){
execute(statement); // :1425 — executeUpdate, no commit of its own
con.commit(); // :1426
}
}The pre-statement placement is justified by the mysql/oracle implicit pre-DDL commit (javadoc
:1344-1345). Postgres has no such thing: DDL is transactional, so a 40P01 at :1425 has
committed nothing and con.rollback() at :947 undoes the attempt entirely — but the flag still
reaches replayReason() through the finally at :972 and kills the replay at :1050. A
rollback-safe deadlock becomes a hard write failure, on the path RootContainer.open() walks ~25
times per suffix.
Fail-safe (a lost retry, never a double apply), but it defeats the guard's own purpose. On the
postgres branches, raise the flag after con.commit() returns.
issue (non-blocking): testADropReportedByTheReleaseReachesThePool does not exercise the release
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStorageRetryTest.java:510
// the operation is rejected for its own reasons, and the release that follows it is where the drop surfaces
doThrow(new SQLException("connection reset", "08006")).when(released).rollback();Unchained, so it fires on the attempt's own con.rollback() at JDBCStorage.java:948 — before any
release. dropped is true and distrustPool() runs in-catch at :964, i.e. the test pins the same
lines its sibling does.
Consequence: JDBCStorage.java:994 and its read() twin at :894 are dead in the suite — reaching
them needs the in-catch rollback to succeed and the release rollback to fail, and :462/:510 are
the only rollback stubs in the class. Nothing calls storage.read() at all. Deleting both branches
leaves the suite green. The relocation itself is also unpinned: verify(pooled).isValid(...) at
:521 passes under the pre-PR post-release placement too.
doNothing().doThrow(new SQLException("connection reset", "08006")).when(released).rollback();The production code is right; the claim that this test pins it isn't.
issue (non-blocking): failureScope() was not moved onto the shared walk
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:624-657
It pushes getCause() and getNextException() only — no suppressed branch — under a new comment
aligning it with firstLinkMatching. A class 08 arriving only as a suppressed exception scores
TREE, not SESSION: the outcome that same comment says the unbounded walk exists to avoid. Latent
today (the only caller runs on the stamp connection), but two walks now exist that can drift.
issue (non-blocking): conflictSummary's last fallback contradicts its javadoc
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1244
Javadoc says "the first SQLException of the failure"; firstLinkMatching(failure, WITH_THE_RELEASE, e -> true) pops suppressed before cause and returns the release's exception. Log-only, reachable
when the replay was decided on isClosed(con) alone — so the one line a replay leaves names the
release's SQLState instead of the statement's, which is the mis-description the rewrite was for.
Make the last fallback cause-only, or correct the javadoc.
issue (non-blocking): the clamp does not prevent the saturation its javadoc names
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:190-211
The window is clamped to ttl, but ttl itself has no upper bound. Set both near Long.MAX_VALUE:
the clamp branch is not taken, toNanos saturates at :79, and System.nanoTime() - provenAt >= window at :783 is never true — every connection trusted for the life of the server, the exact
outcome the javadoc claims to rule out. Caffeine saturates expireAfterAccess rather than throwing.
testTheWindowIsClampedToTheIdleTimeOfThePool only ever puts an extreme value on the window.
issue (non-blocking): the finally that closes the stamp session can replace the failure being unwound
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:972
} finally {
partlyCommitted=txn.partlyCommitted;
txn.stampSession.close(); // :973 — an unchecked throw here replaces `e` outright
}The write then reports the stamp-session failure instead of the drop, and is not replayed.
Pre-existing — flagged because moving distrustPool() into the inner catch fixed the neighbouring
half of exactly this and left this half. Suppress it into the original instead.
todo (non-blocking): the postgres index guard has no test
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1416-1428
driverNameOf(con) is con.getClass().getName(); a Mockito mock matches no engine, so openTree
returns before any index branch in both new write() tests — the test javadoc concedes it ("a mock
of no recognized driver"). The largest behavioural change in openTree, and the per-branch
partlyCommitted assignments, have zero coverage; verify(statements, never()).executeUpdate()
passes vacuously with respect to the index DDL. Stub getMetaData().getDriverName(), or use a
connection class whose name contains postgres.
todo (non-blocking): MAX_CHAIN_LINKS is unpinned in both directions
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:80
The deepest chain in any data provider is depth 3. Reverting 64 to 16 is undetectable by the suite.
One test with a 65-link chain closes it.
question (non-blocking): is getIndexInfo schema-qualified?
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1422
isExistsIndex passes a null schema, so a same-named table in another schema of a shared database
could satisfy the guard and the index would never be created where it is needed. Identifier folding
is not the concern — names are 63-char lowercase hex, no truncation, and postgres folds down.
I could not close this without a real multi-schema pgjdbc check.
praise: testASettingWorthWarningAboutStillInitializesTheClass genuinely pins the field move —
the loader really redefines the class, assertNotSame catches accidental parent delegation, and all
four rows throw ExceptionInInitializerError if warnedOnce moves back below aliveBypassNanos.
Rare to see a <clinit> ordering bug pinned at all.
|
Both blockers are fixed and the rest with them, in 1. The alive stamp outliving the distrustFixed, and the race is exactly as traced — nothing serializes the two, and since the stamp is never rewritten on release the connection sits at the head of the deque and is handed out for the whole window.
2.
|
… 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.
…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.
… asked, and raise the commit flag on the side each engine commits on The stamp of a validation was filed once the answer was in, so a proof that started before another operation reported a drop could be younger than the distrust it is compared against - nothing serializes the two - and the connection went on being handed out unvalidated for the rest of the window, from the head of the deque, by the very check that exists to stop that. It is read before the round trip now, in the borrow and in the connect alike: reading it early only ever ages a proof, which costs a validation and never skips one. partlyCommitted was raised in front of every statement that commits, which is what mysql and oracle need and what postgresql and sql server do not: there DDL runs inside the transaction, so a create index the engine rolled back had committed nothing and write() rolls the attempt back whole - and the flag took it out of the replay all the same, on the path RootContainer.open() walks for every tree of a suffix the first time a backend is opened after the index was added. Every such statement now goes through commitStatement(), which raises the flag before the statement only on the engines that commit before it, and before the commit on all of them: a statement that fails is replayable, a commit that fails is not. Also from the review: * failureScope() reads the suppressed exceptions, by sharing the walk of the other classifiers rather than keeping one of its own - a class 08 arriving from the close() of a statement scored TREE, which left the tree unstamped for the life of the backend. * The last fallback of conflictSummary() names the statement rather than the release behind it, as its javadoc says: the walk reaches the suppressed exceptions before the cause. * The alive window is bounded by MAX_ALIVE_BYPASS_MS as well as by the ttl it is clamped to. The ttl has no upper bound of its own, and with both saturating the conversion to nanoseconds every connection of the pool stayed trusted for the life of the server - the outcome the clamp is documented to rule out. * An unchecked failure of the comment session no longer replaces the failure being unwound: it is suppressed into it, so the replay is still decided on the failure that explains it, and a transaction that has just committed is not reported as one that failed. Tests: the postgres and mysql branches of openTree() are reached at all - the fixture asserts that the name of its mock names an engine - the budget of the walk is pinned from both sides, the drop a read runs into reaches the pool, and testADropReportedByTheReleaseReachesThePool now fails on the release rather than on the rollback of the attempt, which is what it claimed to pin. Every one of these fails on the code as it was: checked by putting each behaviour back.
924b0d5 to
71490e2
Compare
…its connections one by one Nothing limited how many connections a backend opened. The pool was an unbounded queue behind a cache with no maximum size, so a burst of concurrent operations opened as many connections as there were threads asking, and the only ceiling left was the max_connections of the database itself. The TTL sat on the pool rather than on a connection - expireAfterAccess keyed by the connection string, reset by every borrow and every return - so under continuous traffic nothing ever expired and the peak of a burst stayed open for as long as the backend saw any traffic at all. Caffeine was left without a scheduler besides, so an entry was only ever expired by a later cache operation, and a backend gone idle - the one case the TTL exists for - performs none. The cache entry is replaced by a Pool per connection string: a permit per live connection sized once from the new pool.max property, an idle deque handed out from the end it is returned to, and a sweep of its own that closes what nothing has borrowed for the TTL. The pool is reference counted by the storages using it, since it belongs to a database rather than to a backend and two backends may address one. Rebased onto master, which has since taken OpenIdentityPlatform#876, OpenIdentityPlatform#880 and OpenIdentityPlatform#883. Four points of contact this had to decide rather than merge: - pollIdle() is told whether the borrow trusts the alive window of OpenIdentityPlatform#879 and asks isUsable() as master wrote it. The pool of this change and that window are one layer, not two. - A connection carries the poolable flag of OpenIdentityPlatform#872, the proof of life of OpenIdentityPlatform#879 and the pool, the meter and the permit of this change together. close() sends a connection that may not be pooled through Pool.destroy() rather than closing it, or the bound would lose a place for every one of them. - getValidatedConnection() and distrustPool() of OpenIdentityPlatform#879 read db-directory again where this change had already established that the string open() registered with is the one to use: a db-directory changed on a running backend sent each of them to a pool holding none of this storage's connections. All three go through poolKey() now. - A nested borrow takes a permit when the pool has room, and goes on unmetered only when the pool stands at its bound. The exemption a nested borrow carries is from the wait rather than from the pool, which is what Pool.held has always documented; the code exempted it from both, so a connection borrowed inside another was never pooled again and the pool stopped handing out the connection returned last. Not carried over: the validation of a pooled connection is not clamped to what is left of the borrow. Master bounds it at the socket instead, and the pollIdle loop checks the deadline after each connection it discards, so a drain overruns by at most the one validation it is inside - which is what master does today. CachedConnectionTestCase: 83/83.
…ain, and take every borrow through one method The write transaction of a read-only storage stopped throwing when OpenIdentityPlatform#874 replaced the refusal in its constructor with a check per operation - which RootContainer.open() needs, and which an import does not: the merge with master left startImport() handing out an importer for a storage that is not writeable, one that would take a connection, begin its transaction and fail at the first tree it clears rather than at its start. An import writes by definition, so ImporterImpl refuses such a storage where it is built, which is where it was refused before that change. What reaches it is a storage that was already open: import-ldif and rebuild-index close it first, and startImport() opens a closed one READ_WRITE. And both borrows of this storage go through getConnection(boolean): OpenIdentityPlatform#883 moved startImport() onto getValidatedConnection(), which a test standing in for getConnection() no longer intercepted, so the two tests of that path reached for a real database - the connection string of a mock configuration - instead of the connection they had prepared.
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. It is clamped to an hour behind that, since the ttl has no upper bound of its own — with both set high enough the conversion to nanoseconds saturates,nanoTime() - provenAt >= windowis never true again, and every connection of the pool stays trusted for the life of the server, which is the outcome the first clamp is there to rule out.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.The stamp stands for the moment the connection was asked, not the moment its answer was filed. It is compared against the distrust of the pool as an ordering of two moments, and nothing serializes the two: a validation is allowed five seconds, so a proof taken before another operation reported a drop and filed after it would be the younger of the pair, and the connection — at the head of the deque, where LIFO puts it — would go on being handed out unvalidated for the rest of the window by the check that exists to stop exactly that. Reading the stamp early only ever ages a proof, which costs a validation and never skips one.
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 by the one helper every statement of the transaction that commits goes through, never once for a 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.Which side of the statement the flag goes up on is the engine's answer. mysql and oracle commit before a DDL statement whether asked to or not, so there the work behind it is committed by the statement itself and the flag has to be up before it is issued: the statement that fails has committed everything before it just as surely as the one that succeeds. postgresql and sql server run DDL inside the transaction, and a DML statement commits of its own accord nowhere — one that fails there has committed nothing,
write()rolls the attempt back whole, and a flag raised in front of it would take a conflict the engine itself undid out of the replay. On those the flag goes up in front of the commit instead, which is the call that leaves the outcome of the transaction unknown when it fails. So a statement that fails stays replayable and a commit that fails does not, on every engine.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, 122 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;openTree()are reached at all — the fixture asserts that the name of its mock names an engine, sincedriverNameOf()reads the class name and a mock of plainConnectionmatches no engine — and there a create index the engine rolled back is replayed while one mysql had committed before is not;Each of the tests above fails on the code as it was before this branch changed it: checked by putting every one of those behaviours back and running the suite against it.
StampConnectionTestCasecovers the scope of a failed stamp read from the suppressed chain as well as from the cause and the next exception, 5 methods, green.PgSqlTestCaseagainst postgres in docker: 54 methods, green.