From 655132fb059cfa70fb338d54f1bb0fb48198fb0f Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 02 Sep 2026 06:44:10 +0000
Subject: [PATCH] [#873] Give each backend its own compressed schema trees (#881)

---
 opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java                     |   60 ++
 opendj-server-legacy/src/messages/org/opends/messages/backend.properties                                    |    6 
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java            |   12 
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java                  |    5 
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java     |  299 ++++++++++++-
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java               |   17 
 opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java                       |   63 +++
 opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java                         |  140 ++++--
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java                  |   18 
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java |  337 ++++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java                            |  205 +++++++++
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java        |   23 +
 opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java                           |   40 +
 opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java                            |   28 +
 14 files changed, 1,169 insertions(+), 84 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java
index e30c748..920b866 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java
+++ b/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);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
index 0309d69..f1b9419 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
+++ b/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);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java
index cc9b870..4db4d9f 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java
+++ b/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)
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java
index ea23395..e99db98 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java
+++ b/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);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java
index 5b53791..efb49c5 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/OnDiskMergeImporter.java
+++ b/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();
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java
index 76fb256..0647565 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java
+++ b/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);
+      }
     }
   }
 
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java
index 94cb42d..ad1dcd6 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.java
+++ b/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)
     {
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java
index 172388e..42ff1da 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/TracedStorage.java
+++ b/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);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java
index 1f9ab9e..c846687 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/spi/ReadableTransaction.java
+++ b/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);
 }
