/* * 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.replication.plugin; import static java.util.concurrent.TimeUnit.*; import static org.assertj.core.api.Assertions.*; import static org.opends.server.TestCaseUtils.*; import static org.opends.server.replication.plugin.LDAPReplicationDomain.*; import static org.opends.server.util.CollectionUtils.*; import static org.testng.Assert.*; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicBoolean; import org.forgerock.opendj.config.server.ConfigChangeResult; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; import org.opends.server.TestCaseUtils; import org.opends.server.plugins.ShortCircuitPlugin; import org.opends.server.replication.ReplicationTestCase; import org.opends.server.replication.common.CSNGenerator; import org.opends.server.replication.protocol.DeleteMsg; import org.opends.server.replication.server.DataServerHandler; import org.opends.server.replication.server.ReplServerFakeConfiguration; import org.opends.server.replication.server.ReplicationServer; import org.opends.server.replication.service.ReplicationBroker; import org.opends.server.replication.service.ReplicationDomain; import org.opends.server.types.Entry; import org.opends.server.types.OperationType; import org.opends.server.util.TestTimer; import org.opends.server.util.TestTimer.CallableVoid; import org.testng.annotations.Test; /** * Tests which sessions a configuration change restarts. A domain which stopped its own * session - one which is shutting down, or whose data is being replaced - owns it and is the * one which brings it back; one which is running its session is given a new one, and the * replication server hears the change over it. */ @SuppressWarnings("javadoc") public class SessionRestartTest extends ReplicationTestCase { private static final int RS_ID = 601; private static final int DS_ID = 1; /** The replica whose changes the domain under test replays. */ private static final int PUBLISHER_ID = 2; private static final int GROUP_ID = 1; @Test public void aConfigurationChangeDoesNotStartTheSessionOfADisabledDomain() throws Exception { final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING); ReplicationServer replicationServer = null; LDAPReplicationDomain domain = null; try { final int rsPort = TestCaseUtils.findFreePort(); replicationServer = createReplicationServer(rsPort, "sessionRestartTestDisabledDb"); final DomainFakeCfg domainCfg = newDomainCfg(baseDN, rsPort); domain = MultimasterReplication.createNewDomain(domainCfg); domain.start(); assertTrue(domain.isConnected()); // The data this domain replicates is about to be replaced by an import or a restore. domain.disable(); assertFalse(domain.isConnected()); final ConfigChangeResult ccr = changeEclIncludes(domain, domainCfg); assertFalse(domain.isConnected(), "a configuration change started the session of a disabled domain"); // the restart the change asked for was refused, and said so rather than reported as applied assertTrue(ccr.adminActionRequired(), "the refused restart was reported as fully applied"); } finally { if (domain != null) { MultimasterReplication.deleteDomain(baseDN); } remove(replicationServer); } } @Test public void aConfigurationChangeDoesNotStartTheSessionOfAShutDownDomain() throws Exception { final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING); ReplicationServer replicationServer = null; LDAPReplicationDomain domain = null; try { final int rsPort = TestCaseUtils.findFreePort(); replicationServer = createReplicationServer(rsPort, "sessionRestartTestShutdownDb"); final DomainFakeCfg domainCfg = newDomainCfg(baseDN, rsPort); domain = MultimasterReplication.createNewDomain(domainCfg); domain.start(); assertTrue(domain.isConnected()); domain.shutdown(); assertFalse(domain.isConnected()); final ConfigChangeResult ccr = changeEclIncludes(domain, domainCfg); assertFalse(domain.isConnected(), "a configuration change started the session of a domain which has shut down"); // the restart the change asked for was refused, and said so rather than reported as applied assertTrue(ccr.adminActionRequired(), "the refused restart was reported as fully applied"); } finally { if (domain != null) { MultimasterReplication.deleteDomain(baseDN); } remove(replicationServer); } } /** * The twin of the two above: a domain which is running its session is given a new one by * the change, and the replication server is what tells. The attributes the external * changelog includes are stored in the domain before the session is restarted for them, * so the domain says the change is applied whether or not the restart ran; the * replication server hears the list once, in the {@code StartSessionMsg} of a session, and * a {@code DataServerHandler} which carries the new list is a session started after the * change. */ @Test public void aConfigurationChangeRestartsTheSessionOfALiveDomain() throws Exception { final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING); ReplicationServer replicationServer = null; LDAPReplicationDomain domain = null; try { final int rsPort = TestCaseUtils.findFreePort(); replicationServer = createReplicationServer(rsPort, "sessionRestartTestLiveDb"); final DomainFakeCfg domainCfg = newDomainCfg(baseDN, rsPort); domain = MultimasterReplication.createNewDomain(domainCfg); domain.start(); assertTrue(domain.isConnected()); // What the session which is running told the replication server: no attribute at all. assertThat(eclIncludesHeardBy(replicationServer, baseDN)).doesNotContain("cn"); final ConfigChangeResult ccr = changeEclIncludes(domain, domainCfg); // the restart was run, so there is nothing to tell the administrator to wait for assertFalse(ccr.adminActionRequired(), "a restart which was run was reported as refused: " + ccr.getMessages()); final ReplicationServer rs = replicationServer; new TestTimer.Builder().maxSleep(5, SECONDS).sleepTimes(100, MILLISECONDS).toTimer() .repeatUntilSuccess(new CallableVoid() { @Override public void call() throws Exception { assertThat(eclIncludesHeardBy(rs, baseDN)) .as("the replication server was never told the new list, so no session was" + " started after the change") .contains("cn"); } }); } finally { if (domain != null) { MultimasterReplication.deleteDomain(baseDN); } remove(replicationServer); } } /** * A replay thread which could not apply a change stops the session so that the change * is delivered again, and waits out a backoff before it starts the session back. The * session it stopped is its claim; a configuration change which stops and starts the * session while it waits leaves that claim stale, and the thread which comes back from * its wait leaves the session which replaced the one it stopped alone. *
* What a stale claim which is not declined does to the session is nothing today: the
* broker and the listener thread are up already, and starting them again is a no-op. The
* one thing which tells a declined claim from one which was acted on is the generation of
* the session, which counts every start: the thread which acted on its stale claim leaves
* it one past where the restart which replaced its session left it.
*/
@Test
public void aReplayThreadLeavesAloneTheSessionWhichReplacedTheOneItStopped() throws Exception
{
final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING);
ReplicationServer replicationServer = null;
LDAPReplicationDomain domain = null;
ReplicationBroker publisher = null;
try
{
final int rsPort = TestCaseUtils.findFreePort();
replicationServer = createReplicationServer(rsPort, "sessionRestartTestStaleClaimDb");
domain = MultimasterReplication.createNewDomain(newDomainCfg(baseDN, rsPort));
domain.start();
assertTrue(domain.isConnected());
final Entry entry = TestCaseUtils.addEntry(
"dn: cn=stale claim," + baseDN,
"objectClass: top",
"objectClass: person",
"cn: stale claim",
"sn: claim");
final String uuid = getEntry(entry.getName(), 1, true).parseAttribute("entryuuid").asString();
publisher = openReplicationSession(baseDN, PUBLISHER_ID, 100, rsPort, 1000);
final LDAPReplicationDomain replica = domain;
final Object serviceStateLock = serviceStateLockOf(replica);
try
{
/*
* The backend refuses the delete for longer than the replay is retried in place,
* so the replay thread stops the session for the change to be delivered again -
* and serves it once the session was restarted for it, so that the change is
* applied over the session started next and asks for no restart of its own.
*/
ShortCircuitPlugin.registerShortCircuit(OperationType.DELETE, "PreParse",
ResultCode.UNAVAILABLE.intValue(), IN_PLACE_REPLAY_ATTEMPTS + 2);
publisher.publish(new DeleteMsg(entry.getName(), new CSNGenerator(PUBLISHER_ID, 0).newCSN(), uuid));
/*
* The lock is taken and let go until it is taken while the session is stopped: the
* replay thread stopped it under the lock and let the lock go for its wait, so the
* claim it holds is standing, and the lock is held for the rest of that wait. The
* restart below runs while the claim is standing however short the wait is.
*/
final long claim;
final long replaced;
final long deadline = System.currentTimeMillis() + SECONDS.toMillis(30);
while (true)
{
synchronized (serviceStateLock)
{
if (!replica.isConnected())
{
claim = sessionGenerationOf(replica);
// Something else restarts the session while the replay thread waits: the
// attributes the external changelog includes are changed.
replica.changeConfig(newTreeSet("cn"), new TreeSet