From 3847676be90a0b5b53d25f1a85ac37a038574d22 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 04 Aug 2026 08:33:38 +0000
Subject: [PATCH] [#819] Release the log when a changelog DB constructor cannot read its limits (#833)

---
 opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileReplicaDB.java           |   12 +++
 opendj-server-legacy/src/test/java/org/opends/server/replication/server/changelog/file/FileReplicaDBTest.java       |  110 ++++++++++++++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileChangeNumberIndexDB.java |   21 +++++-
 3 files changed, 137 insertions(+), 6 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileChangeNumberIndexDB.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileChangeNumberIndexDB.java
index 642c29d..fb69d03 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileChangeNumberIndexDB.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileChangeNumberIndexDB.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.replication.server.changelog.file;
 
@@ -121,11 +122,21 @@
   {
     this.changelogDB = changelogDB;
     log = replicationEnv.getOrCreateCNIndexDB();
-    final ChangeNumberIndexRecord newestRecord = readLastRecord();
-    newestChangeNumber = getChangeNumber(newestRecord);
-    // initialization of the lastGeneratedChangeNumber from the DB content
-    // if DB is empty => last record does not exist => default to 0
-    lastGeneratedChangeNumber = new AtomicLong(newestChangeNumber);
+    try
+    {
+      final ChangeNumberIndexRecord newestRecord = readLastRecord();
+      newestChangeNumber = getChangeNumber(newestRecord);
+      // initialization of the lastGeneratedChangeNumber from the DB content
+      // if DB is empty => last record does not exist => default to 0
+      lastGeneratedChangeNumber = new AtomicLong(newestChangeNumber);
+    }
+    catch (ChangelogException | RuntimeException e)
+    {
+      // This instance never escapes the failed constructor, so nobody could ever call shutdown()
+      // to release the reference taken on the log by getOrCreateCNIndexDB().
+      log.close();
+      throw e;
+    }
 
     // Monitoring registration
     DirectoryServer.deregisterMonitorProvider(dbMonitor);
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileReplicaDB.java b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileReplicaDB.java
index ac4ec81..0dd267e 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileReplicaDB.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/replication/server/changelog/file/FileReplicaDB.java
@@ -115,7 +115,17 @@
     this.replicationServer = replicationServer;
     this.replicationEnv = replicationEnv;
     this.log = createLog(replicationEnv, cryptoSuite);
-    this.csnLimits = new CSNLimits(readOldestCSN(), readNewestCSN());
+    try
+    {
+      this.csnLimits = new CSNLimits(readOldestCSN(), readNewestCSN());
+    }
+    catch (ChangelogException | RuntimeException e)
+    {
+      // This instance never escapes the failed constructor, so nobody could ever call shutdown()
+      // to release the reference taken on the log by createLog().
+      log.close();
+      throw e;
+    }
 
     DirectoryServer.deregisterMonitorProvider(dbMonitor);
     DirectoryServer.registerMonitorProvider(dbMonitor);
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/server/changelog/file/FileReplicaDBTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/server/changelog/file/FileReplicaDBTest.java
index 17be42f..5e3ce5e 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/replication/server/changelog/file/FileReplicaDBTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/server/changelog/file/FileReplicaDBTest.java
@@ -12,10 +12,12 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.replication.server.changelog.file;
 
 import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
 
@@ -23,6 +25,7 @@
 import org.forgerock.i18n.slf4j.LocalizedLogger;
 import org.forgerock.opendj.config.server.ConfigException;
 import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ByteStringBuilder;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.util.time.TimeService;
 import org.opends.server.TestCaseUtils;
@@ -359,6 +362,113 @@
     }
   }
 