diff --git a/opendj-server-legacy/src/messages/org/opends/messages/backend.properties b/opendj-server-legacy/src/messages/org/opends/messages/backend.properties
index a8d16b8..eebfe84 100644
--- a/opendj-server-legacy/src/messages/org/opends/messages/backend.properties
+++ b/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
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java
index 9e784d3..d73da22 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/cassandra/TestCase.java
+++ b/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() {
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
index 22e8ea1..cd8057e 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -237,6 +237,211 @@
 	}
 
 	/**
+	 * treeExists() has to answer for a table that was never created rather than fail: it is how the
+	 * compressed schema tells a backend with nothing to migrate from one whose definitions are still
+	 * under the shared prefix (#873), and every other statement of this storage fails outright on a
+	 * table that does not exist.
+	 */
+	@Test
+	public void testTreeExistsAnswersForAMissingTable() throws Exception {
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+		final TreeName present = new TreeName("testTreeExists", "present");
+		final TreeName absent = new TreeName("testTreeExists", "absent");
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.openTree(present, true);
+					assertTrue(txn.treeExists(present));
+					assertFalse(txn.treeExists(absent));
+				}
+			});
+			// the read path has to answer as well: export-ldif and verify-index open read-only, where
+			// no tree is created and the question cannot be settled by writing one
+			storage.read(new ReadOperation<Void>() {
+				@Override
+				public Void run(ReadableTransaction txn) throws Exception {
+					assertTrue(txn.treeExists(present));
+					assertFalse(txn.treeExists(absent));
+					return null;
+				}
+			});
+			storage.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					txn.deleteTree(present);
+					assertFalse(txn.treeExists(present));
+				}
+			});
+		} finally {
+			try {
+				storage.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						txn.deleteTree(present);
+					}
+				});
+			} catch (Exception ignored) {}
+			storage.close();
+		}
+	}
+
+	/**
+	 * The compressed schema definitions of this backend must live in a table of its own. The tree
+	 * name they used to carry held no backend qualifier, so its table name was a constant that every
+	 * JDBC backend of every server sharing the database mapped to, and two of them overwrote each
+	 * other's token definitions there (#873). The backend of this suite has been opened and populated
+	 * by PluggableBackendImplTestCase#setUp, so its own table exists by now.
+	 */
+	@Test
+	public void testCompressedSchemaTableIsQualifiedByBackendId() throws Exception {
+		final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+		try {
+			storage.open(AccessMode.READ_WRITE);
+			final String shared = JDBCStorage.toTableName(new TreeName("compressed_schema", "compressed_attributes"));
+			final String own = JDBCStorage.toTableName(
+					new TreeName("compressed_schema_" + getBackendId(), "compressed_attributes"));
+			assertFalse(shared.equals(own), "the qualified tree name must map to a table of its own");
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				assertTrue(isExistingTable(con, own), own + " (this backend's own definitions) is missing");
+				assertFalse(isExistingTable(con, shared), shared + " is the table every backend used to share");
+			}
+		} finally {
+			storage.close();
+		}
+	}
+
+	/**
+	 * Reading a tree must not enrol it in the storage's tree map: removeStorageFiles() drops every
+	 * table that map names, and the compressed schema reads the tree its definitions used to be
+	 * shared under - which on a shared database is another backend's to keep (#873). Asking whether
+	 * the tree is there is only the first of those reads: the migration counts it and copies it out
+	 * too, so one guarded statement would not be enough.
+	 */
+	@Test
+	public void testProbingATreeDoesNotPutItUpForRemoval() throws Exception {
+		final TreeName foreign = new TreeName("testProbe", "foreign");
+		final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
+		owner.open(AccessMode.READ_WRITE);
+		owner.write(new WriteOperation() {
+			@Override
+			public void run(WriteableTransaction txn) throws Exception {
+				txn.openTree(foreign, true);
+				txn.put(foreign, key(1), value(1));
+			}
+		});
+		owner.close();
+
+		// a second storage on the same database, which never opened that tree - the shape of two
+		// backends addressing one database
+		final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
+		try {
+			other.open(AccessMode.READ_WRITE);
+			other.read(new ReadOperation<Void>() {
+				@Override
+				public Void run(ReadableTransaction txn) throws Exception {
+					// every read the compressed schema runs against a tree it does not own: it asks
+					// whether the tree is there, counts it, reads a key of it and walks it (#873)
+					assertTrue(txn.treeExists(foreign));
+					assertEquals(txn.getRecordCount(foreign), 1);
+					assertEquals(txn.read(foreign, key(1)), value(1));
+					try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(foreign)) {
+						assertTrue(cursor.next());
+						assertEquals(cursor.getKey(), key(1));
+					}
+					return null;
+				}
+			});
+			assertFalse(other.listTrees().contains(foreign), "a tree only read must not be listed for removal");
+
+			other.removeStorageFiles();
+
+			try (final Connection con = DriverManager.getConnection(getJdbcUrl())) {
+				assertTrue(isExistingTable(con, JDBCStorage.toTableName(foreign)),
+						"clearing one backend dropped a table it had only asked about");
+			}
+		} finally {
+			other.close();
+			final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
+			try {
+				cleanup.open(AccessMode.READ_WRITE);
+				cleanup.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						txn.deleteTree(foreign);
+					}
+				});
+			} catch (Exception ignored) {
+			} finally {
+				cleanup.close();
+			}
+		}
+	}
+
+	/**
+	 * The other side of the same rule: a cursor reads through the non-enrolling name, but deleting
+	 * through one writes to the tree, so it is a tree this backend owns and removeStorageFiles()
+	 * has to be able to name it.
+	 */
+	@Test
+	public void testDeletingThroughACursorPutsTheTreeUpForRemoval() throws Exception {
+		final TreeName tree = new TreeName("testCursorDelete", "tree");
+		final JDBCStorage owner = new JDBCStorage(createBackendCfg(), null);
+		owner.open(AccessMode.READ_WRITE);
+		owner.write(new WriteOperation() {
+			@Override
+			public void run(WriteableTransaction txn) throws Exception {
+				txn.openTree(tree, true);
+				txn.put(tree, key(1), value(1));
+			}
+		});
+		owner.close();
+
+		// a storage that never opened that tree, so nothing but the delete can enrol it
+		final JDBCStorage other = new JDBCStorage(createBackendCfg(), null);
+		try {
+			other.open(AccessMode.READ_WRITE);
+			other.write(new WriteOperation() {
+				@Override
+				public void run(WriteableTransaction txn) throws Exception {
+					try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(tree)) {
+						assertTrue(cursor.next());
+						cursor.delete();
+					}
+				}
+			});
+			assertTrue(other.listTrees().contains(tree), "a tree written through a cursor must be listed for removal");
+		} finally {
+			other.close();
+			final JDBCStorage cleanup = new JDBCStorage(createBackendCfg(), null);
+			try {
+				cleanup.open(AccessMode.READ_WRITE);
+				cleanup.write(new WriteOperation() {
+					@Override
+					public void run(WriteableTransaction txn) throws Exception {
+						txn.deleteTree(tree);
+					}
+				});
+			} catch (Exception ignored) {
+			} finally {
+				cleanup.close();
+			}
+		}
+	}
+
+	private static boolean isExistingTable(Connection con, String tableName) throws SQLException {
+		try (final ResultSet rs = con.getMetaData().getTables(null, null, null, new String[]{"TABLE"})) {
+			while (rs.next()) {
+				if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
 	 * Forward repositioning inside the already-fetched batch must be served from the buffer without SQL,
 	 * and batch sizes must grow from "fetchsize.initial" to "fetchsize" on sequential reads (#860).
 	 */
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java
index e3b01b8..00cbcec 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java
+++ b/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
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java
new file mode 100644
index 0000000..f2b98de
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java
@@ -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);
+    }
+  }
+}

--
Gitblit v1.10.0