From 7243c66412f9bc84f84d83d84487ad44e40bd223 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Wed, 16 Sep 2026 18:18:52 +0000
Subject: [PATCH] [#995] Classify the failure of a task on the message that reports it (#996)

---
 opendj-server-legacy/src/main/java/org/opends/server/tools/dsreplication/ReplicationCliMain.java |   12 
 opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/InstallerHelperTest.java      |  182 ++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/Installer.java                |    7 
 opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java          |   95 +++++++++++
 opendj-server-legacy/src/main/java/org/opends/admin/ads/util/ConnectionUtils.java                |   32 ++++
 opendj-server-legacy/src/test/java/org/opends/server/tasks/TaskLogMessagesTestCase.java          |  117 ++++++++++++++
 6 files changed, 434 insertions(+), 11 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/admin/ads/util/ConnectionUtils.java b/opendj-server-legacy/src/main/java/org/opends/admin/ads/util/ConnectionUtils.java
index 3501eff..3036dd1 100644
--- a/opendj-server-legacy/src/main/java/org/opends/admin/ads/util/ConnectionUtils.java
+++ b/opendj-server-legacy/src/main/java/org/opends/admin/ads/util/ConnectionUtils.java
@@ -13,10 +13,16 @@
  *
  * Copyright 2008-2010 Sun Microsystems, Inc.
  * Portions Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.admin.ads.util;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
 import org.forgerock.opendj.ldap.Attribute;
+import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.Entry;
 
 /**
@@ -44,4 +50,30 @@
     Attribute attr = entry.getAttribute(attrDesc);
     return (attr != null && !attr.isEmpty()) ? attr.firstValueAsString() : null;
   }
+
+  /**
+   * Returns all the values of this attribute decoded as UTF-8 strings, in the order they were
+   * returned by the server.
+   *
+   * @param entry
+   *          the entry
+   * @param attrDesc
+   *          the attribute description
+   * @return all the values of this attribute decoded as UTF-8 strings, an empty list if the
+   *         attribute is not present.
+   */
+  public static List<String> allValuesAsStrings(Entry entry, String attrDesc)
+  {
+    Attribute attr = entry.getAttribute(attrDesc);
+    if (attr == null || attr.isEmpty())
+    {
+      return Collections.emptyList();
+    }
+    List<String> values = new ArrayList<>(attr.size());
+    for (ByteString value : attr)
+    {
+      values.add(value.toString());
+    }
+    return values;
+  }
 }
