mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
11 hours ago 774272578900c27258f497fd01da347de49e430f
[#930] Build the names a JDBC backend gives its own trees once, from the id it was created with (#1007)
2 files modified
1 files added
242 ■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java 120 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogNameTestCase.java 103 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java 19 ●●●● patch | view | raw | blame | history
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);
        }
    }