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/jdbc/JDBCStorage.java | 140 ++++++++++++++++++++++++++++++++--------------
1 files changed, 98 insertions(+), 42 deletions(-)
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);
--
Gitblit v1.10.0