opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
@@ -95,12 +95,18 @@ return con; } } Connection conNew = null; try { final Connection conNew = DriverManager.getConnection(connectionString); conNew = DriverManager.getConnection(connectionString); conNew.setAutoCommit(false); conNew.setTransactionIsolation(TRANSACTION_READ_COMMITTED); return new CachedConnection(connectionString, conNew); } catch (SQLException e) { // max_connection server error: try recursion for reuse connection if (conNew != null) { // the connection was established but not set up: nothing else would close it try { conNew.close(); } catch (SQLException e2) {} } return getConnection(connectionString, (waitTime == 0) ? 1 : waitTime * 2); } } opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -34,11 +34,13 @@ import org.opends.server.types.RestoreConfig; import org.opends.server.util.BackupManager; import java.io.Closeable; import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.sql.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static org.opends.server.backends.pluggable.spi.StorageUtils.addErrorMessage; import static org.opends.server.util.StaticUtils.stackTraceToSingleLineString; @@ -88,6 +90,14 @@ return statement.executeUpdate(); } // unlike execute(), tolerates statements that return a result set ("analyze table" on mysql) void executeAny(PreparedStatement statement) throws SQLException { if (logger.isTraceEnabled()) { logger.trace(LocalizableMessage.raw("jdbc: %s",statement)); } statement.execute(); } Connection getConnection() throws Exception { return CachedConnection.getConnection(config.getDBDirectory()); } @@ -111,6 +121,10 @@ @Override public void close() { storageStatus = StorageStatus.lockedDown(LocalizableMessage.raw("closed")); // a stamp that the database rejected is remembered for as long as the storage is open, so // that it is not reissued for every tree on every open; disabling and re-enabling the // backend is the way to try again once the privilege has been granted unstampableTrees.clear(); } final LoadingCache<TreeName,String> tree2table = Caffeine.newBuilder() @@ -151,6 +165,567 @@ return name; } private static final String[] NO_ARGS=new String[0]; // Comment statements take a lock (a metadata lock on mysql, a schema modification lock on sql // server, a ddl lock on oracle), and mysql and sql server wait for it without limit by // default (lock_wait_timeout is a year, lock_timeout is infinite): a stamp could queue behind // an unrelated transaction of another session on the same database and - on mysql - park // every other query on the table behind itself. The stamp is a diagnostic aid, so every // dialect is told to give up after this many seconds instead of waiting. private static final int COMMENT_LOCK_TIMEOUT_SECONDS=5; // The comment statement runs on a connection of its own (newStampConnection() below), and a // driver waits for a connect attempt without limit unless it is told otherwise: a database // that keeps its established connections alive but accepts no new ones (a moved vip, a proxy // at its connection limit) would otherwise hang the open of a tree - dsconfig // create-backend-index opens one on a running server - instead of leaving a table unstamped. // Every dialect gets the same bound, in the unit its own driver property takes. private static final int STAMP_CONNECT_TIMEOUT_SECONDS=10; // Not one of the four drivers bounds the whole login attempt with its connect property alone: // postgres and mysql apply theirs to socket.connect(), oracle's own reference says // CONNECT_TIMEOUT "doesn't include user authentication", and the sql server driver leaves the // read of the prelogin answer unbounded - TDSChannel.open() gives the socket // min(what is left of loginTimeout, socketTimeout) and socketTimeout defaults to 0, which is // "wait forever". Those reads - of the prelogin handshake, of tls, of authentication - are // exactly where a proxy that accepts a connection and then goes quiet leaves the driver, so // every dialect carries a read bound as well. All four are socket read timeouts, so the bound // outlives the login phase and covers the comment statement too, which is why it is kept well // clear of the lock bound above: the statement gives up on a contended lock long before the // socket gives up on the server. On a mysql connection with tls (the sslMode=PREFERRED default // of connector/j) the wall clock of a dead peer is twice this, since closing an SSLSocket // drains input waiting for close_notify and pays the read bound a second time. private static final int STAMP_READ_TIMEOUT_SECONDS=30; // Trees whose stamp failed for a reason that is not going to change by itself: an account that // may not comment its tables (no ALTER privilege, for instance) would otherwise reissue the // statement for every tree on every open. A failure that says nothing about the table - a lock // timeout, a connection that broke - is not remembered (failureScope() below), so a contended // moment does not leave the backend unstamped until it is restarted. Forgotten when the // storage is closed, so re-enabling the backend is enough to try again once the privilege has // been granted, without a restart of the server. private final Set<TreeName> unstampableTrees=ConcurrentHashMap.newKeySet(); /** * The engines whose comment statement, comment readback and statistics refresh this backend * knows, with the session settings a comment statement needs: the driver properties that * bound the connect attempt of the connection it runs on, and the statement that bounds its * wait for the table lock. */ enum Dialect { /** postgresql: lock_timeout takes milliseconds; connectTimeout bounds socket.connect(), loginTimeout the whole login the driver runs on a thread of its own, socketTimeout every read after it - all three in seconds. */ POSTGRES("set lock_timeout = "+(COMMENT_LOCK_TIMEOUT_SECONDS*1000), "connectTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, "loginTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, "socketTimeout", STAMP_READ_TIMEOUT_SECONDS), /** mysql: lock_wait_timeout takes seconds; connectTimeout bounds the socket connect and socketTimeout every read after it, both in milliseconds. */ MYSQL("set session lock_wait_timeout="+COMMENT_LOCK_TIMEOUT_SECONDS, "connectTimeout", STAMP_CONNECT_TIMEOUT_SECONDS*1000, "socketTimeout", STAMP_READ_TIMEOUT_SECONDS*1000), /** oracle: ddl_lock_timeout takes seconds and defaults to 0 (give up at once), but it can be raised globally; the connect and read bounds take milliseconds. */ ORACLE("alter session set ddl_lock_timeout="+COMMENT_LOCK_TIMEOUT_SECONDS, "oracle.net.CONNECT_TIMEOUT", STAMP_CONNECT_TIMEOUT_SECONDS*1000, "oracle.jdbc.ReadTimeout", STAMP_READ_TIMEOUT_SECONDS*1000), /** ms sql server: lock_timeout takes milliseconds; loginTimeout bounds the socket connect, in seconds, and socketTimeout the prelogin read it leaves open - and every read after it - in milliseconds. */ MICROSOFT("set lock_timeout "+(COMMENT_LOCK_TIMEOUT_SECONDS*1000), "loginTimeout", STAMP_CONNECT_TIMEOUT_SECONDS, "socketTimeout", STAMP_READ_TIMEOUT_SECONDS*1000); final String lockTimeoutSql; // The driver properties bounding the login attempt of a stamp connection: the one bounding // the socket connect and the one bounding the reads behind it, each in the unit its own // driver takes. newStampConnection() hands the driver a copy of them: a driver is free to // write into the map it is passed, and the sql server one gives a supplied property // precedence over the same property of the url. final Properties connectProperties=new Properties(); /** For the three drivers whose connect property and read property bound the login between them. */ Dialect(String lockTimeoutSql, String connectProperty, int connectValue, String readProperty, int readValue) { this.lockTimeoutSql=lockTimeoutSql; connectProperties.setProperty(connectProperty, String.valueOf(connectValue)); connectProperties.setProperty(readProperty, String.valueOf(readValue)); } /** For the one driver bounding the login itself, on top of the socket connect and the reads behind it. */ Dialect(String lockTimeoutSql, String connectProperty, int connectValue, String loginProperty, int loginValue, String readProperty, int readValue) { this(lockTimeoutSql, connectProperty, connectValue, readProperty, readValue); connectProperties.setProperty(loginProperty, String.valueOf(loginValue)); } } static String driverNameOf(Connection con) { return ((CachedConnection) con).parent.getClass().getName(); } // The dialect behind a pooled connection, or null for an engine none of the statements of this // class fit: it is left unstamped and its statistics untouched rather than fed untested SQL. static Dialect dialectOf(Connection con) { final String driverName=driverNameOf(con); if (driverName.contains("postgres")) { return Dialect.POSTGRES; }else if (driverName.contains("mysql")) { return Dialect.MYSQL; }else if (driverName.contains("oracle")) { return Dialect.ORACLE; }else if (driverName.contains("microsoft")) { return Dialect.MICROSOFT; } return null; } /** Outcome of a comment stamp: openTree() ignores it, tests tell the cases apart. */ enum CommentResult { /** the table now carries its tree name */ STAMPED, /** the stored comment already matched: no statement was issued */ UP_TO_DATE, /** neither comment syntax nor readback is known for this engine */ UNSUPPORTED, /** the comment could not be read back or stored */ FAILED } // Splices a value into a single-quoted SQL literal for the comment DDL, which takes no bind // parameters: doubles every quote, and every backslash on dialects where backslash is an // escape character inside literals. The scan of the escaped result is defence in depth: it // re-verifies that no quote (or live backslash) is left unpaired and able to terminate the // literal, so a regression in the escaping throws instead of reaching the database. private static String sqlLiteral(String value, boolean backslashIsEscape) { final String escaped=(backslashIsEscape?value.replace("\\","\\\\"):value).replace("'","''"); for (int i=0;i<escaped.length();i++) { final char c=escaped.charAt(i); if (c=='\'' || (backslashIsEscape && c=='\\')) { if (i+1>=escaped.length() || escaped.charAt(i+1)!=c) { throw new IllegalArgumentException("unpaired "+c+" in SQL literal: "+escaped); } i++; } } return "'"+escaped+"'"; } // Whether backslash is an escape character inside string literals on this mysql connection: // under the NO_BACKSLASH_ESCAPES sql mode it is an ordinary character, and doubling it there // would store a comment that never matches its tree name - re-stamping the table forever. // Asked of the very session that parses the literal: sql_mode is a session setting, and a // session opened at another moment can have been given another value of it. boolean isMysqlBackslashEscape(Connection con) throws SQLException { try (final PreparedStatement statement=con.prepareStatement("select @@sql_mode"); final ResultSet rs=executeResultSet(statement)) { final String sqlMode=rs.next() ? rs.getString(1) : null; return sqlMode==null || !sqlMode.toUpperCase().contains("NO_BACKSLASH_ESCAPES"); } } // A connection of its own for the comment statements, outside the pool: they need session // settings (the lock timeout above) that a pooled connection would carry over to whoever // borrows it next, since CachedConnection.close() only rolls back. Connection newStampConnection(Dialect dialect) throws SQLException { final Properties properties=new Properties(); properties.putAll(dialect.connectProperties); final Connection con=DriverManager.getConnection(config.getDBDirectory(), properties); try { con.setAutoCommit(false); executeSessionStatement(con, dialect.lockTimeoutSql); // give up instead of waiting for another session // postgres undoes a plain SET when the transaction that ran it is rolled back, and a // failed stamp is rolled back with the connection kept (StampSession.reset() below): // commit the setting, or the first failure of a sweep would leave every tree after it // stamped without the very bound this connection exists to carry. The session settings // of the other three dialects are not transactional - the commit costs them an empty // transaction. con.commit(); }catch (SQLException e) { // nothing else holds this connection yet: it would leak try { con.close(); }catch (SQLException e2) {} throw e; } return con; } // The connection the comment statements of one sweep of openTree() calls share. Opening a // backend opens every tree it holds (about 25 for a stock suffix), so a connection per stamp // would mean that many physical connects on the first open after an upgrade - the one open // that stamps them all. Opened lazily: a sweep that finds every comment up to date, which is // every open after the first, opens nothing at all. final class StampSession implements Closeable { private Connection con; // Whether backslash escapes inside a literal on the connection above (mysql @@sql_mode). // It is a session setting of a connection the whole sweep shares, so the sweep asks once // instead of once per tree, and forgets it together with the session it describes. private Boolean mysqlBackslashEscape; // Set when a stamp failed for a reason no other tree of this sweep would escape either: a // connect that did not go through, a lock the statement gave up on. Each remaining tree // would pay that same bound - or that same connect attempt - again, which is a backend // open held for the bound times the number of its trees, all for a diagnostic aid. The // sweep gives up instead; nothing about the trees is remembered, so the next open retries. private boolean gaveUp; Connection connection(Dialect dialect) throws SQLException { if (con==null) { con=newStampConnection(dialect); } return con; } // Asked by the mysql statement only, and only once the connection above is open. boolean backslashIsEscape() throws SQLException { if (mysqlBackslashEscape==null) { mysqlBackslashEscape=isMysqlBackslashEscape(con); } return mysqlBackslashEscape; } void giveUp() { gaveUp=true; } boolean hasGivenUp() { return gaveUp; } // A statement that failed can leave the session unusable (postgres refuses every further // statement of the transaction with 25P02 until it is rolled back), so the stamp of the // next tree gets a clean one: rolled back, or replaced when even the rollback fails. void reset() { if (con!=null) { try { con.rollback(); }catch (SQLException e) { close(); } } } @Override public void close() { if (con!=null) { try { con.close(); }catch (SQLException e) { logger.trace(LocalizableMessage.raw("jdbc: unable to close the comment connection: %s", stackTraceToSingleLineString(e))); } con=null; } mysqlBackslashEscape=null; // it described the session that has just gone } } // A session setting must reach the server as a plain batch: the sql server driver runs a // prepared statement through sp_executesql, and a setting made there is reverted when that // call returns - before the statement it is meant to protect ever runs. private void executeSessionStatement(Connection con, String sql) throws SQLException { try (final Statement statement=con.createStatement()) { if (logger.isTraceEnabled()) { logger.trace(LocalizableMessage.raw("jdbc: %s",sql)); } statement.execute(sql); } } // Table names are opaque SHA-224 hashes, so on the database side there is no way to tell // which tree a table holds. Stamp each table with its tree name (visible in "\dt+" and the // information schema) so database-level troubleshooting does not require recomputing hashes. // Runs on a dedicated connection, never on the transaction that opened the tree: comment // statements are DDL (an implicit commit on mysql and oracle), and a failing // sp_addextendedproperty rolls the whole transaction back on sql server - either would // corrupt work pending on the caller's connection (e.g. the trusted flag written by // DefaultIndex.afterOpen()). The comment is a diagnostic aid: a failed attempt only logs and // must not fail the backend. CommentResult commentTable(TreeName treeName, Dialect dialect) { try (final StampSession session=new StampSession()) { // a stamp of its own: no sweep to share a connection with return commentTable(treeName, dialect, session); } } CommentResult commentTable(TreeName treeName, Dialect dialect, StampSession session) { final String tableName=getTableName(treeName); if (dialect==null) { // no comment syntax and readback known for other engines: leave the table unstamped return CommentResult.UNSUPPORTED; } if (unstampableTrees.contains(treeName)) { // the database already rejected this one: do not ask again return CommentResult.FAILED; } if (session.hasGivenUp()) { // an earlier tree of this sweep lost the session every tree of it needs logger.debug(LocalizableMessage.raw("jdbc: table %s is left unstamped: the stamp of an earlier table of this open lost its connection", tableName)); return CommentResult.FAILED; } final String treeComment=treeName.toString(); try { // The readback runs on the stamp connection, not on one borrowed from the pool: the // caller of openTree() is inside a transaction and holding a pooled connection already, // and a pool that cannot open a second one waits for a peer to return one - which here // is the very thread that is waiting. The dialect comes from the caller's connection // for the same reason: finding it out must not cost a borrow either. final Connection con=session.connection(dialect); // comment statements are DDL (metadata lock on mysql, ddl lock on oracle) and openTree() // runs on every backend open: only stamp when the stored comment is absent or stale final String storedComment=readStoredComment(con, dialect, tableName); // end the read: this connection is shared by every tree of the sweep and must not hold // a transaction open across all of them con.commit(); if (treeComment.equals(storedComment)) { return CommentResult.UP_TO_DATE; } final String sql; final String[] args; switch (dialect) { case MYSQL: // ALTER TABLE takes no binds; whether backslash escapes inside the literal depends on the sql mode of this session sql="alter table "+tableName+" comment "+sqlLiteral(treeComment,session.backslashIsEscape()); args=NO_ARGS; break; case MICROSOFT: // no COMMENT ON in t-sql: MS_Description extended property (procedure arguments take binds) sql="declare @s sysname = schema_name()" +" if exists (select 1 from sys.extended_properties where class=1 and major_id=object_id(?) and minor_id=0 and name='MS_Description')" +" exec sys.sp_updateextendedproperty N'MS_Description', ?, N'SCHEMA', @s, N'TABLE', ?" +" else" +" exec sys.sp_addextendedproperty N'MS_Description', ?, N'SCHEMA', @s, N'TABLE', ?"; args=new String[]{tableName, treeComment, tableName, treeComment, tableName}; break; case POSTGRES: // no binds in ddl; the E'' form keeps backslash an escape character regardless of standard_conforming_strings sql="comment on table "+tableName+" is E"+sqlLiteral(treeComment,true); args=NO_ARGS; break; case ORACLE: // no binds in ddl; backslash is never an escape character in oracle literals sql="comment on table "+tableName+" is "+sqlLiteral(treeComment,false); args=NO_ARGS; break; default: // a dialect this switch was never told about must not inherit another one's ddl throw new IllegalStateException("no comment statement for dialect "+dialect); } try (final PreparedStatement statement=con.prepareStatement(sql)) { for (int i=0;i<args.length;i++) { statement.setString(i+1,args[i]); } executeAny(statement); con.commit(); } return CommentResult.STAMPED; }catch (Exception e) { session.reset(); // what the failed statement left behind must not poison the stamp of the next tree final FailureScope scope=failureScope(e, dialect); if (scope==FailureScope.SESSION) { // the connection itself is gone, and every tree left in this sweep needs one: each // would pay the same connect attempt again, ~25 of them for a stock suffix session.giveUp(); }else if (scope==FailureScope.TREE) { unstampableTrees.add(treeName); } logger.warn(LocalizableMessage.raw("jdbc: unable to comment table %s with tree name %s, it stays unstamped %s (the comment is a diagnostic aid: the backend is unaffected): %s", tableName, treeName, scope==FailureScope.TREE?"until this backend is closed":"for now", stackTraceToSingleLineString(e))); return CommentResult.FAILED; } } /** What a failed stamp says about stamping again - this tree, and the trees behind it in the same sweep. */ enum FailureScope { /** * The database rejected the statement: an account that may not comment its tables, say. It * would be rejected again for this tree on every open, so the tree is remembered and not * asked again while this backend is open. Says nothing about the other trees of the sweep, * which are stamped as usual - the privilege may well be missing for this one table alone. */ TREE, /** * Another session held the table locked and the stamp gave up on the bound above. Nothing * is remembered - the next open tries again - and the sweep goes on: the lock belongs to * this table, and the trees behind it are no more likely to be contended than usual. */ MOMENT, /** * The connection the sweep runs on is gone, or was never established. Every tree left in * the sweep would run into the same thing, one connect attempt each, so the sweep ends; * nothing is remembered, since this says nothing about any of the tables. */ SESSION } // What a failed stamp says about trying again. Both chains of the failure are walked: a driver // reports the vendor error of a rejected 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 would classify // a lock timeout as a rejection, which leaves the tree unstamped for the life of the backend // over a moment of contention. static FailureScope failureScope(Throwable failure, Dialect dialect) { FailureScope scope=FailureScope.TREE; final Deque<Throwable> pending=new ArrayDeque<>(); final Set<Throwable> seen=Collections.newSetFromMap(new IdentityHashMap<Throwable,Boolean>()); if (failure!=null) { pending.push(failure); } while (!pending.isEmpty()) { final Throwable e=pending.pop(); if (!seen.add(e)) { // a driver that chains an exception back to itself must not loop this walk continue; } if (e.getCause()!=null) { pending.push(e.getCause()); } if (!(e instanceof SQLException)) { continue; } final SQLException sqlException=(SQLException) e; if (sqlException.getNextException()!=null) { pending.push(sqlException.getNextException()); } final FailureScope found=scopeOf(sqlException, dialect); if (found==FailureScope.SESSION) { // nothing further down either chain can weaken this one return FailureScope.SESSION; } if (found==FailureScope.MOMENT) { scope=FailureScope.MOMENT; } } return scope; } // What one exception of the chain says on its own. private static FailureScope scopeOf(SQLException e, Dialect dialect) { final String sqlState=e.getSQLState(); if (e instanceof SQLTransientConnectionException || e instanceof SQLNonTransientConnectionException || e instanceof SQLRecoverableException // what oracle throws for a connection that has gone || (sqlState!=null && sqlState.startsWith("08"))) { // connection exception return FailureScope.SESSION; } if (e instanceof SQLTimeoutException || e instanceof SQLTransientException) { return FailureScope.MOMENT; } if (dialect==null) { // the failure came before the engine was known return FailureScope.TREE; } switch (dialect) { case POSTGRES: // 55P03 lock not available: lock_timeout expired return "55P03".equals(sqlState) ? FailureScope.MOMENT : FailureScope.TREE; case MYSQL: // 1205 lock wait timeout exceeded return e.getErrorCode()==1205 ? FailureScope.MOMENT : FailureScope.TREE; case ORACLE: // ORA-00054 resource busy, ORA-04021 timeout occurred while waiting to lock object return e.getErrorCode()==54 || e.getErrorCode()==4021 ? FailureScope.MOMENT : FailureScope.TREE; case MICROSOFT: // 1222 lock request time out period exceeded return e.getErrorCode()==1222 ? FailureScope.MOMENT : FailureScope.TREE; default: // a dialect with no lock timeout code of its own here: its failures are not treated as ones of the moment return FailureScope.TREE; } } // Returns the comment currently stored on the table, or null when there is none. The dialect is // passed in rather than read off the connection: this runs on the stamp connection, which is // not a pooled one, and only for the dialects commentTable() recognizes. String readStoredComment(Connection con, Dialect dialect, String tableName) throws SQLException { final String sql; final String arg; switch (dialect) { case POSTGRES: sql="select obj_description(to_regclass(?), 'pg_class')"; arg=tableName; break; case MYSQL: sql="select table_comment from information_schema.tables where table_schema=database() and table_name=?"; arg=tableName; break; case ORACLE: sql="select comments from user_tab_comments where table_name=?"; arg=tableName.toUpperCase(); break; case MICROSOFT: sql="select cast(value as nvarchar(4000)) from sys.extended_properties where class=1 and major_id=object_id(?) and minor_id=0 and name='MS_Description'"; arg=tableName; break; default: // a dialect this switch was never told about must not inherit another one's catalog throw new IllegalStateException("no table comment readback for dialect "+dialect); } try (final PreparedStatement statement=con.prepareStatement(sql)) { statement.setString(1,arg); try (final ResultSet rs=executeResultSet(statement)) { return rs.next() ? rs.getString(1) : null; } } } // Statistics upkeep after an import is bounded and can be turned off: gathering statistics of // a freshly loaded table is a full scan on oracle (dbms_stats defaults to AUTO_SAMPLE_SIZE, // and the entries themselves live in the blob column it reads), which a multi-million entry // backend would otherwise pay in full, with no way to cap or skip it, after import-ldif has // already reported its final status. static final String STATISTICS_PROPERTY="org.openidentityplatform.opendj.jdbc.statistics"; static final String STATISTICS_TIMEOUT_PROPERTY=STATISTICS_PROPERTY+".timeout"; private static final int STATISTICS_TIMEOUT_SECONDS_DEFAULT=600; // A bulk load leaves the optimizer statistics of freshly created tables stale (a table that // was never analyzed can make the planner badly misestimate the "where k>? order by k" cursor // batches - see OpenIdentityPlatform/OpenDJ#859), so refresh them once the data is in place. // Only the trees the import actually wrote are refreshed: rebuild-index imports a few index // trees, and gathering statistics of the whole backend on its behalf is a full scan per // table on oracle. Statistics upkeep is best-effort: a failure must not fail the import that // produced the data, so failures are only logged - the return value makes them observable to tests. boolean updateTableStatistics(Connection con, Collection<TreeName> trees) { if (!Boolean.parseBoolean(System.getProperty(STATISTICS_PROPERTY,"true"))) { logger.debug(LocalizableMessage.raw("jdbc: statistics refresh turned off by %s", STATISTICS_PROPERTY)); return false; // nothing was refreshed } final Dialect dialect=dialectOf(con); if (dialect==null) { // no portable statistics refresh for other engines return false; // nothing was refreshed: reporting success here would make the assertion of the tests vacuous } final int timeoutSeconds=Math.max(0,Integer.getInteger(STATISTICS_TIMEOUT_PROPERTY,STATISTICS_TIMEOUT_SECONDS_DEFAULT)); boolean allRefreshed=true; for (final TreeName treeName : trees) { final String tableName=getTableName(treeName); // The statement is chosen inside the try, so that the guard of the default branch // degrades to "this table was not refreshed" like every other failure here: the // contract above is that a refresh which failed never fails the import that produced // the data, and a throw escaping this loop would break it. try { final String sql; final String[] args; switch (dialect) { case POSTGRES: sql="analyze "+tableName; args=NO_ARGS; break; case MYSQL: sql="analyze table "+tableName; args=NO_ARGS; break; case ORACLE: sql="begin dbms_stats.gather_table_stats(user, ?); end;"; args=new String[]{tableName.toUpperCase()}; break; case MICROSOFT: sql="update statistics "+tableName; args=NO_ARGS; break; default: // a dialect this switch was never told about must not inherit another one's statement throw new IllegalStateException("no statistics refresh for dialect "+dialect); } try (final PreparedStatement statement=con.prepareStatement(sql)) { statement.setQueryTimeout(timeoutSeconds); // 0: wait without limit for (int i=0;i<args.length;i++) { statement.setString(i+1,args[i]); } if (dialect==Dialect.MYSQL) { // mysql reports analyze problems as a result row, not an SQLException try (final ResultSet rs=executeResultSet(statement)) { while (rs.next()) { if ("error".equalsIgnoreCase(rs.getString("Msg_type"))) { throw new SQLException(rs.getString("Msg_text")); } } } }else { executeAny(statement); } con.commit(); } }catch (Exception e) { try { con.rollback(); } catch (SQLException e2) {} allRefreshed=false; logger.warn(LocalizableMessage.raw("jdbc: unable to refresh statistics of table %s (tree %s): %s", tableName, treeName, stackTraceToSingleLineString(e))); } } return allRefreshed; } @Override public void removeStorageFiles() throws StorageRuntimeException { final boolean isOpen=getStorageStatus().isWorking(); @@ -180,6 +755,11 @@ } catch (Exception e) { throw new StorageRuntimeException(e); } // all tables are gone: forget the mappings so listTrees() consumers skip the dropped trees for (final TreeName treeName : trees) { tree2table.invalidate(treeName); unstampableTrees.remove(treeName); // a table recreated later deserves a fresh stamp attempt } } if (!isOpen) { close(); @@ -197,14 +777,17 @@ @Override public void write(WriteOperation writeOperation) throws Exception { try (final Connection con=getConnection()) { final WriteableTransactionTransactionImpl txn=new WriteableTransactionTransactionImpl(con); try { writeOperation.run(new WriteableTransactionTransactionImpl(con)); writeOperation.run(txn); con.commit(); } catch (Exception e) { try { con.rollback(); } catch (SQLException ex) {} throw e; } finally { // the comment connection lives no longer than the trees it stamped txn.stampSession.close(); } } } @@ -274,6 +857,11 @@ } private final class WriteableTransactionTransactionImpl extends ReadableTransactionImpl implements WriteableTransaction { // Shared by every table this transaction stamps: opening a backend opens all its trees, // and each stamp of its own connection would be a physical connect of its own. Closed by // write() (and by ImporterImpl.close()) when the transaction is done with. final StampSession stampSession=new StampSession(); public WriteableTransactionTransactionImpl(Connection con) { super(con); if (!accessMode.isWriteable()) { @@ -363,6 +951,9 @@ } } // mssql: k is varbinary(max), which cannot be an index key column - cursor batches stay unindexed there // the dialect is taken off this transaction's own connection: finding it out must // not cost a borrow from a pool this thread is already holding a connection of commentTable(treeName, dialectOf(con), stampSession); } } @@ -397,6 +988,9 @@ throw new StorageRuntimeException(e); } } // forget the mapping so listTrees() consumers (updateTableStatistics) skip the dropped table tree2table.invalidate(treeName); unstampableTrees.remove(treeName); // a table recreated later deserves a fresh stamp attempt } @Override @@ -698,9 +1292,22 @@ final Connection con; final ReadableTransactionImpl txr; final WriteableTransactionTransactionImpl txw; // The trees this import wrote: close() refreshes the statistics of these and only these, // so rebuilding a single index does not gather statistics for the whole backend. A full // import legitimately covers every tree - AbstractTwoPhaseImportStrategy.beforePhaseOne // clears them all before the first record is written - including when the import is // aborted, since close() runs from the try-with-resources of OnDiskMergeImporter. final Set<TreeName> writtenTrees = ConcurrentHashMap.newKeySet(); // Set when the import failed or was cancelled. Its trees hold whatever the import got // through before it stopped - beforePhaseOne cleared them all, so that can be nothing at // all - and the operator is going to run it again, so there is nothing worth describing // to the optimizer here: on oracle gathering those statistics is a full scan per table // that would delay the report of a failure, or of a cancellation, by all of its duration. volatile boolean aborted = false; final Boolean isOpen; public ImporterImpl() { isOpen=getStorageStatus().isWorking(); if (!isOpen) { @@ -720,26 +1327,46 @@ } @Override public void aborted() { aborted = true; } @Override public void close() { try { con.commit(); con.close(); try { con.commit(); if (aborted) { logger.debug(LocalizableMessage.raw("jdbc: import aborted: statistics of the trees it wrote are left alone")); }else { updateTableStatistics(con, writtenTrees); } } finally { // the pooled connection must be returned even when the commit or a statistics statement throws try { con.close(); } finally { txw.stampSession.close(); } } } catch (SQLException e) { throw new StorageRuntimeException(e); } if (!isOpen) { JDBCStorage.this.close(); } finally { if (!isOpen) { JDBCStorage.this.close(); } } } @Override public void clearTree(TreeName name) { txw.clearTree(name); writtenTrees.add(name); } @Override public void put(TreeName treeName, ByteSequence key, ByteSequence value) { txw.put(treeName, key, value); writtenTrees.add(treeName); } @Override opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java
@@ -1195,6 +1195,33 @@ private void doImport(final Source source) throws InterruptedException, ExecutionException { try { importAllEntries(source); } catch (Throwable t) { // Cancellation lands here as well (see the InterruptedException below). The trees hold an // incomplete import: the storage must not treat what is in them as the final data. Errors // are caught too - an import killed by an OutOfMemoryError leaves the trees just as partial // as one killed by an exception. try { importStrategy.aborted(); } catch (Throwable notified) { // What went wrong here matters less than what brought the import down: the report of the // failure is the point of this block. Concrete for the motivating case, an import killed // by an OutOfMemoryError, where notifying the storage allocates. t.addSuppressed(notified); } throw t; } } private void importAllEntries(final Source source) throws InterruptedException, ExecutionException { final long phaseOneStartTime = System.currentTimeMillis(); final PhaseOneWriteableTransaction transaction = new PhaseOneWriteableTransaction(importStrategy); importedCount.set(0); @@ -1327,6 +1354,12 @@ closeSilently(bufferPool); } /** Tells the storage that the import stopped before it was through, so that its data is not final. */ void aborted() { importer.aborted(); } abstract Callable<Void> newPhaseTwoTask(TreeName treeName, Chunk source, PhaseTwoProgressReporter progressReporter); void afterPhaseTwo(EntryContainer entryContainer) opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java
@@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2015 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.pluggable; @@ -227,6 +228,14 @@ } @Override public void aborted() { traceEnter("aborted"); importer.aborted(); traceLeave("aborted"); } @Override public void close() { traceEnter("close"); opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/Importer.java
@@ -12,6 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.pluggable.spi; @@ -74,6 +75,17 @@ */ SequentialCursor<ByteString, ByteString> openCursor(TreeName treeName); /** * Notifies this importer that the import failed or was cancelled, before {@link #close()} runs: what the trees hold * is an incomplete import that is going to be run again. Implementations doing work of their own in {@link #close()} * on the assumption that the data is final - refreshing the optimizer statistics of a database, for instance - can * skip it. The default implementation does nothing. */ default void aborted() { // nothing by default: an importer that treats a partial import like a complete one is not wrong, only wasteful } @Override void close(); } opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/StampConnectionTestCase.java
New file @@ -0,0 +1,352 @@ /* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.jdbc; import org.forgerock.opendj.server.config.server.JDBCBackendCfg; import org.opends.server.DirectoryServerTestCase; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import java.io.IOException; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLNonTransientConnectionException; import java.sql.SQLTimeoutException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotSame; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; /** * The bounds of the connection the table comment statements run on, and the classification of a * stamp that failed. None of it needs a database: the first two cases are about what the backend * hands its driver and what the driver then does with a server that never answers, the last is a * pure function of an exception. Keeping them out of the container suites is the point - those * skip themselves whole when no docker is reachable, and a bound nothing exercises is a bound * that can be deleted without a single test going red. */ @SuppressWarnings("javadoc") public class StampConnectionTestCase extends DirectoryServerTestCase { /** * A ceiling generous enough that a loaded machine cannot cross it, and far below what an * unbounded driver does: a login left unbounded against a silent server does not come back at * all - it sits in the read of the prelogin answer until something else tears the socket down. */ private static final long GIVE_UP_CEILING_SECONDS = 120; private ProbeDriver probeDriver; @BeforeClass public void registerProbeDriver() throws SQLException { probeDriver = new ProbeDriver(); DriverManager.registerDriver(probeDriver); } @AfterClass(alwaysRun = true) public void deregisterProbeDriver() throws SQLException { if (probeDriver != null) { DriverManager.deregisterDriver(probeDriver); } } private static JDBCStorage storageFor(String url) { final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); when(cfg.getBackendId()).thenReturn("stampProbe"); when(cfg.getDBDirectory()).thenReturn(url); return new JDBCStorage(cfg, null); } /** * Every dialect must bound both phases of a login attempt: the socket connect, and the reads * behind it - of the prelogin handshake, of tls, of authentication. Not one of the four * drivers covers both with a single property, the sql server one included: its loginTimeout * bounds the connect and leaves the prelogin read open. */ @Test public void testEveryDialectDeclaresBothBounds() { for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) { assertTrue(dialect.connectProperties.size() >= 2, "the login of " + dialect + " is not bounded in both phases: " + dialect.connectProperties); for (final String name : dialect.connectProperties.stringPropertyNames()) { assertTrue(Integer.parseInt(dialect.connectProperties.getProperty(name)) > 0, name + " of " + dialect + " bounds nothing: " + dialect.connectProperties.getProperty(name)); } } } /** * The declaration above is worth nothing unless it reaches the driver, and nothing else in the * suite notices if it stops doing so: dropping the properties from the connect call leaves * every stamp unbounded again, which no round-trip test can see against a database that * answers. */ @Test public void testStampConnectionHandsItsBoundsToTheDriver() throws Exception { final JDBCStorage storage = storageFor(ProbeDriver.URL); for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) { probeDriver.lastProperties = null; storage.newStampConnection(dialect).close(); final Properties handed = probeDriver.lastProperties; assertNotNull(handed, "no properties were handed to the driver for " + dialect); for (final Map.Entry<Object, Object> bound : dialect.connectProperties.entrySet()) { assertEquals(handed.getProperty((String) bound.getKey()), bound.getValue(), bound.getKey() + " of " + dialect + " did not reach the driver"); } // a driver is free to write into the map it is passed: the declaration must not be it assertNotSame(handed, dialect.connectProperties, "the driver was handed the declaration of " + dialect + " rather than a copy of it"); } } /** * What the bounds are for: a database that keeps its established connections alive but accepts * no new ones - a moved vip, a proxy at its connection limit - usually completes the tcp * connect and then goes quiet, which leaves the driver in a read. Unbounded, that hangs the * open of a tree; dsconfig create-backend-index opens one on a running server. * <p> * The four dialects are attempted at once, so the suite pays the bound of the slowest of them * rather than the sum of all four. */ @Test public void testEveryDriverGivesUpOnASilentServer() throws Exception { try (final SilentServer silent = new SilentServer()) { final List<Callable<String>> attempts = new ArrayList<>(); for (final JDBCStorage.Dialect dialect : JDBCStorage.Dialect.values()) { final JDBCStorage storage = storageFor(silent.urlFor(dialect)); attempts.add(new Callable<String>() { @Override public String call() { final long startedAt = System.nanoTime(); try (final Connection con = storage.newStampConnection(dialect)) { return dialect + " connected to a server that never answered"; } catch (Exception expected) { // the failure itself is the point: which one it is belongs to the driver } final long tookSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt); return tookSeconds <= GIVE_UP_CEILING_SECONDS ? null : dialect + " took " + tookSeconds + "s to give up on a silent server"; } }); } final ExecutorService attempted = Executors.newFixedThreadPool(attempts.size()); try { final StringBuilder failures = new StringBuilder(); for (final Future<String> attempt : attempted.invokeAll(attempts, GIVE_UP_CEILING_SECONDS * 2, TimeUnit.SECONDS)) { final String failure = attempt.isCancelled() // the driver never came back at all ? "a driver did not give up on a silent server within " + (GIVE_UP_CEILING_SECONDS * 2) + "s" : attempt.get(); if (failure != null) { failures.append(failure).append('\n'); } } if (failures.length() > 0) { fail(failures.toString()); } } finally { attempted.shutdownNow(); } } } /** * A stamp that the database rejected is remembered, so that an account which may not comment * its tables does not reissue the statement for every tree on every open. A stamp that lost * its connection ends the sweep, since every tree behind it needs that same connection. A * stamp that gave up on a lock is neither: the lock belongs to that one table. */ @Test public void testFailureScopeTellsTheThreeApart() { assertEquals(JDBCStorage.failureScope( new SQLException("permission denied for table", "42501"), JDBCStorage.Dialect.POSTGRES), JDBCStorage.FailureScope.TREE, "a rejected statement was not remembered"); assertEquals(JDBCStorage.failureScope( new SQLException("lock not available", "55P03"), JDBCStorage.Dialect.POSTGRES), JDBCStorage.FailureScope.MOMENT, "a lock timeout was not read as one of the moment"); assertEquals(JDBCStorage.failureScope( new SQLException("connection closed", "08006"), JDBCStorage.Dialect.POSTGRES), JDBCStorage.FailureScope.SESSION, "a connection exception did not end the sweep"); assertEquals(JDBCStorage.failureScope( new SQLNonTransientConnectionException("socket closed"), JDBCStorage.Dialect.MICROSOFT), JDBCStorage.FailureScope.SESSION, "a connection exception of the driver did not end the sweep"); assertEquals(JDBCStorage.failureScope( new SQLTimeoutException("query timed out"), JDBCStorage.Dialect.MYSQL), JDBCStorage.FailureScope.MOMENT, "a timeout was not read as a failure of the moment"); } /** * 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. Reading only the cause chain classifies * a lock timeout as a rejection, which leaves the tree unstamped until the next start over a * moment of contention. */ @Test public void testFailureScopeWalksBothChains() { final SQLException reportedAsTheCause = new SQLException("statement failed", new SQLException("lock wait timeout exceeded", "HY000", 1205)); assertEquals(JDBCStorage.failureScope(reportedAsTheCause, JDBCStorage.Dialect.MYSQL), JDBCStorage.FailureScope.MOMENT, "a lock timeout on the cause chain was missed"); final SQLException reportedAsTheNext = new SQLException("statement failed"); reportedAsTheNext.setNextException(new SQLException("lock wait timeout exceeded", "HY000", 1205)); assertEquals(JDBCStorage.failureScope(reportedAsTheNext, JDBCStorage.Dialect.MYSQL), JDBCStorage.FailureScope.MOMENT, "a lock timeout on the next-exception chain was missed"); // a connection exception anywhere in either chain outweighs the rest: the session is gone final SQLException connectionGone = new SQLException("statement failed"); connectionGone.setNextException(new SQLException("communications link failure", "08S01")); assertEquals(JDBCStorage.failureScope(connectionGone, JDBCStorage.Dialect.MYSQL), JDBCStorage.FailureScope.SESSION, "a connection exception on the next-exception chain was missed"); // a driver that chains an exception back to itself must not make the walk loop final SQLException selfReferring = new SQLException("statement failed"); selfReferring.setNextException(selfReferring); assertEquals(JDBCStorage.failureScope(selfReferring, JDBCStorage.Dialect.MYSQL), JDBCStorage.FailureScope.TREE, "a self-referring chain was not walked to an end"); } /** Accepts connections and answers nothing at all, the shape of a proxy at its connection limit. */ private static final class SilentServer implements AutoCloseable { private final ServerSocket listening; private final List<Socket> accepted = new ArrayList<>(); private final Thread acceptor; SilentServer() throws IOException { listening = new ServerSocket(0, 16, InetAddress.getLoopbackAddress()); acceptor = new Thread(new Runnable() { @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { final Socket socket = listening.accept(); synchronized (accepted) { // held open, and never written to accepted.add(socket); } } catch (IOException closed) { return; } } } }, "silent-server"); acceptor.setDaemon(true); acceptor.start(); } String urlFor(JDBCStorage.Dialect dialect) { final String host = listening.getInetAddress().getHostAddress(); final int port = listening.getLocalPort(); switch (dialect) { case POSTGRES: return "jdbc:postgresql://" + host + ":" + port + "/probe?user=probe&password=probe"; case MYSQL: return "jdbc:mysql://" + host + ":" + port + "/probe?user=probe&password=probe"; case ORACLE: return "jdbc:oracle:thin:probe/probe@//" + host + ":" + port + "/probe"; case MICROSOFT: return "jdbc:sqlserver://" + host + ":" + port + ";databaseName=probe;user=probe;password=probe"; default: throw new IllegalStateException("no probe url for dialect " + dialect); } } @Override public void close() throws IOException { acceptor.interrupt(); listening.close(); synchronized (accepted) { for (final Socket socket : accepted) { try { socket.close(); } catch (IOException ignored) { } } } } } /** Records the properties a stamp connection hands its driver, and connects to nothing. */ private static final class ProbeDriver implements Driver { static final String URL = "jdbc:stampprobe:"; volatile Properties lastProperties; @Override public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) { return null; // not ours: DriverManager goes on to the next driver } lastProperties = info; final Connection con = mock(Connection.class); when(con.createStatement()).thenReturn(mock(Statement.class)); return con; } @Override public boolean acceptsURL(String url) { return url != null && url.startsWith(URL); } @Override public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; } @Override public int getMajorVersion() { return 1; } @Override public int getMinorVersion() { return 0; } @Override public boolean jdbcCompliant() { return false; } @Override public java.util.logging.Logger getParentLogger() throws SQLFeatureNotSupportedException { throw new SQLFeatureNotSupportedException(); } } } opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -21,6 +21,7 @@ import org.opends.server.backends.pluggable.PluggableBackendImplTestCase; import org.opends.server.backends.pluggable.spi.AccessMode; import org.opends.server.backends.pluggable.spi.Cursor; import org.opends.server.backends.pluggable.spi.Importer; import org.opends.server.backends.pluggable.spi.ReadOperation; import org.opends.server.backends.pluggable.spi.ReadableTransaction; import org.opends.server.backends.pluggable.spi.TreeName; @@ -35,17 +36,22 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.atomic.AtomicInteger; import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotEquals; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; @@ -320,6 +326,816 @@ } } /** * Each table must be stamped with the tree name it stores: table names are opaque SHA-224 * hashes, so without the comment there is no way to tell the trees apart on the database * side (#859). The single quote in the base DN exercises the comment escaping. */ @Test public void testTreeNameStoredAsTableComment() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); // a quote and a backslash in the tree name exercise the literal escaping (backslash is an escape character in mysql) final TreeName tree = new TreeName("o=comment'te\\st", "dn2id"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); assertEquals(readTableComment(storage.getTableName(tree)), tree.toString()); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } String readTableComment(String tableName) throws Exception { final String url = getJdbcUrl(); final String sql; if (url.startsWith("jdbc:postgresql")) { sql = "select obj_description('" + tableName + "'::regclass, 'pg_class')"; } else if (url.startsWith("jdbc:mysql")) { sql = "select table_comment from information_schema.tables where table_schema=database() and table_name='" + tableName + "'"; } else if (url.startsWith("jdbc:oracle")) { sql = "select comments from user_tab_comments where table_name='" + tableName.toUpperCase() + "'"; } else if (url.startsWith("jdbc:sqlserver")) { // class=1 is the table itself: major_id is only unique within a class sql = "select cast(value as nvarchar(4000)) from sys.extended_properties where class=1 and major_id=object_id('" + tableName + "') and minor_id=0 and name='MS_Description'"; } else { throw new SkipException("no table comment query for " + url); } try (final Connection con = DriverManager.getConnection(url); final Statement st = con.createStatement(); final ResultSet rs = st.executeQuery(sql)) { return rs.next() ? rs.getString(1) : null; } } void writeTableComment(String tableName, String comment) throws Exception { final String url = getJdbcUrl(); final String sql; if (url.startsWith("jdbc:postgresql") || url.startsWith("jdbc:oracle")) { sql = "comment on table " + tableName + " is '" + comment + "'"; } else if (url.startsWith("jdbc:mysql")) { sql = "alter table " + tableName + " comment '" + comment + "'"; } else if (url.startsWith("jdbc:sqlserver")) { // exec arguments must be constants or variables: schema_name() cannot be passed inline sql = "declare @s sysname = schema_name()" + " exec sys.sp_updateextendedproperty N'MS_Description', N'" + comment + "', N'SCHEMA', @s, N'TABLE', N'" + tableName + "'"; } else { throw new SkipException("no table comment statement for " + url); } try (final Connection con = DriverManager.getConnection(url); final Statement st = con.createStatement()) { st.execute(sql); } } /** * Removes the stored comment, so that the next stamp has to create one rather than replace * it: on sql server that is sp_addextendedproperty, which is the statement reported to wait * for an uncommitted row of another session. */ void clearTableComment(String tableName) throws Exception { final String url = getJdbcUrl(); if (!url.startsWith("jdbc:sqlserver")) { writeTableComment(tableName, "stale"); // the other engines have one statement for both cases return; } try (final Connection con = DriverManager.getConnection(url); final Statement st = con.createStatement()) { st.execute("declare @s sysname = schema_name()" + " exec sys.sp_dropextendedproperty N'MS_Description', N'SCHEMA', @s, N'TABLE', N'" + tableName + "'"); } } /** The dialect of the database this suite runs against, as the backend detects it from the driver. */ JDBCStorage.Dialect dialect() { final String url = getJdbcUrl(); if (url.startsWith("jdbc:postgresql")) { return JDBCStorage.Dialect.POSTGRES; } else if (url.startsWith("jdbc:mysql")) { return JDBCStorage.Dialect.MYSQL; } else if (url.startsWith("jdbc:oracle")) { return JDBCStorage.Dialect.ORACLE; } else if (url.startsWith("jdbc:sqlserver")) { return JDBCStorage.Dialect.MICROSOFT; } throw new SkipException("no dialect for " + url); } /** * What this connection reports as its lock bound, in the unit and the rendering of its own * engine, or null where reading it needs a privilege the test user does not have: oracle * keeps ddl_lock_timeout in v$parameter, which an application user cannot select from. */ String sessionLockBound(Connection con) throws Exception { final String url = getJdbcUrl(); final String sql; if (url.startsWith("jdbc:postgresql")) { sql = "show lock_timeout"; } else if (url.startsWith("jdbc:mysql")) { sql = "select @@session.lock_wait_timeout"; } else if (url.startsWith("jdbc:sqlserver")) { sql = "select @@lock_timeout"; } else { return null; } try (final Statement st = con.createStatement(); final ResultSet rs = st.executeQuery(sql)) { return rs.next() ? rs.getString(1) : null; } } /** * Comment statements are DDL (a metadata lock on mysql, a ddl lock on oracle), so a table * whose stored comment already matches its tree name must not be re-stamped on subsequent * opens - while a stale comment must be refreshed. */ @Test public void testCommentStampSkippedWhenAlreadyStored() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); final TreeName tree = new TreeName("o=commentSkip", "dn2id"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); // stamps the freshly created table } }); assertEquals(readTableComment(storage.getTableName(tree)), tree.toString()); // UP_TO_DATE and not FAILED: the statement was skipped, not rejected assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.UP_TO_DATE, "an up-to-date comment was re-stamped"); writeTableComment(storage.getTableName(tree), "stale"); assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.STAMPED, "a stale comment was not re-stamped"); assertEquals(readTableComment(storage.getTableName(tree)), tree.toString()); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** * A stamp must never queue behind another session's transaction. Comment statements take a * lock (a metadata lock on mysql, a schema modification lock on sql server) and both engines * wait for it without limit by default - lock_wait_timeout is a year, lock_timeout is * infinite - so an unbounded stamp could hang the backend open and, on mysql, park every * other query on that table behind itself. Whether an uncommitted row of another session * conflicts with the statement at all differs between engines and versions, so the assertion * is on the timing: the call comes back rather than waiting for that transaction to end. */ @Test(timeOut = 180000) public void testCommentStampGivesUpOnLock() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); final TreeName tree = new TreeName("o=commentLock", "dn2id"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); final String tableName = storage.getTableName(tree); clearTableComment(tableName); // no comment stored: the next attempt must issue a statement try (final Connection blocker = DriverManager.getConnection(getJdbcUrl())) { blocker.setAutoCommit(false); try (final PreparedStatement st = blocker.prepareStatement("insert into " + tableName + " (h,k) values (?,?)")) { st.setString(1, String.format("%1$-128s", "blocker").replace(' ', 'x')); st.setBytes(2, new byte[]{1}); st.executeUpdate(); } // the row is left uncommitted, so the lock it holds is still there final long start = System.currentTimeMillis(); final JDBCStorage.CommentResult result = storage.commentTable(tree, dialect()); final long elapsedMs = System.currentTimeMillis() - start; blocker.rollback(); // giving up and stamping anyway are both fine here - the engines differ in whether an // uncommitted row of another session conflicts with the comment statement at all. // Waiting for that session to finish is what must never happen. // the bound is 5 s (COMMENT_LOCK_TIMEOUT_SECONDS): the slack is for the connect and the // statement around it, not for a regression of the bound itself assertTrue(elapsedMs < 20000, "the comment statement waited " + elapsedMs + " ms for a lock, result " + result); if (result == JDBCStorage.CommentResult.STAMPED) { // it reported success: the comment must be there assertEquals(readTableComment(tableName), tree.toString()); } } } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** * A failing comment stamp must never disturb the transaction that opened the tree: it used * to roll back the caller's connection, silently discarding writes pending in the same * transaction (the way DefaultIndex.afterOpen() writes the trusted flag between openTree() calls). * The write pending during the failing stamp deliberately targets another tree: a statement * left pending on the very table being stamped - a write, or on mysql any statement, since a * transaction holds a shared metadata lock on every table it touched - would make the comment * statement wait for the caller's own lock, which is a shape no production path has. */ @Test public void testCommentFailureLeavesTransactionIntact() throws Exception { final TreeName stamped = new TreeName("o=commentFailure", "dn2id"); final TreeName written = new TreeName("o=commentFailure", "id2entry"); final JDBCStorage setUp = new JDBCStorage(createBackendCfg(), null); try { // create both tables up front, with a storage that stamps them normally setUp.open(AccessMode.READ_WRITE); setUp.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(stamped, true); txn.openTree(written, true); } }); } finally { setUp.close(); } final AtomicInteger stampAttempts = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override Connection newStampConnection(Dialect dialect) throws SQLException { stampAttempts.incrementAndGet(); throw new SQLException("injected comment failure"); // no sql state, no vendor code: a rejection, not a failure of the moment } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.put(written, key(1), value(1)); // pending in this transaction... txn.openTree(stamped, true); // ...while the comment machinery fails } }); storage.read(new ReadOperation<Void>() { @Override public Void run(ReadableTransaction txn) throws Exception { assertEquals(txn.read(written, key(1)), value(1), "failing comment stamp discarded a pending write"); return null; } }); // the failure is remembered: an unstampable table is not asked again while this backend is open assertEquals(storage.commentTable(stamped, dialect()), JDBCStorage.CommentResult.FAILED); assertEquals(stampAttempts.get(), 1, "a failed stamp was reissued"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(stamped); txn.deleteTree(written); } }); } catch (Exception ignored) {} storage.close(); } } /** * A stamp that failed for a reason of the moment - the lock timeout the statement is given, * a connection that broke - must be attempted again: only a failure saying that this table * cannot be commented at all is remembered, or one contended moment would leave a backend * unstamped until it is restarted. */ @Test public void testTransientStampFailureIsRetried() throws Exception { final TreeName tree = new TreeName("o=transientStamp", "dn2id"); final AtomicInteger stampAttempts = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override Connection newStampConnection(Dialect dialect) throws SQLException { stampAttempts.incrementAndGet(); throw new SQLException("injected connection failure", "08006"); // connection exception: a failure of the moment } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); // stamp #1, fails } }); assertEquals(storage.commentTable(tree, dialect()), JDBCStorage.CommentResult.FAILED); assertEquals(stampAttempts.get(), 2, "a stamp that failed for a reason of the moment was not attempted again"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** * Opening a backend opens every tree it holds - about 25 for a stock suffix - and the first * open after an upgrade stamps them all: the trees of one open must share one connection * rather than make a physical connect each. One per open is what the comment machinery costs, * readback included - the readback runs on that same connection, because the thread doing the * open is inside a transaction and holding a pooled connection already. */ @Test public void testCommentStampsShareOneConnection() throws Exception { final TreeName[] trees = { new TreeName("o=commentSweep", "dn2id"), new TreeName("o=commentSweep", "id2entry"), new TreeName("o=commentSweep", "state") }; final AtomicInteger connects = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override Connection newStampConnection(Dialect dialect) throws SQLException { connects.incrementAndGet(); return super.newStampConnection(dialect); } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); // freshly created: every one of them is stamped } } }); for (final TreeName tree : trees) { assertEquals(readTableComment(storage.getTableName(tree)), tree.toString()); } assertEquals(connects.get(), 1, "the stamps of one open did not share a connection"); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); } } }); // three trees, one more connect: the open that finds every comment in place issues no // statement and takes no lock, and pays one connection for the whole sweep either way assertEquals(connects.get(), 2, "the trees of an open that found every comment in place did not share a connection"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.deleteTree(tree); } } }); } catch (Exception ignored) {} storage.close(); } } /** * The bound a stamp connection is given must survive a stamp that failed. Postgres undoes a * plain SET when the transaction that ran it is rolled back, and a failed stamp is rolled * back with the connection kept and reused - one connection serves every tree of a backend * open - so every tree stamped after the first failure used to run with no bound at all, * which is what the bound exists to prevent. */ @Test public void testLockBoundSurvivesAFailedStamp() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); // no table was ever created for this tree, so its comment statement fails - on a connection // that stays usable, which is the case the session rolls back rather than replaces final TreeName missing = new TreeName("o=lockBound", "neverCreated"); try { storage.open(AccessMode.READ_WRITE); final JDBCStorage.Dialect dialect = dialect(); final String bound; try (final Connection fresh = storage.newStampConnection(dialect)) { bound = sessionLockBound(fresh); // what a connection carrying the bound reports } try (final JDBCStorage.StampSession session = storage.new StampSession()) { assertEquals(storage.commentTable(missing, dialect, session), JDBCStorage.CommentResult.FAILED, "stamping a table that does not exist was reported as done"); if (bound != null) { // oracle: ddl_lock_timeout is only in v$parameter, which the test user cannot read assertEquals(sessionLockBound(session.connection(dialect)), bound, "the lock bound was lost when the failed stamp was rolled back"); } } } finally { storage.close(); } } /** * A stamp that lost its connection ends the sweep it happened in: every tree behind it needs * that same connection, so each would pay the same connect attempt again. That is about 25 of * them for a stock suffix, all for a diagnostic aid. Nothing is remembered, so the next open * tries again. */ @Test public void testConnectionFailureEndsTheSweep() throws Exception { final TreeName[] trees = { new TreeName("o=sweepGiveUp", "dn2id"), new TreeName("o=sweepGiveUp", "id2entry"), new TreeName("o=sweepGiveUp", "state") }; final AtomicInteger stampAttempts = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override Connection newStampConnection(Dialect dialect) throws SQLException { stampAttempts.incrementAndGet(); throw new SQLException("injected connection failure", "08006"); // connection exception: the session is gone } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); } } }); assertEquals(stampAttempts.get(), 1, "a connection that was gone was paid once per tree of the same open"); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); } } }); assertEquals(stampAttempts.get(), 2, "the open after a lost connection did not try again"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.deleteTree(tree); } } }); } catch (Exception ignored) {} storage.close(); } } /** * A lock belongs to the table it is held on, so a stamp that gave up on one must not cost the * trees behind it their comments: the trees of an open are stamped in a fixed order, and a * table left permanently contended by another session would otherwise mean nothing is ever * stamped, on any open. Nothing is remembered either - the open that follows stamps the table * whose moment has passed. * <p> * The failure is injected at the readback rather than at the comment statement, which is built * inline; what is under test is the classification of the failure and what the sweep does with * it, and those do not depend on which of the two statements produced it. */ @Test public void testContendedTableDoesNotEndTheSweep() throws Exception { final TreeName[] trees = { new TreeName("o=sweepContended", "dn2id"), new TreeName("o=sweepContended", "id2entry"), new TreeName("o=sweepContended", "state") }; final AtomicInteger contended = new AtomicInteger(1); // the first tree, for one sweep only final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override String readStoredComment(Connection con, Dialect dialect, String tableName) throws SQLException { if (tableName.equals(getTableName(trees[0])) && contended.getAndDecrement() > 0) { throw lockTimeoutOf(dialect); // as if another session held this one table } return super.readStoredComment(con, dialect, tableName); } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); } } }); assertNotEquals(readTableComment(storage.getTableName(trees[0])), trees[0].toString(), "the contended table was stamped anyway"); for (int i = 1; i < trees.length; i++) { assertEquals(readTableComment(storage.getTableName(trees[i])), trees[i].toString(), "one contended table cost the trees behind it their comments"); } storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); } } }); assertEquals(readTableComment(storage.getTableName(trees[0])), trees[0].toString(), "a table left unstamped by a contended moment was not stamped by the open that followed"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.deleteTree(tree); } } }); } catch (Exception ignored) {} storage.close(); } } /** The failure a dialect reports when a statement gave up on the lock bound it was given. */ static SQLException lockTimeoutOf(JDBCStorage.Dialect dialect) { switch (dialect) { case POSTGRES: return new SQLException("canceling statement due to lock timeout", "55P03"); case MYSQL: return new SQLException("Lock wait timeout exceeded; try restarting transaction", "HY000", 1205); case ORACLE: return new SQLException("ORA-00054: resource busy and acquire with NOWAIT specified", "61000", 54); case MICROSOFT: return new SQLException("Lock request time out period exceeded", "HY000", 1222); default: throw new IllegalStateException("no lock timeout failure for dialect " + dialect); } } /** * @@sql_mode decides whether a backslash escapes inside the comment literal. It belongs to * the session, and the stamps of one open share a connection, so it is asked once for the * whole sweep rather than once per tree - and only on mysql, the one engine whose literal * depends on it. */ @Test public void testSqlModeProbedOncePerSweep() throws Exception { final TreeName[] trees = { new TreeName("o=sqlModeProbe", "dn2id"), new TreeName("o=sqlModeProbe", "id2entry"), new TreeName("o=sqlModeProbe", "state") }; final AtomicInteger probes = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override boolean isMysqlBackslashEscape(Connection con) throws SQLException { probes.incrementAndGet(); return super.isMysqlBackslashEscape(con); } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.openTree(tree, true); // freshly created: every one of them is stamped } } }); for (final TreeName tree : trees) { assertEquals(readTableComment(storage.getTableName(tree)), tree.toString()); } assertEquals(probes.get(), dialect() == JDBCStorage.Dialect.MYSQL ? 1 : 0, "the sql mode of one sweep was not asked exactly once"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName tree : trees) { txn.deleteTree(tree); } } }); } catch (Exception ignored) {} storage.close(); } } // The bounds of a stamp connection are covered by StampConnectionTestCase: what they are worth // is whether they reach the driver and whether the driver then gives up on a server that never // answers, and neither needs - nor can be staged by - a database container. /** * An import that failed or was cancelled leaves trees holding an incomplete import that is * going to be run again: refreshing statistics of it describes data nobody will query, and on * oracle it is a full scan per table between the failure and its report. */ @Test public void testAbortedImportSkipsStatistics() throws Exception { final TreeName tree = new TreeName("o=abortedImport", "dn2id"); final AtomicInteger refreshes = new AtomicInteger(); final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null) { @Override boolean updateTableStatistics(Connection con, Collection<TreeName> trees) { refreshes.incrementAndGet(); return super.updateTableStatistics(con, trees); } }; try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); try (final Importer importer = storage.startImport()) { importer.put(tree, key(1), value(1)); importer.aborted(); // what OnDiskMergeImporter reports when the import throws or is cancelled } assertEquals(refreshes.get(), 0, "statistics were refreshed for an import that was aborted"); try (final Importer importer = storage.startImport()) { importer.put(tree, key(2), value(2)); } assertEquals(refreshes.get(), 1, "statistics were not refreshed for an import that finished"); } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** * The statistics refresh must be possible to turn off: on oracle it gathers with * AUTO_SAMPLE_SIZE, a full scan of every table the import wrote. */ @Test public void testStatisticsRefreshCanBeTurnedOff() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); final TreeName tree = new TreeName("o=statisticsOff", "dn2id"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); System.setProperty(JDBCStorage.STATISTICS_PROPERTY, "false"); try (final Connection con = CachedConnection.getConnection(getJdbcUrl())) { assertFalse(storage.updateTableStatistics(con, Collections.singleton(tree)), "the refresh ran with " + JDBCStorage.STATISTICS_PROPERTY + "=false"); } } finally { System.clearProperty(JDBCStorage.STATISTICS_PROPERTY); try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** deleteTree() must forget the tree: statistics refresh iterates known trees and must skip dropped tables. */ @Test public void testDeleteTreeForgetsTree() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); final TreeName tree = new TreeName("o=deleteTree", "dn2id"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); assertTrue(storage.listTrees().contains(tree)); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); assertFalse(storage.listTrees().contains(tree), "deleteTree() left the tree in the tree-to-table cache"); } finally { storage.close(); } } /** A bulk import must refresh optimizer statistics: fresh tables were never analyzed (#859). */ @Test public void testImportRefreshesTableStatistics() throws Exception { final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null); final TreeName tree = new TreeName("testImportAnalyze", "tree"); try { storage.open(AccessMode.READ_WRITE); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(tree, true); } }); suspendAutomaticStatistics(storage.getTableName(tree)); try (final Importer importer = storage.startImport()) { for (int i = 0; i < 40; i++) { importer.put(tree, key(i), value(i)); } } assertTableStatisticsFresh(storage.getTableName(tree)); // import swallows statistics failures by design: assert directly that the // dialect-specific refresh statement is accepted by this database try (final Connection con = CachedConnection.getConnection(getJdbcUrl())) { assertTrue(storage.updateTableStatistics(con, Collections.singleton(tree)), "statistics refresh reported failures"); } } finally { try { storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(tree); } }); } catch (Exception ignored) {} storage.close(); } } /** * Suspends the automatic statistics upkeep of the engines that have it, so that what the * assertion below sees was produced by the refresh of the import and by nothing else: InnoDB * recalculates innodb_table_stats.n_rows on its own (innodb_stats_auto_recalc is on by * default), which would let the assertion pass with no "analyze table" ever issued. */ void suspendAutomaticStatistics(String tableName) throws Exception { final String url = getJdbcUrl(); if (!url.startsWith("jdbc:mysql")) { return; // nothing refreshes what is asserted below on the other engines within a test run } try (final Connection con = DriverManager.getConnection(url); final Statement st = con.createStatement()) { st.execute("alter table " + tableName + " stats_auto_recalc=0"); } } void assertTableStatisticsFresh(String tableName) throws Exception { final String url = getJdbcUrl(); final String sql; if (url.startsWith("jdbc:postgresql")) { // reltuples stays -1/0 until the first ANALYZE sql = "select reltuples::bigint from pg_class where relname='" + tableName + "'"; } else if (url.startsWith("jdbc:oracle")) { // num_rows stays null until dbms_stats gathers statistics sql = "select num_rows from user_tables where table_name='" + tableName.toUpperCase() + "'"; } else if (url.startsWith("jdbc:mysql")) { // n_rows in the persistent stats table is refreshed by ANALYZE TABLE, and - with the // automatic recalculation suspended above - by nothing else: it stays 0 without it sql = "select n_rows from mysql.innodb_table_stats where database_name=database() and table_name='" + tableName + "'"; } else if (url.startsWith("jdbc:sqlserver")) { // last_updated stays null until the first UPDATE STATISTICS sql = "select count(*) from sys.stats s cross apply sys.dm_db_stats_properties(s.object_id, s.stats_id) p" + " where s.object_id=object_id('" + tableName + "') and p.last_updated is not null"; } else { throw new SkipException("no statistics query for " + url); } try (final Connection con = DriverManager.getConnection(url); final Statement st = con.createStatement(); final ResultSet rs = st.executeQuery(sql)) { assertTrue(rs.next(), "table " + tableName + " not found"); final long rows = rs.getLong(1); assertFalse(rs.wasNull(), "statistics were never gathered for " + tableName); assertTrue(rows > 0, "statistics of " + tableName + " look stale: " + rows); } } /** Cursor operations must keep working when the tree spans several "fetchsize" batches. */ @Test public void testCursorCrossesFetchSizeBatches() throws Exception {