diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
index 3da7d5694a..0c577b1df6 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -635,6 +635,10 @@ long nanoTime() {
private final AtomicBoolean ddlLockBoundNotSetWarned = new AtomicBoolean();
private final AtomicLong ddlLockBoundLeftBehindWarned = new AtomicLong();
private static final long DDL_LOCK_BOUND_WARNING_INTERVAL_MS = 10000;
+ // And a third way, which is no failure of anything: an engine this backend knows no lock setting
+ // for is left unbounded deliberately, and says so once - see reportTheEngineIsNotKnown(). Not
+ // private, so that a case can read what a storage has already said without reading a log.
+ final AtomicBoolean ddlLockBoundEngineUnknownWarned = new AtomicBoolean();
/**
* The socket read timeout of one connection, and the statements running on it. This second
@@ -1301,6 +1305,12 @@ static String storedIdentifier(DatabaseMetaData metaData, String name) throws SQ
* {@code dsconfig create-backend-index} is answered by {@code alter system set ddl_lock_timeout},
* not by this.
*
+ * An engine behind a driver this backend does not know is left alone as well, and is told about
+ * rather than left silent - see {@code reportTheEngineIsNotKnown()}. {@link #dialectOf} reads the
+ * engine off the class name of the driver, so a mariadb, percona or aurora driver against a live
+ * mysql is one of these: the bound stays off there, and the line saying so is what an operator has
+ * to go on.
+ *
* What it costs is the round trips of the statements around each DDL - three on mysql and sql
* server (reading the value back, setting the bound, giving the value back), two on postgres (the
* savepoint a failed setting is taken back to, and the setting), none on oracle - and only on the
@@ -2127,12 +2137,15 @@ public void close() {
* this reachable from a test with no database behind it.
*
* Nothing of ours is set where it could not be taken off again, and a failure of the readback is
- * never the failure of the DDL: this runs on a pooled connection, so a setting left behind reaches
- * every statement of whoever borrows it next - on sql server that is every lock wait of theirs,
- * row locks included, and {@link #isConflict} classifies error 1222 as no replayable conflict. A
- * session this backend could not take its bound off again is kept out of the pool for that reason
- * ({@link CachedConnection#keepOutOfThePool}), since the validation of the next borrow is
- * {@code isValid()} - a liveness check a connection carrying a stale setting passes.
+ * never the failure of the DDL: this runs mostly on a pooled connection, so a setting left behind
+ * reaches every statement of whoever borrows it next - on sql server that is every lock wait of
+ * theirs, row locks included, and {@link #isConflict} classifies error 1222 as no replayable
+ * conflict. A session this backend could not take its bound off again is kept out of the pool for
+ * that reason ({@link CachedConnection#keepOutOfThePool}), since the validation of the next borrow
+ * is {@code isValid()} - a liveness check a connection carrying a stale setting passes. The one
+ * connection here that is not the pool's is the catalog's own, which {@code createCatalogTable()}
+ * creates its table on: there a setting left behind reaches the rest of that write and goes with
+ * the connection, which is closed with it.
*
* The DDL runs whatever any of that did, and it runs under the same rewrite either way: a setting
* can reach the server and fail only as the statement carrying it is closed, which no driver tells
@@ -2141,10 +2154,21 @@ public void close() {
*/
T withDdlLockBound(Connection con, Dialect dialect, Execution action) throws SQLException {
final int seconds=ddlLockBoundSeconds();
- // Asked first with nothing displaced yet, which is what tells an engine this bound is never put
- // on - oracle, and one none of these settings fit - from an engine it is put on. What the session
- // actually carries is read below, and can take the bound off again all by itself.
- if (dialect==null || seconds<=0 || dialect.ddlLockBoundSql(seconds, null)==null) {
+ if (seconds<=0) { // the wait is left exactly as unbounded as it was, and nobody asked otherwise
+ return action.run();
+ }
+ if (dialect==null) {
+ // The one branch where a bound was asked for and none is put on, which is why it is the one
+ // that says so: an engine none of these settings fit is fed none of them - untested SQL is no
+ // thing to send a database on the path a backend opens by - and the silence around that is
+ // what an operator has no way of finding out.
+ reportTheEngineIsNotKnown(con);
+ return action.run();
+ }
+ // Asked with nothing displaced yet, which is what tells an engine this bound is never put on -
+ // oracle - from an engine it is put on. What the session actually carries is read below, and can
+ // take the bound off again all by itself.
+ if (dialect.ddlLockBoundSql(seconds, null)==null) {
return action.run();
}
final String query=dialect.ddlLockBoundQuery();
@@ -2337,12 +2361,41 @@ private void reportTheWaitIsLeftUnbounded(Dialect dialect, String sql, Exception
}
}
+ /**
+ * Said once per storage, where the engine behind a connection is not one this backend knows a lock
+ * setting for. Leaving the bound off such an engine is the conservative reading and stays - untested
+ * SQL is no thing to send a database on the path a backend opens by - but a deployment that asked
+ * for the bound has no way of finding out that it got none, which is the silence this ends. It is
+ * the argument the strict parsing of {@value #DDL_LOCK_TIMEOUT_PROPERTY} is made with, one property
+ * later.
+ *
+ * {@link #dialectOf} reads the engine off the class name of the driver, so this is not the engine
+ * of an exotic database alone: a mariadb, percona or aurora driver against a live mysql answers
+ * null here, and that is a session whose {@code lock_wait_timeout} is a year - the very wait this
+ * bound exists to end. {@link CachedConnection} says the same of a url it knows no connect bound
+ * for and cannot say it for this one: it keys on the url, which such a driver takes as a mysql one.
+ *
+ * The driver is named rather than the url, since the driver is what this reads and what a
+ * deployment would change - and a url carries the password of the account this backend works as.
+ */
+ private void reportTheEngineIsNotKnown(Connection con) {
+ if (ddlLockBoundEngineUnknownWarned.compareAndSet(false, true)) {
+ logger.warn(LocalizableMessage.raw("jdbc: the wait of a DDL for a lock is left unbounded on this"
+ + " database: %s is not a driver this backend knows a lock setting of an engine for, so %s"
+ + " bounds nothing here and a DDL - the create table and create index of an open, the drop"
+ + " table of a clear - waits for a lock another session holds for as long as this engine"
+ + " lets it", driverNameOf(con), DDL_LOCK_TIMEOUT_PROPERTY));
+ }
+ }
+
/**
* Gives the session back the value it carried. Best effort, and never the outcome of the DDL: this
* runs from a {@code finally} while the caller may be being unwound, where a throw would replace
* the failure that brought it there (JLS 14.20.2) - the very one saying what went wrong.
*
- * A connection this failed on does not go back into the pool. Leaving it to the next borrow to
+ * A connection this failed on is handed on to nobody. A pooled one is closed rather than given
+ * back, and the catalog's own - the one connection reaching this that was never in the pool - is
+ * closed with the write that opened it. Leaving it to the next borrow to
* notice does not work: that validation is {@code con.isValid()}, a liveness check which a
* connection whose reset failed for a transient reason passes while still carrying our bound, and
* on sql server it would then cut every lock wait of that borrower at it - row locks included,
@@ -2371,8 +2424,9 @@ private void restoreDdlLockBound(Connection con, Dialect dialect, String bound,
if (now-last >= DDL_LOCK_BOUND_WARNING_INTERVAL_MS && ddlLockBoundLeftBehindWarned.compareAndSet(last, now)) {
logger.warn(LocalizableMessage.raw("jdbc: the lock bound of a DDL could not be taken off a connection"
+ " of this %s database, which may have been left carrying \"%s\" instead of the value it had:"
- + " that connection is closed rather than pooled, so no borrow after this one gives up on a lock"
- + " at a bound of %s it never asked for (%s)", dialect, bound, DDL_LOCK_TIMEOUT_PROPERTY,
+ + " that connection is not handed on - a pooled one is closed rather than given back, and the"
+ + " catalog's own is closed with the write that opened it - so nothing after this gives up on a"
+ + " lock at a bound of %s it never asked for (%s)", dialect, bound, DDL_LOCK_TIMEOUT_PROPERTY,
stackTraceToSingleLineString(e)));
}
}
@@ -4756,17 +4810,31 @@ void readEnrolledTrees(TreeName catalog) {
* An account that may write its rows but not create a table is a configuration this can meet,
* so the failure says which table it was and why the backend wanted it, rather than reaching
* the operator as a bare SQL error inside ERR_OPEN_ENV_FAIL.
+ *
+ * It waits for its lock under {@link JDBCStorage#DDL_LOCK_TIMEOUT_PROPERTY} like every other DDL
+ * of this backend, and a lock it gives up on names that property: this statement is issued on the
+ * catalog's own connection rather than through {@code commitStatement()}, which is the funnel
+ * that bounds the rest, so the bound is put on here.
*/
void createCatalogTable(TreeName catalog) {
final String tableName=getTableName(catalog);
try {
final Connection catalogCon=catalogSession.connection();
- try (final PreparedStatement statement=catalogCon.prepareStatement("create table "+tableName+" ("+getTableDialect()+")")) {
- // bulk like every other create table of this backend (#882): it is DDL nobody waits on,
- // and the class of a client operation is not what a statement of this kind can be given
- execute(statement, StatementBound.BULK);
- }
- catalogCon.commit();
+ // Under the same bound as every other DDL of this backend, although this one reaches no
+ // commitStatement(): it is a create table of an open like the ones openTree() issues, and
+ // it queues for the same kind of lock - another process creating this very table inside a
+ // transaction it has not committed is a wait three engines out of four never end. The
+ // commit is inside the bound because on postgres it is that commit which ends the
+ // transaction a "set local" belongs to.
+ withDdlLockBound(catalogCon, dialectOf(catalogCon), () -> {
+ try (final PreparedStatement statement=catalogCon.prepareStatement("create table "+tableName+" ("+getTableDialect()+")")) {
+ // bulk like every other create table of this backend (#882): it is DDL nobody waits on,
+ // and the class of a client operation is not what a statement of this kind can be given
+ execute(statement, StatementBound.BULK);
+ }
+ catalogCon.commit();
+ return null;
+ });
} catch (SQLException | RuntimeException e) {
// the unchecked one as well, for the reason enrolInCatalog() takes it: what the statement
// left behind has to be rolled back whatever class the failure arrived in, this connection
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCDdlLockBoundTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCDdlLockBoundTestCase.java
index c7e623995f..ad482e0290 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCDdlLockBoundTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/JDBCDdlLockBoundTestCase.java
@@ -49,6 +49,7 @@
import static java.util.Collections.singletonList;
import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
@@ -83,19 +84,30 @@ public class JDBCDdlLockBoundTestCase extends DirectoryServerTestCase {
/** The backend the storage of a case is configured as, which is what names its tree catalog. */
private static final String BACKEND_ID = "ddlLockBound";
- /** That catalog's table, which the connection of a case answers as not being there: see engine(). */
- private static final String NO_CATALOG_TABLE =
+ /**
+ * That catalog's table, which the connection of a case answers as not being there (see engine()):
+ * a case reaching {@code openTree()} therefore takes the branch that creates it.
+ */
+ private static final String CATALOG_TABLE =
JDBCStorage.toTableName(new TreeName(JDBCStorage.CATALOG_BASE_DN, BACKEND_ID));
/** What the connection of a case was asked to run, in the order it was asked to run it. */
private final List issued = new ArrayList<>();
+ /**
+ * What the catalog's own connection was asked to run. The create table of the catalog is issued
+ * on that connection rather than on the caller's ({@code CatalogSession}), so what is issued
+ * around it is read off a list of its own instead of out of the middle of the caller's.
+ */
+ private final List catalogIssued = new ArrayList<>();
+
private JDBCStorage storage;
@BeforeMethod
public void createStorage() {
storage = new JDBCStorage(backendCfg(), null);
issued.clear();
+ catalogIssued.clear();
}
/** A configuration naming this backend, which is all any case here reads off one. */
@@ -191,6 +203,44 @@ public void testAnEngineThisBackendDoesNotKnowIsLeftAlone() throws Exception {
assertEquals(issued, singletonList(THE_DDL));
}
+ /**
+ * ... and is told so once, rather than left to be found out. {@code dialectOf()} keys on the class
+ * name of the driver, so a mariadb, percona or aurora driver against a live mysql answers null
+ * here - and that is a session whose {@code lock_wait_timeout} is a year, which is the wait this
+ * bound exists to end. The strict parsing of the property exists so that a deployment which asked
+ * for a bound is never quietly left with none, and this is the same silence one property later.
+ */
+ @Test
+ public void testAnEngineThisBackendDoesNotKnowIsReported() throws Exception {
+ storage.withDdlLockBound(recording(mock(Connection.class), "0"), null, theDdl());
+
+ assertTrue(storage.ddlLockBoundEngineUnknownWarned.get(),
+ "a driver this backend knows no lock bound for left the wait of every DDL unbounded and unsaid");
+ }
+
+ /** A deployment that turned the bound off asked for none anywhere, and has nothing to act on. */
+ @Test
+ public void testAWaitNobodyAskedToBoundIsNotReportedAsAnUnknownEngine() throws Exception {
+ System.setProperty(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY, "0");
+
+ storage.withDdlLockBound(recording(mock(Connection.class), "0"), null, theDdl());
+
+ assertFalse(storage.ddlLockBoundEngineUnknownWarned.get(),
+ "a bound nobody asked for was reported as an engine this backend does not know");
+ }
+
+ /**
+ * And oracle is an engine this backend knows perfectly well: it is left to its own
+ * {@code ddl_lock_timeout} on purpose, which is a decision rather than a gap to report.
+ */
+ @Test
+ public void testOracleIsNotReportedAsAnEngineThisBackendDoesNotKnow() throws Exception {
+ storage.withDdlLockBound(recording(mock(Connection.class), "0"), Dialect.ORACLE, theDdl());
+
+ assertFalse(storage.ddlLockBoundEngineUnknownWarned.get(),
+ "the engine left alone deliberately was reported as one this backend cannot bound");
+ }
+
/** Turning the bound off costs no round trip either: the DDL waits exactly as it did before. */
@Test
public void testAnUnboundedWaitIssuesNoSessionStatement() throws Exception {
@@ -556,6 +606,95 @@ private void givingUpOnTheLookup(final Connection con, final SQLException failur
when(con.getMetaData()).thenReturn(metaData);
}
+ /**
+ * The create table of the tree catalog is the DDL of this backend that goes through neither
+ * {@code commitStatement()} nor the drop loop: {@code openTree()} creates that table on the
+ * catalog's own connection before it enrols the tree it is opening, and a backend upgraded from a
+ * version that kept no catalog meets it on the open of every one of its trees.
+ */
+ @Test
+ public void testTheCreateOfTheCatalogTableIsBounded() throws Exception {
+ final JDBCStorage bounded = storageHandingOut(engine(postgresConnection.class, "0"),
+ engine(postgresConnection.class, "0", catalogIssued));
+
+ bounded.write(txn -> txn.openTree(TREE, true));
+
+ assertEquals(firstOfTheCatalog(2), asList("set local lock_timeout = 5000",
+ "create table " + CATALOG_TABLE + " (h char(128),k bytea,v bytea,primary key(h,k))"));
+ }
+
+ /**
+ * And it goes through the same helper every other DDL of this backend does, so the session of an
+ * engine whose setting outlives the transaction is read back first and given its value back after
+ * - on a connection which is not the pool's, and which the write that opened it closes.
+ */
+ @Test
+ public void testTheCreateOfTheCatalogTableGivesMysqlItsValueBack() throws Exception {
+ final JDBCStorage bounded = storageHandingOut(engine(mysqlConnection.class, "31536000"),
+ engine(mysqlConnection.class, "31536000", catalogIssued));
+
+ bounded.write(txn -> txn.openTree(TREE, true));
+
+ assertEquals(firstOfTheCatalog(4), asList("select @@session.lock_wait_timeout",
+ "set session lock_wait_timeout=5",
+ "create table " + CATALOG_TABLE + " (h char(128),k varbinary(255),v longblob,primary key(h,k))",
+ "set session lock_wait_timeout=31536000"));
+ }
+
+ /**
+ * A lock that create gave up on names the property that ended the wait, all the way out to the
+ * operator: the failure is wrapped as the backend not being able to create the table which holds
+ * its catalog, and a bare 55P03 inside that says nothing about which wait ended or what to raise.
+ */
+ @Test
+ public void testALockTheCreateOfTheCatalogTableGaveUpOnNamesTheProperty() throws Exception {
+ final JDBCStorage bounded = storageHandingOut(engine(postgresConnection.class, "0"),
+ failingTheCreate(engine(postgresConnection.class, "0", catalogIssued),
+ new SQLException("canceling statement due to lock timeout", "55P03")));
+
+ try {
+ bounded.write(txn -> txn.openTree(TREE, true));
+ fail("a create of the catalog table that gave up on a lock has to reach the caller");
+ }catch (Exception expected) {
+ assertTrue(namesTheProperty(expected),
+ "the lock this bound ended reached the operator unnamed: " + expected);
+ }
+ }
+
+ /** The statements of the catalog's connection a case reads, without running off its end. */
+ private List firstOfTheCatalog(int statements) {
+ return catalogIssued.subList(0, Math.min(statements, catalogIssued.size()));
+ }
+
+ /** Whether the failure, or any link of its chain, names the property that ended the wait. */
+ private static boolean namesTheProperty(Throwable failure) {
+ for (Throwable link = failure; link != null; link = link.getCause()) {
+ if (link.getMessage() != null
+ && link.getMessage().contains(JDBCStorage.DDL_LOCK_TIMEOUT_PROPERTY)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** A connection whose create table is the statement the engine refuses, with the given failure. */
+ private Connection failingTheCreate(final Connection con, final SQLException failure) throws SQLException {
+ // doAnswer() rather than when(): the connection has been given a prepareStatement() already, and
+ // calling it inside a when() would run that answer - which stubs a mock of its own - in the
+ // middle of this stubbing, which mockito reads as a stubbing that never named a method
+ doAnswer(invocation -> {
+ final String sql = (String) invocation.getArguments()[0];
+ catalogIssued.add(sql);
+ final PreparedStatement statement = mock(PreparedStatement.class);
+ when(statement.getConnection()).thenReturn(con);
+ if (sql.startsWith("create table ")) {
+ when(statement.executeUpdate()).thenThrow(failure);
+ }
+ return statement;
+ }).when(con).prepareStatement(anyString());
+ return con;
+ }
+
/** A catalog naming each of the given trees at the table its name hashes to. */
private static Map catalogOf(TreeName... trees) {
final Map catalog = new LinkedHashMap<>();
@@ -725,13 +864,19 @@ private JDBCStorage.Execution theDdl() {
* setting with the value a session of that engine carries.
*/
private Connection recording(final Connection con, final String carries) throws SQLException {
+ return recording(con, carries, issued);
+ }
+
+ /** The same, recording into the list the statements of this connection belong in. */
+ private Connection recording(final Connection con, final String carries, final List into)
+ throws SQLException {
final Statement statement = mock(Statement.class);
when(statement.execute(anyString())).thenAnswer(invocation -> {
- issued.add((String) invocation.getArguments()[0]);
+ into.add((String) invocation.getArguments()[0]);
return false;
});
when(statement.executeQuery(anyString())).thenAnswer(invocation -> {
- issued.add((String) invocation.getArguments()[0]);
+ into.add((String) invocation.getArguments()[0]);
final ResultSet carried = mock(ResultSet.class);
when(carried.next()).thenReturn(true, false);
when(carried.getString(1)).thenReturn(carries);
@@ -787,15 +932,25 @@ private Connection refusingToGiveTheValueBack(final Connection con, final String
interface postgresConnection extends Connection {
}
+ /** The same for mysql, whose setting outlives the transaction and is read back and put back. */
+ interface mysqlConnection extends Connection {
+ }
+
/**
* A connection of the given engine, recording the statements it is asked to run - the DDL among the
* session settings around it - over a catalog holding the table of every tree named here.
*/
private Connection engine(Class extends Connection> engine, String carries) throws SQLException {
- final Connection con = recording(mock(engine), carries);
+ return engine(engine, carries, issued);
+ }
+
+ /** The same, recording into the list the statements of this connection belong in. */
+ private Connection engine(Class extends Connection> engine, String carries, List into)
+ throws SQLException {
+ final Connection con = recording(mock(engine), carries, into);
when(con.isValid(anyInt())).thenReturn(true);
when(con.prepareStatement(anyString())).thenAnswer(invocation -> {
- issued.add((String) invocation.getArguments()[0]);
+ into.add((String) invocation.getArguments()[0]);
final PreparedStatement statement = mock(PreparedStatement.class);
when(statement.getConnection()).thenReturn(con);
return statement;
@@ -809,11 +964,21 @@ private Connection engine(Class extends Connection> engine, String carries) th
// backend upgraded from a version that kept no catalog takes the shortest way to the funnel
// carrying them - the catalog would otherwise want a connection of its own, which is not a
// connection this mock hands out. CatalogConnectionTestCase covers that one.
- when(tables.next()).thenReturn(!NO_CATALOG_TABLE.equals(asked), false);
+ when(tables.next()).thenReturn(!CATALOG_TABLE.equals(asked), false);
// the name the catalog was asked about, so that every tree of a case is found to exist
when(tables.getString("TABLE_NAME")).thenReturn(asked);
return tables;
});
+ // The index of a tree is found missing, which is the shortest way through openTree() to the
+ // catalog table it creates on the way: a getIndexInfo() no case answers comes back null, which
+ // is a NullPointerException inside the lookup rather than a case. The create index that then
+ // follows is issued on the caller's connection, where these cases read nothing.
+ when(metaData.getIndexInfo(any(), any(), anyString(), anyBoolean(), anyBoolean()))
+ .thenAnswer(invocation -> {
+ final ResultSet indexes = mock(ResultSet.class);
+ when(indexes.next()).thenReturn(false);
+ return indexes;
+ });
when(con.getMetaData()).thenReturn(metaData);
return con;
}
@@ -824,12 +989,39 @@ private Connection engine(Class extends Connection> engine, String carries) th
* around a DDL, not the pool that produced the connection carrying them.
*/
private JDBCStorage storageHandingOut(final Connection con) {
+ return storageHandingOut(con, null);
+ }
+
+ /**
+ * The same, handing out the given connection for the tree catalog as well: that connection is not
+ * a pooled one - {@code CatalogSession} opens it through {@code newCatalogConnection()} - so it is
+ * given its own seam rather than borrowed through the one above.
+ */
+ private JDBCStorage storageHandingOut(final Connection con, final Connection catalogCon) {
final JDBCStorage handing = new JDBCStorage(backendCfg(), null) {
@Override
Connection getConnection(boolean trusted) {
return new CachedConnection("jdbc:mock", con);
}
+ @Override
+ Connection newCatalogConnection(long budgetDeadline) throws SQLException {
+ if (catalogCon == null) {
+ throw new SQLException("this case opens no catalog connection");
+ }
+ return catalogCon;
+ }
+
+ @Override
+ Connection newStampConnection(Dialect dialect) throws SQLException {
+ // The stamp of an open is no part of any case here, and commentTable() takes this for
+ // what it is - a stamp connection that could not be made, which leaves the table
+ // unstamped and the open unaffected. Answered here rather than left to fail on its own,
+ // because failing on its own means DriverManager: this suite needs no database, and a
+ // mock-only case has no business registering every jdbc driver on the classpath.
+ throw new SQLException("this case stamps nothing");
+ }
+
@Override
public StorageStatus getStorageStatus() {
return StorageStatus.working(); // open already, so an importer borrows and no more