From 655132fb059cfa70fb338d54f1bb0fb48198fb0f Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 02 Sep 2026 06:44:10 +0000
Subject: [PATCH] [#873] Give each backend its own compressed schema trees (#881)
---
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 205 insertions(+), 0 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 22e8ea1..cd8057e 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
@@ -237,6 +237,211 @@
}
/**
+ * treeExists() has to answer for a table that was never created rather than fail: it is how the
+ * compressed schema tells a backend with nothing to migrate from one whose definitions are still
+ * under the shared prefix (#873), and every other statement of this storage fails outright on a
+ * table that does not exist.
+ */
+ @Test
+ public void testTreeExistsAnswersForAMissingTable() throws Exception {
+ final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+ final TreeName present = new TreeName("testTreeExists", "present");
+ final TreeName absent = new TreeName("testTreeExists", "absent");
+ try {
+ storage.open(AccessMode.READ_WRITE);
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.openTree(present, true);
+ assertTrue(txn.treeExists(present));
+ assertFalse(txn.treeExists(absent));
+ }
+ });
+ // the read path has to answer as well: export-ldif and verify-index open read-only, where
+ // no tree is created and the question cannot be settled by writing one
+ storage.read(new ReadOperation<Void>() {
+ @Override
+ public Void run(ReadableTransaction txn) throws Exception {
+ assertTrue(txn.treeExists(present));
+ assertFalse(txn.treeExists(absent));
+ return null;
+ }
+ });
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(present);
+ assertFalse(txn.treeExists(present));
+ }
+ });
+ } finally {
+ try {
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(present);
+ }
+ });
+ } catch (Exception ignored) {}
+ storage.close();
+ }
+ }
+
+ /**
+ * The compressed schema definitions of this backend must live in a table of its own. The tree
+ * name they used to carry held no backend qualifier, so its table name was a constant that every
+ * JDBC backend of every server sharing the database mapped to, and two of them overwrote each
+ * other's token definitions there (#873). The backend of this suite has been opened and populated
+ * by PluggableBackendImplTestCase#setUp, so its own table exists by now.
+ */
+ @Test
+ public void testCompressedSchemaTableIsQualifiedByBackendId() throws Exception {
+ final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+ try {
+ storage.open(AccessMode.READ_WRITE);
+ final String shared = JDBCStorage.toTableName(new TreeName("compressed_schema", "compressed_attributes"));
+ final String own = JDBCStorage.toTableName(
+ new TreeName("compressed_schema_" + getBackendId(), "compressed_attributes"));
+ assertFalse(shared.equals(own), "the qualified tree name must map to a table of its own");
+ try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+ assertTrue(isExistingTable(con, own), own + " (this backend's own definitions) is missing");
+ assertFalse(isExistingTable(con, shared), shared + " is the table every backend used to share");
+ }
+ } finally {
+ storage.close();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ @Test
+ public void testProbingATreeDoesNotPutItUpForRemoval() throws Exception {
+ final TreeName foreign = new TreeName("testProbe", "foreign");
+ final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
+ 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));
+ }
+ });
+ owner.close();
+
+ // a second storage on the same database, which never opened that tree - the shape of two
+ // backends addressing one database
+ final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
+ try {
+ other.open(AccessMode.READ_WRITE);
+ other.read(new ReadOperation<Void>() {
+ @Override
+ public Void run(ReadableTransaction txn) throws Exception {
+ // every read the compressed schema runs against a tree it does not own: it asks
+ // whether the tree is there, counts it, reads a key of it and walks it (#873)
+ assertTrue(txn.treeExists(foreign));
+ assertEquals(txn.getRecordCount(foreign), 1);
+ assertEquals(txn.read(foreign, key(1)), value(1));
+ try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(foreign)) {
+ assertTrue(cursor.next());
+ assertEquals(cursor.getKey(), key(1));
+ }
+ return null;
+ }
+ });
+ assertFalse(other.listTrees().contains(foreign), "a tree only read must not be listed for removal");
+
+ other.removeStorageFiles();
+
+ try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+ assertTrue(isExistingTable(con, JDBCStorage.toTableName(foreign)),
+ "clearing one backend dropped a table it had only asked about");
+ }
+ } finally {
+ other.close();
+ final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
+ try {
+ cleanup.open(AccessMode.READ_WRITE);
+ cleanup.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(foreign);
+ }
+ });
+ } catch (Exception ignored) {
+ } finally {
+ cleanup.close();
+ }
+ }
+ }
+
+ /**
+ * The other side of the same rule: a cursor reads through the non-enrolling name, but deleting
+ * through one writes to the tree, so it is a tree this backend owns and removeStorageFiles()
+ * has to be able to name it.
+ */
+ @Test
+ public void testDeletingThroughACursorPutsTheTreeUpForRemoval() throws Exception {
+ final TreeName tree = new TreeName("testCursorDelete", "tree");
+ final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
+ owner.open(AccessMode.READ_WRITE);
+ owner.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.openTree(tree, true);
+ txn.put(tree, key(1), value(1));
+ }
+ });
+ owner.close();
+
+ // a storage that never opened that tree, so nothing but the delete can enrol it
+ final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
+ try {
+ other.open(AccessMode.READ_WRITE);
+ other.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
+ assertTrue(cursor.next());
+ cursor.delete();
+ }
+ }
+ });
+ assertTrue(other.listTrees().contains(tree), "a tree written through a cursor must be listed for removal");
+ } finally {
+ other.close();
+ final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
+ try {
+ cleanup.open(AccessMode.READ_WRITE);
+ cleanup.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(tree);
+ }
+ });
+ } catch (Exception ignored) {
+ } finally {
+ cleanup.close();
+ }
+ }
+ }
+
+ private static boolean isExistingTable(Connection con, String tableName) throws SQLException {
+ try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) {
+ while (rs.next()) {
+ if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
* Forward repositioning inside the already-fetched batch must be served from the buffer without SQL,
* and batch sizes must grow from "fetchsize.initial" to "fetchsize" on sequential reads (#860).
*/
--
Gitblit v1.10.0