diff --git a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/Installer.java b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/Installer.java
index 91a495e..0d3096c 100644
--- a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/Installer.java
+++ b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/Installer.java
@@ -4446,7 +4446,8 @@
           }
         }
 
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        List<String> logMsgs = InstallerHelper.getTaskLogMessages(sr);
+        String logMsg = InstallerHelper.getRelevantLogMessage(logMsgs);
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
@@ -4489,7 +4490,7 @@
           else if (!TaskState.isSuccessful(taskState) || taskState == STOPPED_BY_ERROR)
           {
             ApplicationException ae = new ApplicationException(ReturnCode.APPLICATION_ERROR, errorMsg, null);
-            if (lastLogMsg == null || helper.isPeersNotFoundError(lastLogMsg))
+            if (helper.isPeersNotFoundError(logMsgs))
             {
               logger.warn(LocalizableMessage.raw("Throwing peer not found error.  " + "Last Log Msg: " + lastLogMsg));
               // Assume that this is a peer not found error.
@@ -4645,7 +4646,7 @@
             newSearchRequest(dn, BASE_OBJECT, "(objectclass=*)", "ds-task-log-message", "ds-task-state");
         SearchResultEntry sr = conn.getConnection().searchSingleEntry(searchRequest);
 
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        String logMsg = InstallerHelper.getRelevantLogMessage(InstallerHelper.getTaskLogMessages(sr));
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
diff --git a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java
index 919ff3d..3c5e499 100644
--- a/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java
+++ b/opendj-server-legacy/src/main/java/org/opends/quicksetup/installer/InstallerHelper.java
@@ -20,8 +20,10 @@
 import static com.forgerock.opendj.cli.Utils.*;
 import static com.forgerock.opendj.util.OperatingSystem.*;
 
+import static org.opends.admin.ads.util.ConnectionUtils.allValuesAsStrings;
 import static org.opends.messages.QuickSetupMessages.*;
 import static org.opends.quicksetup.Installation.*;
+import static org.opends.server.config.ConfigConstants.ATTR_TASK_LOG_MESSAGES;
 import static org.opends.server.types.ExistingFileBehavior.*;
 import static org.opends.server.types.HostPort.*;
 
@@ -36,6 +38,7 @@
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -53,6 +56,7 @@
 import org.forgerock.opendj.config.PropertyException;
 import org.forgerock.opendj.config.server.ConfigException;
 import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.Entry;
 import org.forgerock.opendj.server.config.client.BackendCfgClient;
 import org.forgerock.opendj.server.config.client.CryptoManagerCfgClient;
 import org.forgerock.opendj.server.config.client.LocalBackendCfgClient;
@@ -70,6 +74,7 @@
 import org.opends.messages.BackendMessages;
 import org.opends.messages.CoreMessages;
 import org.opends.messages.ReplicationMessages;
+import org.opends.messages.Severity;
 import org.opends.quicksetup.Application;
 import org.opends.quicksetup.ApplicationException;
 import org.opends.quicksetup.JavaArguments;
@@ -106,6 +111,23 @@
   private static final long ONE_MEGABYTE = 1024L * 1024;
 
   /**
+   * The severity field of a message logged by a task with an error severity, as rendered by
+   * {@code org.opends.server.backends.task.Task#addLogMessage}: it follows the timestamp and
+   * precedes the message count, so it cannot be mistaken for the text of the message.
+   */
+  private static final String ERROR_SEVERITY_FIELD =
+      "] severity=\"" + Severity.ERROR.name() + "\" msgCount=";
+  /**
+   * The message id field of the peers not found error, as rendered by
+   * {@code org.opends.server.backends.task.Task#addLogMessage}: a message is identified in a task
+   * log by its resource name and its ordinal, not by its ordinal alone, and the field ends where
+   * the message text starts, so the ordinal is matched whole.
+   */
+  private static final String PEERS_NOT_FOUND_MSG_ID_FIELD = "msgID="
+      + ReplicationMessages.ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN.resourceName() + "-"
+      + ReplicationMessages.ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN.ordinal() + " message=\"";
+
+  /**
    * Invokes the method ConfigureDS.configMain with the provided parameters.
    * @param args the arguments to be passed to ConfigureDS.configMain.
    * @return the return code of the ConfigureDS.configMain method.
@@ -675,17 +697,86 @@
   }
 
   /**
+   * Returns the messages logged by the task described by the provided entry, in the order the
+   * server logged them.
+   *
+   * @param taskEntry
+   *          the entry of the task.
+   * @return the messages logged by the task, an empty list if it logged none.
+   */
+  public static List<String> getTaskLogMessages(Entry taskEntry)
+  {
+    return allValuesAsStrings(taskEntry, ATTR_TASK_LOG_MESSAGES);
+  }
+
+  /**
+   * Returns the message that best describes the outcome of a task among the messages it logged.
+   * <p>
+   * This is the last message logged with the {@link Severity#ERROR} severity when there is one:
+   * a task keeps logging after it failed - the task scheduler itself appends a completion notice
+   * once the task is over - and those trailing messages hide the cause of the failure. It is the
+   * last message logged otherwise.
+   *
+   * @param logMsgs
+   *          the messages logged by the task, as returned by {@link #getTaskLogMessages(Entry)}.
+   * @return the most relevant message, {@code null} if the task logged none.
+   */
+  public static String getRelevantLogMessage(List<String> logMsgs)
+  {
+    String lastErrorMsg = null;
+    for (String logMsg : logMsgs)
+    {
+      if (logMsg.contains(ERROR_SEVERITY_FIELD))
+      {
+        lastErrorMsg = logMsg;
+      }
+    }
+    if (lastErrorMsg != null)
+    {
+      return lastErrorMsg;
+    }
+    return !logMsgs.isEmpty() ? logMsgs.get(logMsgs.size() - 1) : null;
+  }
+
+  /**
    * Tells whether the provided log message corresponds to a peers not found
    * error during the initialization of a replica or not.
+   * <p>
+   * The message is recognized by the id a task logs it with, which is made of the name of the
+   * resource the message belongs to and of its ordinal within that resource.
    *
    * @param logMsg
-   *          the log message.
+   *          the log message, may be {@code null}.
    * @return {@code true} if the log message corresponds to a peers not
    *         found error during initialization, {@code false} otherwise.
    */
   public boolean isPeersNotFoundError(String logMsg)
   {
-    return logMsg.contains("=" + ReplicationMessages.ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN.ordinal());
+    return logMsg != null && logMsg.contains(PEERS_NOT_FOUND_MSG_ID_FIELD);
+  }
+
+  /**
+   * Tells whether one of the provided log messages corresponds to a peers not found error during
+   * the initialization of a replica or not.
+   * <p>
+   * All the messages must be tested: the message reporting the failure is neither the first one
+   * logged by the task nor, since the task scheduler appends a completion notice, the last one.
+   *
+   * @param logMsgs
+   *          the log messages of the task, as returned by {@link #getTaskLogMessages(Entry)}.
+   * @return {@code true} if one of the log messages corresponds to a peers not
+   *         found error during initialization, {@code false} otherwise.
+   */
+  public boolean isPeersNotFoundError(Collection<String> logMsgs)
+  {
+    for (String logMsg : logMsgs)
+    {
+      if (isPeersNotFoundError(logMsg))
+      {
+        return true;
+      }
+    }
+    return false;
   }
 
   /**
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/tools/dsreplication/ReplicationCliMain.java b/opendj-server-legacy/src/main/java/org/opends/server/tools/dsreplication/ReplicationCliMain.java
index bfed2aa..4dbb239 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/tools/dsreplication/ReplicationCliMain.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/tools/dsreplication/ReplicationCliMain.java
@@ -1420,7 +1420,7 @@
       try
       {
         SearchResultEntry sr = getLastSearchResult(conn, taskDN, "ds-task-log-message", "ds-task-state");
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        String logMsg = InstallerHelper.getRelevantLogMessage(InstallerHelper.getTaskLogMessages(sr));
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
@@ -1574,7 +1574,7 @@
             "ds-task-purge-conflicts-historical-purge-completed-in-time",
             "ds-task-purge-conflicts-historical-purge-completed-in-time",
             "ds-task-purge-conflicts-historical-last-purged-changenumber");
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        String logMsg = InstallerHelper.getRelevantLogMessage(InstallerHelper.getTaskLogMessages(sr));
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
@@ -6734,7 +6734,7 @@
       try
       {
         SearchResultEntry sr = getLastSearchResult(conn, dn, "ds-task-log-message", "ds-task-state");
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        String logMsg = InstallerHelper.getRelevantLogMessage(InstallerHelper.getTaskLogMessages(sr));
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
@@ -6870,7 +6870,8 @@
           }
         }
 
-        String logMsg = firstValueAsString(sr, "ds-task-log-message");
+        List<String> logMsgs = InstallerHelper.getTaskLogMessages(sr);
+        String logMsg = InstallerHelper.getRelevantLogMessage(logMsgs);
         if (logMsg != null && !logMsg.equals(lastLogMsg))
         {
           logger.info(LocalizableMessage.raw(logMsg));
@@ -6906,8 +6907,7 @@
             ClientException ce = new ClientException(
                 ReturnCode.APPLICATION_ERROR, errorMsg,
                 null);
-            if (lastLogMsg == null
-                || helper.isPeersNotFoundError(lastLogMsg))
+            if (helper.isPeersNotFoundError(logMsgs))
             {
               logger.warn(LocalizableMessage.raw("Throwing peer not found error.  "+
                   "Last Log Msg: "+lastLogMsg));
diff --git a/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/InstallerHelperTest.java b/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/InstallerHelperTest.java
new file mode 100644
index 0000000..c4d28ab
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/quicksetup/installer/InstallerHelperTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.quicksetup.installer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.opends.messages.ReplicationMessages.ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.LinkedAttribute;
+import org.forgerock.opendj.ldap.LinkedHashMapEntry;
+import org.opends.server.DirectoryServerTestCase;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the classification of the messages a task logs into its entry, as read by
+ * {@code dsreplication} and by the quick setup while they wait for a task to complete.
+ * <p>
+ * The messages below are the ones a real initialization task logs, in the form rendered by
+ * {@code org.opends.server.backends.task.Task#addLogMessage}: a start notice from the task
+ * scheduler, then whatever the task logged, then the completion notice the scheduler appends
+ * once the task is over - all of them before the terminal task state becomes visible.
+ */
+@SuppressWarnings("javadoc")
+public class InstallerHelperTest extends DirectoryServerTestCase
+{
+  private static final String TASK_STARTED =
+      "[09/Sep/2026:12:06:48 +0000] severity=\"NOTICE\" msgCount=0 msgID=org.opends.messages.backend-413"
+      + " message=\"Initialize From Replica task quicksetup-initialize3 started execution\"";
+  private static final String PEERS_NOT_FOUND =
+      "[09/Sep/2026:12:08:48 +0000] severity=\"ERROR\" msgCount=1 msgID=org.opends.messages.replication-47"
+      + " message=\"Domain dc=example,dc=com: the server with serverId=12345 is unreachable\"";
+  private static final String IMPORT_NOT_SUPPORTED =
+      "[09/Sep/2026:12:08:48 +0000] severity=\"ERROR\" msgCount=2 msgID=org.opends.messages.replication-82"
+      + " message=\" Initialization cannot be done because import is not supported by the backend userRoot\"";
+  private static final String TASK_FINISHED =
+      "[09/Sep/2026:12:08:48 +0000] severity=\"NOTICE\" msgCount=3 msgID=org.opends.messages.backend-414"
+      + " message=\"Initialize From Replica task quicksetup-initialize3 finished execution in the state"
+      + " Stopped by error\"";
+
+  /**
+   * The message id of the peers not found error, as a task renders it. The predicate under test is
+   * built from the message descriptor: this pins the descriptor to the form the other tests use.
+   */
+  @Test
+  public void peersNotFoundErrorIsIdentifiedByResourceNameAndOrdinal()
+  {
+    assertThat(ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN.resourceName() + "-"
+        + ERR_NO_REACHABLE_PEER_IN_THE_DOMAIN.ordinal()).isEqualTo("org.opends.messages.replication-47");
+  }
+
+  @Test
+  public void peersNotFoundErrorIsRecognized()
+  {
+    assertThat(new InstallerHelper().isPeersNotFoundError(PEERS_NOT_FOUND)).isTrue();
+  }
+
+  /**
+   * The ordinal alone identifies no message: it is unique within a message file only, and the
+   * count of the messages a task logged carries the same digits.
+   */
+  @Test
+  public void anotherMessageCarryingTheOrdinalOfThePeersNotFoundErrorIsNotRecognized()
+  {
+    final String otherError =
+        "[09/Sep/2026:12:08:48 +0000] severity=\"ERROR\" msgCount=47 msgID=org.opends.messages.replication-45"
+        + " message=\"On domain dc=example,dc=com, initialization of server with serverId:12345 has been"
+        + " requested from a server with an invalid serverId:0. \"";
+    assertThat(new InstallerHelper().isPeersNotFoundError(otherError)).isFalse();
+  }
+
+  /**
+   * The ordinal is unique within a message file only: message 47 of another file is not this error.
+   * Nor is a message of the same file whose ordinal merely starts with the same digits.
+   */
+  @Test
+  public void aMessageOfAnotherResourceOrWithALongerOrdinalIsNotRecognized()
+  {
+    final String waitingOnStartTime =
+        "[09/Sep/2026:12:08:48 +0000] severity=\"INFORMATION\" msgCount=1 msgID=org.opends.messages.task-47"
+        + " message=\"Waiting on start time\"";
+    final String longerOrdinal =
+        "[09/Sep/2026:12:08:48 +0000] severity=\"ERROR\" msgCount=1 msgID=org.opends.messages.replication-470"
+        + " message=\"Domain dc=example,dc=com: the server with serverId=12345 is unreachable\"";
+    assertThat(new InstallerHelper().isPeersNotFoundError(waitingOnStartTime)).isFalse();
+    assertThat(new InstallerHelper().isPeersNotFoundError(longerOrdinal)).isFalse();
+  }
+
+  @Test
+  public void aNoticeIsNotAPeersNotFoundError()
+  {
+    assertThat(new InstallerHelper().isPeersNotFoundError(TASK_STARTED)).isFalse();
+    assertThat(new InstallerHelper().isPeersNotFoundError((String) null)).isFalse();
+  }
+
+  /**
+   * The failure of a task is reported neither by the first message it logs nor by the last one, so
+   * all of them have to be tested. This is the sequence of the task of issue #995.
+   */
+  @Test
+  public void peersNotFoundErrorIsFoundAmongAllTheMessagesOfTheTask()
+  {
+    final InstallerHelper helper = new InstallerHelper();
+    assertThat(helper.isPeersNotFoundError(Arrays.asList(TASK_STARTED, PEERS_NOT_FOUND, TASK_FINISHED))).isTrue();
+    assertThat(helper.isPeersNotFoundError(Arrays.asList(TASK_STARTED, IMPORT_NOT_SUPPORTED, TASK_FINISHED)))
+        .isFalse();
+    assertThat(helper.isPeersNotFoundError(Collections.<String> emptyList())).isFalse();
+  }
+
+  /** The completion notice appended by the task scheduler must not hide the cause of the failure. */
+  @Test
+  public void relevantLogMessageIsTheLastErrorRatherThanTheLastMessage()
+  {
+    assertThat(InstallerHelper.getRelevantLogMessage(
+        Arrays.asList(TASK_STARTED, PEERS_NOT_FOUND, TASK_FINISHED))).isEqualTo(PEERS_NOT_FOUND);
+    assertThat(InstallerHelper.getRelevantLogMessage(
+        Arrays.asList(TASK_STARTED, PEERS_NOT_FOUND, IMPORT_NOT_SUPPORTED, TASK_FINISHED)))
+        .isEqualTo(IMPORT_NOT_SUPPORTED);
+  }
+
+  /**
+   * The severity of a message is a field of its own, not a word of its text: a notice whose text
+   * names the state of a failed task is not the error.
+   */
+  @Test
+  public void aNoticeMentioningAnErrorIsNotTheError()
+  {
+    final String notice =
+        "[09/Sep/2026:12:08:48 +0000] severity=\"NOTICE\" msgCount=3 msgID=org.opends.messages.backend-414"
+        + " message=\"Initialize From Replica task quicksetup-initialize3 finished execution in the state"
+        + " STOPPED_BY_ERROR\"";
+    assertThat(InstallerHelper.getRelevantLogMessage(Arrays.asList(TASK_STARTED, PEERS_NOT_FOUND, notice)))
+        .isEqualTo(PEERS_NOT_FOUND);
+  }
+
+  @Test
+  public void relevantLogMessageIsTheLastMessageWhenTheTaskLoggedNoError()
+  {
+    assertThat(InstallerHelper.getRelevantLogMessage(Arrays.asList(TASK_STARTED, TASK_FINISHED)))
+        .isEqualTo(TASK_FINISHED);
+    assertThat(InstallerHelper.getRelevantLogMessage(Collections.<String> emptyList())).isNull();
+  }
+
+  @Test
+  public void taskLogMessagesAreReadInTheOrderTheyWereLogged()
+  {
+    final Entry taskEntry = new LinkedHashMapEntry("ds-task-id=quicksetup-initialize3,cn=Scheduled Tasks,cn=Tasks");
+    taskEntry.addAttribute(new LinkedAttribute("ds-task-log-message",
+        TASK_STARTED, PEERS_NOT_FOUND, TASK_FINISHED));
+
+    final List<String> logMsgs = InstallerHelper.getTaskLogMessages(taskEntry);
+
+    assertThat(logMsgs).containsExactly(TASK_STARTED, PEERS_NOT_FOUND, TASK_FINISHED);
+    assertThat(new InstallerHelper().isPeersNotFoundError(logMsgs)).isTrue();
+    assertThat(InstallerHelper.getRelevantLogMessage(logMsgs)).isEqualTo(PEERS_NOT_FOUND);
+  }
+
+  @Test
+  public void taskWithoutLogMessagesReadsAsAnEmptyLog()
+  {
+    final Entry taskEntry = new LinkedHashMapEntry("ds-task-id=quicksetup-initialize3,cn=Scheduled Tasks,cn=Tasks");
+
+    assertThat(InstallerHelper.getTaskLogMessages(taskEntry)).isEmpty();
+    assertThat(InstallerHelper.getRelevantLogMessage(InstallerHelper.getTaskLogMessages(taskEntry))).isNull();
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/tasks/TaskLogMessagesTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/tasks/TaskLogMessagesTestCase.java
new file mode 100644
index 0000000..c0e14db
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/tasks/TaskLogMessagesTestCase.java
@@ -0,0 +1,117 @@
+/*
+ * 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.tasks;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.opends.messages.BackendMessages.NOTE_TASK_FINISHED;
+import static org.opends.messages.BackendMessages.NOTE_TASK_STARTED;
+import static org.opends.messages.ToolMessages.ERR_LDIFEXPORT_NO_BACKENDS_FOR_ID;
+import static org.opends.server.config.ConfigConstants.ATTR_TASK_LOG_MESSAGES;
+import static org.opends.server.protocols.internal.InternalClientConnection.getRootConnection;
+import static org.opends.server.protocols.internal.Requests.newSearchRequest;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.opends.quicksetup.installer.InstallerHelper;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.backends.task.TaskState;
+import org.opends.server.protocols.internal.InternalSearchOperation;
+import org.opends.server.types.Attribute;
+import org.opends.server.types.Entry;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests what the messages a task logs into its entry look like once the task is over, since the
+ * quick setup and {@code dsreplication} read them to report and to classify the failure of the
+ * tasks they run - see {@code InstallerHelper} and issue #995.
+ */
+@SuppressWarnings("javadoc")
+public class TaskLogMessagesTestCase extends TasksTestCase
+{
+  @BeforeClass
+  public void startServer() throws Exception
+  {
+    TestCaseUtils.startServer();
+  }
+
+  /**
+   * The failure of a task is reported by neither the first nor the last message it logged: the
+   * task scheduler frames the execution with a start and a completion notice, and both of them are
+   * in the entry before the terminal task state is, so a reader that waits for that state always
+   * sees them. The cause has to be looked up among all the messages.
+   */
+  @Test
+  public void failureOfATaskIsReportedBetweenTheNoticesOfTheScheduler() throws Exception
+  {
+    final String taskDN = "ds-task-id=" + UUID.randomUUID() + ",cn=Scheduled Tasks,cn=Tasks";
+    // Exporting an unknown backend fails the task without touching any data.
+    final Entry taskEntry = TestCaseUtils.makeEntry(
+        "dn: " + taskDN,
+        "objectclass: top",
+        "objectclass: ds-task",
+        "objectclass: ds-task-export",
+        "ds-task-class-name: org.opends.server.tasks.ExportTask",
+        "ds-task-export-backend-id: no-such-backend",
+        "ds-task-export-ldif-file: " + TestCaseUtils.createTempFile());
+    testTask(taskEntry, TaskState.STOPPED_BY_ERROR, 60);
+
+    final List<String> logMsgs = getLogMessages(DN.valueOf(taskDN));
+    assertThat(logMsgs.size()).isGreaterThanOrEqualTo(3);
+    assertThat(logMsgs.get(0)).contains(msgIdField(NOTE_TASK_STARTED.resourceName(), NOTE_TASK_STARTED.ordinal()));
+    assertThat(logMsgs.get(logMsgs.size() - 1))
+        .contains(msgIdField(NOTE_TASK_FINISHED.resourceName(), NOTE_TASK_FINISHED.ordinal()));
+
+    final String failure = InstallerHelper.getRelevantLogMessage(logMsgs);
+    assertThat(failure).contains("severity=\"ERROR\"")
+        .contains(msgIdField(ERR_LDIFEXPORT_NO_BACKENDS_FOR_ID.resourceName(),
+            ERR_LDIFEXPORT_NO_BACKENDS_FOR_ID.ordinal()));
+    assertThat(logMsgs.indexOf(failure)).isGreaterThan(0).isLessThan(logMsgs.size() - 1);
+    assertThat(new InstallerHelper().isPeersNotFoundError(logMsgs)).isFalse();
+  }
+
+  /**
+   * The id a message is logged with, in the form rendered by
+   * {@code org.opends.server.backends.task.Task#addLogMessage}. This is what the classification of
+   * a task failure matches on, so the two must agree.
+   */
+  private String msgIdField(String resourceName, int ordinal)
+  {
+    return "msgID=" + resourceName + "-" + ordinal;
+  }
+
+  private List<String> getLogMessages(DN taskDN)
+  {
+    final InternalSearchOperation searchOperation =
+        getRootConnection().processSearch(newSearchRequest(taskDN, SearchScope.BASE_OBJECT));
+    final Entry taskEntry = searchOperation.getSearchEntries().getFirst();
+
+    final List<String> logMsgs = new ArrayList<>();
+    for (Attribute attribute : taskEntry.getAllAttributes(ATTR_TASK_LOG_MESSAGES))
+    {
+      for (ByteString value : attribute)
+      {
+        logMsgs.add(value.toString());
+      }
+    }
+    return logMsgs;
+  }
+}

--
Gitblit v1.10.0