+  /**
+   * Reproduces https://github.com/OpenIdentityPlatform/OpenDJ/issues/819: a constructor which
+   * cannot read the CSN limits must release the log it just opened, otherwise the Log instance
+   * stays pinned in the JVM-wide log cache together with its file handles.
+   */
+  @Test
+  public void testFailedConstructorReleasesLog() throws Exception
+  {
+    ReplicationServer replicationServer = null;
+    File testRoot = null;
+    ReplicationEnvironment dbEnv = null;
+    FileReplicaDB replicaDB = null;
+    try
+    {
+      TestCaseUtils.startServer();
+      replicationServer = configureReplicationServer(100000, 10);
+      testRoot = createCleanDir();
+      dbEnv = new ReplicationEnvironment(testRoot.getPath(), replicationServer, TimeService.SYSTEM);
+      replicaDB = new FileReplicaDB(1, TEST_ROOT_DN, replicationServer, createCryptoSuite(false), dbEnv);
+
+      final CSN[] csns = generateCSNs(1, 0, 2);
+      replicaDB.add(new DeleteMsg(TEST_ROOT_DN, csns[0], "uid"));
+      replicaDB.add(new DeleteMsg(TEST_ROOT_DN, csns[1], "uid"));
+      waitChangesArePersisted(replicaDB, 2);
+      replicaDB.shutdown();
+      replicaDB = null;
+
+      // A rotated log file is opened without validation (its bounds come from the file name),
+      // so reading the oldest CSN is the first operation touching its corrupted content.
+      final File logDirectory = findReplicaLogDirectory(testRoot);
+      assertNotNull(logDirectory, "could not find the replica DB log directory under " + testRoot);
+      final File corruptedLogFile = writeCorruptedReadOnlyLogFile(logDirectory);
+      try
+      {
+        replicaDB = new FileReplicaDB(1, TEST_ROOT_DN, replicationServer, createCryptoSuite(false), dbEnv);
+        org.testng.Assert.fail("Expected the constructor to fail on the corrupted log file " + corruptedLogFile);
+      }
+      catch (ChangelogException expected)
+      {
+        debugInfo("testFailedConstructorReleasesLog", "constructor failed as expected: " + expected.getMessage());
+      }
+
+      // Reopening once the corrupted file is gone must succeed: if the failed constructor leaked
+      // its reference, the log cache returns the stale Log instance which still contains the
+      // corrupted log file, and this constructor fails although the directory is sane again.
+      assertTrue(corruptedLogFile.delete());
+      replicaDB = new FileReplicaDB(1, TEST_ROOT_DN, replicationServer, createCryptoSuite(false), dbEnv);
+      assertLimits(replicaDB, csns[0], csns[1]);
+    }
+    finally
+    {
+      shutdown(replicaDB);
+      if (dbEnv != null)
+      {
+        dbEnv.shutdown();
+      }
+      remove(replicationServer);
+      TestCaseUtils.deleteDirectory(testRoot);
+    }
+  }
+
+  /** Returns the directory holding the log files of the single replica DB under the provided directory. */
+  private File findReplicaLogDirectory(File directory)
+  {
+    if (new File(directory, Log.HEAD_LOG_FILE_NAME).isFile())
+    {
+      return directory;
+    }
+    final File[] children = directory.listFiles();
+    if (children != null)
+    {
+      for (File child : children)
+      {
+        if (child.isDirectory())
+        {
+          final File found = findReplicaLogDirectory(child);
+          if (found != null)
+          {
+            return found;
+          }
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Writes a log file named like a rotated log file but holding a record which cannot be decoded:
+   * a record length followed by garbage instead of an update message.
+   */
+  private File writeCorruptedReadOnlyLogFile(File logDirectory) throws Exception
+  {
+    final String key = new CSN(1, 1, 1).toString();
+    final File file = new File(logDirectory, key + "_" + key + ".log");
+    final ByteStringBuilder record = new ByteStringBuilder();
+    record.appendInt(8);
+    for (int i = 0; i < 8; i++)
+    {
+      record.appendByte(0xFF);
+    }
+    try (FileOutputStream output = new FileOutputStream(file))
+    {
+      output.write(record.toByteArray());
+    }
+    return file;
+  }
+
   private void assertNextCSN(FileReplicaDB replicaDB, final CSN startCSN,
       final PositionStrategy positionStrategy, final CSN expectedCSN)
       throws ChangelogException

--
Gitblit v1.10.0