[#877] Bound every statement of the JDBC backend by the class of its call site - #882
[#877] Bound every statement of the JDBC backend by the class of its call site#882vharseko wants to merge 5 commits into
Conversation
|
@maximthomas one commit added since the review was requested — 3da33ea, worth a look before you start rather than after. The socket read timeout armed behind the cancel of a statement was being set unconditionally. It is the cancel's bound plus a margin, so it is the looser of the two by construction, which means a connection that already carried a read timeout of its own had it replaced — with a weaker one — for the duration of every statement. Nothing in this repository sets such a timeout today, but #885 asks for exactly that setting, and it would have been silently ignored while a statement was running, which is when it matters.
Two tests came with it, so The "Two layers" section of the description now says this too. |
maximthomas
left a comment
There was a problem hiding this comment.
Reviewed 58f2874 and 3da33ea. The OPERATION/BULK split is a sound design, and the "only ever tighten" guard in 3da33ea is correct: previous == 0 arms, 0 < previous <= backstop is left alone, previous > backstop is tightened and restored, and the -1 sentinel cannot collide because getNetworkTimeout() is non-negative by contract.
Two issues should be addressed before merge; the rest are minor.
Row transfer runs outside both timeout layers (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:148-163 releases the backstop before the caller reads a single row:
try {
return execution.run(); // returns a live ResultSet
}catch (SQLException e) {
throw timedOut(e, bound, seconds, startedAt);
}finally {
releaseBackstop(statement, backstop); // socket read timeout back to 0
}The drain happens afterwards — read() at :376-378, and fetchBatch() at :658-662 which pulls up to fetchsize (default 1000) rows.
setQueryTimeout covering ResultSet.next() is explicitly optional per the JDBC javadoc ("drivers may also apply this limit to ResultSet methods"). Of the four supported drivers:
- PostgreSQL / MySQL — fully buffered at execute (
setFetchSizeis never called;useCursorFetchdefaults false). Not affected. - Oracle (ojdbc8) —
defaultRowPrefetchis 10 against batches of 1000, andOracleStatement.fetchMoreRowscallsbeginTimeout()only whenserverCursor == true, which is false by default. Roughly 99 of every 100 round trips run with neither timeout armed, plus the LOB round trips forv blob. - SQL Server (mssql-jdbc) —
responseBuffering=adaptiveis the default, andTDSCommand.startResponsecancelsTDSTimeoutTaskright after the firstreadPacket();cancelQueryTimeoutdefaults to-1.
On SQL Server this means #877's own symptom is only nondeterministically fixed: default READ COMMITTED takes shared locks, so a select really does block on a row another session holds, and whether that block lands inside execute() (covered) or after the first 8 KB packet (uncovered) depends on where the locked row sits in the batch. Meanwhile the javadoc at :134-144 states that "A statement of this backend has to end" and that the socket read timeout "ends the wait even when the cancel is not acted upon" — not delivered on half the supported engines. That assurance also discourages the one mitigation that does cover the drain (a connection-level socket timeout), which on Oracle is not even reachable through the URL; it needs oracle.net.READ_TIMEOUT.
Suggested fix: hold the backstop for the statement's life — release it where the ResultSet/statement is closed rather than when executeQuery returns.
positionToLastKey() is bounded as OPERATION on the backend-open path (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:782-784:
public boolean positionToLastKey() {
if (fetchBatch(null,null,0,true,1)) {With no condition this emits select k,v from <table> order by k desc offset ? rows fetch next ? rows only through the single-argument executeResultSet (:108), i.e. OPERATION, 120 s.
It runs on every backend open, unconditionally per base DN:
BackendImpl.java:200 newRootContainer(...) — outside the only try/catch, which starts at :206 and wraps getEntryCount() — → RootContainer.java:225 ec.getHighestEntryID(txn) → EntryContainer.java:660-665 cursor.positionToLastKey() on id2entry.
On SQL Server k is varbinary(max) (:428) and cannot be an index key, as the comment at :478 already notes, so this is a full scan plus a top-1 sort. A large mssql backend that previously opened slowly now fails to open, and the only escape is disabling the bound globally.
This also contradicts the PR's own taxonomy: getRecordCount already uses BULK (:392), whose javadoc reads "a whole table at once" (:88) — which is exactly what an unfiltered order by k desc is.
One line at :658 covers this, positionToIndex, and the mssql first-batch case:
executeResultSet(statement, condition==null ? StatementBound.BULK : StatementBound.OPERATION)Nits
setQueryTimeoutis unguarded (JDBCStorage.java:153): it sits outside anytry, unlikearmBackstop(), which catches and degrades. The JDBC spec allowsSQLFeatureNotSupportedException, and this backend accepts an arbitrary URL with an ANSI fallback dialect — on such a driver every statement now fails where it previously worked.timedOut()misattributes late failures (:224): classification is purely by elapsed time, so a connection reset at 121 s is reported as "raiseorg.openidentityplatform.opendj.jdbc.query.timeout".System.currentTimeMillis()also means an NTP step backwards hides a real timeout and a step forwards manufactures one —System.nanoTime()is the right clock for a duration.backstopWarnedis static (:172): one warning per JVM, shared across instances. With a driver lackingsetNetworkTimeoutit fires once during startup and is then permanently silent; a second JDBC backend never reports it at all.- Two execution sites bypass
bounded()(:410,:484):getMetaData().getTables(...)andgetIndexInfo(...)are unbounded in both phases and run once per tree on every open. The commit message's "all nineteen execution sites" is accurate for those nineteen, but there are 21 places this backend sends work to the database — and the commit names "a table waiting for a metadata lock" as a motivating hazard, which is precisely what these two are exposed to. positionToIndexis O(offset) on every engine (:796):offset ? rows fetch next ?is driven straight from a client-supplied VLV position (VLVIndex.java:701), so a deep VLV request now errors at 120 s instead of answering slowly.BULKdefaults to 0 (:89): one of the three hangs named in the method's own javadoc — a table waiting for a metadata lock — stays unfixed under stock settings, e.g. the MySQLcreate indexinopenTree()(:458). The trade-off is reasonable, but the description reads as if the whole class is closed.ImporterImpl.close()has nofinally(:838):con.commit(); con.close();— and the bulk bound makes this reachable, since a throwingclearTree()closes the importer and a throwingcommit()then skipscon.close(), leaking the connection with its transaction and locks.
…scan of a backend open its own class The review of OpenIdentityPlatform#882 found the bound covering less than its javadoc claims. The rows were read after it was released. bounded() put the socket read timeout back in a finally that runs before the caller has seen a single row, and the transfer is where the wait lives: a driver hands rows over as they are asked for, and setQueryTimeout covering ResultSet.next() is optional in the JDBC contract. PostgreSQL and MySQL buffer a result whole and were never affected, but oracle prefetches ten rows against batches of a thousand and mssql buffers adaptively, so on both of them nearly every round trip of a batch ran with neither layer armed - and on mssql, where a select under READ COMMITTED really does block on a row another session holds, that is the symptom of OpenIdentityPlatform#877 itself. executeResultSet() hands the rows to its caller now instead of returning a live ResultSet, so the transfer, the classification of a failure and the release of the backstop all happen where the statement does. positionToLastKey() was bounded as an operation. It has no key to seek on, so it is an "order by k desc" over the whole table - a scan and a sort of it on mssql, where k is a varbinary(max) that cannot be an index key - and every open of a backend runs it once per base DN, through EntryContainer.getHighestEntryID(), outside the try/catch of BackendImpl.openBackend(). Two minutes there turns a large backend that opens slowly into one that does not open at all. It takes the bulk class now, which fetchBatch() receives from its caller rather than deriving from the shape of the query: the first batch of every cursor carries no condition either, and that one is on a search path and stays an operation, as does the VLV offset of positionToIndex() - a client-driven offset is exactly what has to give the worker thread back. Also from the review: - the catalog lookups of openTree(), getTables() and getIndexInfo(), went through no bound at all. DatabaseMetaData takes no query timeout, so they get the socket read timeout alone, as the operations they are: they run once per tree on every open, behind the same locks as the create table they guard; - setQueryTimeout() was called outside any try. The contract allows SQLFeatureNotSupportedException and this backend takes whatever URL a deployment configures, so such a driver now degrades to the backstop with one warning instead of failing every statement it is given; - the bound was measured on the wall clock, which a step of it could lengthen or shorten. It is the monotonic clock now; - the warning about a driver that will not take a backstop was static: the first backend to hit it silenced it for every other one in the JVM. It is per storage, and the same is true of the new one above it; - ImporterImpl.close() ran "con.commit(); con.close();" with nothing between them, so a commit that throws left the connection out of the pool for good, holding the transaction and the locks of that import - and the bulk bound makes that reachable, since a clearTree() that gives up closes the importer on its way out. The connection goes back whatever the commit does, and the storage this importer opened is closed whatever the connection does; - seconds() said a value that is not a number leaves a class unbounded. Integer.getInteger() falls back to its default instead, which is what the test asserted all along; the javadoc, the test name and the description of the bulk class shipping unbounded say so now. JDBCStatementBoundTestCase covers the rows being read while the bound is still armed and a failure during that transfer being measured against it, a driver without a query timeout still running under the backstop, a catalog lookup being bounded, and the class of both kinds of cursor batch - 15 tests, no database, 3 s. PgSql 39/39, MySql 39/39, MsSql 39/39, Oracle 39/39 and the JDBC EncryptedTestCase 34/34 pass with no skips.
|
Thank you — both major findings were real, and every line reference in the review checked out. All of it is addressed in Row transfer runs outside both timeout layersConfirmed and fixed. Your driver survey matches what is in the file:
|
…by the class of its call site Not one statement of this backend was given a setQueryTimeout, so a row locked by an unrelated session, a table waiting for a metadata lock or a database that stopped answering mid-query parked the worker thread that issued it for good - the half of OpenIdentityPlatform#872 that lives behind a successful login, where the bound on establishing a connection cannot reach. The bound goes on the statement rather than on the session: a pooled connection cannot carry a session setting, since CachedConnection.close() only rolls back and a statement_timeout of one operation would then apply to whoever borrows the connection next. All nineteen execution sites already went through execute()/executeResultSet(), so that is where it is applied, by the class of the call site - one value cannot serve both. An entry read is a single row of an index and is bounded by org.openidentityplatform.opendj.jdbc.query.timeout (120 s by default), while the count of a tree, the delete that empties one before an import, create index and drop table are a scan or a rewrite of a whole table: they take minutes on a populated backend and keep a bound of their own, org.openidentityplatform.opendj.jdbc.bulk.timeout, unbounded by default. A backend start counts its entries and an import clears every tree, so a single default would have broken both. The bound is applied in two layers, because the first one is not answered everywhere: setQueryTimeout cancels the statement and keeps the connection, and a socket read timeout armed for the duration of the statement ends the wait even when the cancel is not acted upon. Oracle needs it: a session blocked in a row-lock enqueue does not process the break its driver sends, and the container suite caught the statement parked in a socket read with its timeout armed and never arriving. Reaching the second layer costs the connection, which is the price of a wait the database was not going to end. A failure that arrives before the bound is passed through untouched, so a lock wait reported in class 40 stays the conflict a caller can replay; one that arrives at the bound is reported with the property that produced it - no driver knows why it was cancelled - carrying over the SQL state and the error number, and without the statement itself, which a driver renders with its parameters bound. JDBCStatementBoundTestCase covers the policy and both classification branches without a database; the container suites block a write and a bulk statement behind an uncommitted transaction of another session and require each to give up inside the bound of its own class.
…bound, never loosen one The socket read timeout armed behind the cancel of a statement was set unconditionally, so a connection already carrying a read timeout of its own had it replaced for the duration of every statement - by a looser value, by construction, since the backstop is deliberately the cancel's bound plus a margin. A deployment that bounds the reads of its connections (the setting OpenIdentityPlatform#885 asks for) would have found that bound ignored exactly while a statement was running, which is when it matters. It is armed now only when there is something to gain: when the connection carries no bound at all, which is "no timeout" in the JDBC contract, or when the one it carries is looser than the backstop. Where nothing is changed, nothing is put back afterwards either.
…scan of a backend open its own class The review of OpenIdentityPlatform#882 found the bound covering less than its javadoc claims. The rows were read after it was released. bounded() put the socket read timeout back in a finally that runs before the caller has seen a single row, and the transfer is where the wait lives: a driver hands rows over as they are asked for, and setQueryTimeout covering ResultSet.next() is optional in the JDBC contract. PostgreSQL and MySQL buffer a result whole and were never affected, but oracle prefetches ten rows against batches of a thousand and mssql buffers adaptively, so on both of them nearly every round trip of a batch ran with neither layer armed - and on mssql, where a select under READ COMMITTED really does block on a row another session holds, that is the symptom of OpenIdentityPlatform#877 itself. executeResultSet() hands the rows to its caller now instead of returning a live ResultSet, so the transfer, the classification of a failure and the release of the backstop all happen where the statement does. positionToLastKey() was bounded as an operation. It has no key to seek on, so it is an "order by k desc" over the whole table - a scan and a sort of it on mssql, where k is a varbinary(max) that cannot be an index key - and every open of a backend runs it once per base DN, through EntryContainer.getHighestEntryID(), outside the try/catch of BackendImpl.openBackend(). Two minutes there turns a large backend that opens slowly into one that does not open at all. It takes the bulk class now, which fetchBatch() receives from its caller rather than deriving from the shape of the query: the first batch of every cursor carries no condition either, and that one is on a search path and stays an operation, as does the VLV offset of positionToIndex() - a client-driven offset is exactly what has to give the worker thread back. Also from the review: - the catalog lookups of openTree(), getTables() and getIndexInfo(), went through no bound at all. DatabaseMetaData takes no query timeout, so they get the socket read timeout alone, as the operations they are: they run once per tree on every open, behind the same locks as the create table they guard; - setQueryTimeout() was called outside any try. The contract allows SQLFeatureNotSupportedException and this backend takes whatever URL a deployment configures, so such a driver now degrades to the backstop with one warning instead of failing every statement it is given; - the bound was measured on the wall clock, which a step of it could lengthen or shorten. It is the monotonic clock now; - the warning about a driver that will not take a backstop was static: the first backend to hit it silenced it for every other one in the JVM. It is per storage, and the same is true of the new one above it; - ImporterImpl.close() ran "con.commit(); con.close();" with nothing between them, so a commit that throws left the connection out of the pool for good, holding the transaction and the locks of that import - and the bulk bound makes that reachable, since a clearTree() that gives up closes the importer on its way out. The connection goes back whatever the commit does, and the storage this importer opened is closed whatever the connection does; - seconds() said a value that is not a number leaves a class unbounded. Integer.getInteger() falls back to its default instead, which is what the test asserted all along; the javadoc, the test name and the description of the bulk class shipping unbounded say so now. JDBCStatementBoundTestCase covers the rows being read while the bound is still armed and a failure during that transfer being measured against it, a driver without a query timeout still running under the backstop, a catalog lookup being bounded, and the class of both kinds of cursor batch - 15 tests, no database, 3 s. PgSql 39/39, MySql 39/39, MsSql 39/39, Oracle 39/39 and the JDBC EncryptedTestCase 34/34 pass with no skips.
1485984 to
b7c7421
Compare
|
Rebased onto master (0b9c0f6), which had moved on under the JDBC backend since this branch was cut (#886 catalog lookup, #866 table stamping and statistics, #867 SQL Server upsert). Conflicts and how they were resolved:
One thing beyond the conflict markers: this PR replaces
|
maximthomas
left a comment
There was a problem hiding this comment.
Reviewed at b7c74213279a3816ef1337858e7563265450104e against master 0b9c0f63f5. The rebase itself is clean — I checked the four seams you listed and found no loss: isExistsTable() propagates a timeout instead of returning false (no spurious create table), the lifted final byte[] value keeps hashParam(con), all four #867 hashParam sites survive, and timedOut() copies SQLState and vendor code so #867's retry classifier still sees a class-40 conflict as retryable and a cancel (57014 / ORA-01013 / HY008 / 70100) as not. One blocker, two majors, one minor below.
The backstop is connection-wide, but the importer shares one connection across all import threads (blocker)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:246-252, :310-312, :222-224
armBackstop() sets Connection.setNetworkTimeout() — a property of the socket, not of the statement. ImporterImpl holds exactly one Connection (:1729, assigned :1758) and gives it to both transactions:
txr = new ReadableTransactionImpl(con);
txw = new WriteableTransactionTransactionImpl(con);OnDiskMergeImporter drives that single Importer from nbThreads phase-one workers (OnDiskMergeImporter.java:903/:921/:943) and one phase-two task per tree (:1286 invokeParallel). Two consequences:
(a) ImporterImpl.clearTree (:1803-1805 → txw.clearTree :1387, BULK) short-circuits at :222-224 and arms nothing, but runs on a socket a concurrent OPERATION set to 150 000 ms. Pooled connections start at networkTimeout 0 (the dialect connect properties go only to newStampConnection, :582-585), so the arm always takes effect. A multi-minute delete from <table> dies at 150 s, the driver closes the connection, and import-ldif fails where it previously completed slowly — and since BULK never entered bounded(), timedOut() never runs, so the error names nothing.
(b) With N concurrent statements only the first arms; the rest hit
if (previous > 0 && previous <= backstop) {
return -1;
}and arm nothing, then the first restores 0 at :252 while they are still in flight. During any import most statements run with the socket layer off — the layer the javadoc at :215-218 says exists because Oracle "does not process the break its driver sends".
Cheapest fix: skip the backstop on the importer's connection; it is the only shared one, every other is borrowed per read()/write(). Thorough fix: move arm/release into CachedConnection behind a lock with an outstanding-statement count, arm to the tightest value requested, restore only at zero, and let BULK register "no bound" instead of short-circuiting before armBackstop.
The cursor batch bound aborts rebuild-index on SQL Server (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1582
The first batch of a cursor has no seek predicate, so fetchBatch (:1549-1560) issues an unconditioned order by k over the whole table, and next() gives it OPERATION (120 s):
if (buffer.isEmpty() && !fetchBatch(currentKeyDb==null?null:">", currentKeyDb, 0, false,
adaptiveBatchSize(), StatementBound.OPERATION)) {On mssql k is not indexable — your own comment at :1364 says so, and the create index (k) at :1332-1360 covers postgres/mysql/oracle only:
// mssql: k is varbinary(max), which cannot be an index key column - cursor batches stay unindexed thereso SQL Server scans and TOP-N sorts the whole table. rebuild-index reaches this path: OnDiskMergeImporter.java:1086 importer.openCursor(id2Entry.getName()) in ID2EntrySource.processAllEntries, constructed at :551 under rebuildIndex(...), entered from BackendImpl.java:786. Past 120 s the driver cancels, fetchBatch throws, and the rebuild aborts — master ran the same scan unbounded (master's fetchBatch took no bound and used the untimed executeResultSet(statement)). No client is waiting, so the OPERATION rationale does not apply here.
Fix: take next()'s class from the caller the way fetchBatch already does elsewhere — importer/rebuild cursors are BULK, client-search cursors stay OPERATION. Same pattern you already applied to positionToLastKey (:1694).
The statistics refresh gets the query timeout but not the backstop (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:959, :977
statement.setQueryTimeout(timeoutSeconds); // 0: wait without limit
...
executeAny(statement);executeAny() (:354-359) just calls statement.execute() — no bounded(), so armBackstop never runs, and the connection is a pooled one with networkTimeout 0. This is the only statement in the file with layer 1 and not layer 2. It runs on Oracle, as dbms_stats.gather_table_stats, inside ImporterImpl.close() — after the data is committed. By the premise of your own javadoc at :215-218, if the cancel is not acted upon the 600 s bound never arrives and import-ldif parks forever at the end of a successful import, holding the pooled connection, with no error.
Not a regression (master had neither layer here), but the PR title says every statement is bound and this is the counterexample, on the named engine. The tool already exists: the bounded(Connection, StatementBound, Execution) overload isExistsTable uses arms only the socket backstop, so wrapping these statements in it keeps the 600 s bound and closes the gap.
The container bound test cannot fail for the reason it exists (minor)
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java:254, :282-286
final int boundSeconds = 5;
...
} catch (Exception expected) {
// the bound was reached and the transaction rolled back
}
final long elapsed = System.currentTimeMillis() - startedAt;
assertTrue(elapsed < 120000, "gave up only after " + elapsed + " ms");The bound is 5 s and the asserted ceiling is 120 s. On MySQL the blocked statement runs on a pooled connection and lockTimeoutSql reaches only stamp connections (:582, :588), so InnoDB's own 50 s innodb_lock_wait_timeout ends the wait inside the ceiling — both testWriteBlockedByAnotherSessionGivesUpAtItsBound and testBulkStatementGivesUpAtItsOwnBound pass with bounded() deleted. And any exception satisfies the catch, so a run failing instantly for an unrelated reason passes at t≈0 too.
Assert against the bound (elapsed >= boundSeconds * 1000 and elapsed < boundSeconds * 4 * 1000) and assert the exception is the one the bound produces — SQLTimeoutException, or that the message names the property, which timedOut() puts there. Setting the untested classes to "0" and clearing every property in the finally is right and worth keeping.
Nits
- Vacuous BULK assertion:
JDBCStatementBoundTestCase.java:117-118sets BULK's property to a non-number and assertsBULK.seconds() == 0, but BULK's default is already0— it passes whether the fallback works, the value parses as 0, or the property is never read. Set a numeric value first, then a non-numeric one. Only the OPERATION half at:114-115currently pins the fallback. - Javadoc claims coverage the suite does not have:
JDBCStatementBoundTestCase.java:143-144says the backstop is put back "so a bulk statement sharing the connection is not cut by the bound of an entry read", but the test drives one mock connection from one thread in sequence. That is exactly the property the blocker above shows the code lacks. (The rest of the suite is load-bearing: removing the tighten-only guard fails:167-177, dropping the release fails theinOrderat:158/:248, misassigningpositionToLastKey/nextfails:325-340.) - Comment on the wrong method: the "The connection goes back whatever the commit does…" block landed above
aborted(), which only sets a flag. It describesclose(), which is where the nestedfinallyit explains actually is.
…ion between the statements on it The backstop behind the cancel is a property of the socket, and it was armed and put back per statement. On the one connection this backend shares - the importer holds a single one for the whole of an import and writes to it from every phase-one worker and every phase-two task - that meant the first statement to finish took the backstop away from those still in flight, while a statement of a class carrying no bound ran under whatever a concurrent one had armed, dying at a bound it was never given and naming no property for it. The value armed is now the loosest of the bounds in flight, a statement with no bound of its own takes it off for as long as it runs, and what the connection carried before is put back when the last of them is through. The batches of a cursor take the class of whoever opened it: an import or a rebuild-index walks a whole tree with no client waiting, and on mssql walks it unindexed, so bounding those as entry reads aborted a rebuild that ran to the end before. Client cursors stay operations. The statistics refresh had the cancel but not the backstop, on oracle - the engine that does not act on a break - at the very end of a successful import. It now runs under both, under the bound of its own property rather than of a class, which would have put 120s over a statement allowed 600. The container bound test asserted a 120s ceiling on a 5s bound and accepted any exception, so it passed with the bound deleted; it now asserts the window of the bound and that the failure names the property that produced it.
|
Thank you — the blocker and both majors were real, and every line reference checked out again. All of it is addressed in The backstop is connection-wide, but the importer shares one connectionConfirmed, and taken the thorough way rather than by skipping the importer's connection: the socket read timeout is now arbitrated between the statements running on one connection instead of being armed and put back per statement. A
The state is keyed by identity on the connection of the driver, since Your trace of where the overlap comes from holds, and it is narrower than "phase one runs on N threads": One corollary the review did not name, which the shared release also fixes: The cursor batch bound aborts
|
maximthomas
left a comment
There was a problem hiding this comment.
Reviewed at c1508e0fc68cc24f64c31f9d804931cc1eda045f against 0b9c0f63f5.
The Backstop redesign is right. I tried hard to break it and could not: the "a failed restore leaves
a stale networkTimeout on a pooled connection" theory is unreachable, because on all three
non-postgres drivers every throwing path in setNetworkTimeout implies the connection is already
closed, and a dead one fails isValid(0) on borrow. The "the restore strips the stamp connection's
connect-property read timeout" theory is also false — all four drivers do report that value from
getNetworkTimeout() (ojdbc via T4CConnection's override, not PhysicalConnection), so
previous is 30000, the tighten-only guard returns, and nothing is armed there at all. Fixes (3),
(4), (5) and (6) check out.
Fix (2) does not. It went in one level too low, and it is the one that makes a server fail to start.
The bulk cursor class was added below the SPI, so only the importer can reach it (blocker)
openBulkCursor() is package-private on ReadableTransactionImpl and is not on the SPI, which
declares only openCursor:
// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1376
public Cursor<ByteString, ByteString> openCursor(TreeName treeName) {
return new CursorImpl(isReadOnly, con, treeName, StatementBound.OPERATION);
}
...
// :1387 — not on org.opends.server.backends.pluggable.spi.ReadableTransaction
Cursor<ByteString, ByteString> openBulkCursor(TreeName treeName) {
return new CursorImpl(isReadOnly, con, treeName, StatementBound.BULK);
}Its only caller is ImporterImpl.openCursor() (:1970). Every other holder of a
ReadableTransaction lands on :1376 and keeps 120 s per batch — and on SQL Server every batch is a
full scan and sort, as the file's own comment at :1500 says, because k is a varbinary(max) and
create index (k) covers only postgres/mysql/oracle (:1470/:1479/:1491).
That is still rebuild-index's problem, plus export-ldif, verify-index and dbtest. But the path
that matters is not a command line:
LDAPReplicationDomain.computeGenerationId() :3191-3193
-> exportBackend(null, true) -> backend.exportLDIF(exportConfig)
-> BackendImpl.java:624 new ExportJob
-> ExportJob.java:175 txn.openCursor(id2entry.getName()) // OPERATION, 120 s per batch
computeGenerationId() is called at LDAPReplicationDomain.java:3326, on the if (!found) branch of
loadGenerationId() — the first start of a replicated domain, with no operator involved (also at
:3605 after a failed import, and via initializeRemote for a total update). Master had no bound on
any cursor, so this server started before the PR. Now, on a large enough SQL Server backend, the
generation ID is never computed and the domain does not come up.
Putting the choice on the SPI fixes all of these at once; covering only
ExportJob/VerifyJob/BackendStat would leave the four cursor call sites in DN2URI, VLVIndex,
PersistentCompressedSchema and ID2Entry unclassified — I did not trace those to their callers.
The importer's own writes and reads keep the 120 s bound, and the new comment says they do not (major)
// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:1965
// Bulk, like everything else an import does: this walks a whole tree with no client waitingOnly clearTree (:1521) and deleteTree (:1533) are BULK. ImporterImpl.put (:1955) reaches
upsert, whose every dialect branch calls the one-argument overload:
// :191
int execute(PreparedStatement statement) throws SQLException {
return execute(statement, StatementBound.OPERATION);
}and ImporterImpl.read (:1960) reaches the two-argument executeResultSet at :1369, also
OPERATION. Master issued both raw, with no setNetworkTimeout anywhere and no socketTimeout on
pooled connections, so every row an import writes gains a 120 s ceiling it did not have.
Reachability is narrower than the cursor case and I want to be accurate about it: this needs a
concurrent writer, not merely a large import. The importer's own threads share one Connection
(:1907), so they are one session and cannot lock-block each other. But h is the primary key on
every dialect and the default lock wait is forever on mssql, postgres and oracle, so an upsert blocked
by an LDAP write on the same table during an online ImportTask or rebuild-index does sit until
120 s and then fails the import. MySQL escapes only because its own 50 s innodb_lock_wait_timeout
fires first.
Either give put/read BULK — which is what the comment already claims — or leave them OPERATION
deliberately and correct the comment, which is currently false about the code directly beneath it.
The new concurrency tests pin re-entrancy, not concurrency (minor)
Two of the three issue their second statement from inside a Mockito Answer, on the same thread:
// opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCStatementBoundTestCase.java:205
when(operation.executeUpdate()).thenAnswer(new Answer<Integer>() {
public Integer answer(InvocationOnMock invocation) throws Throwable {
storage.execute(bulk, StatementBound.BULK); // what another thread of the import is doing
return 1;
}
});Backstop's synchronized blocks are reentrant, so this takes both monitors trivially — a lost update
on bounds/unbounded/holders under real threads would still pass. The one genuinely two-threaded
test (:227) gives both statements OPERATION "7", so two distinct bounds are never merged or
decremented concurrently.
testTheBackstopFollowsTheLoosestBoundInFlight (:281) also arms the loose bound first and lets the
tight one join, so wanted never changes and applyBackstop's re-arm branch is never entered with
armed > 0 && wanted > 0. Nothing tests the reverse order (OPERATION in flight, BULK joins, must
re-arm 37000 -> 130000) or the tighten-back-down when the looser statement finishes first.
And testTheBatchesOfAnImportCursorAreBulk (:478) passes BULK to CursorImpl by hand;
openBulkCursor appears nowhere in either test file, so reverting ImporterImpl.openCursor to
txr.openCursor() — the blocker above — passes the whole suite.
The cross-thread test hangs the build instead of failing it (minor)
JDBCStatementBoundTestCase:227 has three untimed waits — mayFinish.await() (:239),
running.await() (:259), concurrent.join() (:268) — and no timeOut on the method or the class,
unlike TestCase.java:208/:229. running.countDown() is inside the mock's Answer (:238), so if
storage.execute(lingering) throws before reaching it, the main thread parks at :259 forever.
The background throwable is also dropped:
// :243
public void run() {
try { storage.execute(lingering); }
catch (SQLException e) { throw new RuntimeException(e); } // nothing captures this
}join() does not rethrow, and releaseBackstop runs in a finally, so a failure after the
countdown still leaves the InOrder verification passing.
The container test times with the wall clock (minor)
// opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java:293
final long startedAt = System.currentTimeMillis();
...
assertTrue(elapsed >= boundSeconds * 1000L, ...); // :316, no slacktimedOut()'s own comment says the production measurement is taken from the monotonic clock "which a
step of the wall clock can neither lengthen nor shorten"; the test that checks it uses the wall clock,
with zero slack on the floor.
Round 4's finding is otherwise genuinely fixed — namesTheBound (:306) works, because timedOut()
only puts the property in the message once the bound has elapsed, so an instant unrelated failure can
no longer pass. What it does not exclude is a post-bound unrelated failure, since timedOut()
relabels any SQLException arriving after seconds as that property's breach.
Nits
unsupporteddoes not survive, so its comment is wrong: the comment atJDBCStorage.java:437
says a driver with no network timeout "is not asked again", butunsupportedis a field of
Backstop, andreleaseBackstopdrops the entry whenever--holders <= 0(:388). With one
statement at a time the latch dies at every release, so such a driver is asked, throws and is caught
once per statement forever.- The one-shot warning is spent by the wrong cause:
backstopWarned(:294) is CAS'd at:440
inside the single catch its own comment says serves both causes. The common one — a connection on
its way out — permanently consumes the one shot and silences the genuine "this driver has no network
timeout" warning for the life of the storage. - "Only the comment DDL is left outside both layers" is not exhaustive:
executeSessionStatement— theset lock_timeout/alter session set ddl_lock_timeoutissued from
newStampConnection— is outside both layers too. Harmless for the same reason (it runs on a stamp
connection), but worth naming under "Out of scope". timedOut()names a property theConnectionoverload never set:bounded(Connection, StatementBound, Execution)sets no query timeout, yet a failure aftersecondsis rewritten to
"did not finish within the 120s of ...jdbc.query.timeout: raise that property". On a pooled
connection the only layer in force there is the backstop at bound+30 s, so a catalog lookup that
fails at 121 s from a reset connection points the operator at the wrong knob.positionToKey()anddelete()never gotbatchBound(:1812,:1767) and stay OPERATION on
a bulk cursor. Unreachable today —ImporterImpl.openCursordeclaresSequentialCursor, and
delete()throws on the read-only transaction — so this is consistency only.
…it belongs to, not to the call site that reaches it The bulk class of a cursor's batches went in below the SPI: openBulkCursor() was package-private on the JDBC transaction, so only the importer could reach it and every other holder of a ReadableTransaction kept the bound of an entry read over a walk of a whole tree. On mssql such a walk is a scan and a sort of the table for every batch - k is a varbinary(max) there, which cannot be an index key - so an export, a verify and dbtest failed at two minutes on a backend large enough, and so did two paths with nobody at a command line: the read that checks id2entry is there on every open of a backend (ID2Entry.afterOpen, through EntryContainer.open, outside the try/catch of BackendImpl.openBackend), and the generation ID a replicated domain computes for itself the first time it starts (LDAPReplicationDomain.loadGenerationId -> computeGenerationId -> exportLDIF). The choice is on the SPI now, as a default method answering exactly as openCursor(), so every engine that bounds nothing inherits it unchanged and the walks no client waits on ask for it. An import takes the bulk class for every statement it issues, not only for the two that empty a tree: the class belongs to the transaction, so put(), read() and the batches of the importer's cursor take it as well. Only the catalog lookups of openTree() keep the operation class whoever runs them - they read a data dictionary rather than the data, so a wait there is another session's metadata lock, which is one of the waits this bound exists to end. A statement bounded by the socket read timeout alone - a DatabaseMetaData lookup takes no query timeout, and a driver is free to refuse one - is measured against what that layer really allows it, its bound plus the margin, rather than against a property that bounded nothing: a connection reset at 121 s was reported as a query timeout of 120 s and sent the operator to the wrong knob. A driver with no network timeout at all is told apart from a connection that failed the call: the first is remembered for the storage, which is the scope of a driver, the second only while the statements on that connection run, and each has a warning of its own instead of the common cause spending the single shot the real one needs. positionToKey() and delete() take the class of their cursor, and the session statement of a stamp connection is named as being outside both layers. The tests that drove two statements from one thread drive them from two, the one that could hang the build bounds every wait and rethrows what the background thread threw, the order that re-arms the backstop upwards and tightens it back is covered, the wiring of an import is pinned through a real ImporterImpl, the bulk cursor of the open path through ID2Entry, and the container test measures with the monotonic clock, as the code it checks does.
|
Thank you — the blocker, the major and every minor were real, and each line reference checked out again. All of it is addressed in The bulk cursor class was added below the SPIConfirmed and fixed the way you proposed: I traced the four call sites you left open, and two of them turned out to matter:
Your generation-ID path is exactly as you traced it — The importer's own writes and reads keep the 120 s boundConfirmed, and taken the first of the two ways you offered: the comment is now true of the code. The class is a property of the transaction rather than of a call site, so The catalog lookups of The tests
Nits
Verification
|
Problem
Not one statement of the JDBC backend was given a
setQueryTimeout: nineteen sites inJDBCStorageprepared a statement and waited for the database indefinitely. A row locked by an unrelated session, a table waiting for a metadata lock, or a database that stops answering mid-query parked the worker thread that issued it for good, with nothing in the log to say so.This is the half of #872 that lives behind a successful login. #876 bounded the establishment of a connection; a statement on a connection that is already through was, until now, the one unbounded phase left — and deliberately so, since the read bound of the login is lifted once the login is over, because leaving it in place would fail every statement slower than it.
Change
The bound goes on the statement, not on the session. Setting the engine's own
statement_timeout/MAX_EXECUTION_TIME/LOCK_TIMEOUTonce per connection would be cheaper, but a pooled connection cannot carry a session setting:CachedConnection.close()only rolls back, so whatever one operation set would apply to whoever borrows the connection next — the same reason #866 gives its comment statements a connection outside the pool. All execution sites already went throughexecute()/executeResultSet(), so that is where the bound is applied.One value cannot serve every call site, so the bound is per class of statement:
OPERATIONput/update/delete, the batches a cursor walks for a client, the VLV offset ofpositionToIndex(), the catalog lookups ofopenTree()org.openidentityplatform.opendj.jdbc.query.timeoutBULKselect count(*), thedelete fromofclearTree(), theorder by k descbehindpositionToLastKey(), every statement an import issues, every batch of a cursor opened for a walk of a whole tree,create table,create index,drop tableorg.openidentityplatform.opendj.jdbc.bulk.timeoutanalyze/dbms_stats.gather_table_stats/update statisticsafter an importorg.openidentityplatform.opendj.jdbc.statistics.timeout0, or a negative value, leaves a class unbounded, exactly as this backend ran before; a value that is not a number is ignored in favour of the default, asInteger.getInteger()has it, so a typo cannot silently unbound a class.The split is not theoretical:
BackendImpl.openBackend()logsNOTE_BACKEND_STARTEDwithgetEntryCount(), which is aselect count(*)over id2entry on every backend start, andAbstractTwoPhaseImportStrategy.beforePhaseOnecallsclearTree()for every tree before an import writes its first record.positionToLastKey()belongs to the same class for the same reason: it has no key to seek on, so it is anorder by k descover the whole table — a scan and a sort of it on SQL Server, wherekis avarbinary(max)that cannot be an index key — andRootContainerruns it once per base DN throughEntryContainer.getHighestEntryID()on every open of a backend, outside the try/catch ofBackendImpl.openBackend(). A single 120-second default would have broken the start of a large backend and everyimport-ldif.The default of the operation class also sits above the lock timeout of the engines, which matters for the transaction replay of #867: MySQL surfaces contention as class 40 through
innodb_lock_wait_timeout(50 s) and that has to stay a replayable conflict rather than become a cancelled statement.The class follows the work, not the shape of the statement. An import issues the same
selectand the same upsert a client operation does; what differs is that nobody is waiting on it, and that on SQL Server it works the table unindexed. So the class belongs to the transaction: the two anImporterImplholds are bulk, and with them every statement an import issues —put()throughupsert(),read(), and the batches of the cursor phase one walks (OnDiskMergeImporter.ID2EntrySource). Only the catalog lookups ofopenTree()keep the operation class whoever runs them: they read a data dictionary rather than the data, so a wait there is another session's metadata lock, which is one of the waits this bound exists to end.A walk of a whole tree asks for it through the SPI.
ReadableTransaction.openBulkCursor()is adefaultmethod answering exactly asopenCursor(), so every engine that bounds nothing — JE, PersistIt, Cassandra — inherits it unchanged, and only the JDBC backend gives it a class of its own. What asks for it is what walks a tree whole with no client waiting:ExportJob, the whole-tree passes ofVerifyJob, the tree and index dumps ofdbtest, the load of the compressed schema, and the read that checks id2entry is there. Reading the class off the statement instead would not do: the opening batch of every cursor is the same unconditionedorder by kthatpositionToLastKey()issues, so it would either unbound the first batch of every search or bound the walk of an export as if a client were waiting on it.Two of those have nobody at a command line, which is what makes this a bound that has to be right rather than a preference:
ID2Entry.afterOpen()reads the first batch of a cursor over id2entry on every open of a backend (EntryContainer.open()←RootContainer.openAndRegisterEntryContainers()←BackendImpl.openBackend());LDAPReplicationDomain.loadGenerationId()computes the generation ID of a domain the first time it starts —computeGenerationId()→exportLDIF()→ExportJob, a walk of the whole of id2entry.On SQL Server, where every batch of such a walk is a scan and a sort of the table, both of those failed at two minutes on a backend large enough.
The rows are read inside the bound.
executeResultSet()hands the rows to its caller instead of returning a liveResultSet, so the transfer runs while the bound is still armed. A driver hands rows over as they are asked for, andsetQueryTimeoutcoveringResultSet.next()is optional in the JDBC contract ("drivers may also apply this limit"): PostgreSQL and MySQL buffer a result whole and are not affected, but Oracle prefetches ten rows against batches of a thousand and SQL Server buffers adaptively, so a drain outside the bound is a wait with nothing bounding it — on SQL Server that is #877's own symptom, since aselectunder READ COMMITTED really does block on a row another session holds.Two layers, because the first one is not answered everywhere.
setQueryTimeoutcancels the statement and keeps the connection — every driver implements it by cancelling, not by a socket timeout: pgjdbc opens a connection of its own to send a CancelRequest, mysql-connector-j issuesKILL QUERY, and ojdbc and mssql-jdbc send a break on the same socket. Behind it, a socket read timeout is armed for the duration of the statement and released afterwards, so the wait ends even when the cancel is not acted upon.Oracle is why that second layer exists, and the container suite is what found it: with
setQueryTimeout(5)the blocked write ran for the full 600 s of the test harness, parked inSocketDispatcher.read0underOracleStatement.doExecuteWithTimeout— the timeout was armed and never arrived, because a session blocked in a row-lock enqueue does not process the break its driver sends. Reaching the second layer costs the connection (the driver closes it), which is the price of a wait the database was never going to end on its own.That second layer belongs to the connection, not to the statement, so it is arbitrated between the statements running on one. A socket read timeout is a property of the socket, and this backend does share a connection: an
Importerholds a single one for the whole of an import and writes to it from every phase-one worker and every phase-two task. Armed and released per statement there, the first statement to finish would take the backstop away from every statement still in flight, and a statement of a class carrying no bound would run under whatever value a concurrent one happened to arm — dying at a bound it was never given, and naming no property for it, since such a statement never reaches the classification below.So the value armed is the loosest of the bounds of the statements in flight, a statement with no bound of its own takes the backstop off for as long as it runs, and what the connection carried before is put back when the last of them is through. The state is kept per physical connection, by identity:
CachedConnection.prepareStatement()hands the statement to the connection it wraps, so that is the one a statement reports, while the catalog lookups hold the wrapper of that same connection — both unwrap to the same entry, and an entry lives only while statements are running on its connection.It only ever tightens: it is armed when the connection carries no read timeout at all - 0, "no timeout" in the JDBC contract - or one looser than itself, and where nothing is changed nothing is put back afterwards. Being the cancel's bound plus a margin, it is the looser of the two by construction, so setting it unconditionally would have replaced a read timeout a deployment gave its connections (the setting #885 asks for) exactly while a statement was running.
It is also the only layer the catalog lookups of
openTree()can be given:DatabaseMetaData.getTables()andgetIndexInfo()take no query timeout, and they run once per tree on every open, behind the same locks as thecreate tablethey guard.The statistics refresh keeps a property of its own, under both layers. It is not a class of
StatementBound: what it takes follows the size of the table it describes, so a class would put 120 s over a statement its own property allows 600. It does need the second layer, and on the engine that most needs it — on Oracle this isdbms_stats.gather_table_stats, and it runs at the very end of a successful import, where a cancel that is not acted upon would parkimport-ldifwith the data already committed and nothing left to report.A driver that will not take one of the two layers keeps working, and says which one it is.
setQueryTimeoutraisingSQLFeatureNotSupportedExceptiondegrades to the socket read timeout behind it, with one warning, rather than failing every statement. A driver with no network timeout at all says so the same way, and that is remembered for the storage — the scope of a driver — while a connection that merely failed the call, which is most often one on its way out, is remembered only while its own statements run and never speaks for the connections that are healthy. Each cause has a warning of its own.A failure before the bound is passed through untouched, so a lock wait reported in class 40 stays the conflict a caller can replay. One that arrives at the bound is wrapped in a
SQLTimeoutExceptionnaming the property that produced it — no driver knows why it was cancelled, and every one of them reports a cancellation differently (PostgreSQL 57014, Oracle ORA-01013, and neither of them as aSQLTimeoutException), so the bound is recognized by the time the statement took, measured on the monotonic clock, rather than by the class or the state of its failure. Where the cancel is not in force — aDatabaseMetaDatalookup takes no query timeout, and a driver may refuse one — the statement is measured against what the socket read timeout really allows it, its bound plus the margin, instead of against a property that bounded nothing. The SQL state and the error number are carried over, and the failure being replaced is chained. The statement itself is left out of the message: a driver renders it with its parameters bound, and those are entry data.Tests
JDBCStatementBoundTestCase— 25 tests, no database, 4 s: the defaults, that each class follows its own property, that a value which is not a bound leaves the statement unbounded while one that is not a number falls back to the default, that the bound reaches the statement and that an unbounded class costs no call at all, that the rows are read while the bound is still armed and a failure during that transfer is measured against the bound, that a driver without a query timeout keeps working under the backstop alone while one without a network timeout is asked once and a connection that failed the call is asked again, that a catalog lookup is bounded and that a failure inside the margin of the layer bounding it is passed through, that the scan behind the highest entry id is bulk while the batches a client walks are not, that a cursor opened for a walk of a whole tree takes bulk batches, that every statement of an import is bulk, that the statistics refresh runs under its own bound and the backstop, and both classification branches (a failure inside the bound arrives as the very instance thrown; one at the bound names the property and keeps its SQL state).Five of them are about a connection carrying more than one statement at a time, which is what an import does — all on two threads, since that is the only way one statement outlives another: that the backstop is armed and put back, that it never loosens a tighter bound the connection already carries, that a statement of an unbounded class takes it off while it runs and gives it back afterwards, that it outlasts the statement that armed it, that with two bounds in flight the loosest is what is armed, and that a looser bound joining re-arms it and the tighter one gets its own back when the looser statement leaves.
ID2EntryTest— the read that checks the tree is there when a backend opens asks for a bulk cursor, the call site of this branch that no operator is standing at.TestCase.testWriteBlockedByAnotherSessionGivesUpAtItsBoundandtestBulkStatementGivesUpAtItsOwnBound— another session holds every row of the tree in an uncommitted transaction, and the operation under test has to give up inside its bound. Only the class being tested is bounded and the other is set to 0, so a pass cannot be credited to the wrong property. The bulk case goes throughImporter.clearTree(), which is where thedelete from <table>of that class is reachable. Both assert the window of the bound — from the bound itself to four times it, or to the bound plus the margin of the second layer on Oracle, where that is what ends the wait — and that the failure names the property that produced it, so a run that gives up at MySQL's own 50-secondinnodb_lock_wait_timeout, or falls over at once for an unrelated reason, fails the test. The window is measured on the monotonic clock, astimedOut()measures the bound.All four container suites pass with no skips — PgSql 55/55, MySql 55/55, MsSql 55/55, Oracle 55/55. The blocked write, given a 5-second bound, gives up at it on PostgreSQL, MySQL and SQL Server, and at the bound plus the 30-second margin of the second layer on Oracle — the cancel being ignored there and the socket read timeout ending the wait. No measurable overhead across the change: the suites stay within container noise of where they were.
Out of scope
Two refinements are not covered here, rather than stretched into a branch that cannot reach them. They are filed as #885:
CachedConnection.relaxReadBound(), which only exists on the branch of [#872] Bound the connect of the JDBC pool and report a connect it cannot make #876, so a branch off master cannot touch it without conflicting. The second layer above covers the same failure for the duration of a statement, which is when it matters;CachedConnectionagain), or it costs a round trip per transaction. With the statement bounded the hang is already over; what remains is a more precise error and, on the engines that report a lock wait in class 40, a replayable one.commit()takes no bound of any kind and is left as it stands. So are the two statements that run on a stamp connection — the comment statement of #866 and the session setting (set lock_timeout/alter session set ddl_lock_timeout) issued when that connection is established: a stamp connection is given a lock timeout of its own and a socket read timeout in its connect properties.Follow-up to #872 / #876.
Fixes #877