From 2b8612f7fa2f5e00dff4ff2d8ac912c833e1f7ab Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 09 Sep 2026 07:01:15 +0000
Subject: [PATCH] [#888] Name the trees of a JDBC backend from a catalog in the database (#893)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java | 1027 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,015 insertions(+), 12 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
index 9cc4d97..7e4241b 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -15,8 +15,10 @@
  */
 package org.opends.server.backends.jdbc;
 
+import org.forgerock.i18n.LocalizableMessage;
 import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.ByteStringBuilder;
+import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.server.config.server.JDBCBackendCfg;
 import org.opends.server.backends.pluggable.PluggableBackendImplTestCase;
 import org.opends.server.backends.pluggable.spi.AccessMode;
@@ -41,12 +43,16 @@
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -121,12 +127,68 @@
 
 	@Override
 	protected JDBCBackendCfg createBackendCfg() {
+		return createBackendCfg(getBackendId());
+	}
+
+	/**
+	 * A configuration of another backend on the database of this suite: backends sharing one database
+	 * URL is a configuration nothing forbids, and what one of them clears must be its own tables.
+	 */
+	protected JDBCBackendCfg createBackendCfg(String backendId) {
 		JDBCBackendCfg backendCfg = mockCfg(JDBCBackendCfg.class);
-		when(backendCfg.getBackendId()).thenReturn(getBackendId());
+		when(backendCfg.getBackendId()).thenReturn(backendId);
 		when(backendCfg.getDBDirectory()).thenReturn(getJdbcUrl());
 		return backendCfg;
 	}
 
+	/**
+	 * The same, reached over a connection string of the caller's own: the pools of this backend are
+	 * keyed by it, so a case wanting connections established differently - in another schema of the
+	 * search path, say - asks for them by asking for another url.
+	 */
+	protected JDBCBackendCfg createBackendCfg(String backendId, String jdbcUrl) {
+		final JDBCBackendCfg backendCfg = createBackendCfg(backendId);
+		when(backendCfg.getDBDirectory()).thenReturn(jdbcUrl);
+		return backendCfg;
+	}
+
+	/**
+	 * The same, serving the given base DN: what a clear compares the tree stamp of a table against
+	 * when it says whether the table is this backend's own or another's (#866).
+	 */
+	protected JDBCBackendCfg createBackendCfg(String backendId, DN baseDN) {
+		final JDBCBackendCfg backendCfg = createBackendCfg(backendId);
+		final TreeSet<DN> baseDNs = new TreeSet<>();
+		baseDNs.add(baseDN);
+		when(backendCfg.getBaseDN()).thenReturn(baseDNs);
+		return backendCfg;
+	}
+
+	/** Asked of the database itself, by listing its tables, so that no folding rule of the backend is trusted here. */
+	protected boolean isExistsTable(String tableName) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+			return isExistingTable(con, tableName);
+		}
+	}
+
+	/** Drops a table behind the back of the storage that owns it, which no code path of the backend does. */
+	private void dropTableBehindTheBackend(String tableName) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+			 final Statement st = con.createStatement()) {
+			st.execute("drop table " + tableName);
+		}
+	}
+
+	/** Clears a backend of a test without letting the failure of the clear replace the failure being reported. */
+	protected static void clearQuietly(JDBCStorage storage) {
+		try {
+			storage.removeStorageFiles();
+		} catch (Exception ignored) {
+		} finally {
+			storage.close();
+		}
+	}
+
 	@AfterClass
 	@Override
 	public void cleanUp() throws Exception {
@@ -314,11 +376,16 @@
 	}
 
 	/**
-	 * Reading a tree must not enrol it in the storage's tree map: removeStorageFiles() drops every
-	 * table that map names, and the compressed schema reads the tree its definitions used to be
-	 * shared under - which on a shared database is another backend's to keep (#873). Asking whether
-	 * the tree is there is only the first of those reads: the migration counts it and copies it out
-	 * too, so one guarded statement would not be enough.
+	 * Reading a tree must not put it up for removal: a clear drops what the catalog of the backend
+	 * names (#888), and the compressed schema reads the tree its definitions used to be shared under
+	 * - which on a shared database is another backend's to keep (#873). Asking whether the tree is
+	 * there is only the first of those reads: the migration counts it and copies it out too, so one
+	 * guarded statement would not be enough.
+	 * <p>
+	 * The two storages are two backends and not one addressing the same database, which is what the
+	 * case is about: what a backend owns is recorded in a catalog named after its backend id and
+	 * outlives the process that opened the tree, so a second storage of the same id would be shown
+	 * the tree its own earlier open had enrolled - and would be right to be.
 	 */
 	@Test
 	public void testProbingATreeDoesNotPutItUpForRemoval() throws Exception {
@@ -334,9 +401,9 @@
 		});
 		owner.close();
 
-		// a second storage on the same database, which never opened that tree - the shape of two
+		// a second backend on the same database, which never opened that tree - the shape of two
 		// backends addressing one database
