From 92d88ca699cd8090a26b92cbe46789d2b848195f Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 07 Sep 2026 09:30:36 +0000
Subject: [PATCH] [#889] Keep a change the replay could not apply out of the ServerState (#892)

---
 opendj-server-legacy/src/test/java/org/opends/server/replication/ReplicationTestCase.java |  201 ++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 194 insertions(+), 7 deletions(-)

diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/ReplicationTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/ReplicationTestCase.java
index 54d858c..0a2028f 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/replication/ReplicationTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/ReplicationTestCase.java
@@ -102,6 +102,40 @@
   /** Generation id for a fully empty domain. */
   public static final long EMPTY_DN_GENID = GenerationIdChecksum.EMPTY_BACKEND_GENERATION_ID;
 
+  /** How many times {@link #assertMonitorAttrValueStays} reads a value by default. */
+  private static final int MONITOR_ATTR_SAMPLES = 5;
+
+  /** How long {@link #assertMonitorAttrValueStays} waits between two reads. */
+  private static final long MONITOR_ATTR_SAMPLE_INTERVAL_IN_MS = 200;
+
+  /**
+   * How long {@link #assertMonitorAttrValueStays} waits for the monitor entry of a domain
+   * to be registered again before it gives up on reading it: longer than the
+   * {@code MAX_REPLAY_RETRY_DELAY_IN_MS} a session restart holds it down for.
+   */
+  private static final long MONITOR_ATTR_SAMPLE_GRACE_IN_MS = 30000;
+
+  /**
+   * How much longer than the samples it asks for {@link #assertMonitorAttrValueStays}
+   * runs before it gives up: the samples of a domain which keeps restarting its session
+   * are taken a restart apart, and waiting for all of them would outlast the fork.
+   */
+  private static final long MONITOR_ATTR_SAMPLES_DEADLINE_IN_MS = 60000;
+
+  /**
+   * How many samples it takes for {@link #assertMonitorAttrValueStays} to outlast the
+   * session restart which brings a change back, so that a counter only that delivery
+   * could bump a second time is watched while it arrives.
+   * <p>
+   * It covers a domain which restarts its session for the first time, which waits
+   * {@code LDAPReplicationDomain.REPLAY_RETRY_DELAY_IN_MS} before reconnecting. The wait
+   * of a domain which has been restarting its session in a row is longer - it climbs to
+   * {@code MAX_REPLAY_RETRY_DELAY_IN_MS} - so a redelivery is outside this window there;
+   * sampling for ten seconds at every call site to cover it would cost more than the
+   * assertions are worth.
+   */
+  protected static final int MONITOR_ATTR_SAMPLES_ACROSS_A_REDELIVERY = 12;
+
   /** The internal connection used for operation. */
   protected InternalClientConnection connection;
 
@@ -484,17 +518,170 @@
       @Override
       public Long call() throws Exception
       {
-        String monitorFilter = "(&(cn=Directory server*)(domain-name=" + baseDN + "))";
-        InternalSearchOperation op =
-            connection.processSearch(newSearchRequest("cn=replication,cn=monitor", WHOLE_SUBTREE, monitorFilter));
-        Assertions.assertThat(op.getSearchEntries()).as("Could not read monitoring information").isNotEmpty();
-
-        SearchResultEntry entry = op.getSearchEntries().getFirst();
-        return entry.parseAttribute(attr).asLong();
+        Long value = readMonitorAttrValue(baseDN, attr);
+        Assertions.assertThat(value)
+            .as("the monitor entry of %s is not registered", baseDN).isNotNull();
+        return value;
       }
     });
   }
 
