opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -1205,11 +1205,13 @@ * The catalog is per backend and named after the backend id alone: a process that has opened * nothing can still find its table, and backends sharing one database URL - which nothing * forbids (#873) - never name each other's trees. The id goes in escaped, for the reason {@link * #escapedBackendId} states: a name that does not survive being read back is a table of this * backend that its own clear cannot recognize. * OwnNames#escapedBackendId} states: a name that does not survive being read back is a table of * this backend that its own clear cannot recognize. * <p> * Built once and remembered; see {@link OwnNames}. */ TreeName getCatalogTree() { return new TreeName(CATALOG_BASE_DN, escapedBackendId()); return ownNames().catalogTree; } /** @@ -3287,6 +3289,8 @@ leftovers.unreadable.addAll(standing); return leftovers; } // normalized once for the whole scan and not per table: see isOwnTree() final Set<String> ownBaseDNs=ownBaseDNs(); for (final String tableName : standing) { final TreeName stamp; try { @@ -3306,7 +3310,7 @@ } if (stamp==null) { leftovers.unattributed.add(tableName); } else if (isOwnTree(stamp)) { } else if (isOwnTree(stamp, ownBaseDNs)) { leftovers.ours.add(tableName+" ("+stamp+")"); } } @@ -3347,23 +3351,75 @@ * here for the reason {@link #SHARED_COMPRESSED_SCHEMA_TREES} is: the prefix is built by a private * method of {@code PersistentCompressedSchema}, escapes and all. A table stamped with one of these * carries this backend's id in plain text, so a clear that finds one standing can say whose it is. * <p> * Built once and remembered; see {@link OwnNames}. */ private String ownCompressedSchemaBaseDN() { return SHARED_COMPRESSED_SCHEMA_BASE_DN+"_"+escapedBackendId(); String ownCompressedSchemaBaseDN() { return ownNames().compressedSchemaBaseDN; } /** * The backend id as one component of a tree name. A tree name is {@code /<base DN>/<id>} and is * read back by splitting on its slashes ({@code TreeName.valueOf}), so an id carrying one of them * would name a tree that parses into another tree than it was built from - and a table is stamped * with that name (#866), so a clear reading the stamp of a table of this backend's own would then * fail to recognize it and pass it over in silence. The escape is the one {@code * PersistentCompressedSchema} spells its own prefix with, percent first so that the escape of the * slash cannot be produced twice, and it leaves an id of the ordinary shape exactly as it is - * which is what keeps the table names of an installation unchanged. * The names this backend gives the trees that are its own rather than a base DN's: the escaped * id of {@link #escapedBackendId}, the catalog tree of {@link #getCatalogTree} and the base DN of * {@link #ownCompressedSchemaBaseDN}. Held in one object so that the three can never come from * two different ids. * <p> * Read from the configuration once rather than per call, for the reason {@link * #poolConnectionString} is read once: {@code applyConfigurationChange()} replaces {@code config} * whole, while the tables of this storage - the catalog table among them - stand under the id * they were created with, and a name read again from a configuration that has moved would leave * this storage naming a catalog nothing has ever written and its own tables attributed to * nobody. The configuration framework holds backend-id read-only - "The backend ID may not be * altered after the backend is created in the server" - so no change made through it renames a * live backend, which is what makes reading the id once correct in the first place. * <p> * That it also takes the escape and the allocation off every enrolment, off every clear and off * every table of a clear's leftover scan (#930) is the smaller half of it: every one of those * call sites is already paying a round trip to the database. */ private String escapedBackendId() { return config.getBackendId().replace("%", "%25").replace("/", "%2F"); private static final class OwnNames { /** * The backend id as one component of a tree name. A tree name is {@code /<base DN>/<id>} and is * read back by splitting on its slashes ({@code TreeName.valueOf}), so an id carrying one of * them would name a tree that parses into another tree than it was built from - and a table is * stamped with that name (#866), so a clear reading the stamp of a table of this backend's own * would then fail to recognize it and pass it over in silence. The escape is the one {@code * PersistentCompressedSchema} spells its own prefix with, percent first so that the escape of * the slash cannot be produced twice, and it leaves an id of the ordinary shape exactly as it * is - which is what keeps the table names of an installation unchanged. */ final String escapedBackendId; final TreeName catalogTree; final String compressedSchemaBaseDN; OwnNames(String backendId) { escapedBackendId=backendId.replace("%", "%25").replace("/", "%2F"); catalogTree=new TreeName(CATALOG_BASE_DN, escapedBackendId); compressedSchemaBaseDN=SHARED_COMPRESSED_SCHEMA_BASE_DN+"_"+escapedBackendId; } } private volatile OwnNames ownNames; /** * The names above, built at the first call that needs one, and not in the constructor: a storage * is constructed by callers that go on to name no tree of it at all - the bounds of a statement * are asked of one whose configuration carries no backend id whatsoever in the tests of {@code * JDBCStatementBoundTestCase} - and a construction reading the id would fail there, where today * nothing reads it. * <p> * Two callers arriving at once may each build one, and the names of both are the same names. * Every field of {@link OwnNames} is final, so a caller reading the reference reads the names * whole and not half-built. */ private OwnNames ownNames() { final OwnNames built=ownNames; if (built!=null) { return built; } final OwnNames names=new OwnNames(config.getBackendId()); ownNames=names; return names; } /** @@ -3374,23 +3430,33 @@ * backend id (#873) and so belongs to this backend as plainly as any tree of a base DN it serves - * where the legacy pair, named from a literal, belongs to no backend in particular and is reported * by nobody. * <p> * The base DNs are handed in rather than read here: they are the same for every table of one * scan, and normalizing a DN builds its string from every RDN of it ({@code * DN.toNormalizedUrlSafeString} memoizes nothing), which is a cost the caller pays once instead * of once per table. */ private boolean isOwnTree(TreeName treeName) { if (getCatalogTree().equals(treeName) || ownCompressedSchemaBaseDN().equals(treeName.getBaseDN())) { return true; } private boolean isOwnTree(TreeName treeName, Set<String> ownBaseDNs) { return getCatalogTree().equals(treeName) || ownCompressedSchemaBaseDN().equals(treeName.getBaseDN()) || ownBaseDNs.contains(treeName.getBaseDN()); } /** * The base DNs this backend serves, in the form the trees of an entry container are named after: * every one of them is named from the normalized base DN, which is what {@code EntryContainer} * builds its tree names from. */ private Set<String> ownBaseDNs() { final SortedSet<DN> baseDNs=config.getBaseDN(); if (baseDNs==null) { return false; return Collections.emptySet(); } final Set<String> normalized=new HashSet<>(); for (final DN baseDN : baseDNs) { // every tree of an entry container is named after the normalized form of its base DN, // which is what EntryContainer builds its tree names from if (treeName.getBaseDN().equals(baseDN.toNormalizedUrlSafeString())) { return true; } normalized.add(baseDN.toNormalizedUrlSafeString()); } return false; return normalized; } /** opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogNameTestCase.java
New file @@ -0,0 +1,103 @@ /* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.server.backends.jdbc; import org.forgerock.opendj.server.config.server.JDBCBackendCfg; import org.opends.server.DirectoryServerTestCase; import org.opends.server.backends.pluggable.spi.TreeName; import org.testng.annotations.Test; import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; /** * The names a JDBC backend gives the trees that are its own rather than a base DN's: its catalog * (#888) and its pair of compressed schema trees (#881). Both are built from the backend id, and * the tables behind them are created under the id the storage was built with - so what this class * pins is that the names do not move under a storage that has already created them. * <p> * No database is needed: the names are read from the configuration and nothing else. */ @SuppressWarnings("javadoc") public class CatalogNameTestCase extends DirectoryServerTestCase { private static JDBCStorage storageFor(String backendId) { final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); when(cfg.getBackendId()).thenReturn(backendId); return new JDBCStorage(cfg, null); } /** * The catalog is named after the backend id, and after the one this storage was built with: a * configuration handed to {@code applyConfigurationChange()} replaces {@code config} whole, so a * name read from it again would follow an id changed under a running backend - and the tables of * this storage, the catalog table among them, stand under the id they were created with. The * backend id is read-only in the configuration framework and no such change can be made through * it, which is exactly why the storage may read it once; a rename reaching this method by any * other route must not leave the storage naming a catalog nothing has ever written. */ @Test public void testTheCatalogKeepsTheBackendIdTheStorageWasBuiltWith() { final JDBCStorage storage = storageFor("pinnedBackend"); final TreeName built = storage.getCatalogTree(); assertEquals(built, new TreeName(JDBCStorage.CATALOG_BASE_DN, "pinnedBackend")); final JDBCBackendCfg renamed = mockCfg(JDBCBackendCfg.class); when(renamed.getBackendId()).thenReturn("renamedBackend"); storage.applyConfigurationChange(renamed); assertEquals(storage.getCatalogTree(), built, "the catalog followed a backend id changed under the storage, naming a table nothing created"); } /** * The second name built from the id, the base DN of this backend's own pair of compressed schema * trees (#881), is pinned for the reason the catalog is: the pair stands under the id the storage * created it with, and a name read again from a configuration that has moved would leave a clear * reporting this backend's own pair as a table attributable to nobody. */ @Test public void testTheCompressedSchemaNameKeepsTheBackendIdTheStorageWasBuiltWith() { final JDBCStorage storage = storageFor("pinnedBackend"); final String built = storage.ownCompressedSchemaBaseDN(); assertEquals(built, JDBCStorage.SHARED_COMPRESSED_SCHEMA_BASE_DN + "_pinnedBackend"); final JDBCBackendCfg renamed = mockCfg(JDBCBackendCfg.class); when(renamed.getBackendId()).thenReturn("renamedBackend"); storage.applyConfigurationChange(renamed); assertEquals(storage.ownCompressedSchemaBaseDN(), built, "the compressed schema pair followed a backend id changed under the storage"); } /** * Both names carry the id escaped, and escaped the way {@code PersistentCompressedSchema} spells * the prefix of the same pair: the percent first, so that the escape of a slash cannot itself be * escaped a second time. A tree name is {@code /<base DN>/<id>} and is read back by splitting on * its slashes, so an id spelled into one unescaped names a tree that parses into another tree * than it was built from - and the clear reading the stamp of such a table would pass a table of * this backend's own over in silence. An id of that shape is possible: {@code backend-id} is a * plain string in the configuration definition, constrained to nothing. */ @Test public void testBothNamesEscapeTheSlashAndThePercentOfTheBackendId() { final JDBCStorage storage = storageFor("a/b%c"); assertEquals(storage.getCatalogTree(), new TreeName(JDBCStorage.CATALOG_BASE_DN, "a%2Fb%25c")); assertEquals(storage.ownCompressedSchemaBaseDN(), JDBCStorage.SHARED_COMPRESSED_SCHEMA_BASE_DN + "_a%2Fb%25c"); } } opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -2909,9 +2909,10 @@ /** * 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. * tree of a base DN this backend serves, or with one of the trees this backend names after its own * id, 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 { @@ -2922,6 +2923,9 @@ 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); // one of the pair this backend names after its own id (#881) rather than after a base DN: such // a table is this backend's own by a term of the decision that no base DN of it can answer for final TreeName ownSchema = new TreeName(storage.ownCompressedSchemaBaseDN(), "compressed_attributes"); final JDBCStorage neighbour = new JDBCStorage(createBackendCfg(getBackendId() + "_reportedNeighbour", neighbourBaseDN), null); try { @@ -2930,6 +2934,7 @@ @Override public void run(WriteableTransaction txn) throws Exception { txn.openTree(owned, true); txn.openTree(ownSchema, true); } }); neighbour.open(AccessMode.READ_WRITE); @@ -2959,6 +2964,8 @@ "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.ours.toString().toLowerCase().contains(storage.getTableName(ownSchema).toLowerCase()), "a table of this backend's own compressed schema pair was not reported as its own: " + leftovers.ours); assertTrue(leftovers.unreadable.isEmpty(), "the stamp of a table this database does give up was reported as unreadable: " + leftovers.unreadable); } @@ -2969,6 +2976,7 @@ // purpose is dropped here by hand, as the report says such a table has to be clearQuietly(storage); dropTableIfExists(storage.getTableName(owned)); dropTableIfExists(storage.getTableName(ownSchema)); } } @@ -2995,6 +3003,11 @@ 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); // and the catalog standing beside it is that backend's own: the clear of the case above // dropped its own catalog by hand, so this is where a catalog is there to be reported assertTrue(theirs.ours.toString().toLowerCase() .contains(other.getTableName(other.getCatalogTree()).toLowerCase()), "a backend's own standing catalog was not reported as its own: " + theirs.ours); } }