-		final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
+		final JDBCStorage other = new JDBCStorage(createBackendCfg(getBackendId() + "_probe"), null);
 		try {
 			other.open(AccessMode.READ_WRITE);
 			other.read(new ReadOperation<Void>() {
@@ -399,7 +466,11 @@
 		});
 		owner.close();
 
-		// a storage that never opened that tree, so nothing but the delete can enrol it
+		// a second storage of the same backend, which never opened that tree itself: the delete takes
+		// the enrolling name and the table it writes to is the one the tree names. What puts a tree up
+		// for removal is the row its backend's catalog holds (#888) - written by the openTree above and
+		// outliving the storage that made it - so this asserts the listing of a backend and not a
+		// side effect of the statement, which is what a listing of a catalog can assert
 		final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
 		try {
 			other.open(AccessMode.READ_WRITE);
@@ -1109,7 +1180,10 @@
 			storage.write(new WriteOperation() {
 				@Override
 				public void run(WriteableTransaction txn) throws Exception {
-					txn.put(written, key(1), value(1)); // pending in this transaction...
+					// pending in this transaction, and it has to still be pending when the stamp is
+					// attempted: both trees were enrolled by the storage above, so the openTree below
+					// records nothing in the catalog and commits nothing of what is written here
+					txn.put(written, key(1), value(1));
 					txn.openTree(stamped, true); // ...while the comment machinery fails
 				}
 			});
@@ -1120,9 +1194,12 @@
 					return null;
 				}
 			});
-			// the failure is remembered: an unstampable table is not asked again while this backend is open
+			// the failure is remembered: an unstampable table is not asked again while this backend is open.
+			// Counted from what the open itself attempted rather than from one: the open stamps the tree and
+			// the catalog of the backend, and how many tables an open has to stamp is not what this is about
+			final int attemptsOfTheOpen = stampAttempts.get();
 			assertEquals(storage.commentTable(stamped, dialect()), JDBCStorage.CommentResult.FAILED);