+  /**
+   * Reads a monitor attribute of a replication domain, once.
+   *
+   * @param baseDN the base DN of the domain whose monitor entry to read
+   * @param attr the monitor attribute to read
+   * @return the value of the attribute, or {@code null} when the monitor entry of the
+   *         domain is not registered - which it is not for as long as its session to the
+   *         replication server is down
+   * @throws Exception if the monitor could not be searched, or if the entry is there and
+   *                   does not publish the attribute, which is a wrong name rather than
+   *                   something to wait for
+   */
+  private Long readMonitorAttrValue(final DN baseDN, final String attr) throws Exception
+  {
+    String monitorFilter = "(&(cn=Directory server*)(domain-name=" + baseDN + "))";
+    InternalSearchOperation op =
+        connection.processSearch(newSearchRequest("cn=replication,cn=monitor", WHOLE_SUBTREE, monitorFilter));
+    if (op.getSearchEntries().isEmpty())
+    {
+      return null;
+    }
+    SearchResultEntry entry = op.getSearchEntries().getFirst();
+    Long value = entry.parseAttribute(attr).asLong();
+    Assertions.assertThat(value)
+        .as("the monitor entry of %s does not publish %s", baseDN, attr).isNotNull();
+    return value;
+  }
+
+  /**
+   * Waits for a monitor attribute of a replication domain to reach the expected value.
+   * <p>
+   * The monitor entry of a domain is deregistered for as long as its session to the
+   * replication server is down, which is what a replay failure does to it, and a counter
+   * is bumped a moment after the change or the delivery it counts was dealt with:
+   * reading the value once would be a race on both counts.
+   * <p>
+   * The read is deliberately {@link #readMonitorAttrValue(DN, String)} rather than the
+   * retrying {@link #getMonitorAttrValue(DN, String)}: a {@link TestTimer} budget is a
+   * number of steps rather than a deadline, so one timer waiting on another multiplies
+   * them - 150 steps around a read which sleeps ten seconds of its own is 25 minutes,
+   * long past the {@code org.opends.test.timeout} the fork is killed on. One timer owns
+   * the deadline here, and a monitor entry which is not registered is one failed poll -
+   * so the deadline has to be wide enough for a domain which is restarting its session to
+   * register it again, which takes the backoff of that restart.
+   *
+   * @param baseDN the base DN of the domain whose monitor entry to read
+   * @param attributeName the monitor attribute to read
+   * @param expected the value it must reach
+   * @param message what is being asserted
+   * @throws Exception if the value was not reached in time
+   */
+  protected void assertMonitorAttrValueEventually(
+      final DN baseDN, final String attributeName, final long expected, final String message)
+      throws Exception
+  {
+    TestTimer timer = new TestTimer.Builder()
+      .maxSleep(60, SECONDS)
+      .sleepTimes(200, MILLISECONDS)
+      .toTimer();
+    timer.repeatUntilSuccess(new CallableVoid()
+    {
+      @Override
+      public void call() throws Exception
+      {
+        assertEquals(readMonitorAttrValue(baseDN, attributeName), (Long) expected, message);
+      }
+    });
+  }
+
+  /**
+   * Checks that a monitor attribute of a replication domain holds the expected value and
+   * keeps holding it, over {@link #MONITOR_ATTR_SAMPLES} samples.
+   *
+   * @param baseDN the base DN of the domain whose monitor entry to read
+   * @param attributeName the monitor attribute to read
+   * @param expected the value it must hold
+   * @param message what is being asserted
+   * @throws Exception if the value changes, or if the monitor entry can not be read
+   */
+  protected void assertMonitorAttrValueStays(
+      final DN baseDN, final String attributeName, final long expected, final String message)
+      throws Exception
+  {
+    assertMonitorAttrValueStays(baseDN, attributeName, expected, MONITOR_ATTR_SAMPLES, message);
+  }
+
+  /**
+   * Checks that a monitor attribute of a replication domain holds the expected value and
+   * keeps holding it, over the provided number of samples.
+   * <p>
+   * Waiting for a value to be reached is not enough to tell that something happened only
+   * once: a counter which is bumped a second time goes through the expected value on its
+   * way, and the first poll which sees it passes.
+   * <p>
+   * The samples have to outlast whatever could bump the counter a second time, or the
+   * assertion only reads like it is watching for it. The default is enough for a second
+   * attempt of the same delivery, which is fifty milliseconds away; a counter which a
+   * change delivered again could bump has to be watched for longer than the session
+   * restart which brings that delivery, so those call sites pass
+   * {@link #MONITOR_ATTR_SAMPLES_ACROSS_A_REDELIVERY}.
+   * <p>
+   * The monitor entry of a domain is gone for as long as its session is down, which a
+   * session restart in the middle of the window does: a read which comes back with
+   * nothing is not a sample rather than a failure, and the samples asked for are taken
+   * once it is back. So a restart stretches the window rather than shortening it, which
+   * is the right way round for what is being asserted, and the entry staying away for
+   * {@link #MONITOR_ATTR_SAMPLE_GRACE_IN_MS} is what fails the assertion. The samples are
+   * bounded all the same: a domain which restarts its session over and over would
+   * otherwise have this wait for one readable moment per restart until the fork is killed
+   * for taking too long, which says nothing about the value being watched.
+   *
+   * @param baseDN the base DN of the domain whose monitor entry to read
+   * @param attributeName the monitor attribute to read
+   * @param expected the value it must hold
+   * @param samples how many times to read the value, at least
+   *                {@link #MONITOR_ATTR_SAMPLE_INTERVAL_IN_MS} apart
+   * @param message what is being asserted
+   * @throws Exception if the value changes, or if the monitor entry can not be read
+   */
+  protected void assertMonitorAttrValueStays(final DN baseDN, final String attributeName,
+      final long expected, final int samples, final String message) throws Exception
+  {
+    final long now = System.currentTimeMillis();
+    final long deadline = now + samples * MONITOR_ATTR_SAMPLE_INTERVAL_IN_MS
+        + MONITOR_ATTR_SAMPLES_DEADLINE_IN_MS;
+    long readableBy = now + MONITOR_ATTR_SAMPLE_GRACE_IN_MS;
+    int taken = 0;
+    while (taken < samples)
+    {
+      final Long value = readMonitorAttrValue(baseDN, attributeName);
+      if (value != null)
+      {
+        assertEquals(value, (Long) expected, message);
+        taken++;
+        if (taken == samples)
+        {
+          // Every sample which was asked for held the value: how long they took to take
+          // is not what this is asserting.
+          return;
+        }
+        readableBy = System.currentTimeMillis() + MONITOR_ATTR_SAMPLE_GRACE_IN_MS;
+      }
+      else if (System.currentTimeMillis() > readableBy)
+      {
+        fail("the monitor entry of " + baseDN + " was not registered again in "
+            + MONITOR_ATTR_SAMPLE_GRACE_IN_MS + "ms: " + message);
+      }
+      if (System.currentTimeMillis() > deadline)
+      {
+        fail("only " + taken + " of " + samples + " samples of " + attributeName
+            + " could be read before the deadline: " + message);
+      }
+      Thread.sleep(MONITOR_ATTR_SAMPLE_INTERVAL_IN_MS);
+    }
+  }
+
   protected void checkEntryHasAttributeValue(final DN dn, final String attrTypeStr, final String valueString,
       int timeoutInSecs, String notFoundErrorMsg) throws Exception
   {

--
Gitblit v1.10.0