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