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

Valery Kharseko
17 hours ago 655132fb059cfa70fb338d54f1bb0fb48198fb0f
[#873] Give each backend its own compressed schema trees (#881)
13 files modified
1 files added
1253 ■■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java 60 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java 140 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java 28 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java 40 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java 12 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java 299 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java 5 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java 18 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java 23 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/backend.properties 6 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java 63 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java 205 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java 17 ●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java 337 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java
@@ -27,6 +27,7 @@
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.slf4j.LocalizedLogger;
@@ -63,6 +64,7 @@
import com.datastax.oss.driver.api.core.cql.ResultSet;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.cql.Statement;
import com.datastax.oss.driver.api.core.servererrors.InvalidQueryException;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
@@ -204,6 +206,13 @@
            System.setProperty("datastax-java-driver.profiles."+profile+".basic.request.timeout", "30 seconds");
        }
    }
    // The wordings a server uses for a table or a keyspace that is not there. Matched rather than
    // the exception type, which covers the whole INVALID protocol code: see namesAnAbsentTable().
    // "Undefined column name ..." deliberately does not match - that table exists.
    static final Pattern ABSENT_TABLE=Pattern.compile(
        "unconfigured (table|columnfamily)|(table|keyspace)[^,]* does not exist|undefined (table|keyspace)",
        Pattern.CASE_INSENSITIVE);
    private final class TransactionImpl implements ReadableTransaction,WriteableTransaction {
        
        final AccessMode accessMode;
@@ -248,6 +257,57 @@
        }
        @Override
        public boolean treeExists(TreeName treeName) {
            // Every tree of this backend is a partition of the one table named after the backend id,
            // so a tree has no existence apart from its rows: "exists" here means "holds at least one
            // record". A LIMIT 1 lookup rather than getRecordCount() to avoid the full partition scan
            // a count would run.
            try {
                return execute(
                    prepared.get("SELECT key FROM "+getTableName()+" WHERE baseDN=:baseDN and indexId=:indexId LIMIT 1").bind()
                        .setString("baseDN", treeName.getBaseDN()).setString("indexId", treeName.getIndexId())
                ).one()!=null;
            }catch (InvalidQueryException e) {
                // The backend's own table has not been created yet - a read-only open of a backend
                // that was never written, where openTree() creates nothing - so none of its trees
                // can exist either. The driver reports it from prepare() as much as from execute().
                //
                // Narrowly, twice over, because calling a populated tree absent would have the
                // compressed schema restart its token allocation from zero and overwrite the
                // definitions the entries already written were encoded with (#873):
                //
                // - InvalidQueryException carries the whole INVALID protocol code, so the rejection
                //   has to name an absent table or keyspace. An undefined column, say, means the
                //   table is there with a shape this backend did not write, and answering "absent"
                //   for it would be that same corruption;
                // - and only a read-only open may answer it at all. A writeable open has just run
                //   CREATE TABLE IF NOT EXISTS through openTree(), so a rejection there is a fault
                //   and must fail the open, as it did before this method existed and loadTrees()
                //   opened its cursor unconditionally. This is also where a table a coordinator has
                //   not caught up with lands - what a rolling upgrade produces, since schema
                //   agreement is never reached in a mixed-version cluster - and it fails loudly
                //   rather than being taken for a table that was never created.
                if (accessMode.isWriteable() || !namesAnAbsentTable(e)) {
                    throw e;
                }
                return false;
            }
        }
        /**
         * Whether a rejected query says that the table or the keyspace is not there, as opposed to
         * anything else the INVALID protocol code covers. The driver offers nothing finer than the
         * server's own message - the code is one value for the whole bucket - so the wordings of
         * the server are matched, across the versions that changed them ("unconfigured
         * columnfamily" became "unconfigured table", and a keyspace is reported as not existing).
         * A wording that is not among them is not read as an absent table: it fails the open, which
         * is the side to err on.
         */
        private boolean namesAnAbsentTable(InvalidQueryException e) {
            return e.getMessage()!=null && ABSENT_TABLE.matcher(e.getMessage()).find();
        }
        @Override
        public void deleteTree(TreeName treeName) {
            checkReadOnly();
            openTree(treeName,true);
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -205,28 +205,63 @@
        unstampableTrees.clear();
    }
    // The trees this storage has taken an interest in, and the tables they map to. listTrees() -
    // and through it removeStorageFiles() - reads this, so a tree only belongs here once this
    // backend uses it: see toTableName() below for the trees that are merely asked about.
    final LoadingCache<TreeName,String> tree2table = Caffeine.newBuilder()
        .build(treeName -> {
            try {
                final MessageDigest md = MessageDigest.getInstance("SHA-224");
                final byte[] messageDigest = md.digest(treeName.toString().getBytes());
                final StringBuilder hashtext = new StringBuilder(56);
                for (byte b : messageDigest) {
                    String hex = Integer.toHexString(0xff & b);
                    if (hex.length() == 1) hashtext.append('0');
                    hashtext.append(hex);
                }
                return "opendj_" + hashtext;
            } catch (NoSuchAlgorithmException e) {
                throw new RuntimeException(e);
        .build(JDBCStorage::toTableName);
    /**
     * The table a tree name maps to. A pure function of the name, so that a tree can be read
     * without being entered into tree2table: the compressed schema reads the tree its definitions
     * used to be shared under (#873), a tree this backend does not own, and removeStorageFiles()
     * drops every table tree2table names.
     * <p>
     * Which of the two a statement takes therefore says who owns the tree it names: a path that
     * creates or writes one - openTree(), clearTree(), deleteTree(), put(), update(), delete() -
     * takes the enrolling {@link #getTableName(TreeName)}, and a read-only path - read(),
     * getRecordCount(), isExistsTable() and the cursor - takes {@link #readTableName(TreeName)},
     * which computes this only for a tree that is not enrolled already. Every tree this backend
     * owns passes through openTree(name, true) as it is opened, so listTrees() still names the
     * complete owned set.
     */
    static String toTableName(TreeName treeName) {
        try {
            final MessageDigest md = MessageDigest.getInstance("SHA-224");
            final byte[] messageDigest = md.digest(treeName.toString().getBytes());
            final StringBuilder hashtext = new StringBuilder(56);
            for (byte b : messageDigest) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hashtext.append('0');
                hashtext.append(hex);
            }
        });
            return "opendj_" + hashtext;
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
    String getTableName(TreeName treeName) {
        return tree2table.get(treeName);
    }
    /**
     * The table a tree name maps to, for a statement that only reads it. Answered from the memo of
     * {@link #getTableName(TreeName)} where the tree is in it, and computed without being put there
     * otherwise.
     * <p>
     * Every tree this backend owns is enrolled as it is opened, so the per-entry read path stays a
     * map lookup: {@link #toTableName(TreeName)} takes a JCA provider lookup and a digest per call,
     * which read() would otherwise pay for every entry of every search. Only a tree this backend
     * does not own - the shared compressed schema tree the migration of #873 reads - is computed,
     * twice per open of the backend.
     */
    String readTableName(TreeName treeName) {
        final String enrolled=tree2table.getIfPresent(treeName);
        return enrolled!=null ? enrolled : toTableName(treeName);
    }
    /**
     * The form a catalog pattern has to take to match an identifier this backend created unquoted.
     * An unquoted identifier is folded when it is stored - to upper case on oracle, to lower case
     * on postgresql - and a metadata pattern is matched against the stored form, not against the
@@ -1305,7 +1340,7 @@
        @Override
        public ByteString read(TreeName treeName, ByteSequence key) {
            try (final PreparedStatement statement=con.prepareStatement("select v from "+getTableName(treeName)+" where h="+hashParam(con)+" and k=?")){
            try (final PreparedStatement statement=con.prepareStatement("select v from "+readTableName(treeName)+" where h="+hashParam(con)+" and k=?")){
                statement.setString(1,key2hash.get(ByteBuffer.wrap(key.toByteArray())));
                statement.setBytes(2,real2db(key.toByteArray()));
                try(ResultSet rc=executeResultSet(statement)) {
@@ -1323,13 +1358,47 @@
        @Override
        public long getRecordCount(TreeName treeName) {
            try (final PreparedStatement statement=con.prepareStatement("select count(*) from "+getTableName(treeName));
            try (final PreparedStatement statement=con.prepareStatement("select count(*) from "+readTableName(treeName));
                 final ResultSet rc=executeResultSet(statement)){
                return rc.next() ? rc.getLong(1) : 0;
            }catch (SQLException e) {
                throw new StorageRuntimeException(e);
            }
        }
        @Override
        public boolean treeExists(TreeName treeName) {
            return isExistsTable(treeName);
        }
        // Readable, not writeable: the caller that asks about a tree this backend does not own is
        // the compressed schema migration (#873), which probes the shared tree from the writeable
        // transaction of RootContainer.open() but must not create or enrol it. Answering that from
        // the readable transaction keeps the probe available to every reader, and costs nothing:
        // the writeable one inherits it.
        boolean isExistsTable(TreeName treeName) {
            final String tableName = readTableName(treeName);
            try {
                final DatabaseMetaData metaData = con.getMetaData();
                // asked of the catalog by name: openTree(createOnDemand) calls this for every tree
                // of the backend - about 25 of them for a stock suffix, on every open - and listing
                // every table of the database each time costs the whole catalog once per tree, on a
                // database this backend may well be sharing with something else
                try (final ResultSet rs = metaData.getTables(null, null,
                        storedIdentifier(metaData, tableName), new String[]{"TABLE"})) {
                    while (rs.next()) {
                        // the name still has to be compared: "_" is a single-character wildcard in a
                        // metadata pattern, so "opendj_<hash>" also matches a table named "opendjX<hash>"
                        if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
                            return true;
                        }
                    }
                }
            } catch (Exception e) {
                throw new StorageRuntimeException(e);
            }
            return false;
        }
    }
    /**
     * A transaction able to write, unless the storage was opened read-only: then it may open an existing tree and
@@ -1405,30 +1474,6 @@
            return driverName.contains("mysql") || driverName.contains("oracle");
        }
        boolean isExistsTable(TreeName treeName) {
            final String tableName = getTableName(treeName);
            try {
                final DatabaseMetaData metaData = con.getMetaData();
                // asked of the catalog by name: openTree(createOnDemand) calls this for every tree
                // of the backend - about 25 of them for a stock suffix, on every open - and listing
                // every table of the database each time costs the whole catalog once per tree, on a
                // database this backend may well be sharing with something else
                try (final ResultSet rs = metaData.getTables(null, null,
                        storedIdentifier(metaData, tableName), new String[]{"TABLE"})) {
                    while (rs.next()) {
                        // the name still has to be compared: "_" is a single-character wildcard in a
                        // metadata pattern, so "opendj_<hash>" also matches a table named "opendjX<hash>"
                        if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
                            return true;
                        }
                    }
                }
            } catch (Exception e) {
                throw new StorageRuntimeException(e);
            }
            return false;
        }
        String getTableDialect() {
            if (driverNameOf(con).contains("oracle")) {
                return "h char(128),k raw(2000),v blob,primary key(h,k)";
@@ -1645,7 +1690,10 @@
    // repositioning transfer "fetchsize" rows over the network (#860).
    final class CursorImpl implements Cursor<ByteString, ByteString> {
        final Connection con;
        final TreeName treeName;
        final String tableName;
        // the enrolling name, resolved once and only if this cursor ever deletes
        String writeTableName;
        final boolean isReadOnly;
        final int batchSize=Math.max(1,Integer.getInteger("org.openidentityplatform.opendj.jdbc.fetchsize",1000));
        final int initialBatchSize=Math.min(batchSize,Math.max(1,Integer.getInteger("org.openidentityplatform.opendj.jdbc.fetchsize.initial",32)));
@@ -1662,7 +1710,10 @@
        public CursorImpl(boolean isReadOnly, Connection con, TreeName treeName) {
            this.isReadOnly=isReadOnly;
            this.con=con;
            this.tableName=getTableName(treeName);
            this.treeName=treeName;
            // the read statements below take the non-enrolling name: a cursor is how the migration
            // of #873 reads the shared tree, and reading a tree must not put it up for removal
            this.tableName=readTableName(treeName);
            this.limitClause=((CachedConnection)con).parent.getClass().getName().contains("mysql")
                ? " limit ?,?" : " offset ? rows fetch next ? rows only";
        }
@@ -1743,7 +1794,12 @@
            if (isReadOnly) {
                throw new UnsupportedOperationException();
            }
            try (final PreparedStatement statement=con.prepareStatement("delete from "+tableName+" where h="+hashParam(con)+" and k=?")){
            if (writeTableName==null) {
                // the enrolling name, unlike the read statements above: this writes to the tree, so
                // it is one this backend owns, and removeStorageFiles() has to know about it
                writeTableName=getTableName(treeName);
            }
            try (final PreparedStatement statement=con.prepareStatement("delete from "+writeTableName+" where h="+hashParam(con)+" and k=?")){
                statement.setString(1,key2hash.get(ByteBuffer.wrap(db2real(currentKeyDb))));
                statement.setBytes(2,currentKeyDb);
                execute(statement);
opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java
@@ -428,6 +428,21 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      // Deliberately not getOrOpenTree(): dbConfig() sets allowCreate, so asking the tree itself
      // would create the very database whose absence is being tested.
      try
      {
        return env.getDatabaseNames().contains(toDatabaseName(treeName));
      }
      catch (DatabaseException e)
      {
        throw new StorageRuntimeException(e);
      }
    }
    @Override
    public Cursor<ByteString, ByteString> openCursor(final TreeName treeName)
    {
      try
@@ -551,6 +566,12 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      return delegate.treeExists(treeName);
    }
    @Override
    public void openTree(TreeName treeName, boolean createOnDemand)
    {
      if (createOnDemand)
@@ -638,6 +659,13 @@
    {
      return 0;
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      // No environment was ever opened, so nothing is stored.
      return false;
    }
  }
  private WriteableTransaction newWriteableTransaction(Transaction txn)
opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java
@@ -468,6 +468,12 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      return PDBStorage.this.treeExists(treeName);
    }
    @Override
    public Cursor<ByteString, ByteString> openCursor(final TreeName treeName)
    {
      try
@@ -691,6 +697,12 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      return delegate.treeExists(treeName);
    }
    @Override
    public void openTree(TreeName treeName, boolean createOnDemand)
    {
      if (createOnDemand)
@@ -820,6 +832,13 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      // No volume was ever opened, so nothing is stored.
      return false;
    }
    @Override
    public <T> T read(ReadOperation<T> operation) throws Exception
    {
      return operation.run(this);
@@ -832,6 +851,27 @@
    }
  }
  /**
   * Tells whether the volume holds a tree of that name. Deliberately not implemented by asking for
   * an {@link Exchange}: {@code getExchange(volume, name, true)} would create the tree being tested,
   * and with {@code false} Persistit reports its absence by throwing.
   */
  boolean treeExists(final TreeName treeName)
  {
    if (volume == null)
    {
      return false;
    }
    try
    {
      return volume.getTree(treeName.toString(), false) != null;
    }
    catch (PersistitException e)
    {
      throw new StorageRuntimeException(e);
    }
  }
  Exchange getNewExchange(final TreeName treeName, final boolean create) throws PersistitException
  {
    final Exchange ex = db.getExchange(volume, treeName.toString(), create);
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java
@@ -1629,6 +1629,12 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      throw new UnsupportedOperationException();
    }
    @Override
    public long getRecordCount(TreeName treeName)
    {
      throw new UnsupportedOperationException();
@@ -4208,6 +4214,12 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      throw new UnsupportedOperationException();
    }
    @Override
    public void openTree(TreeName name, boolean createOnDemand)
    {
      throw new UnsupportedOperationException();
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java
@@ -18,6 +18,7 @@
package org.opends.server.backends.pluggable;
import static org.opends.messages.BackendMessages.*;
import static org.opends.server.util.StaticUtils.stackTraceToSingleLineString;
import java.io.IOException;
import java.util.Collection;
@@ -33,6 +34,7 @@
import org.opends.server.api.CompressedSchema;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.Storage;
import org.opends.server.backends.pluggable.spi.StorageRuntimeException;
import org.opends.server.backends.pluggable.spi.TreeName;
@@ -56,14 +58,40 @@
  /** The name of the tree used to store compressed object class set definitions. */
  private static final String DB_NAME_OC = "compressed_object_classes";
  /** The compressed attribute description schema tree. */
  private static final TreeName adTreeName = new TreeName("compressed_schema", DB_NAME_AD);
  /** The compressed object class set schema tree. */
  private static final TreeName ocTreeName = new TreeName("compressed_schema", DB_NAME_OC);
  /**
   * The tree prefix every backend shared before the definitions were separated per backend.
   * <p>
   * Every other tree of a backend is named from {@link EntryContainer#getTreePrefix()} and so
   * carries its base DN, but these two belong to the backend rather than to any one of its base
   * DNs and were named from a literal. That is harmless where a storage holds a single backend -
   * JE and PDB give each its own directory, and the Cassandra backend names its table after the
   * backend id - but the JDBC backend derives its table name from the tree name alone, so two
   * JDBC backends addressing one database met in this pair of tables. Each allocated tokens from
   * the size of its own in-memory map under its own lock, so both handed out the same token for
   * different attribute descriptions and overwrote each other's definitions; after a restart the
   * entries of the losing backend decoded as the wrong attributes, silently (issue #873).
   * <p>
   * Kept only to be read: {@link #load} migrates what it finds here into the backend's own trees
   * and never writes to it again.
   */
  private static final String LEGACY_TREE_PREFIX = "compressed_schema";
  private static final TreeName LEGACY_AD_TREE_NAME = new TreeName(LEGACY_TREE_PREFIX, DB_NAME_AD);
  private static final TreeName LEGACY_OC_TREE_NAME = new TreeName(LEGACY_TREE_PREFIX, DB_NAME_OC);
  /** The compressed attribute description schema tree of this backend. */
  private final TreeName adTreeName;
  /** The compressed object class set schema tree of this backend. */
  private final TreeName ocTreeName;
  /** The id of the backend these definitions belong to. */
  private final String backendId;
  /** The storage in which the trees are held. */
  private final Storage storage;
  /** How many definitions the migration copied, reported by {@link #reportMigration()} once committed. */
  private long migratedDefinitions;
  private final ByteStringBuilder storeAttributeWriterBuffer = new ByteStringBuilder();
  private final ASN1Writer storeAttributeWriter = ASN1.getWriter(storeAttributeWriterBuffer);
  private final ByteStringBuilder storeObjectClassesWriterBuffer = new ByteStringBuilder();
@@ -74,6 +102,9 @@
   *
   * @param serverContext
   *          The server context.
   * @param backendId
   *          The id of the backend whose definitions are held, which qualifies the trees so that
   *          two backends sharing one database do not share one token space.
   * @param storage
   *          A reference to the storage in which the trees will be held.
   * @param txn a non null transaction
@@ -86,14 +117,41 @@
   *           If an error occurs while loading and processing the compressed
   *           schema definitions.
   */
  PersistentCompressedSchema(ServerContext serverContext, final Storage storage, WriteableTransaction txn,
      AccessMode accessMode) throws StorageRuntimeException, InitializationException
  PersistentCompressedSchema(ServerContext serverContext, String backendId, final Storage storage,
      WriteableTransaction txn, AccessMode accessMode) throws StorageRuntimeException, InitializationException
  {
    super(serverContext);
    this.storage = storage;
    this.backendId = backendId;
    final String treePrefix = treePrefix(backendId);
    this.adTreeName = new TreeName(treePrefix, DB_NAME_AD);
    this.ocTreeName = new TreeName(treePrefix, DB_NAME_OC);
    load(txn, accessMode.isWriteable());
  }
  /**
   * Qualifies the legacy prefix with the backend id. {@link TreeName} documents that it assumes no
   * name component contains a '/', and {@code TreeName.valueOf} splits the string form at the last
   * one, so a prefix carrying a '/' would come back as a different name than it went in as.
   * Nothing rules a '/' out of a backend id - ds-cfg-backend-id is a plain string - so it is
   * escaped rather than passed through. The escape is reversible, so two distinct backend ids can
   * never produce one prefix.
   * <p>
   * The qualifier is the backend id rather than a base DN because this object is built once per
   * {@link RootContainer}, before any entry container exists, and a backend holding two base DNs
   * has no single prefix to borrow. The price is that these two trees, alone among the trees of a
   * backend, do not follow the entries when a JDBC backend is deleted and re-created under another
   * id over the same database: every other tree is named from its base DN and is found again,
   * while these are not, leaving the token allocation to restart at zero over entries encoded with
   * the definitions of the old id. Changing a backend id over populated storage needs an export
   * and a re-import - as it always has on Cassandra, where the one table of a backend is named
   * after the id and the entries do not survive the rename either.
   */
  private static String treePrefix(String backendId)
  {
    return LEGACY_TREE_PREFIX + "_" + backendId.replace("%", "%25").replace("/", "%2F");
  }
  @Override
  protected void storeAttribute(final byte[] encodedAttribute,
      final String attributeName, final Iterable<String> attributeOptions)
@@ -154,56 +212,219 @@
  private void load(WriteableTransaction txn, boolean shouldCreate)
      throws StorageRuntimeException, InitializationException
  {
    txn.openTree(adTreeName, shouldCreate);
    txn.openTree(ocTreeName, shouldCreate);
    if (shouldCreate)
    {
      // Asked for only where the trees may be created. A read-only open must leave the storage as
      // it found it, and passing the flag on would not: JEStorage.openTree ignores createOnDemand
      // and reaches env.openDatabase() with setAllowCreate(true), so an offline export-ldif or
      // verify-index of a backend that has not migrated yet would leave two empty databases behind.
      // Nothing below needs the trees open - every read is guarded by treeExists().
      txn.openTree(adTreeName, true);
      txn.openTree(ocTreeName, true);
    }
    if (needsLegacyDefinitions(txn))
    {
      if (shouldCreate)
      {
        migrateLegacyDefinitions(txn);
      }
      else
      {
        // Read-only: nothing may be written, so the legacy definitions are read where they lie.
        // Loaded first, so that a token this backend has already migrated, and since re-used under
        // its own prefix, decodes to its own definition. That settles the decode map only:
        // CompressedSchema.loadAttributeToMaps keys the encode map by attribute description, so a
        // legacy description displaced from a token stays in it and would encode to a token that
        // now decodes to another attribute. Harmless only because nothing encodes during a
        // read-only open - export-ldif and verify-index decode.
        loadTrees(txn, LEGACY_OC_TREE_NAME, LEGACY_AD_TREE_NAME);
      }
    }
    loadTrees(txn, ocTreeName, adTreeName);
  }
  /**
   * Tells whether the definitions under {@link #LEGACY_TREE_PREFIX} are still needed, either
   * because this backend has not been opened since the upgrade that separated them, or because a
   * previous migration did not run to completion - on a storage without transactions the copy can
   * stop halfway. Once migrated, this backend's trees only ever grow, so they can no longer hold
   * fewer records than the legacy ones and the question is settled without reading either tree.
   * <p>
   * That invariant holds in one direction only. A version from before the separation resumes
   * writing to the legacy pair, so after a downgrade and a second upgrade the counts can agree
   * while the definitions behind them have diverged, and nothing is migrated. Downgrading across
   * the separation is not a supported path - the legacy trees are left in place so that a backend
   * still running an earlier version can go on reading them, not so that this one can go back.
   * <p>
   * The question is settled again on every open, nothing recording that it has been answered
   * before: two existence probes, and the record counts only where the legacy trees are still
   * there. That is what not writing a marker of this backend's own into a database it may be
   * sharing with another one costs.
   * <p>
   * A backend created after the upgrade on a database that already holds legacy definitions copies
   * them although it has no entries of its own. Deliberate: what it inherits is a consistent
   * token-to-definition mapping and costs one copy, whereas asking instead whether its own trees
   * are absent would answer "nothing to migrate" for the half-copied trees an interrupted
   * migration leaves behind - the one case this method exists to catch.
   */
  private boolean needsLegacyDefinitions(ReadableTransaction txn)
  {
    final long legacyAdCount = recordCount(txn, LEGACY_AD_TREE_NAME);
    final long legacyOcCount = recordCount(txn, LEGACY_OC_TREE_NAME);
    return (legacyAdCount > 0 || legacyOcCount > 0)
        && (recordCount(txn, adTreeName) < legacyAdCount || recordCount(txn, ocTreeName) < legacyOcCount);
  }
  /** The number of records of a tree, without asking a storage to materialize one that is absent. */
  private long recordCount(ReadableTransaction txn, TreeName treeName)
  {
    return txn.treeExists(treeName) ? txn.getRecordCount(treeName) : 0;
  }
  private void migrateLegacyDefinitions(WriteableTransaction txn) throws InitializationException
  {
    migratedDefinitions = migrateTree(txn, LEGACY_AD_TREE_NAME, adTreeName)
        + migrateTree(txn, LEGACY_OC_TREE_NAME, ocTreeName);
  }
  private long migrateTree(WriteableTransaction txn, TreeName from, TreeName to) throws InitializationException
  {
    try
    {
      return copyMissingRecords(txn, from, to);
    }
    catch (final StorageRuntimeException e)
    {
      // Left with the type the storage gave it. The migration runs inside the WriteOperation of
      // RootContainer.open(), and PDBStorage.write() decides by type what to do with what leaves
      // it: a transaction conflict reaches its retry as a RollbackException only, so wrapping it
      // here would turn a conflict that used to be replayed into a permanent failure of the open.
      // Nothing is lost - a storage failure that is not a conflict still fails the open, through
      // ERR_OPEN_ENV_FAIL, and leaves the trees as they were, since the transaction rolls back.
      throw e;
    }
    catch (final Exception e)
    {
      logger.traceException(e);
      // Deliberately fatal to the open. Loading no definitions at all would restart token
      // allocation from zero and decode every entry written so far as the wrong attributes,
      // without reporting anything.
      throw new InitializationException(
          ERR_COMPSCHEMA_CANNOT_MIGRATE.get(backendId, from, to, stackTraceToSingleLineString(e)), e);
    }
  }
  /**
   * Reports a migration that has been committed, and reports nothing where none was needed.
   * <p>
   * Called by {@link RootContainer#open} once the transaction the migration ran in has committed,
   * and only then: a copy is undone by a rollback - a later failure of the same transaction, or a
   * conflict PersistIt replays - and this line is the only evidence the migration path emits, so
   * one standing for a copy that was rolled back, or repeated once per replay, would be worse than
   * none at all.
   */
  void reportMigration()
  {
    if (migratedDefinitions > 0)
    {
      logger.info(NOTE_COMPSCHEMA_MIGRATED, migratedDefinitions, backendId,
          LEGACY_AD_TREE_NAME, LEGACY_OC_TREE_NAME, adTreeName, ocTreeName);
    }
  }
  /**
   * Copies the records of {@code from} that {@code to} does not already hold, and returns how many
   * were copied. The legacy tree is left in place: on a shared database it may still be the only
   * copy a backend that has not been upgraded yet has.
   * <p>
   * A key already present in {@code to} is never overwritten, which is what makes this safe to
   * re-run after an interrupted migration and safe against a legacy tree that a backend of an
   * earlier version is still writing to.
   */
  private long copyMissingRecords(WriteableTransaction txn, TreeName from, TreeName to)
  {
    if (!txn.treeExists(from))
    {
      return 0;
    }
    long copied = 0;
    try (Cursor<ByteString, ByteString> cursor = txn.openCursor(from))
    {
      while (cursor.next())
      {
        final ByteString key = cursor.getKey();
        if (txn.read(to, key) == null)
        {
          txn.put(to, key, cursor.getValue());
          copied++;
        }
      }
    }
    return copied;
  }
  /**
   * Loads the object class set definitions and then the attribute description definitions of the
   * provided pair of trees into the maps. A tree that does not exist contributes nothing: a
   * read-only open creates none, and a storage that keeps one object per tree fails outright when
   * asked to read one that was never written.
   */
  private void loadTrees(ReadableTransaction txn, TreeName ocTree, TreeName adTree) throws InitializationException
  {
    // Cursor through the object class database and load the object class set
    // definitions. At the same time, figure out the highest token value and
    // initialize the object class counter to one greater than that.
    try (Cursor<ByteString, ByteString> ocCursor = txn.openCursor(ocTreeName))
    if (txn.treeExists(ocTree))
    {
      while (ocCursor.next())
      try (Cursor<ByteString, ByteString> ocCursor = txn.openCursor(ocTree))
      {
        final byte[] encodedObjectClasses = ocCursor.getKey().toByteArray();
        final ASN1Reader reader = ASN1.getReader(ocCursor.getValue());
        reader.readStartSequence();
        final List<String> objectClassNames = new LinkedList<>();
        while (reader.hasNextElement())
        while (ocCursor.next())
        {
          objectClassNames.add(reader.readOctetStringAsString());
          final byte[] encodedObjectClasses = ocCursor.getKey().toByteArray();
          final ASN1Reader reader = ASN1.getReader(ocCursor.getValue());
          reader.readStartSequence();
          final List<String> objectClassNames = new LinkedList<>();
          while (reader.hasNextElement())
          {
            objectClassNames.add(reader.readOctetStringAsString());
          }
          reader.readEndSequence();
          loadObjectClasses(encodedObjectClasses, objectClassNames);
        }
        reader.readEndSequence();
        loadObjectClasses(encodedObjectClasses, objectClassNames);
      }
    }
    catch (final IOException e)
    {
      logger.traceException(e);
      throw new InitializationException(ERR_COMPSCHEMA_CANNOT_DECODE_OC_TOKEN.get(e.getMessage()), e);
      catch (final IOException e)
      {
        logger.traceException(e);
        throw new InitializationException(ERR_COMPSCHEMA_CANNOT_DECODE_OC_TOKEN.get(e.getMessage()), e);
      }
    }
    // Cursor through the attribute description database and load the attribute set definitions.
    try (Cursor<ByteString, ByteString> adCursor = txn.openCursor(adTreeName))
    if (txn.treeExists(adTree))
    {
      while (adCursor.next())
      try (Cursor<ByteString, ByteString> adCursor = txn.openCursor(adTree))
      {
        final byte[] encodedAttribute = adCursor.getKey().toByteArray();
        final ASN1Reader reader = ASN1.getReader(adCursor.getValue());
        reader.readStartSequence();
        final String attributeName = reader.readOctetStringAsString();
        final List<String> attributeOptions = new LinkedList<>();
        while (reader.hasNextElement())
        while (adCursor.next())
        {
          attributeOptions.add(reader.readOctetStringAsString());
          final byte[] encodedAttribute = adCursor.getKey().toByteArray();
          final ASN1Reader reader = ASN1.getReader(adCursor.getValue());
          reader.readStartSequence();
          final String attributeName = reader.readOctetStringAsString();
          final List<String> attributeOptions = new LinkedList<>();
          while (reader.hasNextElement())
          {
            attributeOptions.add(reader.readOctetStringAsString());
          }
          reader.readEndSequence();
          loadAttribute(encodedAttribute, attributeName, attributeOptions);
        }
        reader.readEndSequence();
        loadAttribute(encodedAttribute, attributeName, attributeOptions);
      }
    }
    catch (final IOException e)
    {
      logger.traceException(e);
      throw new InitializationException(ERR_COMPSCHEMA_CANNOT_DECODE_AD_TOKEN.get(e.getMessage()), e);
      catch (final IOException e)
      {
        logger.traceException(e);
        throw new InitializationException(ERR_COMPSCHEMA_CANNOT_DECODE_AD_TOKEN.get(e.getMessage()), e);
      }
    }
  }
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java
@@ -137,10 +137,13 @@
        @Override
        public void run(WriteableTransaction txn) throws Exception
        {
          compressedSchema = new PersistentCompressedSchema(serverContext, storage, txn, accessMode);
          compressedSchema = new PersistentCompressedSchema(serverContext, backendId, storage, txn, accessMode);
          openAndRegisterEntryContainers(txn, config.getBaseDN(), accessMode);
        }
      });
      // after the write, never inside it: a compressed schema migration is only worth reporting
      // once the transaction that copied it has committed, and a replayed operation runs twice
      compressedSchema.reportMigration();
    }
    catch(StorageRuntimeException e)
    {
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java
@@ -288,6 +288,15 @@
    }
    @Override
    public boolean treeExists(TreeName name)
    {
      traceEnter("treeExists", "name", name);
      final boolean exists = txn.treeExists(name);
      traceLeave("treeExists", "name", name, "exists", exists);
      return exists;
    }
    @Override
    public Cursor<ByteString, ByteString> openCursor(final TreeName name)
    {
      traceEnter("openCursor", "name", name);
@@ -366,6 +375,15 @@
    }
    @Override
    public boolean treeExists(TreeName name)
    {
      traceEnter("treeExists", "name", name);
      final boolean exists = txn.treeExists(name);
      traceLeave("treeExists", "name", name, "exists", exists);
      return exists;
    }
    @Override
    public Cursor<ByteString, ByteString> openCursor(final TreeName name)
    {
      traceEnter("openCursor", "name", name);
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java
@@ -12,6 +12,7 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2014-2015 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.backends.pluggable.spi;
@@ -51,4 +52,26 @@
   * @return the number of key/value pairs in the provided tree.
   */
  long getRecordCount(TreeName treeName);
  /**
   * Returns whether the tree whose name is provided is present in the storage.
   * <p>
   * This is not the same question as whether the tree is empty, and it cannot be answered by
   * reading from the tree: a storage is free to materialize a tree on first access - the JE backend
   * opens its databases with {@code setAllowCreate(true)} - or to reject the access outright, as the
   * JDBC backend does when no table of that name exists. Callers that must distinguish "never
   * written" from "written and since emptied", such as the compressed schema deciding whether it
   * has anything to migrate, need this instead.
   * <p>
   * A storage whose trees have no existence of their own cannot keep that distinction: the
   * Cassandra backend holds every tree of a backend as a partition of the one table named after
   * the backend id, so it answers whether the partition holds a record and a tree that was emptied
   * reports itself absent. Nothing may be inferred from a {@code false} beyond "there is nothing
   * to read here".
   *
   * @param treeName
   *          the tree name
   * @return {@code true} if the tree exists, {@code false} otherwise
   */
  boolean treeExists(TreeName treeName);
}
opendj-server-legacy/src/messages/org/opends/messages/backend.properties
@@ -1108,3 +1108,9 @@
NOTE_IMPORT_MIGRATION_START_616=Migrating %s entries for base DN %s so that they are preserved by the partial import
WARN_PSEARCH_BACKEND_UNAVAILABLE_617=The persistent search is being terminated because backend %s is \
 no longer available
