From 97dbf50dd56ee7640411ac61e6aa288f208a4375 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 01 Sep 2026 08:54:30 +0000
Subject: [PATCH] [#874] Grant the offline tools a read-only JDBC transaction instead of refusing it (#880)

---
 opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java |   29 +++++++++++++++++++++++++++--
 1 files changed, 27 insertions(+), 2 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 d1d8055..c94088c 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
@@ -1043,6 +1043,16 @@
 			}
 		}
 	}
+	/**
+	 * A transaction able to write, unless the storage was opened read-only: then it may open an existing tree and
+	 * read it, and every mutating operation throws {@link ReadOnlyStorageException} instead.
+	 * <p>
+	 * The mode is checked per operation rather than refused here, because {@code RootContainer.open(AccessMode)}
+	 * asks for a write transaction even in read-only mode - that is where it opens the compressed schema and the
+	 * entry containers - so refusing to hand one out failed the offline {@code export-ldif}, {@code verify-index}
+	 * and {@code backendstat} before they read anything (#874). Both other storages of this server already have
+	 * this shape: {@code PDBStorage.ReadOnlyStorageImpl} and {@code CASStorage.TransactionImpl.checkReadOnly()}.
+	 */
 	private final class WriteableTransactionTransactionImpl extends ReadableTransactionImpl implements WriteableTransaction {
 
 		// Shared by every table this transaction stamps: opening a backend opens all its trees,
@@ -1052,10 +1062,17 @@
 
 		public WriteableTransactionTransactionImpl(Connection con) {
 			super(con);
-			if (!accessMode.isWriteable()) {
+			//captured once rather than read per operation: the access mode of the storage is mutable state -
+			//ImporterImpl reopens the storage READ_WRITE under its caller - and a transaction has to keep the mode
+			//it was created with. It also drives isReadOnly, so that a cursor this transaction opens refuses
+			//delete() as well.
+			isReadOnly = !accessMode.isWriteable();
+		}
+
+		void checkReadOnly() {
+			if (isReadOnly) {
 				throw new ReadOnlyStorageException();
 			}
-			isReadOnly = false;
 		}
 
 		boolean isExistsTable(TreeName treeName) {
@@ -1096,6 +1113,7 @@
 		@Override
 		public void openTree(TreeName treeName, boolean createOnDemand) {
 			if (createOnDemand) {
+				checkReadOnly();
 				if (!isExistsTable(treeName)) {
 					try (final PreparedStatement statement=con.prepareStatement("create table "+getTableName(treeName)+" ("+getTableDialect()+")")){
 						execute(statement);
@@ -1158,6 +1176,7 @@
 		}
 		
 		public void clearTree(TreeName treeName) {
+			checkReadOnly();
 			try (final PreparedStatement statement=con.prepareStatement("delete from "+getTableName(treeName))){
 				execute(statement);
 				con.commit();
@@ -1168,6 +1187,7 @@
 
 		@Override
 		public void deleteTree(TreeName treeName) {
+			checkReadOnly();
 			if (isExistsTable(treeName)) {
 				try (final PreparedStatement statement = con.prepareStatement("drop table " + getTableName(treeName))) {
 					execute(statement);
@@ -1183,6 +1203,7 @@
 
 		@Override
 		public void put(TreeName treeName, ByteSequence key, ByteSequence value) {
+			checkReadOnly();
 			try {
 				upsert(treeName, key, value);
 			} catch (SQLException e) {
@@ -1250,6 +1271,9 @@
 
 		@Override
 		public boolean update(TreeName treeName, ByteSequence key, UpdateFunction f) {
+			//checked before the read, so that a read-only transaction reports the mode rather than the value it
+			//computed being equal to the stored one
+			checkReadOnly();
 			final ByteString oldValue=read(treeName,key);
 			final ByteSequence newValue=f.computeNewValue(oldValue);
 			if (Objects.equals(newValue, oldValue))
@@ -1266,6 +1290,7 @@
 
 		@Override
 		public boolean delete(TreeName treeName, ByteSequence key) {
+			checkReadOnly();
 			try (final PreparedStatement statement=con.prepareStatement("delete from "+getTableName(treeName)+" where h="+hashParam(con)+" and k=?")){
 				statement.setString(1,key2hash.get(ByteBuffer.wrap(key.toByteArray())));
 				statement.setBytes(2,real2db(key.toByteArray()));

--
Gitblit v1.10.0