-			assertEquals(stampAttempts.get(), 1, "a failed stamp was reissued");
+			assertEquals(stampAttempts.get(), attemptsOfTheOpen, "a failed stamp was reissued");
 		} finally {
 			try {
 				storage.write(new WriteOperation() {
@@ -1832,4 +1909,930 @@
 			storage.close();
 		}
 	}
+
+	/**
+	 * removeStorageFiles() has to clear a backend this process has never opened: offline import-ldif
+	 * configures the backend and calls it before anything opens the root container, so answering from
+	 * the trees this process happens to have touched dropped nothing at all - an offline
+	 * "import-ldif --clearBackend" cleared a JDBC backend of nothing (#888).
+	 */
+	@Test
+	public void testABackendIsClearedByAProcessThatNeverOpenedIt() throws Exception {
+		final TreeName tree = new TreeName("testOfflineClear", "tree");
+		// the neighbour serves a base DN of its own, so that its table is one it reports as its own:
+		// what this case asserts of the clear next door is then an absence and not a vacuity
+		final DN neighbourBaseDN = DN.valueOf("dc=offline-clear-neighbour,dc=com");
+		final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "tree");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null);
+		final JDBCStorage neighbour =
+			new JDBCStorage(createBackendCfg(getBackendId() + "_neighbour", neighbourBaseDN), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+					txn.put(tree, key(1), value(1));
+				}
+			});
+			neighbour.open(AccessMode.READ_WRITE);
+			neighbour.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(neighbourTree, true);
+					txn.put(neighbourTree, key(1), value(1));
+				}
+			});
+		} catch (Exception e) {
+			// the clears of the case below are reached by no failure of this half, and nothing but
+			// @BeforeClass ever drops what it leaves behind
+			clearQuietly(storage);
+			clearQuietly(neighbour);
+			throw e;
+		} finally {
+			storage.close();
+			neighbour.close();
+		}
+
+		// configured and never opened, nothing touched: what BackendImpl.importLDIF holds offline
+		final JDBCStorage offline = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null);
+		try {
+			assertTrue(offline.listTrees().contains(tree),
+				"the tree of a backend this process never opened has to be named by its catalog");
+
+			offline.removeStorageFiles();
+
+			assertFalse(isExistsTable(offline.getTableName(tree)), "the table of the tree survived the clear");
+			assertFalse(isExistsTable(offline.getTableName(offline.getCatalogTree())), "the catalog survived the clear");
+			final Set<TreeName> cleared = offline.listTrees();
+			assertFalse(cleared.contains(tree), "a cleared backend still names its tree");
+			assertFalse(cleared.contains(offline.getCatalogTree()), "a cleared backend still names its catalog");
+			// the neighbour is named by a catalog of its own: what one backend clears is never another's
+			assertTrue(isExistsTable(neighbour.getTableName(neighbourTree)),
+				"the clear of one backend dropped the table of another backend of the same database");
+			// nor does it report another backend's tables as tables of its own: a table is named after
+			// the hash of its tree name and says nothing about whose it is, but it is stamped with that
+			// tree name (#866), and the neighbour's trees are trees of no base DN this backend serves
+			assertReportsNothingOf(offline, neighbour, neighbourTree);
+		} finally {
+			// in a finally of their own: a failed assertion above must not leave the tables of either
+			// backend behind for the rest of the class, which nothing but @BeforeClass ever drops
+			clearQuietly(neighbour);
+			clearQuietly(offline);
+		}
+	}
+
+	/**
+	 * A dropped tree has to leave the catalog together with its table: a row outliving its table
+	 * would make backendstat name a tree that is not there, and would put a table that is already
+	 * gone up for removal (#888).
+	 */
+	@Test
+	public void testADeletedTreeIsNoLongerNamedByTheCatalog() throws Exception {
+		final TreeName kept = new TreeName("testCatalogDelete", "kept");
+		final TreeName dropped = new TreeName("testCatalogDelete", "dropped");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_deleted"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(kept, true);
+					txn.openTree(dropped, true);
+				}
+			});
+			final Set<TreeName> opened = storage.listTrees();
+			assertTrue(opened.contains(kept) && opened.contains(dropped), "an opened tree is not named by the catalog");
+
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.deleteTree(dropped);
+				}
+			});
+
+			final Set<TreeName> remaining = storage.listTrees();
+			assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
+			assertFalse(remaining.contains(dropped), "the catalog still names a tree that was deleted");
+			// and the removal that follows must not stumble over the tree it no longer names
+			storage.removeStorageFiles();
+			assertFalse(isExistsTable(storage.getTableName(kept)), "the table of the tree survived the clear");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * A row of the catalog whose table is not there any more must not fail the clear, and must not
+	 * stop it dropping the rest. Nothing of the backend leaves such a row behind - deleteTree() takes
+	 * it out in the commit that drops the table - but a table dropped by hand, or a catalog restored
+	 * from a backup older than the database, leaves exactly this (#888).
+	 */
+	@Test
+	public void testAClearSkipsACatalogRowWhoseTableIsGone() throws Exception {
+		final TreeName kept = new TreeName("testStaleCatalogRow", "kept");
+		final TreeName vanished = new TreeName("testStaleCatalogRow", "vanished");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_stale"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(kept, true);
+					txn.openTree(vanished, true);
+				}
+			});
+			dropTableBehindTheBackend(storage.getTableName(vanished));
+			assertTrue(storage.listTrees().contains(vanished),
+				"the catalog was expected to go on naming the tree whose table was dropped behind its back");
+
+			storage.removeStorageFiles();
+
+			assertFalse(isExistsTable(storage.getTableName(kept)),
+				"a row of the catalog whose table is gone stopped the clear dropping the rest");
+			assertFalse(isExistsTable(storage.getTableName(storage.getCatalogTree())), "the catalog survived the clear");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * Naming a tree in order to read it must never put it up for removal: the tree read may be held
+	 * by another backend of the same database, which nothing forbids (#873). Only
+	 * openTree(createOnDemand) enrols.
+	 */
+	@Test
+	public void testReadingATreeDoesNotPutItUpForRemoval() throws Exception {
+		final TreeName owned = new TreeName("testReadDoesNotEnrol", "owned");
+		final TreeName foreign = new TreeName("testReadDoesNotEnrolForeign", "tree");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reader"), null);
+		final JDBCStorage owner = new JDBCStorage(createBackendCfg(getBackendId() + "_owner"), null);
+		try {
+			owner.open(AccessMode.READ_WRITE);
+			owner.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(foreign, true);
+					txn.put(foreign, key(1), value(1));
+				}
+			});
+
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(owned, true); // the catalog of this backend comes into being here
+					txn.openTree(foreign, false); // read, not owned
+				}
+			});
+			assertEquals(storage.read(new ReadOperation<ByteString>() {
+				@Override
+				public ByteString run(ReadableTransaction txn) throws Exception {
+					return txn.read(foreign, key(1));
+				}
+			}), value(1), "the tree of the other backend could not be read");
+
+			assertFalse(storage.listTrees().contains(foreign), "reading a tree enrolled it in the catalog");
+			storage.removeStorageFiles();
+			assertTrue(isExistsTable(owner.getTableName(foreign)),
+				"the clear dropped a tree this backend had only read");
+			assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear");
+		} finally {
+			clearQuietly(owner);
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * The compressed schema trees named from a literal carry no backend qualifier, so on a database
+	 * addressed by several backends they are the same pair for all of them: a clear must leave them
+	 * where they lie (#881). A tool asking a backend what trees it holds has to be shown them all the
+	 * same, which is what keeps them out of the catalog and inside listTrees().
+	 */
+	@Test
+	public void testTheSharedCompressedSchemaTreesAreNamedButNeverCleared() throws Exception {
+		final TreeName owned = new TreeName("testSharedCompressedSchema", "owned");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_schema"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(owned, true);
+					// opened, never written to: since #881 no backend of this class makes the literal-named
+					// pair, so this openTree is what creates these two tables - and the finally below is what
+					// removes them again, a clear being required to leave them exactly where they lie
+					for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
+						txn.openTree(shared, true);
+					}
+				}
+			});
+			// both of them: the pair is a hand-copy of two privates of PersistentCompressedSchema, and
+			// a literal naming a tree that does not exist would go unseen if one of them were never asked
+			// for - the tree it names would be neither shown by listTrees() nor spared by a clear
+			final Set<TreeName> named = storage.listTrees();
+			for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
+				assertTrue(named.contains(shared),
+					"a tool asking this backend for its trees was not shown " + shared);
+			}
+
+			storage.removeStorageFiles();
+
+			for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
+				assertTrue(isExistsTable(JDBCStorage.toTableName(shared)),
+					"the clear dropped " + shared + ", which another backend of this database may be the only owner of");
+			}
+			assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear");
+		} finally {
+			// the pair is dropped by hand here, and by nothing of the backend: a clear must leave it
+			// where it lies, which is the whole of what this case asserts. It is this case's to remove
+			// because it is this case that made it - since #881 each backend keeps its definitions in a
+			// pair of its own, so the literal-named pair belongs to no backend of this class any more
+			// and the openTree above is what created these two tables. Left standing they would be a
+			// legacy pair this database does not have, which
+			// testCompressedSchemaTableIsQualifiedByBackendId asserts about and TestNG may run after
+			// this case as easily as before it
+			clearQuietly(storage);
+			for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) {
+				try {
+					dropTableBehindTheBackend(JDBCStorage.toTableName(shared));
+				} catch (SQLException ignored) { // a case that failed before it made them leaves none to drop
+				}
+			}
+		}
+	}
+
+	/**
+	 * The row of a deleted tree must not be left to the enclosing transaction: a terminal failure
+	 * later in it - write() replays a class 40 conflict and rethrows everything else - would roll the
+	 * row back over a table that is already gone, and nothing would put it right, a deleted tree not
+	 * being opened again (#888).
+	 */
+	@Test
+	public void testADeletedTreeStaysOutOfTheCatalogWhenItsTransactionFails() throws Exception {
+		final TreeName kept = new TreeName("testCatalogDeleteRollback", "kept");
+		final TreeName deleted = new TreeName("testCatalogDeleteRollback", "deleted");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_rollback"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(kept, true);
+					txn.openTree(deleted, true);
+				}
+			});
+
+			try {
+				storage.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						txn.deleteTree(deleted);
+						// terminal, and no conflict for write() to replay: everything this transaction
+						// still owes goes back, and the row of the deleted tree must not be part of it
+						throw new IllegalStateException("the transaction of a deleteTree failed");
+					}
+				});
+				fail("the write was expected to fail");
+			} catch (Exception expected) {
+				// what the case is about is what the failure left behind
+			}
+
+			assertFalse(isExistsTable(storage.getTableName(deleted)), "the failed transaction brought a dropped table back");
+			final Set<TreeName> remaining = storage.listTrees();
+			assertFalse(remaining.contains(deleted),
+				"the catalog names a tree whose table the failed transaction left dropped");
+			assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * The same, for the branch a deleteTree takes when the table is not there any more: nothing is
+	 * dropped, so there is no commit of a drop for the row to be carried out of the catalog by, and
+	 * the commit the delete is given on the catalog's own connection is the whole of what takes it
+	 * out. Left to the enclosing transaction, the row would go back with it and the catalog would name
+	 * a tree with no table for good - the state a clear can only skip and report, never repair (#888).
+	 */
+	@Test
+	public void testADeletedTreeStaysOutOfTheCatalogWhenItsTableIsAlreadyGone() throws Exception {
+		final TreeName kept = new TreeName("testCatalogDeleteNoTable", "kept");
+		final TreeName deleted = new TreeName("testCatalogDeleteNoTable", "deleted");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_noTable"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(kept, true);
+					txn.openTree(deleted, true);
+				}
+			});
+			// what an interrupted change of an earlier run leaves: a row of the catalog naming a table
+			// that is not there any more. The deleteTree below therefore drops nothing at all
+			dropTableBehindTheBackend(storage.getTableName(deleted));
+
+			try {
+				storage.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						txn.deleteTree(deleted);
+						// terminal, and no conflict for write() to replay: everything this transaction
+						// still owes goes back, and the row of the deleted tree must not be part of it
+						throw new IllegalStateException("the transaction of a deleteTree failed");
+					}
+				});
+				fail("the write was expected to fail");
+			} catch (Exception expected) {
+				// what the case is about is what the failure left behind
+			}
+
+			final Set<TreeName> remaining = storage.listTrees();
+			assertFalse(remaining.contains(deleted),
+				"the catalog names a tree whose table was already gone when it was deleted");
+			assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * The row of a tree whose table is already standing must not be left to the enclosing transaction
+	 * either. That is the open which fills the catalog of a backend upgraded from a version keeping
+	 * none: it creates no table, so nothing else of openTree() commits anything, and a transaction
+	 * failing after the enrolment would take the whole of it back - leaving a backend whose tables
+	 * are named by no catalog and whose next clear therefore drops nothing at all (#888).
+	 * <p>
+	 * The row is written and committed on a connection of the catalog's own, so this holds on every
+	 * engine for the same reason: nothing the caller's transaction does - or fails to do - reaches it.
+	 * On the branch before this one the row rode the caller's connection, and the case was green on
+	 * postgres for a reason of that engine alone (openTree() asks there for the cursor index of every
+	 * tree on every open and commits that, carrying the row with it) while the other three lost it.
+	 */
+	@Test
+	public void testAReopenedTreeStaysInTheCatalogWhenItsTransactionFails() throws Exception {
+		final TreeName tree = new TreeName("testCatalogEnrolRollback", "tree");
+		final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null);
+		try { // the tables of the backend, made by a storage that then goes away
+			setUp.open(AccessMode.READ_WRITE);
+			setUp.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+				}
+			});
+		} finally {
+			setUp.close();
+		}
+		// and the rest of what an installation upgraded to a version keeping a catalog holds: a
+		// catalog naming none of those tables
+		emptyTheCatalog(setUp.getTableName(setUp.getCatalogTree()));
+
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			assertFalse(storage.listTrees().contains(tree), "the catalog of the case was not emptied");
+
+			try {
+				storage.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						// the table is there, so this open creates none: the enrolment is the only thing
+						// this transaction has written when it fails
+						txn.openTree(tree, true);
+						// terminal, and no conflict for write() to replay: everything this transaction
+						// still owes goes back, and the row naming a standing table must not be part of it
+						throw new IllegalStateException("the transaction of an openTree failed");
+					}
+				});
+				fail("the write was expected to fail");
+			} catch (Exception expected) {
+				// what the case is about is what the failure left behind
+			}
+
+			assertTrue(storage.listTrees().contains(tree),
+				"the catalog forgot a tree whose table is standing: a clear of this backend would drop nothing");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * A tree the catalog already records at the table this version records it at is not enrolled
+	 * again by an open: the row would be the row that is already there. A row recording any other
+	 * table is, though - a removal drops the table the row records, so a row naming one this backend
+	 * would not create leaves the real table standing, named by nothing and dropped by no clear ever
+	 * after. Which of the two a row is has to be decided by what it records and not by its presence.
+	 */
+	@Test
+	public void testARowRecordingAnotherTableIsEnrolledAgain() throws Exception {
+		final TreeName tree = new TreeName("testCatalogStaleRow", "tree");
+		final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null);
+		try { // the table and its row, by a storage that then goes away
+			setUp.open(AccessMode.READ_WRITE);
+			setUp.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+				}
+			});
+		} finally {
+			setUp.close();
+		}
+		final String catalogTable = setUp.getTableName(setUp.getCatalogTree());
+		// what a version naming its tables otherwise would have left: a row of the right tree
+		// recording a table this one would never create
+		recordAnotherTable(catalogTable, "opendj_00000000000000000000000000000000000000000000000000000000");
+
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+				}
+			});
+
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				assertEquals(
+					storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree),
+					storage.getTableName(tree),
+					"a row recording a table this backend does not hold was left as it was: its tree is named at a table no clear can drop");
+			}
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * A clear which removed no tree of its backend says why, and says it where the one table it did
+	 * drop was its own catalog: a catalog standing over rows that name nothing - the state a backup
+	 * restored beside older tables leaves - is one drop and no tree removed, which is the outcome of
+	 * #888 exactly and not a clear that did something.
+	 * <p>
+	 * Decided on the drops of trees and not on every drop for that reason. Counted the other way the
+	 * line is silent here, since dropping the catalog makes the count one.
+	 */
+	@Test
+	public void testAClearWhichRemovedNoTreeSaysWhyEvenWhereItDroppedItsCatalog() throws Exception {
+		final DN baseDN = DN.valueOf("dc=clear-catalog-only,dc=com");
+		final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
+		final ReportingStorage storage =
+			new ReportingStorage(createBackendCfg(getBackendId() + "_catalogOnly", baseDN));
+		final String catalogTable = storage.getTableName(storage.getCatalogTree());
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(owned, true);
+				}
+			});
+			// the catalog table is there and names nothing, so the clear below has exactly one table to
+			// drop - its own - and leaves the tree standing, named by nothing
+			emptyTheCatalog(catalogTable);
+			storage.close();
+
+			storage.removeStorageFiles();
+
+			assertFalse(isExistsTable(catalogTable), "the clear left its own catalog table standing");
+			assertTrue(isExistsTable(storage.getTableName(owned)),
+				"a table named by no catalog was dropped: nothing may be dropped that cannot be attributed");
+			storage.assertReported("a clear which dropped its catalog and removed no tree of the backend"
+					+ " said nothing about why, which is the silence of #888",
+				"removed no tree of this backend", "has to be started once");
+		} finally {
+			clearQuietly(storage);
+			// left standing on purpose above: its catalog is gone, so no clear of this backend names it
+			dropTableIfExists(storage.getTableName(owned));
+		}
+	}
+
+	/**
+	 * A row recording a name outside the namespace this backend names its tables in is passed over
+	 * rather than reaching a {@code drop table} built from a value read back out of a table - and the
+	 * clear accounts for it, no other line of its report being able to: what such a row records is
+	 * outside the {@code opendj} names the scan of what a clear left standing walks, and is dropped
+	 * by nothing. The row is not there to be read again either - the catalog names itself last, so
+	 * the clear drops that table with the row still in it - which is why the line is asserted here
+	 * along with the drop: it is the only surviving copy of what the row said.
+	 * <p>
+	 * Nothing this version writes makes such a row, which is why the case makes one by hand.
+	 */
+	@Test
+	public void testAClearAccountsForACatalogRowItCannotActOn() throws Exception {
+		final TreeName tree = new TreeName("testCatalogForeignRow", "tree");
+		final ReportingStorage storage = new ReportingStorage(createBackendCfg(getBackendId() + "_foreignRow"));
+		final String tableName = storage.getTableName(tree);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+				}
+			});
+			final String catalogTable = storage.getTableName(storage.getCatalogTree());
+			recordAnotherTable(catalogTable, "a_table_of_something_else");
+
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				final List<String> skipped = new ArrayList<>();
+				assertFalse(storage.readCatalogRows(con, catalogTable, skipped).containsKey(tree),
+					"a row recording a name no table of this backend goes by was read as a tree to drop");
+				assertEquals(skipped.size(), 1, "the row the read passed over was not described to its caller: " + skipped);
+				assertTrue(skipped.get(0).contains("a_table_of_something_else"),
+					"what the row records is named by nothing the clear could report: " + skipped);
+			}
+
+			// the clear still drops what it can: the catalog itself, which it names last
+			storage.removeStorageFiles();
+			assertTrue(isExistsTable(tableName),
+				"the clear dropped the table of a tree its catalog names at another name than that table's");
+			assertFalse(isExistsTable(catalogTable),
+				"the clear left its own catalog table standing, so the row it passed over is still readable"
+					+ " and the line reporting it is not the last copy of what it said");
+			// the report itself and not the read behind it: reportSkippedRows() writes to nothing else,
+			// so both of its call sites could be deleted and every assertion above would still hold
+			storage.assertReported("the row the clear could not act on was reported by no line of it",
+				"a_table_of_something_else", "passed over");
+		} finally {
+			clearQuietly(storage);
+			// left standing on purpose above, so this case removes it rather than the next one meeting it
+			dropTableIfExists(tableName);
+		}
+	}
+
+	/**
+	 * A clear drops the table its catalog records for a tree, not one it derives again from the tree
+	 * name, so that a removal drops what was enrolled even if the naming of tables were ever to
+	 * change. A row recording no table at all - all a version recording the name alone would have
+	 * left - falls back to the derived name rather than naming nothing.
+	 */
+	@Test
+	public void testAClearDropsTheTableTheCatalogRecords() throws Exception {
+		final TreeName tree = new TreeName("testCatalogValue", "tree");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_value"), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(tree, true);
+				}
+			});
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				// asked the way a clear asks it, narrowed to where an unqualified name of the connection
+				// resolves: what the removal reads is this and not a lookup of a shape of its own
+				final Map<TreeName, String> recorded =
+					storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con));
+				assertEquals(recorded.get(tree), storage.getTableName(tree),
+					"the catalog does not record the table holding the tree its row names");
+
+				emptyTheRecordedTableNames(storage.getTableName(storage.getCatalogTree()));
+				assertEquals(storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree),
+					storage.getTableName(tree),
+					"a row recording no table name did not fall back to the name derived from the tree");
+			}
+
+			storage.removeStorageFiles();
+
+			assertFalse(isExistsTable(storage.getTableName(tree)), "the table the catalog named survived the clear");
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * A clear drops the catalog last, after every tree it names: what names the trees has to outlive
+	 * them. Dropping a table is DDL, which mysql and oracle commit as they go, so a clear that fails
+	 * halfway leaves a catalog still naming what is left - and the next attempt finishes it - where one
+	 * that had dropped the catalog first would leave tables nothing names any more and no clear could
+	 * ever reach.
+	 * <p>
+	 * Taken from the drops themselves and not from the map the loop walks: the map is built with the
+	 * catalog put last by hand, so an assertion on it would hold of any loop at all - one that sorted
+	 * the keys, or copied them into a HashSet, included.
+	 */
+	@Test
+	public void testAClearDropsTheCatalogAfterEveryTreeItNames() throws Exception {
+		final TreeName first = new TreeName("testCatalogDropOrder", "first");
+		final TreeName second = new TreeName("testCatalogDropOrder", "second");
+		final List<String> order = new ArrayList<>();
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_order"), null) {
+			@Override
+			void dropTable(Connection con, String tableName) throws SQLException {
+				order.add(tableName);
+				super.dropTable(con, tableName);
+			}
+		};
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(first, true);
+					txn.openTree(second, true);
+				}
+			});
+
+			storage.removeStorageFiles();
+
+			final String catalogTable = storage.getTableName(storage.getCatalogTree());
+			assertTrue(order.contains(storage.getTableName(first)) && order.contains(storage.getTableName(second)),
+				"the clear did not drop the tables of the trees its catalog names: " + order);
+			assertEquals(order.get(order.size() - 1), catalogTable,
+				"the clear dropped the catalog before a tree it names, which no later clear could reach: " + order);
+			assertEquals(order.indexOf(catalogTable), order.size() - 1,
+				"the catalog was dropped more than once: " + order);
+		} finally {
+			clearQuietly(storage);
+		}
+	}
+
+	/**
+	 * What a clear leaves standing it reports, and it reports it as what it is: a table stamped with a
+	 * tree of a base DN this backend serves is its own and can be removed by hand, while a table of a
+	 * backend sharing this database (#873) is that backend's business and no part of this outcome.
+	 * Told apart by the stamp of #866 and by nothing else - a table name is a bare hash.
+	 */
+	@Test
+	public void testAClearReportsTheTablesItCanAttributeToThisBackend() throws Exception {
+		final DN baseDN = DN.valueOf("dc=clear-report,dc=com");
+		final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
+		// a base DN of its own, so that the neighbour is a backend that reports this table as its own:
+		// what this case asserts about the clear of the other one is then an absence and not a vacuity
+		final DN neighbourBaseDN = DN.valueOf("dc=clear-report-neighbour,dc=com");
+		final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "id2entry");
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reported", baseDN), null);
+		final JDBCStorage neighbour =
+			new JDBCStorage(createBackendCfg(getBackendId() + "_reportedNeighbour", neighbourBaseDN), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(owned, true);
+				}
+			});
+			neighbour.open(AccessMode.READ_WRITE);
+			neighbour.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(neighbourTree, true);
+				}
+			});
+			// the state of a backend upgraded from a version keeping no catalog: its tables are there
+			// and nothing names them, so the clear that follows drops nothing at all
+			dropTableBehindTheBackend(storage.getTableName(storage.getCatalogTree()));
+			storage.close();
+
+			storage.removeStorageFiles();
+
+			assertTrue(isExistsTable(storage.getTableName(owned)),
+				"a table named by no catalog was dropped: nothing may be dropped that cannot be attributed");
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				// asked the way a clear asks it, through the same normalisation: a driver naming its
+				// catalog with an empty string names no catalog, and a metadata pattern reads that as
+				// "the tables belonging to no catalog at all", which would answer nothing
+				final JDBCStorage.ClearLeftovers leftovers =
+					storage.leftoverTables(con, JDBCStorage.TableScope.of(storage, con));
+				assertNotNull(leftovers, "the database would not say which tables the clear left standing");
+				assertTrue(leftovers.ours.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()),
+					"a table of a base DN this backend serves was not reported as its own: " + leftovers.ours);
+				assertFalse(leftovers.unattributed.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()),
+					"a table this backend can name was reported as attributable to nobody: " + leftovers.unattributed);
+				assertTrue(leftovers.unreadable.isEmpty(),
+					"the stamp of a table this database does give up was reported as unreadable: " + leftovers.unreadable);
+			}
+			assertReportsNothingOf(storage, neighbour, neighbourTree);
+		} finally {
+			clearQuietly(neighbour);
+			// the catalog of this one is gone, so its clear names nothing: the table it left standing on
+			// purpose is dropped here by hand, as the report says such a table has to be
+			clearQuietly(storage);
+			dropTableIfExists(storage.getTableName(owned));
+		}
+	}
+
+	/**
+	 * Asserts that the clear of one backend says nothing whatsoever about the tables of another - and
+	 * that the silence is one about tables the scan does reach: the backend those tables belong to is
+	 * asked the same question and reports them as its own, so an absence here is a decision and not a
+	 * scan that enumerated nothing.
+	 */
+	private void assertReportsNothingOf(JDBCStorage cleared, JDBCStorage other, TreeName otherTree) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+			final JDBCStorage.ClearLeftovers leftovers =
+				cleared.leftoverTables(con, JDBCStorage.TableScope.of(cleared, con));
+			assertNotNull(leftovers, "the database would not say which tables the clear left standing");
+			final String reported =
+				(leftovers.ours + " " + leftovers.unattributed + " " + leftovers.unreadable).toLowerCase();
+			assertFalse(reported.contains(other.getTableName(otherTree).toLowerCase()),
+				"the clear of one backend reported the table of another: " + reported);
+			assertFalse(reported.contains(other.getTableName(other.getCatalogTree()).toLowerCase()),
+				"the clear of one backend reported the catalog of another: " + reported);
+
+			final JDBCStorage.ClearLeftovers theirs =
+				other.leftoverTables(con, JDBCStorage.TableScope.of(other, con));
+			assertNotNull(theirs, "the database would not say which tables the neighbour is holding");
+			assertTrue(theirs.ours.toString().toLowerCase().contains(other.getTableName(otherTree).toLowerCase()),
+				"the table left unreported is one the scan does not reach at all: " + theirs.ours);
+		}
+	}
+
+	/**
+	 * A clear which dropped nothing at all says why, where the only thing it had to say is a row it
+	 * could not act on: the catalog table went between the read of its rows and the loop that drops
+	 * what they named - an offline tool clearing the same backend, and the one state in which a clear
+	 * passes a row over and still drops nothing - so the account of it has no drop, no tree that had
+	 * lost its table and no leftover of this backend to be decided by. The row is what is left, and
+	 * the line reporting it is the only copy of what that row said.
+	 * <p>
+	 * What the case pins is one term of that condition. It pins it on a database holding nothing of
+	 * anybody else that the scan cannot attribute - the fragments asserted are the counts this case
+	 * owns, and a neighbour of another suite leaving an unstamped table would make the line fire for
+	 * a reason of its own rather than fail this.
+	 */
+	@Test
+	public void testAClearWhichDroppedNothingSaysWhyWhereARowItPassedOverIsAllItHad() throws Exception {
+		final DN baseDN = DN.valueOf("dc=clear-catalog-race,dc=com");
+		final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry");
+		final ReportingStorage storage =
+			new ReportingStorage(createBackendCfg(getBackendId() + "_catalogRace", baseDN));
+		final String catalogTable = storage.getTableName(storage.getCatalogTree());
+		final String ownedTable = storage.getTableName(owned);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(owned, true);
+				}
+			});
+			// the one row of the catalog now records a name outside the namespace this backend names
+			// its tables in, so the read passes it over and the catalog names itself alone
+			recordAnotherTable(catalogTable, "a_table_of_something_else");
+			// and nothing of this backend is left standing for the scan to attribute to it
+			dropTableBehindTheBackend(ownedTable);
+			storage.close();
+
+			// the table goes while the clear is running, which is what leaves the clear with nothing
+			// dropped: an offline tool clearing the same backend a moment earlier. At the second
+			// lookup and not the first, so that the rows are read before the table goes - the first
+			// is catalogTables() asking whether there is a catalog at all
+			storage.takeAwayAtLookupNumber(catalogTable, 2);
+
+			storage.removeStorageFiles();
+
+			assertFalse(isExistsTable(catalogTable), "the catalog table this case takes away was still there");
+			storage.assertReported("a clear which dropped nothing at all and passed a row over said nothing"
+					+ " about why, which is the silence of #888",
+				"the clear removed no tree of this backend", "it dropped 0 table(s) in all",
+				"0 of the trees its catalog names had lost their table already",
+				"and 0 table(s) of this backend were named by no catalog");
+			storage.assertReported("the row the clear could not act on was named by no line of it",
+				"a_table_of_something_else", "passed over");
+		} finally {
+			clearQuietly(storage);
+			dropTableIfExists(ownedTable);
+			dropTableIfExists(catalogTable);
+		}
+	}
+
+	/**
+	 * Takes every row out of a catalog, leaving the tables it named standing: what a backend upgraded
+	 * from a version keeping no catalog holds before its first read-write open fills one in.
+	 */
+	private void emptyTheCatalog(String catalogTable) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+			 final Statement st = con.createStatement()) {
+			st.executeUpdate("delete from " + catalogTable);
+		}
+	}
+
+	/**
+	 * Records the given table for every row of a catalog, as a version naming its tables otherwise
+	 * would have left them: the row names the right tree and a table this version never creates.
+	 */
+	private void recordAnotherTable(String catalogTable, String tableName) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+			 final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) {
+			statement.setBytes(1, tableName.getBytes(StandardCharsets.UTF_8));
+			statement.executeUpdate();
+		}
+	}
+
+	/** Empties the recorded table name of every row of a catalog, as a version recording none would have left it. */
+	private void emptyTheRecordedTableNames(String catalogTable) throws SQLException {
+		try (final Connection con = DriverManager.getConnection(getJdbcUrl());
+			 final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) {
+			statement.setBytes(1, new byte[0]);
+			statement.executeUpdate();
+		}
+	}
+
+	/**
+	 * Drops a table a clear left standing on purpose, so that it is not left behind for the rest of
+	 * the class. A failure here is swallowed rather than replacing the failure of the case it cleans
+	 * up after: what it leaves is dropped by the dropStaleTrees() of the next run of the class.
+	 */
+	private void dropTableIfExists(String tableName) {
+		try {
+			if (isExistsTable(tableName)) {
+				dropTableBehindTheBackend(tableName);
+			}
+		} catch (SQLException ignored) {
+		}
+	}
+
+	/**
+	 * A storage which keeps the lines every clear it runs reports, so that a case can hold that
+	 * account to what it says.
+	 * <p>
+	 * Those lines change no state whatsoever, so a case asserting on the database a clear leaves
+	 * behind passes just as well with all of them deleted - which is how this report came to be
+	 * changed in three rounds of review with nothing able to fail. Here rather than in the case that
+	 * needed it first, for the same reason: the next line of the report wants an assertion too, and a
+	 * helper per case is what got the report where it was. See {@link JDBCStorage#reportClearLine}.
+	 */
+	protected static final class ReportingStorage extends JDBCStorage {
+		private final List<String> lines = Collections.synchronizedList(new ArrayList<String>());
+
+		private volatile String tableToTakeAway;
+		private final AtomicInteger lookupsToLetPass = new AtomicInteger();
+
+		ReportingStorage(JDBCBackendCfg cfg) {
+			super(cfg, null);
+		}
+
+		@Override
+		void reportClearLine(LocalizableMessage line) {
+			lines.add(line.toString());
+			super.reportClearLine(line); // and on to the log, which is where an operator meets it
+		}
+
+		/**
+		 * Takes the named table away just before the given lookup of it, counting from the next one,
+		 * so that the lookup answers as another process taking the table a moment earlier would have
+		 * made it answer. What it models is the one state a clear cannot be put into from outside: a
+		 * table going between the read of the catalog and the loop that drops what that read named.
+		 * <p>
+		 * Which lookup matters, and the count is not decoration: a clear asks about its catalog table
+		 * twice - once in {@code catalogTables()} to decide whether there is a catalog to read at all,
+		 * and once in the drop loop, per entry. Taken away before the first, the clear reads no row,
+		 * passes none over and reports nothing, which is a different case from this one.
+		 * <p>
+		 * Dropped on the very connection the lookup is made on, and not on one of the test's own: the
+		 * clear holds its read of the catalog table until it commits, so a {@code drop table} issued
+		 * from a second session would queue behind the transaction that is waiting for this call to
+		 * return. Inside that transaction the drop takes no lock it does not already hold, and it is
+		 * committed with the loop - or, on the two engines committing DDL as they go, at once.
+		 */
+		void takeAwayAtLookupNumber(String tableName, int nth) {
+			lookupsToLetPass.set(nth - 1);
+			tableToTakeAway = tableName;
+		}
+
+		@Override
+		boolean isExistsTable(Connection con, JDBCStorage.TableScope scope, String tableName) {
+			final String taking = tableToTakeAway;
+			if (taking != null && taking.equalsIgnoreCase(tableName)
+					&& lookupsToLetPass.getAndDecrement() <= 0) {
+				tableToTakeAway = null; // once: every later lookup is answered by the database alone
+				try (final PreparedStatement statement = con.prepareStatement("drop table " + taking)) {
+					statement.execute();
+				} catch (SQLException e) {
+					throw new IllegalStateException("the table this case takes away could not be dropped", e);
+				}
+			}
+			return super.isExistsTable(con, scope, tableName);
+		}
+
+		/** Every line reported so far, in the order the clears that reported them ran. */
+		List<String> reported() {
+			synchronized (lines) {
+				return new ArrayList<>(lines);
+			}
+		}
+
+		/**
+		 * Fails unless one reported line holds every one of the fragments. By fragments and not by the
+		 * whole line: what a case is entitled to pin is the thing the line is about, and a report
+		 * asserted word for word is a report nobody may improve the wording of.
+		 */
+		void assertReported(String whatWentUnsaid, String... fragments) {
+			for (final String line : reported()) {
+				boolean holdsAll = true;
+				for (final String fragment : fragments) {
+					holdsAll &= line.contains(fragment);
+				}
+				if (holdsAll) {
+					return;
+				}
+			}
+			fail(whatWentUnsaid + "; the clear reported: " + reported());
+		}
+	}
 }

--
Gitblit v1.10.0