NOTE_COMPSCHEMA_MIGRATED_618=Migrated %d compressed schema definitions of backend '%s' from the shared trees \
 '%s' and '%s' to '%s' and '%s'. The shared trees are left untouched: on a storage where several backends address \
 one database, such as a JDBC backend, they may still hold the definitions of another backend
ERR_COMPSCHEMA_CANNOT_MIGRATE_619=The compressed schema definitions of backend '%s' could not be migrated from \
 the shared tree '%s' to '%s': %s. The backend cannot be opened, because its entries were encoded against the \
 definitions that were not migrated and would decode as the wrong attributes
opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java
@@ -42,6 +42,7 @@
import com.datastax.oss.driver.api.core.AllNodesFailedException;
import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.config.DriverConfigLoader;
import com.datastax.oss.driver.api.core.servererrors.InvalidQueryException;
import java.net.InetSocketAddress;
import java.util.NoSuchElementException;
@@ -134,6 +135,68 @@
        });
    }
    /** A storage under a backend id of its own, so that its table is one no other test has created. */
    private CASStorage openStorage(String backendId, AccessMode accessMode) throws Exception {
        final CASBackendCfg backendCfg = mockCfg(CASBackendCfg.class);
        when(backendCfg.getBackendId()).thenReturn(backendId);
        when(backendCfg.getDBDirectory()).thenReturn("CASTestCase");
        final CASStorage storage = new CASStorage(backendCfg, null);
        storage.open(accessMode);
        return storage;
    }
    /**
     * The compressed schema asks whether a tree is there before anything has been written (#873), so
     * treeExists() has to answer for a table that openTree() has not created yet. It may only answer
     * where nothing can follow from the answer: a read has nothing to lose, but a writeable
     * transaction has just created the table through openTree(), so a query rejected there is a
     * fault. Reporting a populated tree absent would have the compressed schema allocate its tokens
     * from zero again and overwrite the definitions its entries were encoded with.
     */
    @Test
    public void testTreeExistsAnswersForAMissingTable() throws Exception {
        final CASStorage storage = openStorage("CASTreeExists", AccessMode.READ_WRITE);
        final TreeName tree = new TreeName("testTreeExists", "tree");
        try {
            // the keyspace is there - open(READ_WRITE) creates it - but the table is not
            assertFalse(storage.read(new ReadOperation<Boolean>() {
                @Override
                public Boolean run(ReadableTransaction txn) throws Exception {
                    return txn.treeExists(tree);
                }
            }), "a read of a table that was never created finds no tree");
            try {
                storage.write(new WriteOperation() {
                    @Override
                    public void run(WriteableTransaction txn) throws Exception {
                        txn.treeExists(tree);
                    }
                });
                fail("a writeable transaction must not report a rejected query as an absent tree");
            } catch (InvalidQueryException expected) {
                // the table of a writeable open is created by openTree(), so its absence is a fault
            }
            // Every tree of this backend is a partition of the one table, so it has no existence of
            // its own: it is there once it holds a record, and not before.
            storage.write(new WriteOperation() {
                @Override
                public void run(WriteableTransaction txn) throws Exception {
                    txn.openTree(tree, true);
                    assertFalse(txn.treeExists(tree), "an empty partition holds no record");
                    txn.put(tree, key(0), value(0));
                    assertTrue(txn.treeExists(tree));
                    txn.deleteTree(tree);
                    assertFalse(txn.treeExists(tree));
                }
            });
        } finally {
            dropTree(storage, tree);
            storage.close();
        }
    }
    private static void dropTree(CASStorage storage, final TreeName tree) {
        try {
            storage.write(new WriteOperation() {
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).
     */
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java
@@ -12,6 +12,7 @@
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2015-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.backends.pluggable;
@@ -125,7 +126,8 @@
        cryptoSuite);
  }
  static final class DummyWriteableTransaction implements WriteableTransaction {
  /** Not final: a test that needs a storage to fail, or to behave as one engine does, subclasses it. */
  static class DummyWriteableTransaction implements WriteableTransaction {
    private final Map<TreeName, TreeMap<ByteString, ByteString>> storage = new HashMap<>();
@@ -249,9 +251,20 @@
    }
    @Override
    public boolean treeExists(TreeName treeName)
    {
      return storage.containsKey(treeName);
    }
    @Override
    public void openTree(TreeName name, boolean createOnDemand)
    {
      storage.put(name, new TreeMap<ByteString, ByteString>());
      // Honours createOnDemand, and leaves an already open tree alone rather than replacing it with
      // an empty one, so that a caller opening the same tree twice does not silently lose its records.
      if (createOnDemand)
      {
        storage.putIfAbsent(name, new TreeMap<ByteString, ByteString>());
      }
    }
    @Override
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java
New file
@@ -0,0 +1,337 @@
/*
 * 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.pluggable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.testng.Assert.fail;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
import org.opends.server.backends.pluggable.DefaultIndexTest.DummyWriteableTransaction;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.Storage;
import org.opends.server.backends.pluggable.spi.StorageRuntimeException;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteOperation;
import org.opends.server.core.ServerContext;
import org.opends.server.types.Attributes;
import org.opends.server.types.InitializationException;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
/**
 * The compressed schema of a backend must not be shared with another backend that happens to
 * address the same database, and a backend upgraded from a version that did share it must keep
 * decoding the entries it wrote before the upgrade (issue #873).
 */
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public class PersistentCompressedSchemaTest extends DirectoryServerTestCase
{
  private static final String AD = "compressed_attributes";
  private static final String OC = "compressed_object_classes";
  /** The pair every backend wrote to before the definitions were separated. */
  private static final TreeName LEGACY_AD = new TreeName("compressed_schema", AD);
  private static final TreeName LEGACY_OC = new TreeName("compressed_schema", OC);
  private ServerContext serverContext;
  private SharedDatabase txn;
  private Storage storage;
  @BeforeClass
  public void startServer() throws Exception
  {
    // Needed for the schema: loading a definition resolves its attribute type against the server's.
    TestCaseUtils.startServer();
    serverContext = TestCaseUtils.getServerContext();
  }
  @BeforeMethod
  public void setUp() throws Exception
  {
    // One transaction shared by every backend of a test stands for one database addressed by all of
    // them - the JDBC deployment of the issue. On JE or PDB each backend would have its own.
    txn = new SharedDatabase();
    storage = mock(Storage.class);
    doAnswer(invocation -> {
      ((WriteOperation) invocation.getArguments()[0]).run(txn);
      return null;
    }).when(storage).write(any(WriteOperation.class));
  }
  /**
   * The heart of the issue: both backends allocate the first token of their own map for a different
   * attribute description, so if they shared a tree the second would overwrite the definition of
   * the first and the entries of the first would decode as the wrong attribute after a restart.
   */
  @Test
  public void backendsSharingOneDatabaseDoNotShareTheTokenSpace() throws Exception
  {
    // Both are opened before either writes, as they are at server start: each loads the same state
    // and so holds the same next token. Opening the second one after the first had written would
    // hide the issue, since it would have loaded the definition the first one just stored.
    final PersistentCompressedSchema backendA = open("backendA", AccessMode.READ_WRITE);
    final PersistentCompressedSchema backendB = open("backendB", AccessMode.READ_WRITE);
    final ByteString encodedByA = encode(backendA, "cn");
    final ByteString encodedByB = encode(backendB, "sn");
    // Re-opening is what a restart does: the definitions come back from the trees, not from memory.
    assertThat(decode(open("backendA", AccessMode.READ_WRITE), encodedByA)).isEqualTo("cn");
    assertThat(decode(open("backendB", AccessMode.READ_WRITE), encodedByB)).isEqualTo("sn");
    // and nothing was written under the prefix they used to share
    assertThat(txn.treeExists(LEGACY_AD)).isFalse();
    assertThat(txn.treeExists(LEGACY_OC)).isFalse();
  }
  /** A backend upgraded from a version that wrote under the shared prefix finds its definitions. */
  @Test
  public void definitionsWrittenBeforeTheUpgradeAreMigrated() throws Exception
  {
    final ByteString encoded = encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    assertThat(decode(open("backendA", AccessMode.READ_WRITE), encoded)).isEqualTo("cn");
    assertThat(txn.getRecordCount(ownTree("backendA", AD))).isEqualTo(txn.getRecordCount(LEGACY_AD));
    assertThat(txn.getRecordCount(ownTree("backendA", OC))).isEqualTo(txn.getRecordCount(LEGACY_OC));
  }
  /**
   * The shared trees are read, never emptied: on a database addressed by several backends they may
   * still be the only copy another backend has, and leaving them is what makes a downgrade possible.
   */
  @Test
  public void theSharedTreesSurviveTheMigration() throws Exception
  {
    encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    final long legacyRecords = txn.getRecordCount(LEGACY_AD);
    open("backendA", AccessMode.READ_WRITE);
    assertThat(txn.getRecordCount(LEGACY_AD)).isEqualTo(legacyRecords);
  }
  /**
   * A migration interrupted halfway - a storage without transactions can stop between two records -
   * is finished by the next open rather than leaving the backend with a partial token space.
   */
  @Test
  public void anInterruptedMigrationIsFinishedByTheNextOpen() throws Exception
  {
    final ByteString encoded = encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    open("backendA", AccessMode.READ_WRITE);
    // undo one record of the completed migration, which is what an interrupted one would have left
    txn.delete(ownTree("backendA", AD), firstKeyOf(ownTree("backendA", AD)));
    assertThat(decode(open("backendA", AccessMode.READ_WRITE), encoded)).isEqualTo("cn");
    assertThat(txn.getRecordCount(ownTree("backendA", AD))).isEqualTo(txn.getRecordCount(LEGACY_AD));
  }
  /**
   * export-ldif and verify-index open the root container read-only, where the migration cannot run.
   * The definitions must still be found, and nothing may be written - not even the empty trees an
   * openTree() would leave behind, which is why {@link SharedDatabase} creates one whenever it is
   * asked for a tree at all.
   */
  @Test
  public void aReadOnlyOpenReadsTheSharedTreesWhereTheyLie() throws Exception
  {
    final ByteString encoded = encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    assertThat(decode(open("backendA", AccessMode.READ_ONLY), encoded)).isEqualTo("cn");
    assertThat(txn.treeExists(ownTree("backendA", AD))).isFalse();
    assertThat(txn.treeExists(ownTree("backendA", OC))).isFalse();
  }
  /**
   * The migration fills the gaps of this backend and touches nothing else: where the shared trees
   * and this backend disagree about what a token stands for - which is the state issue #873 leaves
   * behind, two backends having allocated the same token for different attributes - the definition
   * this backend's own entries were encoded against is the one that survives.
   */
  @Test
  public void aDefinitionOfThisBackendIsNeverOverwrittenByTheSharedOne() throws Exception
  {
    final ByteString encodedByA = encode(open("backendA", AccessMode.READ_WRITE), "cn");
    // What another backend left under the shared prefix before the upgrade: the same first token,
    // standing for a different attribute, and one definition beyond what backendA holds, so that
    // the record counts send the migration on its way.
    final PersistentCompressedSchema backendB = open("backendB", AccessMode.READ_WRITE);
    encode(backendB, "sn");
    encode(backendB, "description");
    copyTree(ownTree("backendB", AD), LEGACY_AD);
    copyTree(ownTree("backendB", OC), LEGACY_OC);
    assertThat(decode(open("backendA", AccessMode.READ_WRITE), encodedByA)).isEqualTo("cn");
    // and the definition backendA did not have was still copied, so the migration did run
    assertThat(txn.getRecordCount(ownTree("backendA", AD))).isEqualTo(txn.getRecordCount(LEGACY_AD));
  }
  /**
   * A migration that cannot complete is fatal to the open: carrying on with the definitions half
   * copied would restart the token allocation part way through the map the entries were encoded
   * with, and report nothing.
   */
  @Test
  public void aMigrationThatCannotCompleteFailsTheOpen() throws Exception
  {
    encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    final RuntimeException failure = new IllegalStateException("no space left on device");
    txn.failEveryWriteWith(failure);
    try
    {
      open("backendA", AccessMode.READ_WRITE);
      fail("the open must not succeed on a migration that failed");
    }
    catch (final InitializationException e)
    {
      assertThat(e.getCause()).isSameAs(failure);
      // and the message names the backend and both trees, since which of the two failed is the point
      assertThat(e.getMessage()).contains("backendA")
          .contains(LEGACY_AD.toString()).contains(ownTree("backendA", AD).toString());
    }
  }
  /**
   * A failure the storage reports as its own is left with the type it was given. The migration runs
   * inside the WriteOperation of {@link RootContainer#open}, and PDBStorage.write() decides by type
   * what to do with what leaves it: a transaction conflict it would have replayed must not come out
   * as something else, or the open fails where it used to be retried.
   */
  @Test
  public void aStorageFailureIsLeftForTheStorageToRecognize() throws Exception
  {
    encode(open("backendA", AccessMode.READ_WRITE), "cn");
    makeStoreLookPreUpgrade("backendA");
    final StorageRuntimeException conflict = new StorageRuntimeException("transaction rolled back");
    txn.failEveryWriteWith(conflict);
    try
    {
      open("backendA", AccessMode.READ_WRITE);
      fail("the open must not succeed on a migration that failed");
    }
    catch (final StorageRuntimeException e)
    {
      assertThat(e).isSameAs(conflict);
    }
  }
  private PersistentCompressedSchema open(String backendId, AccessMode accessMode) throws Exception
  {
    return new PersistentCompressedSchema(serverContext, backendId, storage, txn, accessMode);
  }
  private static TreeName ownTree(String backendId, String indexId)
  {
    return new TreeName("compressed_schema_" + backendId, indexId);
  }
  private ByteString encode(PersistentCompressedSchema schema, String attributeName) throws Exception
  {
    final ByteStringBuilder builder = new ByteStringBuilder();
    schema.encodeAttribute(builder, Attributes.create(attributeName, "a value"));
    return builder.toByteString();
  }
  private String decode(PersistentCompressedSchema schema, ByteString encoded) throws Exception
  {
    return schema.decodeAttribute(encoded.asReader()).getAttributeDescription().getAttributeType().getNameOrOID();
  }
  /** Moves the definitions of a backend back under the shared prefix, as an earlier version left them. */
  private void makeStoreLookPreUpgrade(String backendId) throws Exception
  {
    copyTree(ownTree(backendId, AD), LEGACY_AD);
    copyTree(ownTree(backendId, OC), LEGACY_OC);
    txn.deleteTree(ownTree(backendId, AD));
    txn.deleteTree(ownTree(backendId, OC));
  }
  private void copyTree(TreeName from, TreeName to)
  {
    txn.openTree(to, true);
    try (Cursor<ByteString, ByteString> cursor = txn.openCursor(from))
    {
      while (cursor.next())
      {
        txn.put(to, cursor.getKey(), cursor.getValue());
      }
    }
  }
  private ByteString firstKeyOf(TreeName treeName)
  {
    try (Cursor<ByteString, ByteString> cursor = txn.openCursor(treeName))
    {
      assertThat(cursor.next()).isTrue();
      return cursor.getKey();
    }
  }
  /**
   * The one database every backend of a test addresses. Two behaviours of a real storage are
   * modelled on purpose, so that the assertions above can fail:
   * <ul>
   * <li>a tree is materialized whenever one is asked for, whether or not creation was requested.
   * That is what JEStorage does - dbConfig() sets allowCreate, and openTree() reaches
   * env.openDatabase() with it, whatever createOnDemand says - so a read-only open that asks at all
   * leaves empty databases behind an offline tool run;</li>
   * <li>a write can be made to fail, which is the only way to reach the migration's failure path.</li>
   * </ul>
   */
  private static final class SharedDatabase extends DummyWriteableTransaction
  {
    private RuntimeException failure;
    void failEveryWriteWith(RuntimeException failure)
    {
      this.failure = failure;
    }
    @Override
    public void openTree(TreeName name, boolean createOnDemand)
    {
      super.openTree(name, true);
    }
    @Override
    public void put(TreeName treeName, ByteSequence key, ByteSequence value)
    {
      if (failure != null)
      {
        throw failure;
      }
      super.put(treeName, key, value);
    }
  }
}