Stamp JDBC backend tables with their tree name and refresh optimizer statistics after import - #866
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
The table-comment idea is well motivated — in #859 you had to hand the reporter an openssl dgst -sha224 loop just to map a table name to a tree — and the comment round-trip is properly tested on all four databases. The statistics hook point is correct too, and broader than the description claims: it also covers online import tasks, rebuild-index, and replication total-update initialization.
But there is one blocker: the comment escaping is injectable on MySQL, and CodeQL agrees — the gate is red on this PR (java/concatenated-sql-query, severity high, alerts 1267 and 1268), while it passes on #863, #858 and #854.
Note the currently-green checks prove nothing: -P precommit is set only if: runner.os == 'Linux' (.github/workflows/build.yml:93-97) and failsafe exists only in that profile (opendj-server-legacy/pom.xml:1223-1297), so the macOS/Windows legs ran zero tests. The ubuntu legs are still queued.
MySQL comment escaping is injectable via a VLV index name (blocker)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:151 escapes only single quotes:
final String comment=treeName.toString().replace("'","''");
...
sql="alter table "+tableName+" comment '"+comment+"'";The base-DN half is safe (DN.toNormalizedUrlSafeString() percent-encodes), but the index-id half is not. opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java:118 builds it from raw config:
super(new TreeName(entryContainer.getTreePrefix(), "vlv." + config.getName()));and opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/BackendVLVIndexConfiguration.xml:172-185 declares that property as a bare <adm:string /> with no <adm:pattern> — unlike sort-order right above it. Under MySQL's default sql_mode, backslash escapes are live inside '…', so a VLV index named x\', add column zz int -- yields:
alter table opendj_… comment '/dc=example,dc=com/vlv.x\'', add column zz int -- 'The literal ends at the doubled quote and the rest becomes part of the ALTER TABLE. It needs config-write privilege, so it is not remotely exploitable, but it crosses into arbitrary SQL on the backing database — and even with no attacker, a name containing \ corrupts the comment or throws a syntax error swallowed at TRACE. PostgreSQL (standard_conforming_strings=on), Oracle and T-SQL are unaffected.
Bind the value as a parameter where the dialect allows, or escape backslashes for MySQL, and/or constrain the VLV name property. This must clear both CodeQL alerts.
The comment is re-stamped on every backend open (major)
JDBCStorage.java:415 calls commentTable unconditionally, outside the create branch, so every openTree(_, true) issues a comment statement plus a commit — roughly 25 per open for a default single-suffix backend (2 compressed-schema + 5 system + 18 default index trees), and again on every dsconfig create-backend-index.
That is DDL on MySQL and Oracle. On MySQL it takes an exclusive metadata lock with lock_wait_timeout defaulting to a year; on Oracle a DDL lock with ddl_lock_timeout defaulting to 0. Both are startup-hang or silent-failure risks on a shared database.
Guarding it — stamp only when the table was just created, or when the stored comment differs — also disposes of two smaller concerns in the same edit: the new mid-transaction con.commit(), and the con.rollback() at JDBCStorage.java:170 running inside the caller's storage.write(). That rollback is mostly harmless as written (PostgreSQL has already committed at JDBCStorage.java:383-389 and in fact needs the rollback to escape 25P02; MySQL/Oracle implicitly commit before DDL), but on SQL Server it can discard a pending TRUSTED flag write, and swallow-plus-rollback inside someone else's transaction is the wrong shape regardless.
updateTableStatistics analyzes tables that no longer exist (minor)
listTrees() returns tree2table.asMap().keySet() — every TreeName ever hashed in this JVM. deleteTree() at JDBCStorage.java:441-450 drops the table but never invalidates the cache:
for (final TreeName treeName : listTrees()) {
final String tableName=getTableName(treeName);So after a dsconfig delete-backend-index, the next import in that process runs analyze <dropped_table>, and every dead tree produces a logger.warn at JDBCStorage.java:207-208 plus allRefreshed=false — an error-level line about a table the admin deliberately removed. A tree2table.invalidate(treeName) in deleteTree() fixes it.
The statistics test asserts nothing on MySQL and SQL Server (minor)
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java:371-374 returns early:
} else {
// mysql/mssql maintain their estimates on their own: nothing distinguishable to assert
return;
}and the direct assertTrue(storage.updateTableStatistics(con)) cannot fail on MySQL either, because ANALYZE TABLE reports problems as a result-set row (Msg_type='Error') rather than a SQLException, and executeAny() discards the result set.
Concretely: delete the updateTableStatistics(con) call from ImporterImpl.close() and PgSql and Oracle fail, MySql and MsSql still pass. "39/39 on all four suites" is accurate but is not four-database evidence for this half. If you want real coverage, MySQL exposes mysql.innodb_table_stats.n_rows (updated by ANALYZE TABLE) and SQL Server exposes sys.dm_db_stats_properties(object_id('…'), stats_id).last_updated.
Nits
elsereturns from inside the loop:JDBCStorage.java:196-198returnsallRefreshedfor an unrecognized driver from within thefor. Behaviour-equivalent today sincedriverNameis loop-invariant, but it reports "all refreshed" having done nothing, and it will swallow recorded failures the moment dialect selection becomes per-tree. Hoist the switch out of the loop.- H2 is claimed but absent: both new comments cite H2 support; there is no H2 driver dependency, test case or doc anywhere in the repo, so
comment on tableand the statisticselsebranch are untested. Either drop the claim or note it as untested. - SQL Server probe omits
class = 1:major_idinsys.extended_propertiesis unique only within a class, somajor_id=object_id(...) and minor_id=0 and name='MS_Description'atJDBCStorage.java:158can match a non-table property and route tosp_updateextendedpropertyon a table that has none (error 15217, swallowed). - Comment failure is TRACE-only:
JDBCStorage.java:172— a diagnostic that silently fails to be written is hard to notice.debugorinfowould fit better, given the statistics path already useswarn. - Read-only opens are never stamped:
commentTablesits insideif (createOnDemand), andEntryContainer.openpassesshouldCreate = accessMode.isWriteable(). "Existing deployments get stamped after an upgrade" holds only for read-write opens, not forexport-ldif/verify-index/backendstat. - Statistics half vs #859: worth softening the framing. The reporter's load is live LDAP traffic (4000 searches / 750 adds / 250 modifies per 5 min) with no import, so this hook would never have fired for them; #863 fixed the measured symptom; your own remaining diagnosis in that thread was dead-tuple bloat needing
VACUUM (ANALYZE), which this does not do; and PostgreSQL autoanalyze closes the post-import window within about a minute. A deterministic post-bulk-load ANALYZE is still good practice and coversautovacuum=off— just not the fix for #859.
|
Thanks for the thorough review — everything is addressed in the latest push, point by point: MySQL escaping / CodeQL (blocker). Fixed at the root and where the dialect allows it, replaced with binds:
Re-stamped on every open (major). Stale Statistics test asserts nothing on MySQL/SQL Server (minor). Both suggestions taken: the test asserts Nits. All taken: the dialect switch is hoisted out of the per-tree loop (unknown dialects return before doing anything); both Read-only opens / #859 framing. PR description updated: stamping happens on the first read-write open, and the statistics half is described as defence in depth (deterministic first plans, covers disabled auto-stats) rather than the fix for #859's live-traffic symptoms, which #863 addressed. Local runs after the changes: PgSql 39/39, MySql 39/39, Oracle 39/39, MsSql 39/39 — zero skips. |
maximthomas
left a comment
There was a problem hiding this comment.
Stamping tables with their tree name is a genuinely useful diagnostic, and refreshing statistics after import is the right fix for #859. The new tests really do execute (39 tests, 0 skipped) against postgres, mysql and mssql containers.
One blocker though: the comment stamp performs transaction control on a connection it does not own.
commentTable() commits / rolls back the caller's transaction (blocker)
commentTable() is called at the end of openTree() — i.e. on the caller's in-flight storage.write(...) connection, not a private one:
// opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:503
commentTable(con, treeName);// JDBCStorage.java:198-210
executeAny(statement);
con.commit(); // <-- commits the caller's pending work
}
}catch (SQLException|RuntimeException e) {
try {
con.rollback(); // <-- discards the caller's pending work
} catch (SQLException e2) {}
logger.debug(...); // and the caller is never told
}There is pending caller work at that point. AbstractTree.open() is openTree() then afterOpen(), and afterOpen() writes:
// opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/DefaultIndex.java:105-109
if (createOnDemand && !trusted && entryContainer.isEmpty(txn))
{
setTrusted(txn, true); // -> State.addFlagsToIndex -> txn.update, uncommitted
}so the next index's openTree() reaches commentTable() with that update still pending. EntryContainer.java:512-513 has the same shape for VLV indexes.
Running the PR's exact SQL against live engines with one uncommitted row pending:
| engine | comment succeeds | comment fails |
|---|---|---|
| MS SQL 2022 | committed early | row silently lost |
| MySQL 9.2 | committed early | committed early (implicit commit fires before ALTER TABLE is even checked) |
| PostgreSQL 16 | committed early | row lost — but unreachable, see below |
Trigger: table already exists + comment not yet stamped + empty container — i.e. the first restart after upgrading an empty or post-aborted-import backend. If the DB account cannot ALTER the tables, the readback never matches, so this repeats on every openTree() forever, visible only at debug level. Result on mssql: indexes silently stay untrusted.
Two things that make the obvious fixes not work:
- On MS SQL,
sys.sp_addextendedproperty/sp_updateextendedpropertycontain an unqualifiedROLLBACK TRANSACTION, so a failure takes@@TRANCOUNT1 → 0 before thecatchblock runs. A savepoint does not help, and neither does deleting therollback(). - On MySQL and Oracle the comment DDL implicitly commits anyway, so
con.commit()is not really the problem — issuing the statement on the caller's connection is.
PostgreSQL is exempt today only by accident: create index if not exists + con.commit() (JDBCStorage.java:471-477) runs unconditionally just before, flushing anything pending.
Suggested fix — stamp only when the table is created, where create table already owns the commit, and let commentTable() stop touching the transaction:
// JDBCStorage.java:459-467
if (!isExistsTable(treeName)) {
try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){
execute(statement);
commentTable(con, treeName); // no readback, no commit(), no rollback()
con.commit();
}catch (SQLException e) {
throw new StorageRuntimeException(e);
}
}If retro-stamping tables created by older versions matters, do that on a connection of its own (getConnection() in try-with-resources), never on con.
readStoredComment() conflates "no comment" with "no readback" (minor)
// JDBCStorage.java:231-233
}else {
return null; // unknown dialect
}null also means "no comment stored", so the caller stamps unconditionally — for an unknown dialect that means DDL plus a transaction boundary on every openTree(), forever. updateTableStatistics() already gets this right by returning early for unrecognised drivers (JDBCStorage.java:253-255); commentTable() should do the same, or use a distinct sentinel.
Only reachable with a driver outside postgres/mysql/oracle/microsoft. H2 is such a case that otherwise works end-to-end with this backend; MariaDB/SQLite/HSQLDB/Derby are already broken by getTableDialect(), so for them it is theoretical.
rebuild-index re-analyzes every table in the backend (minor)
// JDBCStorage.java:257
for (final TreeName treeName : listTrees()) {listTrees() is the live key set of the tree2table cache — every tree this JVM has hashed, not the trees the import wrote. Rebuilding one attribute index therefore analyzes id2entry, dn2id and ~25 other tables. Negligible for import-ldif (which does write them all), but on Oracle dbms_stats.gather_table_stats with default AUTO_SAMPLE_SIZE is a full scan of each. Tracking the trees actually written in ImporterImpl.put/clearTree would keep the benefit without the collateral cost.
Nits
requireQuotesPaired()is unreachable: both call sites pass an already-escaped string (.replace("'","''")), so every quote run is even-length and the check can never throw. Harmless, but it is not the guard the comment claims — it also ignores backslashes, which matter on postgres withstandard_conforming_strings = off.removeStorageFiles()does not invalidate the cache:deleteTree()now callstree2table.invalidate(treeName)(JDBCStorage.java:539), butremoveStorageFiles()(JDBCStorage.java:312-317) drops every table without invalidating, so a laterupdateTableStatistics()warns once per missing table.ImporterImpl.close()can leak a pooled connection:updateTableStatistics(con)sits betweencon.commit()andcon.close()(JDBCStorage.java:863-866) and only catchesSQLException; aRuntimeExceptionfromdriverNameOf()or the cache loader would skipcon.close().- Test gaps:
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.javacovers the happy path only. Nothing asserts that a second open skips the DDL (areadStoredComment()that always returnednullwould still pass), thatdeleteTree()invalidates the cache, or that a failing comment statement leaves the caller's transaction intact — which is the case that matters most.
|
Thanks for the second pass — all points addressed in the latest push.
The scenario from your table is now a test:
Nits. All taken:
Local runs after the changes: PgSql 42/42, MySql 42/42, Oracle 42/42, MsSql 42/42 — zero skips (39 previous plus the 3 new tests per suite). CodeQL alerts 1267/1268 remain closed. PR description updated to match. |
maximthomas
left a comment
There was a problem hiding this comment.
The round-2 blockers are genuinely fixed. I re-verified the escaping (java/concatenated-sql-query closed), the tree2table invalidation in deleteTree()/removeStorageFiles(), the writtenTrees scoping, and the finally in ImporterImpl.close(). I also round-tripped every dialect's stamp and readback against live PostgreSQL 17, MySQL 9.2, SQL Server 2019 and Oracle Free 23 with a tree name containing both ' and \ — all four store and read back exactly. Resource handling is clean on every path, including both early returns and the catch.
One major issue left, plus nits. No blocker.
Comment DDL can wait forever on a lock (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:216
The dedicated connection removes the self-inflicted variant of this — on MySQL ≥ 8.0.3 the driver defaults useInformationSchema=true, so getTables()/getIndexInfo() on the caller's connection take no metadata lock, and the stamp completes in ~50 ms with the caller's transaction still open. Measured. What is left is everyone else:
- MySQL — any other session holding an open transaction that touched the table blocks
alter table … commenton MDL_EXCLUSIVE.@@lock_wait_timeoutdefaults to31536000(one year) and MDL deadlock detection does not see it. While the ALTER is queued, every other query on that table blocks behind it — measured with a plainselect count(*)from a third session. - SQL Server — another session's uncommitted
INSERTon the table blockssp_addextendedproperty, and@@lock_timeoutis-1. - PostgreSQL and Oracle are fine (
ddl_lock_timeout=0fails fast; PG is saved by the unconditionalcon.commit()atJDBCStorage.java:498).
This is reachable on the first read-write open after upgrading to this build, when the stamp is issued once per tree, and a stalled stamp freezes the table for all sessions. A diagnostic aid should never be able to do that. The failure is already swallowed and logged, so a timeout degrades exactly to "table left unstamped":
// before the DDL, on the stamping connection
if (mysql) { exec("set session lock_wait_timeout=5"); }
if (microsoft) { exec("set lock_timeout 5000"); }
if (postgres) { exec("set local lock_timeout='5s'"); } // oracle already fails fastCI cannot catch this: TestCase.setUp → dropStaleTrees drops every opendj_% table, so each openTree is a fresh create and the retro-stamp path is never taken.
The MySQL statistics assertion has no teeth (minor)
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java:517
sql = "select n_rows from mysql.innodb_table_stats where database_name=database() and table_name='" + tableName + "'";
...
assertTrue(rows > 0, "statistics of " + tableName + " look stale: " + rows);InnoDB's innodb_stats_auto_recalc is ON by default and refreshes n_rows in the background, so this is non-zero without any ANALYZE TABLE. Measured with updateTableStatistics() stubbed to a no-op: n_rows=2, assertion passes, stable across retries. So contrary to the PR description, deleting the updateTableStatistics(con, writtenTrees) call from ImporterImpl.close() does not fail this test on MySQL — the other three dialects do have teeth (PG -1→40, Oracle NULL→40, MSSQL 0→1). assertEquals(rows, 40) or a last_update check would fix it.
A failed stamp repeats on every open, invisibly (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:194
if (treeComment.equals(readStoredComment(con, tableName))) {
return false;
}The guard suppresses the repeat only when the stamp previously succeeded. A MySQL account granted CREATE, INDEX, SELECT, INSERT, UPDATE, DELETE but not ALTER runs this backend perfectly yet fails the stamp on all ~25 trees on every open, forever — and logger.debug at JDBCStorage.java:225 is below the default error, warning, so nothing is ever visible. Its sibling updateTableStatistics() uses logger.warn for the same class of best-effort failure. Remembering the failure per JVM, or matching the warn level, would close it.
Nits
updateTableStatistics()reports success having done nothing (JDBCStorage.java:274): it returnstruefor an unrecognised driver, whilecommentTable()returnsfalsein the same situation (JDBCStorage.java:189). Since the direct assertion isassertTrue(storage.updateTableStatistics(con, …))(TestCase.java:479), any engine outside the dialect switch passes it vacuously.- The test's SQL Server readback omits
class = 1(TestCase.java:316): production was fixed to include it (JDBCStorage.java:247) becausemajor_idis unique only within a class; the test helper still matches onmajor_id/minor_id/namealone, so it can assert on a non-table extended property. assertFalse(storage.commentTable(tree))cannot tell "skipped" from "failed" (TestCase.java:365):commentTable()returnsfalseboth when the comment matches and from thecatch (Exception)atJDBCStorage.java:224, so an implementation that always threw would satisfy it.commentTable()swallowsInterruptedExceptionand drops the interrupt (JDBCStorage.java:224): thecatch (Exception e)coversgetConnection()→LinkedBlockingQueue.poll(...), which throws and clears the interrupt status. Every othergetConnection()caller in the file propagates or wraps; this one should re-assert withThread.currentThread().interrupt().sqlLiteral(value, true)corrupts underNO_BACKSLASH_ESCAPES(JDBCStorage.java:157): doubling backslashes storesa\\bfora\b, which never matches the readback and re-stamps forever. Only reachable through a VLV index name, sinceAVA.toNormalizedUrlSafepercent-encodes\as%5CandBackendVLVIndexConfiguration.xml:172-185still leavesnamean unconstrained<adm:string/>— the same freedom behind the earlier injection finding. Reading the backslash rule from@@sql_modewould settle both.- "Only the trees the import wrote" is every tree for a full import (
JDBCStorage.java:893):AbstractTwoPhaseImportStrategy.beforePhaseOnecallsentryContainer.delete(...), which routes toimporter.clearTree(OnDiskMergeImporter.java:4184-4187), so animport-ldifputs all ~25 trees inwrittenTreesbefore a record is written. Correct for a full import, but it also runs on the abort path (the importer is closed by try-with-resources atOnDiskMergeImporter.java:233), uninterruptible and unbounded — worth a note, since the method comment claims to avoid exactly this on Oracle. testCommentFailureLeavesTransactionIntactenshrines a shape the code cannot survive (TestCase.java:404-410): it doestxn.put(tree, …)thentxn.openTree(tree, true)in one transaction — an uncommitted write followed by the comment DDL on the same table, which measured as a hard block on both MySQL and SQL Server. It only passes because the subclass stubsreadStoredComment()to throw, bailing out before the DDL. No production path has that shape today, but the test presents it as supported.
|
Thanks — all of round 3 is addressed in a6bec79. Details, including one place where my measurements disagree with yours. Comment DDL can wait forever on a lock (major) — fixed, with a caveat about the suggested formThe stamp now bounds its lock wait before issuing the statement: Two things had to change around your suggestion for it to be safe:
Your rationale for PostgreSQL is out of date, by the way: the I could not reproduce the block itself on any engine. The new That test earned its place immediately: it caught my first attempt at the PostgreSQL form ( A failed stamp repeats on every open, invisibly (minor) — fixedLogged at The MySQL statistics assertion has no teeth (minor) — fixedThe test now runs Nits
All four container suites: PgSql 43/43, MySql 43/43, MsSql 43/43, Oracle 43/43, no skips. |
maximthomas
left a comment
There was a problem hiding this comment.
Round 3 holds up: sqlLiteral's doubling + paired scan is sound in both modes, the dialect branch order is right in commentTable() and updateTableStatistics(), the new resources are all in try-with-resources, ImporterImpl.close() fixes the connection leak and the missed JDBCStorage.close(), and deleteTree()'s tree2table.invalidate() is meaningful. I also checked the "no production path leaves a write pending on the table being stamped" claim and it is true - state is the only tree written during an open sweep and it is opened at EntryContainer.java:489, before the first write to it; and because commentTable() runs inline on the caller's thread, no lock cycle can form, so the caller can never be the deadlock victim. One substantive request below; the rest is polish.
Statistics refresh is unbounded, and runs after aborted imports (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:976:
public void close() {
try {
try {
con.commit();
updateTableStatistics(con, writtenTrees);ImporterImpl.close() is the try-with-resources exit at OnDiskMergeImporter.java:233, so it also runs when the import throws or is cancelled (OnDiskMergeImporter.java:1242, throw new InterruptedException("Import processing canceled.")). And AbstractTwoPhaseImportStrategy.beforePhaseOne clears every tree before the first record, so writtenTrees is already the whole backend at that point. An import that dies one second in still gathers statistics on all ~25 tables - describing a backend that was just truncated. The comment at JDBCStorage.java:944 acknowledges this, so it looks deliberate, but it is worth reconsidering: those statistics are wrong by construction, and on Oracle producing them is the most expensive part of the failed run.
Cost, separately: dbms_stats.gather_table_stats(user, ?) defaults to AUTO_SAMPLE_SIZE, a full table scan since 11g, and with ENABLE STORAGE IN ROW on v blob that reads most of the entry payload. There is no setQueryTimeout anywhere in the repo and no opt-out - no system property in the style of CachedConnection's org.openidentityplatform.opendj.jdbc.ttl, no JDBCBackendCfg attribute. So import-ldif on a multi-million-entry Oracle backend logs its final status and then scans every table with nothing to cap it, and an operator has no way to turn that off. Postgres/MySQL/MSSQL are all sampled and cheap - this is Oracle-specific.
Suggest: skip the gather when the import body threw (or clear writtenTrees on the exceptional path), plus a setQueryTimeout and a system-property opt-out.
The stamp connection is un-pooled and has no connect timeout (minor)
JDBCStorage.java:211:
Connection newStampConnection() throws SQLException {
final Connection con=DriverManager.getConnection(config.getDBDirectory());For the cold start this is not new - RootContainer.open() calls storage.open() before storage.write(), and that already connects with no login timeout, so an unreachable DB hangs before commentTable() is reached. But the live-server path is new: EntryContainer.applyConfigurationAdd (EntryContainer.java:207, and :292 for VLV) runs index.open(txn, true) inside storage.write() on a running server. Pre-PR that made zero new physical connects; now each stamped tree makes one, un-pooled and un-timed-out. If pooled connections still work but new connects blackhole (VIP moved, conntrack drop, proxy connection limit), dsconfig create-backend-index hangs indefinitely. First open after upgrade also goes from 1 new connect to ~27. DriverManager.setLoginTimeout() - or just documenting connectTimeout in the db-directory URL - covers both.
One physical connection per tree on the first open after upgrade (minor)
commentTable() opens its own connection per tree, serially, inside the single storage.write() that opens the backend. That is ~25 connects for a stock userRoot (5 core + 18 default-index + 2 compressed-schema trees), once per deployment - the UP_TO_DATE early return at :259 suppresses it afterwards. Worth hoisting one newStampConnection() for the whole sweep, but the magnitude is modest next to what openTree already does per tree per open: isExistsTable() is an unfiltered getMetaData().getTables(null,null,null,...) enumeration of every table in the database, and on Postgres an unconditional create index if not exists + commit. For what it's worth, on Postgres that pre-existing CREATE INDEX IF NOT EXISTS takes a ShareLock (conflicts with DML, waits unbounded) while the new COMMENT ON TABLE takes only ShareUpdateExclusiveLock and is capped at 5 s - the new statement is the safer of the two.
@@sql_mode is probed on a different session than the one parsing the literal (minor)
try (final Connection con=getConnection()) { // pooled, possibly hours old
sql="alter table "+tableName+" comment "+sqlLiteral(treeComment,isMysqlBackslashEscape(con));
}
try (final Connection con=newStampConnection()) { // brand-new session parses itPooled connections are reused for the life of the JVM (CachedConnection expires the per-URL queue, not individual connections, and close() only rolls back), so a runtime SET GLOBAL sql_mode can leave the two sessions disagreeing. Consequence if they do: the stamp succeeds storing the wrong text, returns STAMPED (never memoized), and every later openTree() re-issues the ALTER - the per-open DDL the readback was added to remove.
Low likelihood in practice: both connections use the same URL, so ?sessionVariables=... and init_connect apply identically, NO_BACKSLASH_ESCAPES is non-default, and base DNs cannot contain a backslash at all (AVA.toNormalizedUrlSafeString URL-encodes values, so \ becomes %5C). The only reachable source is a VLV index deliberately named with a backslash - VLVIndex.java:118 uses the raw, un-escaped config.getName(). Unlike driverNameOf, the @@sql_mode probe is a plain select with no CachedConnection cast, so moving it onto the stamp connection is a 3-line change that removes the cross-session assumption entirely.
The failure memo makes transient failures permanent (minor)
}catch (Exception e) {
unstampableTrees.add(treeName);This memoizes every non-interrupt failure - including the 5 s lock timeout this round introduced, and connection blips. The bound's rationale is "give up now, try again later", but there is no later in this JVM: a server that started during one contended moment stays unstamped until restart. Memoizing only permanent-looking failures (or not memoizing lock timeouts) keeps the "no ALTER privilege" case cheap without making contention sticky.
Nits
newStampConnection()leaks onsetAutoCommit: ifcon.setAutoCommit(false)throws, the connection is never closed - the try-with-resources resource expression threw, so the variable was never bound. Same shape pre-exists inCachedConnection.java:99, where it is worse because the catch recurses with doubling backoff.testCommentStampGivesUpOnLocktolerance:assertTrue(elapsedMs < 60000, ...)against a 5 s bound, where the result is allowed to be eitherSTAMPEDorFAILED, means timing is the only real assertion and it has a 12x margin. It stays green ifCOMMENT_LOCK_TIMEOUT_SECONDSregresses to 30. ~20 s keeps the cross-engine slack with teeth.- "a write left pending" understates MySQL: the comment in
testCommentFailureLeavesTransactionIntactsays a write on the stamped table is the trigger, but MySQL holds a shared MDL for any table a transaction has merely touched - andisExistsIndex()touches it on the caller's connection just before the stamp, on exactly the branch that does not commit. "still holding a lock (on mysql, a metadata lock)" would be accurate.
… connection for table comments Round 4 review of OpenIdentityPlatform#866: - an import that failed or was cancelled no longer refreshes optimizer statistics: Importer gained a default aborted() that OnDiskMergeImporter reports from the catch around the whole import, and the JDBC importer skips the refresh. The refresh is bounded as well - setQueryTimeout, 600 s per table by default (org.openidentityplatform.opendj.jdbc.statistics.timeout, 0 for no limit) - and can be turned off entirely with org.openidentityplatform.opendj.jdbc.statistics=false; - the connection the comment statement runs on bounds its connect attempt through the driver property of its dialect (10 s), so a database that keeps its established connections but accepts no new ones leaves a table unstamped instead of hanging the open of a tree; - the comment statements of one open share one connection instead of connecting per tree, which was ~25 physical connects on the first open after an upgrade; - the mysql sql_mode probe moved onto the stamping session, so the session that answers it is the session that parses the literal; - a stamp that failed for a reason of the moment (connection exception, the dialect's lock timeout) is attempted again; only a rejected statement is remembered; - newStampConnection() and CachedConnection.getConnection() no longer leak a connection when setting it up throws. Tests: PgSql 47/47, MySql 47/47, Oracle 47/47, MsSql 47/47, zero skips.
|
Thanks — all of round 4 is addressed. Details below, including the two places where I disagree with the reasoning but took the change anyway. Statistics refresh is unbounded, and runs after aborted imports (major) — fixedThree changes:
One correction to the reasoning, which does not change the outcome: the statistics of an aborted import are not "wrong by construction" — they describe exactly what the tables hold at that moment. And the cost runs the other way round from the example: an import that dies one second in has just had every tree cleared, so gathering statistics of those empty tables is trivial; the expensive gather is the one after a long import, where the data is real. What makes it worth skipping is that the operator is going to run the import again, and that on Oracle the gather delays the report of the failure — or of a cancellation — by its own duration. New test: The stamp connection is un-pooled and has no connect timeout (minor) — fixed
One physical connection per tree on the first open after upgrade (minor) — fixedThe stamps of one
|
maximthomas
left a comment
There was a problem hiding this comment.
Round 4 is genuinely addressed: aborted() reaches every import entry point, the statistics refresh is bounded and optional, the stamp connection is shared and its connect attempt bounded, isTransientFailure() keeps contention from being sticky, and the CachedConnection leak is closed. The shared connection introduced one new defect that is worse than anything round 4 raised.
PostgreSQL loses the lock bound after the first failed stamp (blocker)
newStampConnection() sends the lock bound as the first statement of an uncommitted transaction — opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:269-283:
final Connection con=DriverManager.getConnection(config.getDBDirectory(), properties);
try {
con.setAutoCommit(false);
executeSessionStatement(con, dialect.lockTimeoutSql); // "set lock_timeout = 5000"
}StampSession.reset() rolls back after any failure and keeps the connection (JDBCStorage.java:303-311):
void reset() {
if (con!=null) {
try {
con.rollback();
}catch (SQLException e) {
close();
}
}
}PostgreSQL reverts a plain SET when the surrounding transaction rolls back. Measured on postgres:17 with the pinned pgjdbc 42.7.12:
after SET (same txn): 5s
forced statement error, then ROLLBACK
after ROLLBACK: 0 <-- wait forever
COMMENT ON TABLE behind a conflicting SHARE UPDATE EXCLUSIVE lock:
bound intact: 1004 ms (55P03)
after the rollback: 20003 ms, cancelled by the watchdog -- never returned
55P03 leaves the connection usable, so reset() takes the rollback branch rather than close(), and the same connection is reused. One StampSession serves every tree of every suffix, because RootContainer.java:135 wraps the whole backend open in a single storage.write(). So tree #1's failure — including one caused by the 5 s bound doing its job — disarms the bound for trees #2..N, reinstating the unbounded wait COMMENT_LOCK_TIMEOUT_SECONDS exists to prevent, on the startup and dsconfig create-backend-index path.
MySQL, Oracle and SQL Server are unaffected: their session settings are not transactional.
No test covers it. testCommentFailureLeavesTransactionIntact and testTransientStampFailureIsRetried both stub newStampConnection() to throw, so session.con stays null and reset() is a no-op — the branch where a real connection survives a rollback is never exercised.
Suggest committing the setting inside newStampConnection(), or reissuing dialect.lockTimeoutSql after any reset() that keeps the connection.
The connect bound only covers the TCP connect on three of four dialects (major)
JDBCStorage.java:177-190:
POSTGRES("connectTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, ...),
MYSQL("connectTimeout", STAMP_CONNECT_TIMEOUT_SECONDS*1000, ...),
ORACLE("oracle.net.CONNECT_TIMEOUT", STAMP_CONNECT_TIMEOUT_SECONDS*1000, ...),
MICROSOFT("loginTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, ...);The names, units and values are all correct, but pgjdbc and mysql-connector-j apply connectTimeout only to socket.connect(); reads during TLS and authentication are governed by socketTimeout, which defaults to 0 and is never set here. Oracle's own JDBC reference says oracle.net.CONNECT_TIMEOUT "doesn't include user authentication" — that needs oracle.jdbc.ReadTimeout. Only mssql-jdbc's loginTimeout covers the entire login phase.
The scenario the PR description names — "a proxy at its connection limit" — usually accepts the TCP connection and then stalls, which is exactly the case still unbounded. openTree() on a running server would hang rather than leave the table unstamped.
A sweep-wide failure is paid once per tree (minor)
StampSession.connection() assigns con only on success (JDBCStorage.java:293-298):
Connection connection(Dialect dialect) throws SQLException {
if (con==null) {
con=newStampConnection(dialect);
}
return con;
}so a failed connect leaves it null and every following tree reconnects — the "one physical connect per sweep" property holds only on the success path. isTransientFailure() memoizes neither 08… nor lock timeouts, so the whole cost repeats on every open until the trees are stamped.
MySQL FLUSH TABLES WITH READ LOCK (mysqldump, xtrabackup) blocks every table's alter table … comment at once: ~25 trees x 5 s is about 125 s added to a backend open, for a diagnostic aid. Giving up on the remaining trees after the first lock timeout of a sweep keeps the retry-next-open semantics without the linear cost.
The connect-failure variant is largely masked by the pre-existing unbounded recursion in CachedConnection.getConnection(), which would hang on the pooled readback first.
Nits
@@sql_modeprobed once per tree:JDBCStorage.java:377callsisMysqlBackslashEscape(con)for every tree of the sweep, but the connection is now shared and the value cannot change within that session — ~25 avoidable round-trips. Cache it onStampSession.doImportcatchesException, notThrowable:OnDiskMergeImporter.java:1196-1209— an import killed by anError(OOM on an oversized entry) skipsaborted()and still refreshes statistics on partially-written tables. Wasted work only, not incorrectness.default:means a different dialect in each switch: Oracle atJDBCStorage.java:392, Microsoft at:459,:491and:548. Correct today, but a fifthDialectconstant would silently get Oracle DDL plus SQL Server readback, statistics and error codes instead of a compile error.- mssql-jdbc property precedence:
mergeURLAndSuppliedPropertiesgives the suppliedPropertiesprecedence over the URL, the opposite of pgjdbc and Oracle. Inert while that map carries only the timeout key, but a footgun if credentials are ever added to it.
Verified clean, not worth re-raising
aborted() reaches every import entry point (startImport() has exactly two callers, both always calling doImport; import-ldif, the online ImportTask, rebuild-index and replication total-update all funnel through it, and TracedImporter forwards it). All four new tests have teeth. Failsafe runs forkCount=1, parallel=none, so the global …jdbc.statistics=false cannot race a sibling test. setQueryTimeout is honoured by all four drivers, including Oracle's PL/SQL block. put()/clearTree() never reach openTree(), so StampSession is never touched concurrently by import threads. updateTableStatistics() commits or rolls back per tree, so one failure does not poison the rest on PostgreSQL. The CachedConnection close-on-setup-failure is a clean leak fix. No open CodeQL alerts on the touched files.
…fter a failed stamp, and bound its whole login The lock bound was set inside an uncommitted transaction, and postgres undoes a plain SET when that transaction is rolled back - which is what the session does after a failed stamp while keeping the connection for the trees that follow. Commit the setting, so the first failure of a sweep no longer leaves the rest of it stamping unbounded. Bound the whole login attempt of that connection, not only the socket connect: the connect property of postgres and mysql covers socket.connect() alone and oracle's CONNECT_TIMEOUT excludes authentication, so those three carry a read bound as well - loginTimeout on postgres, socketTimeout on mysql, oracle.jdbc.ReadTimeout on oracle. The sql server loginTimeout already covers the phase by itself. End the sweep when a stamp fails for a reason of the moment: every tree left in the same open would pay that same lock bound, or that same connect attempt, again - about 25 of them for a stock suffix. Ask @@sql_mode once per sweep instead of once per tree, report an aborted import for an Error as well, and name every dialect in the four switches that select per-engine SQL, so a fifth one cannot silently inherit another dialect's statements.
|
Thanks — all of round 5 is addressed. Details below, including one place where the blocker is narrower than described and one thing the fix does not reach. PostgreSQL loses the lock bound after the first failed stamp (blocker) — fixed
Two notes on the analysis, neither of which changes the fix:
New test The connect bound only covers the TCP connect on three of four dialects (major) — fixedEvery dialect now carries a second property covering the reads of TLS and authentication:
PostgreSQL gets
One thing this does not reach, and I would rather not stretch this PR to cover: the pooled readback runs before the stamp, and A sweep-wide failure is paid once per tree (minor) — fixed
New test Nits
Local runs after the changes: PgSql 51/51, MySql 51/51, MsSql 51/51, Oracle 51/51 — zero skips (47 before, plus the four new tests). PR description updated to match. |
…thout indexing an array CodeQL alert 1269 (java/index-out-of-bounds): the varargs constructor read connectPropertyPairs[i+1] without anything guaranteeing the array holds whole pairs. Two constructors take the properties by name instead - one for the driver whose single property covers the whole login phase, one for those needing a read bound as well - so there is no index to get wrong, and the declarations at the constants read the same.
maximthomas
left a comment
There was a problem hiding this comment.
Round 5 is genuinely fixed — I checked rather than took it: on a live PostgreSQL, show lock_timeout now stays 5s after a failed stamp where it previously went to 0. Build is green on JDK 11 and 26, and the 51/51 × 4 with zero skips is real; I ran all four dialects.
Two things left, both the same hazard the PR itself set out to bound.
The SQL Server stamp connection has no bound at all (major)
Dialect.MICROSOFT (opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:198-200) carries one property, and mssql-jdbc discards it. TDSChannel.open():
int socketTimeout = con.getSocketTimeoutMilliseconds(); // SOCKET_TIMEOUT default = 0
if (!con.isConnected()) socketTimeout = Math.min(con.timerRemaining(con.timerExpire), socketTimeout);
tcpSocket.setSoTimeout(socketTimeout); // min(10000, 0) == 0 == foreverloginTimeout bounds the socket connect, never a read. Measured against mssql/server:2022 behind a proxy that keeps established connections working and accepts-then-never-answers new ones — the state named at JDBCStorage.java:157-163 ("a moved vip, a proxy at its connection limit"). Reproduced twice, with independent harnesses:
MASTER openTree(): RETURNED after 0,15s / 0,40s
HEAD openTree(): *** STILL BLOCKED at the 300s and 600s caps ***
:432 newStampConnection(url, {loginTimeout=10})
at com.microsoft.sqlserver.jdbc.SQLServerConnection.prelogin(SQLServerConnection.java:4578)
Thread.interrupt() -> thread alive=true
encrypt is irrelevant — the block precedes TLS. One property fixes it, measured at 10,08 s with SQLState 08S01, which isTransientFailure() already routes into giveUp():
MICROSOFT("set lock_timeout "+(COMMENT_LOCK_TIMEOUT_SECONDS*1000),
"loginTimeout", STAMP_CONNECT_TIMEOUT_SECONDS,
"socketTimeout", STAMP_READ_TIMEOUT_SECONDS*1000); // msPostgreSQL needs socketTimeout too for its post-login reads (blocked 60 s on a blackholed socket), but mind the unit — pgjdbc's is in seconds, so STAMP_READ_TIMEOUT_SECONDS raw, not *1000. Its login is already bounded at 10,8 s, so PostgreSQL alone would be minor. setQueryTimeout is not an alternative: it unblocks none of pgjdbc, mssql-jdbc or Oracle — pgjdbc's cancel travels on a second connection, and postgres logged canceling statement due to user request while the client stayed blocked the full 45 s. Oracle and MySQL are correctly bounded (Oracle measured 30,01 s; do not "simplify" oracle.jdbc.ReadTimeout to oracle.net.READ_TIMEOUT, which is inert via Properties — measured 93 s).
The readback connection is unbounded, and can self-deadlock (major)
commentTable takes a second pooled connection at JDBCStorage.java:421, reaching opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java:100:
final Connection conNew = DriverManager.getConnection(connectionString); // no Properties: no bound
...
} catch (SQLException e) { // max_connection server error: try recursion for reuse connection
return getConnection(connectionString, (waitTime == 0) ? 1 : waitTime * 2);
}Not a rare path. Measured with the real CachedConnection: storage.open() creates one connection and returns it, write():702 takes that same object, so the pool is empty and :421 connects physically on every read-write backend open:
open() borrowed parent=5ed828d | returned it -> pool=1
write():702 borrowed parent=5ed828d (same object? true) -> pool=0
:421 tree#1 NEW PHYSICAL CONNECT? true
Warm pool of 1, new connects silenced, PostgreSQL: blocked >120 s, Thread.interrupt() at 60 s had no effect, while the transaction's own select 1 kept working. Master issues no connect here at all. And a role at CONNECTION LIMIT 1 deadlocks permanently:
MASTER open()+write():702+openTree() completed in 0,473 s (openTree issues no connect)
--- t=90 s :421 TIMED_WAITING recursive getConnection frames=19
The recursion waits for a peer to return a pooled connection, but the only one in existence is the transaction connection held by the thread it is blocking. Master's read()/write() share that loop, but there a peer really does return one; at :421 that is violated by construction.
Fix: take the dialect from the transaction connection — openTree already does exactly that at :809 — and run readStoredComment on session.connection(dialect), which is bounded. Not on the transaction connection: a failed statement aborts the PostgreSQL block (25P02, as StampSession.reset() notes), and commentTable's catch (Exception e) would swallow that and continue the open on a doomed transaction.
The test guarding the bounds cannot detect their absence (minor)
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java:825-830 mirrors the enum literal:
assertEquals(dialect.connectProperties.size(), dialect == JDBCStorage.Dialect.MICROSOFT ? 1 : 2, ...Deleting properties.putAll(dialect.connectProperties) at JDBCStorage.java:291 kills the whole feature — no bound reaches any driver — and still gives Tests run: 51, Failures: 0, Skipped: 0. It also asserts the buggy shape as the contract, so the fix above cannot land without editing it. Its javadoc says the scenario "is not something a container can stage"; two agents staged it with a ~40-line TCP proxy in under a minute.
The statistics default: throw fails the import (minor)
JDBCStorage.java:621 — the switch sits before the try (…) catch (SQLException e), so IllegalStateException propagates out of updateTableStatistics() and ImporterImpl.close(), against the contract stated 20 lines above. The same throws in commentTable (:454) and readStoredComment (:558) are inside a catch and degrade correctly.
One contended table costs the whole backend its comments (minor)
JDBCStorage.java:476 keys the sweep-wide giveUp() on isTransientFailure(), which also fires for a lock on a single table (PG 55P03, MySQL 1205, ORA-00054, MSSQL 1222). Trees open in fixed order, so one permanently contended table means nothing is ever stamped, on any open. Before round 6 the other ~24 still got their comments for one 5 s bound.
aborted() can swallow the Throwable that caused the abort (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java:1202 — if importStrategy.aborted() throws, throw t never runs. Concrete for the motivating case: an OOM-killed import calls aborted(), which for TracedImporter formats strings.
Nits
- The comment states the opposite of what holds:
JDBCStorage.java:165-173says the read bound "outlives the login phase and covers the comment statement too" — true only for MySQL and Oracle;:198says SQL Server'sloginTimeoutneeds "no read bound of its own", which is the belief the major above rests on. - MySQL's 30 s read bound is really ~60 s: measured 60,09 s with
socketTimeout=30000, 30,02 s withsslMode=DISABLED.NetworkResources.forceClose()skips itsshutdownInput()fast path on secure sockets, soSSLSocketImpl.close()drains input waiting forclose_notifyand pays SO_TIMEOUT twice. Connector/J defaults tosslMode=PREFERRED; 15000 gives a true 30 s. - pgjdbc
connectTimeout=10is a no-op: 10 is already thePGProperty.CONNECT_TIMEOUTdefault. testSqlModeProbedOncePerSweepis vacuous on 3 of 4 dialects: the expectation is literally0andbackslashIsEscape()is never reached, so it contributes 1 of the 51 on Pg/MsSql/Oracle without testing anything. Real teeth on MySQL.- A green "51/51" can mean nothing: Testcontainers 1.20.6 negotiates docker-java API
v1.32, Docker Engine 29.6.2 hasMinAPIVersion: 1.40, soisDockerAvailable()goes false and every test throwsSkipExceptionatTestCase.java:82—Tests run: 53, Skipped: 53, BUILD SUCCESS, EXIT=0. Pre-existing;-Dapi.version=1.43is needed for the counts to mean what they say.
Verified clean, not worth re-raising: the commit-after-SET fix and its test (fails on the previous revision with expected [5s] but found [0], PostgreSQL only, as designed); the enum rewrite (compiled and run — connectProperties non-null in both constructors, every name, unit and value correct, no mutable-map leak); gaveUp is per-transaction (stampSession is a field of WriteableTransactionTransactionImpl at :786, fresh per write(), closed at :713); the mysqlBackslashEscape stale-cache path does not exist, since close() clears it outside the if (con!=null) guard; StampSession is single-threaded (ImporterToWriteableTransactionAdapter.openTree throws UnsupportedOperationException); catch (Throwable t) compiles under precise rethrow; no deadlock from the second pooled connection, because openTree commits its DDL at :826/:837/:846/:858 first — I reproduced the SQL Server LCK_M_SCH_S block in the hypothetical (15000 ms → Msg 1222) and measured the real path at 0 ms; testLockBoundSurvivesAFailedStamp and testTransientStampFailureEndsTheSweep both have teeth; and deferring the CachedConnection retry loop to #872 is right, since the gaveUp check sits before the pooled getConnection().
…e its readback off the pool Round 6 review of OpenIdentityPlatform#866: - the sql server stamp connection carried loginTimeout alone, and mssql-jdbc discards it for the reads of a login: TDSChannel.open() hands the socket min(what is left of loginTimeout, socketTimeout), and socketTimeout defaults to 0, so the read of the prelogin answer was left unbounded - which is the very state the bound exists for, a proxy that accepts a connection and then goes quiet. Every dialect now declares the property bounding its connect and the property bounding the reads behind it, postgres included, where socketTimeout takes seconds; - the comment readback borrowed a second connection from the pool, while the thread doing the open was inside a transaction holding one already. Where no further connection can be had, the pool waits for a peer to return one - which there is the very thread it is blocking. The readback runs on the stamp connection now, and the dialect is taken off the caller's own connection, so the comment path takes no borrow at all. An open costs one connection for the whole sweep, readback included; - a stamp that gave up on a lock ended the sweep it was part of, so one permanently contended table meant a backend was never stamped at all, on any open. The two-way isTransientFailure() is a three-way FailureScope: a rejected statement is remembered, a lock is neither remembered nor fatal to the sweep, and only a connection that is gone ends it. Both chains of the failure are walked, getCause() and getNextException(): a driver reports a vendor code as the next exception at least as often as it reports it as the cause, and reading one of the two classified a lock timeout as a rejection - leaving the tree unstamped until the next start over a moment of contention; - the dialect guard of updateTableStatistics() threw out of ImporterImpl.close(), against the best-effort contract stated twenty lines above it. It degrades to "this table was not refreshed", like every other failure of that loop; - OnDiskMergeImporter lost the Throwable that had aborted an import if aborted() threw on the way out; it is suppressed into it instead; - a rejection is forgotten when the storage closes, so re-enabling the backend is enough to try again once the privilege has been granted. Tests: StampConnectionTestCase is new and needs no database. The bounds are the one thing the container suites cannot guard - they skip themselves whole where no docker is reachable, so a bound only they exercise is a bound that a silent skip deletes. Four real drivers against a ServerSocket that accepts and answers nothing, attempted concurrently (31 s); a recording java.sql.Driver for what actually reaches it; the failure classification, including a vendor code only getNextException() reaches and a self-referring chain. Removing the line that hands the bounds to the driver turns it red - all four drivers then sit past 240 s - which is what its predecessor could not do: it mirrored the enum literal and passed. In the container suites, testContendedTableDoesNotEndTheSweep is new, testTransientStampFailureEndsTheSweep is now testConnectionFailureEndsTheSweep, and testCommentStampsShareOneConnection asserts one connection per open rather than none for an open that finds every comment in place.
|
Round 6 is addressed — and both majors were right, including the part I would have argued with a round ago. Thank you for measuring rather than reasoning about The SQL Server stamp connection has no bound at allFixed, and PostgreSQL with it. Every dialect now declares both phases, and the two-constructor split you reviewed last round survives — one constructor for the three drivers whose connect property and read property bound the login between them, one for PostgreSQL, which also wants MICROSOFT("set lock_timeout "+(COMMENT_LOCK_TIMEOUT_SECONDS*1000),
"loginTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, "socketTimeout", STAMP_READ_TIMEOUT_SECONDS*1000);Units as you gave them: mssql-jdbc's The comment at The readback connection is unbounded, and can self-deadlockFixed as prescribed: the readback runs on One detail worth recording, since it is the reason the change is slightly larger than "swap the argument": This does change what a steady-state open costs, and the PR text now says so instead of claiming zero: one connection per open, shared by every tree of it, rather than one pooled borrow per open (which, as you measured at The test guarding the bounds cannot detect their absenceYou were right that this was the finding that mattered most, because it is why the other two survived five rounds. Replaced by
I checked it has teeth the way you asked me to rather than assuming: deleting One contended table costs the whole backend its commentsFixed, and you are right that round 6 made this worse than round 5.
The statistics
|
…mport Table names are opaque SHA-224 hashes of the tree name, so on the database side there was no way to tell which tree a table holds - identifying dn2id in discussion OpenIdentityPlatform#859 required recomputing hashes by hand. openTree() now stores the tree name as the table comment (COMMENT ON TABLE for postgres/oracle/h2, ALTER TABLE ... COMMENT for mysql, the MS_Description extended property for mssql), so "\dt+" and the information schema show it directly; existing deployments get stamped on the next backend open. The same investigation found a bulk-imported dn2id table that was never analyzed, leaving the planner a 13x-off row estimate for the "where k>? order by k" cursor batches. Importer.close() now refreshes optimizer statistics per dialect (ANALYZE / ANALYZE TABLE / dbms_stats.gather_table_stats / UPDATE STATISTICS), covering both import-ldif and rebuild-index. Both operations are best-effort - a failure is logged and never fails the backend or the import - and the statistics path reports success so tests catch rejected SQL on every supported database.
Review follow-up for the table-comment/statistics change: - The MySQL comment literal escaped only single quotes, but backslash is an escape character in MySQL literals, so a tree name containing one (DN escapes, or a crafted VLV index name) could corrupt the comment or break out of the literal. MySQL now escapes backslashes too; MS SQL passes the value and table name as bind parameters to the extended-property procedures and object_id(); Oracle binds the table name in dbms_stats.gather_table_stats; the remaining COMMENT ON / ALTER TABLE splices (DDL takes no binds) are verified by a paired-quotes guard. Clears the CodeQL java/concatenated-sql-query alerts 1267 and 1268. - Comment statements are DDL - a metadata lock on MySQL, a DDL lock on Oracle - and ran on every backend open. The stored comment is now read back from the catalog first and the DDL is issued only when it is absent or stale, so repeated opens cost one SELECT per tree. - deleteTree() invalidates the tree2table mapping so a later statistics refresh does not analyze dropped tables and spam warnings about them. - MySQL ANALYZE TABLE reports problems as a result row rather than an SQLException: Msg_type=error now surfaces as a failure. The sys.extended_properties probes constrain class=1 so a non-table property cannot misroute the add/update choice. Comment failures log at debug instead of trace. The statistics dialect switch is hoisted out of the per-tree loop. - Tests: the comment tree name carries a backslash next to the quote (fails on the old MySQL escaping), and statistics freshness is now asserted on all four databases - mysql.innodb_table_stats.n_rows and sys.dm_db_stats_properties(...).last_updated join the existing pg_class.reltuples and user_tables.num_rows checks.
…trees The comment stamp ran on the transaction that opened the tree: comment DDL implicitly commits on mysql/oracle, and a failing sp_addextendedproperty rolls the whole transaction back on sql server, committing or discarding work pending on the caller's connection (such as the trusted flag DefaultIndex.afterOpen() writes between openTree() calls). commentTable() now runs on a dedicated pooled connection and skips dialects it does not recognize before doing anything. The importer tracks the trees it wrote and close() refreshes statistics for those trees only, so rebuild-index no longer re-analyzes the whole backend. removeStorageFiles() invalidates the tree-to-table cache, and the importer returns its pooled connection in a finally. sqlLiteral() escapes and verifies in one place - quotes, plus backslashes where they are escape characters - and postgres comments use the E'' form so escaping does not depend on standard_conforming_strings.
Review follow-up for the table-comment/statistics change: - Comment statements take a lock (metadata lock on MySQL, schema modification lock on SQL Server, DDL lock on Oracle), and MySQL waits a year while SQL Server waits forever by default, so a stamp could queue behind an unrelated transaction of another session. The stamp now runs on a connection of its own, outside the pool, and bounds its wait first (lock_wait_timeout, LOCK_TIMEOUT, lock_timeout, ddl_lock_timeout). It is outside the pool because a session setting would otherwise be carried over to whoever borrows the connection next: CachedConnection.close() only rolls back. The setting is sent as a plain batch - the SQL Server driver would revert one made inside sp_executesql before the statement it protects. - A failed stamp was logged at debug and reissued on every open, so an account that may not comment its tables retried all trees forever, invisibly. The failure is now logged at warning, as its sibling updateTableStatistics() does, and remembered until the next start; deleteTree() and removeStorageFiles() forget it again. An interrupt taken while waiting for a pooled connection re-asserts the flag instead of being swallowed. - The MySQL comment literal doubled backslashes unconditionally, which stores the wrong comment - and never matches the readback, so the table is re-stamped on every open - under the NO_BACKSLASH_ESCAPES sql mode. The escaping now follows the connection's @@sql_mode. - commentTable() returns an outcome (STAMPED / UP_TO_DATE / UNSUPPORTED / FAILED) instead of a boolean that could not tell "skipped" from "rejected", and updateTableStatistics() reports failure for an engine outside the dialect switch, where it refreshes nothing. Tests: a stamp issued while another session holds an uncommitted row of the table must return promptly (it is what caught an invalid PostgreSQL SET lock_timeout form); the transaction-integrity test keeps its pending write in another tree, injects the failure at the statement rather than at the readback, and checks the failure is not reissued; the MySQL statistics assertion runs with STATS_AUTO_RECALC=0, without which InnoDB's background recalculation satisfied it on its own; the SQL Server readback in the test matches class=1 as production does. PgSql, MySql, MsSql and Oracle container suites: 43/43 each, no skips.
… connection for table comments Round 4 review of OpenIdentityPlatform#866: - an import that failed or was cancelled no longer refreshes optimizer statistics: Importer gained a default aborted() that OnDiskMergeImporter reports from the catch around the whole import, and the JDBC importer skips the refresh. The refresh is bounded as well - setQueryTimeout, 600 s per table by default (org.openidentityplatform.opendj.jdbc.statistics.timeout, 0 for no limit) - and can be turned off entirely with org.openidentityplatform.opendj.jdbc.statistics=false; - the connection the comment statement runs on bounds its connect attempt through the driver property of its dialect (10 s), so a database that keeps its established connections but accepts no new ones leaves a table unstamped instead of hanging the open of a tree; - the comment statements of one open share one connection instead of connecting per tree, which was ~25 physical connects on the first open after an upgrade; - the mysql sql_mode probe moved onto the stamping session, so the session that answers it is the session that parses the literal; - a stamp that failed for a reason of the moment (connection exception, the dialect's lock timeout) is attempted again; only a rejected statement is remembered; - newStampConnection() and CachedConnection.getConnection() no longer leak a connection when setting it up throws. Tests: PgSql 47/47, MySql 47/47, Oracle 47/47, MsSql 47/47, zero skips.
…fter a failed stamp, and bound its whole login The lock bound was set inside an uncommitted transaction, and postgres undoes a plain SET when that transaction is rolled back - which is what the session does after a failed stamp while keeping the connection for the trees that follow. Commit the setting, so the first failure of a sweep no longer leaves the rest of it stamping unbounded. Bound the whole login attempt of that connection, not only the socket connect: the connect property of postgres and mysql covers socket.connect() alone and oracle's CONNECT_TIMEOUT excludes authentication, so those three carry a read bound as well - loginTimeout on postgres, socketTimeout on mysql, oracle.jdbc.ReadTimeout on oracle. The sql server loginTimeout already covers the phase by itself. End the sweep when a stamp fails for a reason of the moment: every tree left in the same open would pay that same lock bound, or that same connect attempt, again - about 25 of them for a stock suffix. Ask @@sql_mode once per sweep instead of once per tree, report an aborted import for an Error as well, and name every dialect in the four switches that select per-engine SQL, so a fifth one cannot silently inherit another dialect's statements.
…thout indexing an array CodeQL alert 1269 (java/index-out-of-bounds): the varargs constructor read connectPropertyPairs[i+1] without anything guaranteeing the array holds whole pairs. Two constructors take the properties by name instead - one for the driver whose single property covers the whole login phase, one for those needing a read bound as well - so there is no index to get wrong, and the declarations at the constants read the same.
…e its readback off the pool Round 6 review of OpenIdentityPlatform#866: - the sql server stamp connection carried loginTimeout alone, and mssql-jdbc discards it for the reads of a login: TDSChannel.open() hands the socket min(what is left of loginTimeout, socketTimeout), and socketTimeout defaults to 0, so the read of the prelogin answer was left unbounded - which is the very state the bound exists for, a proxy that accepts a connection and then goes quiet. Every dialect now declares the property bounding its connect and the property bounding the reads behind it, postgres included, where socketTimeout takes seconds; - the comment readback borrowed a second connection from the pool, while the thread doing the open was inside a transaction holding one already. Where no further connection can be had, the pool waits for a peer to return one - which there is the very thread it is blocking. The readback runs on the stamp connection now, and the dialect is taken off the caller's own connection, so the comment path takes no borrow at all. An open costs one connection for the whole sweep, readback included; - a stamp that gave up on a lock ended the sweep it was part of, so one permanently contended table meant a backend was never stamped at all, on any open. The two-way isTransientFailure() is a three-way FailureScope: a rejected statement is remembered, a lock is neither remembered nor fatal to the sweep, and only a connection that is gone ends it. Both chains of the failure are walked, getCause() and getNextException(): a driver reports a vendor code as the next exception at least as often as it reports it as the cause, and reading one of the two classified a lock timeout as a rejection - leaving the tree unstamped until the next start over a moment of contention; - the dialect guard of updateTableStatistics() threw out of ImporterImpl.close(), against the best-effort contract stated twenty lines above it. It degrades to "this table was not refreshed", like every other failure of that loop; - OnDiskMergeImporter lost the Throwable that had aborted an import if aborted() threw on the way out; it is suppressed into it instead; - a rejection is forgotten when the storage closes, so re-enabling the backend is enough to try again once the privilege has been granted. Tests: StampConnectionTestCase is new and needs no database. The bounds are the one thing the container suites cannot guard - they skip themselves whole where no docker is reachable, so a bound only they exercise is a bound that a silent skip deletes. Four real drivers against a ServerSocket that accepts and answers nothing, attempted concurrently (31 s); a recording java.sql.Driver for what actually reaches it; the failure classification, including a vendor code only getNextException() reaches and a self-referring chain. Removing the line that hands the bounds to the driver turns it red - all four drivers then sit past 240 s - which is what its predecessor could not do: it mirrored the enum literal and passed. In the container suites, testContendedTableDoesNotEndTheSweep is new, testTransientStampFailureEndsTheSweep is now testConnectionFailureEndsTheSweep, and testCommentStampsShareOneConnection asserts one connection per open rather than none for an open that finds every comment in place.
bc06da2 to
eeb9be9
Compare
master added the table stamping of OpenIdentityPlatform#866, which closes the comment connection of a transaction in a finally block, while this branch wrapped the same transaction in a retry loop. The transaction object is now bound per attempt and its stamp session closed with it, so a replay stamps on a session of its own. Both sides also grew a helper returning the driver name behind a connection - driverNameOf() on master, getDriverName() here. Collapsed into master's name, keeping the body of this branch, which tolerates a connection that did not come from the pool. The read() javadoc no longer says that Storage#read asks for a replay: OpenIdentityPlatform#871 turned that contract around on master, and it now forbids one, which is what this implementation already did.
…ts tree stamp, and unenrol after the drop deleteTree() took a tree out of the catalog before dropping its table, so that the commit of the DDL would carry the row with it. On mysql and oracle DDL commits the transaction it finds open before it executes, so there the delete landed first and a drop that then failed - ORA-00054 on a tree another session holds, which write() rethrows unreplayed, it being neither a class 40 state nor ORA-00060 - left a table nothing names, adopted with its stale rows by the next open of that tree and dropped by no clear ever after. The drop goes first now and the row is taken out after it, carrying a commit of its own so that the enclosing transaction can no longer roll it back over a table already gone; the invariant holds on all four engines that way. openTree() goes on writing its row before its table, which is the same rule read the other way round, and the comments say so instead of claiming a symmetry that would be wrong. The report of what a clear did not drop counted every opendj table of the connection and asked for all of them to be removed by hand - the live tables of a backend sharing the database (OpenIdentityPlatform#873) included, which the case of an offline clear in this very suite reproduces. It reads the tree stamp of OpenIdentityPlatform#866 instead: a table stamped with a tree of a base DN this backend does not serve is passed over in silence, one stamped with a tree of this backend is named as its own and so as removable by hand, and one carrying no stamp at all is reported as attributable to nobody. The catalog table is stamped like any other for that reason - a neighbouring backend's catalog is otherwise the one table such a report can attribute to no one - and the line for a clear that dropped nothing prints all three counts, where it used to print the one number that had not fired. It also says what that clear means on a backend upgraded in place: the first offline clear of one finds no catalog and drops nothing, nothing enrolling a tree before removeStorageFiles() runs. The existence lookups the removal makes are narrowed to the database and the schema of the connection. Asked with a null catalog, Connector/J answers for every database of the server, and there the answer decides between skipping a row and dropping the table it names: a table of the same name next door would turn a skip that keeps the clear going into an unqualified drop of a table that is not here, failing the whole clear on this attempt and on every one after it. Three cases added - a write() that throws after deleteTree, the table name a catalog row records and the fallback for a row recording none, and the attribution of what a clear left standing - the shared compressed schema pair is asserted for both of its trees, the setup half of the offline clear case clears what it created when it fails, and unenrolFromCatalog() got the guard enrolInCatalog() has.
Problem
Troubleshooting the JDBC backend on the database side is needlessly hard, as discussion #859 showed while investigating #860:
opendj_89664c…), so tellingdn2idapart fromid2entryrequired recomputing hashes by hand — with the normalized-DN quirks (reversed RDN order) that entails.dn2idtable there had never been analyzed:pg_statshowed a row estimate of 1,104 against the real 13,908, leaving the planner free to misestimate thewhere k>? order by kcursor batches.Change
Tree name stamped as the table comment.
openTree()stores the tree name on the table, visible in\dt+/ the information schema:COMMENT ON TABLE … IS E'…'(theE''form keeps backslash semantics independent ofstandard_conforming_strings)COMMENT ON TABLE … IS '…'(backslash is never an escape character there)ALTER TABLE … COMMENT '…', escaped according to the@@sql_modeof the session that parses the literal (NO_BACKSLASH_ESCAPESturns backslash into an ordinary character)MS_Descriptionextended property (idempotent add/update,class=1), value and table name passed as bind parametersEngines outside these four are left unstamped — mirroring the statistics path — rather than fed untested DDL on every open.
The stamp runs on a connection of its own, outside the pool, never on the transaction that opened the tree: comment statements are DDL (an implicit commit on MySQL and Oracle), and a failing
sp_addextendedpropertyrolls the whole transaction back on SQL Server — either would corrupt work pending on the caller's connection, such as the trusted flagDefaultIndex.afterOpen()writes betweenopenTree()calls. It is outside the pool because the statement needs session settings — the lock timeout below — andCachedConnection.close()only rolls back, so a pooled connection would carry them over to whoever borrows it next.That connection is shared by every tree of one
storage.write(), and the readback runs on it too. An open costs one physical connect, not one per tree — whether it stamps a whole backend (about 25 trees for a stock suffix) or finds every comment already in place. The readback used to borrow a second connection from the pool, which the thread doing an open cannot afford: it is inside a transaction holding a pooled connection already, and a pool that cannot open another one waits for a peer to return one — which there is the very thread it is blocking. The dialect is taken from the caller's own connection for the same reason: finding it out must not cost a borrow either.The whole login attempt of that connection is bounded, not only the socket connect inside it, because not one of the four drivers covers both phases with a single property. Each dialect declares the property that bounds the connect and the property that bounds the reads behind it — the prelogin handshake, TLS, authentication — which is exactly the phase a proxy at its connection limit or a moved VIP leaves unanswered after accepting the TCP connection:
connectTimeout, plusloginTimeoutfor the login pgjdbc runs on a thread of its own (s)socketTimeout(s)connectTimeout(ms)socketTimeout(ms)oracle.net.CONNECT_TIMEOUT(ms) — its own reference says it "doesn't include user authentication"oracle.jdbc.ReadTimeout(ms)loginTimeout(s)socketTimeout(ms)The SQL Server row is the one that is easy to get wrong:
loginTimeoutreads as if it covered the login, butTDSChannel.open()hands the socketmin(what is left of loginTimeout, socketTimeout)andsocketTimeoutdefaults to0— "wait forever" — so the read of the prelogin answer is left open. Bounded, the open of a tree leaves a table unstamped instead of hangingdsconfig create-backend-index, which opens one on a running server.DriverManager.setLoginTimeout()is deliberately not used: it is JVM-global and would change every otherDriverManageruser in the process.Comment statements also take locks (a metadata lock on MySQL, a schema modification lock on SQL Server, a DDL lock on Oracle) and
openTree()runs on every backend open, so:SELECTper tree and one connection for the sweep — no DDL, no lock;lock_wait_timeouton MySQL,LOCK_TIMEOUTon SQL Server,lock_timeouton PostgreSQL,ddl_lock_timeouton Oracle). MySQL waits a year and SQL Server waits forever by default, so an unbounded stamp could queue behind an unrelated transaction of another session on the same database — and on MySQL park every other query on that table behind itself. A diagnostic aid must never be able to do that; giving up is the correct outcome, and the failure path already degrades to "table left unstamped". The bound is committed as soon as it is set: PostgreSQL undoes a plainSETwhen the transaction that ran it is rolled back, and a failed stamp rolls back a connection that goes on to serve the rest of the sweep.Existing deployments get stamped on the first read-write open after an upgrade, without reimporting (read-only tools such as
export-ldif/backendstatnever stamp).Where the value must be spliced into a DDL literal (no bind parameters there),
sqlLiteral()escapes and verifies in one place: quotes are doubled, backslashes are doubled on the dialects where they are escape characters inside literals, and a paired-characters scan of the result guarantees a regression in the escaping throws instead of reaching SQL. This keeps CodeQLjava/concatenated-sql-queryalerts 1267 and 1268 closed.A failed stamp never fails the backend — it is logged at warning, as its sibling
updateTableStatistics()does — and what happens next depends on what the failure says, which is a three-way question rather than the two-way one it looks like:TREEMOMENTSESSIONBoth exception chains are walked when classifying,
getCause()andgetNextException(): a driver reports the vendor error of a failed statement as the next exception of a generic one at least as often as it reports it as the cause, and reading only one of the two classifies a lock timeout as a rejection — leaving a tree unstamped until the next start over a moment of contention. A remembered rejection is forgotten when the storage closes, so disabling and re-enabling the backend is enough to retry once the privilege has been granted; a server restart is not required.Optimizer statistics refreshed after bulk load.
Importer.close()— coveringimport-ldif, online import tasks,rebuild-indexand replication total-update initialization viaOnDiskMergeImporter— refreshes statistics per dialect:ANALYZE(PostgreSQL),ANALYZE TABLE(MySQL — problems reported as a result row surface as failures),dbms_stats.gather_table_statswith the table name bound (Oracle),UPDATE STATISTICS(MS SQL). Only the trees the import actually wrote — tracked throughput()/clearTree()— are refreshed, so rebuilding a single index does not trigger a full-scan statistics pass over the whole backend. A full import does cover every tree, sinceAbstractTwoPhaseImportStrategy.beforePhaseOneclears them all before the first record is written.deleteTree()andremoveStorageFiles()invalidate the tree-to-table cache so dropped trees are never analyzed later, and the importer returns its pooled connection in afinally. Best-effort throughout: every failure of a table — the guard against an unknown dialect included — is logged as a warning and leaves the other tables to be refreshed, never failing the import that produced the data, while the method reports failure so tests catch rejected SQL.An import that failed or was cancelled refreshes nothing: its trees hold an incomplete import that is going to be run again, and on Oracle gathering statistics of them would delay the report of the failure — or of the cancellation — by a full scan per table.
Importergained adefault void aborted()for that;OnDiskMergeImporter.doImport()reports it from the onecatcharound the whole import (so theInterruptedExceptionof a cancelled import is covered too) and rethrows,TracedStorageforwards it, and the JE, PDB and Cassandra importers inherit the no-op. A failure of the notification itself is suppressed into theThrowablethat caused the abort rather than replacing it — concrete for the motivating case, an import killed by anOutOfMemoryError, where notifying the storage allocates.What a completed import does refresh is bounded and optional, since
dbms_statsdefaults toAUTO_SAMPLE_SIZE— a full scan of a table whoseblobcolumn holds the entries:org.openidentityplatform.opendj.jdbc.statistics.timeout—setQueryTimeoutper table, 600 s by default,0for no limit;org.openidentityplatform.opendj.jdbc.statistics=false— no refresh at all,in the style of the
…jdbc.fetchsizeand…jdbc.ttlproperties this backend already has.This half is defence in depth rather than the fix for #859's live-traffic symptoms — #863 addressed the measured problem there, and PostgreSQL autoanalyze closes the post-import window on its own within about a minute. A deterministic post-bulk-load refresh still makes the first post-import query plans predictable and covers deployments with auto-stats disabled.
Tests
StampConnectionTestCaseis new and needs no database at all, which is the point: the container suites skip themselves whole when no docker is reachable, so a bound only they exercise is a bound that can be deleted without a single test going red.testEveryDriverGivesUpOnASilentServer— aServerSocketthat accepts connections and answers nothing, the shape of a proxy at its connection limit; each of the four real drivers must give up on it well inside the ceiling. The four are attempted concurrently, so the suite pays the bound of the slowest rather than the sum: 31 s. Deleting the one line that hands the bounds to the driver makes it fail rather than pass in 31 s.testStampConnectionHandsItsBoundsToTheDriver— a recordingjava.sql.Driver: every declared property must arrive, and as a copy, since a driver is free to write into the map it is passed.testEveryDialectDeclaresBothBounds— both phases are declared, with positive values, for every dialect.testFailureScopeTellsTheThreeApart/testFailureScopeWalksBothChains— the classification above, including a vendor code reachable only throughgetNextException(), and a self-referring chain that must not loop the walk.In the container suites:
testTreeNameStoredAsTableComment— reads the comment back from each database's catalog and compares it to the tree name; a single quote and a backslash in the base DN exercise the literal escaping.testCommentStampSkippedWhenAlreadyStored— a second stamp attempt reportsUP_TO_DATE(and notFAILED: the outcome is an enum, so "skipped" and "rejected" cannot be confused), and a stale comment is re-stamped.testCommentStampsShareOneConnection— three freshly created trees stamped in onestorage.write()cost exactly one physical connect, and the open that follows — every comment in place, readback included — costs exactly one more.testCommentStampGivesUpOnLock— another session holds an uncommitted row of the table while the stamp is issued (on SQL Server the stored property is dropped first, so the statement issp_addextendedpropertyrather than the update): the call must return well inside the 5 s bound it is given, and if it reports success the comment must really be stored. It is also what catches an invalid session-setting statement — the PostgreSQLSET lock_timeoutform was wrong at first and this suite failed on it.testCommentFailureLeavesTransactionIntact— a failing stamp leaves a write pending in the caller's transaction untouched (the pending write targets another tree: a statement left pending on the very table being stamped would make the comment statement wait for the caller's own lock, a shape no production path has), and the failure is not reissued.testTransientStampFailureIsRetried— a stamp that failed with a connection exception is attempted again rather than remembered, unlike the rejected statement of the test above.testConnectionFailureEndsTheSweep— a lost connection is paid once for the whole open, not once per tree, and the open that follows tries again.testContendedTableDoesNotEndTheSweep— one table reporting its dialect's lock-timeout failure leaves the trees behind it stamped, and is itself stamped by the next open.testDeleteTreeForgetsTree— a dropped tree disappears from the tree-to-table cache.testImportRefreshesTableStatistics— asserts freshness on all four databases:pg_class.reltuples > 0(PostgreSQL),user_tables.num_rowsset (Oracle),mysql.innodb_table_stats.n_rows > 0(MySQL, withSTATS_AUTO_RECALC=0set on the table beforehand so InnoDB's background recalculation cannot satisfy the assertion by itself),sys.dm_db_stats_properties(…).last_updatedset (MS SQL) — plus a direct assertion that the dialect-specific refresh statement is accepted.testAbortedImportSkipsStatistics— an import told it was aborted refreshes nothing, while the one that follows does.testStatisticsRefreshCanBeTurnedOff— with…jdbc.statistics=falsethe refresh reports that nothing was refreshed and issues no statement.All four container suites pass locally with 51/51 and zero skips — PgSql, MySql, MsSql, Oracle — as does
StampConnectionTestCase(5/5, no database). The pluggable suites that share the importer SPI (OnDiskMergeImporterTest,DefaultIndexTest,StateTest,DN2IDTest) pass as well.Follow-up to #860 / #863; closes the diagnostics gap from discussion #859.