/* * 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.nio.charset.StandardCharsets.*; import static org.assertj.core.api.Assertions.*; import static org.forgerock.opendj.ldap.ModificationType.*; import static org.opends.messages.ReplicationMessages.*; import static org.opends.server.TestCaseUtils.*; import static org.opends.server.core.DirectoryServer.*; import static org.opends.server.protocols.internal.InternalClientConnection.*; import static org.testng.Assert.*; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicReference; import org.forgerock.opendj.config.server.ConfigChangeResult; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.server.config.meta.ReplicationDomainCfgDefn.AssuredType; import org.opends.server.TestCaseUtils; import org.opends.server.core.ModifyOperation; import org.opends.server.replication.ReplicationTestCase; import org.opends.server.replication.common.AssuredMode; import org.opends.server.replication.protocol.DoneMsg; import org.opends.server.replication.protocol.EntryMsg; import org.opends.server.replication.protocol.InitializeTargetMsg; import org.opends.server.replication.server.ReplServerFakeConfiguration; import org.opends.server.replication.server.ReplicationServer; import org.opends.server.replication.service.ReplicationBroker; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; /** * Tests a configuration change while this replica is the target of a total update. *
* The import of a total update streams over the session of the domain, on its listener * thread, and a configuration change which restarts that session for what it carries stops * the broker the import is reading (issue #1040). Through the server configuration the * change holds the lock of the configuration while it runs, and the restart waits for the * listener thread to end - which needs that lock to enable the backend back once the * stream ends: the change never returns. Reached below the configuration listeners, as the * change of an entry which was accepted before the import started reaches it, the restart * ends the import on the entries which had arrived. *
* The exporter is a broker of this test, so that the test says when the entries arrive:
* the change is made while the import is waiting for them.
*/
@SuppressWarnings("javadoc")
public class ConfigChangeDuringImportTest extends ReplicationTestCase
{
/**
* The memory backend of {@code o=test} loses its data when it is disabled and enabled
* back, which is what an import does to the backend it replaces: a total update needs a
* backend which keeps what was imported into it.
*/
private static final String EXAMPLE_DN = "dc=example,dc=com";
private static final int RS_ID = 612;
private static final int DS_ID = 1;
private static final int EXPORTER_ID = 2;
private static final int INIT_WINDOW = 100;
private static final String DOMAIN_CONFIG_NAME = "config change during import test";
private static final String IMPORTED_ENTRY_DN = "cn=imported,ou=People," + EXAMPLE_DN;
/** How long a configuration change is given to return before it is read as hung. */
private static final long CHANGE_TIMEOUT_IN_MS = 30_000;
private DN baseDN;
private int rsPort;
private ReplicationServer replicationServer;
private LDAPReplicationDomain domain;
/** The entry the domain is configured in, when it is configured through the server. */
private DN domainConfigDN;
private ReplicationBroker exporter;
@BeforeMethod
public void setUpLocal() throws Exception
{
baseDN = DN.valueOf(EXAMPLE_DN);
TestCaseUtils.clearBackend("userRoot", EXAMPLE_DN);
rsPort = TestCaseUtils.findFreePort();
replicationServer = new ReplicationServer(new ReplServerFakeConfiguration(
rsPort, "configChangeDuringImportTestDb", 0, RS_ID, 0, 100, new TreeSet
* The change is made the way {@code dsconfig} makes it, through the server configuration,
* which holds the lock of the configuration for the length of it. Without the refusal the
* attributes are applied and the session restarted for them: the restart stops the broker
* the import is reading and waits for the listener thread, which ends the import on what
* had arrived and then waits for the lock of the configuration to enable the backend back
* - the change never returns, the backend stays deregistered, and every configuration
* change of the server after it waits on the same lock.
*/
@Test(timeOut = 180_000)
public void aChangeOfTheExternalChangelogEntryIsRefusedWhileATotalUpdateRuns() throws Exception
{
configureDomainThroughTheServer();
final String[] exported = exportedEntries();
startImportInto(exported.length);
final ModifyOperation change = changeConfigurationEntry(
DN.valueOf("cn=external changelog," + domainConfigDN), "ds-cfg-ecl-include", "cn");
assertEquals(change.getResultCode(), ResultCode.UNWILLING_TO_PERFORM,
"a change of the external changelog entry was accepted while a total update ran: "
+ change.getErrorMessage());
assertThat(change.getErrorMessage().toString())
.as("the refusal does not say a total update is the reason")
.contains(NOTE_ERR_CANNOT_CHANGE_CONFIG_DURING_TOTAL_UPDATE.get().toString());
finishImport(exported);
assertImported(exported);
assertFalse(domain.getEclIncludes().contains("cn"),
"the refused change of the attributes published to the external changelog was applied");
}
/**
* A change of the domain entry while the import streams is refused, as it was before
* this fix: the twin of the case above, pinned so that the two entries keep answering the
* same thing.
*/
@Test(timeOut = 180_000)
public void aChangeOfTheDomainEntryIsRefusedWhileATotalUpdateRuns() throws Exception
{
configureDomainThroughTheServer();
final String[] exported = exportedEntries();
startImportInto(exported.length);
final ModifyOperation change =
changeConfigurationEntry(domainConfigDN, "ds-cfg-assured-type", "safe-read");
assertEquals(change.getResultCode(), ResultCode.UNWILLING_TO_PERFORM,
"a change of the domain entry was accepted while a total update ran: "
+ change.getErrorMessage());
assertThat(change.getErrorMessage().toString())
.as("the refusal does not say a total update is the reason")
.contains(NOTE_ERR_CANNOT_CHANGE_CONFIG_DURING_TOTAL_UPDATE.get().toString());
finishImport(exported);
assertImported(exported);
assertEquals(domain.getAssuredMode(), AssuredMode.SAFE_DATA_MODE,
"the refused change of the assured configuration was applied");
}
/**
* A change of the domain configuration which reaches the domain while the import streams
* - one accepted before the import started - must leave the session to the import.
*
* The assured configuration is negotiated as the session comes up, so the change asks for
* a restart. The restart is refused and reported, the way it is for a domain disabled for
* a total update: the configuration is stored, the import ends on the session it started
* on, and the session the import brings up next negotiates what was stored.
*/
@Test(timeOut = 180_000)
public void aChangeOfTheDomainConfigurationLeavesTheSessionToTheImport() throws Exception
{
startDomain(domainCfg(AssuredType.NOT_ASSURED));
final String[] exported = exportedEntries();
startImportInto(exported.length);
final Thread listener = listenerThread();
assertNotNull(listener, "the import is running on no listener thread");
final ConfigChangeResult ccr = domain.applyConfigurationChange(domainCfg(AssuredType.SAFE_READ));
assertEquals(ccr.getResultCode(), ResultCode.SUCCESS, ccr.getMessages().toString());
assertTrue(ccr.adminActionRequired(),
"the change was reported as live although the session was not restarted for it");
assertThat(ccr.getMessages().toString())
.contains(NOTE_REPLICATION_DOMAIN_SESSION_NOT_RESTARTED.get(baseDN).toString());
assertSame(listenerThread(), listener,
"the session the import streams over was restarted for the change");
finishImport(exported);
assertImported(exported);
assertEquals(domain.getAssuredMode(), AssuredMode.SAFE_READ_MODE,
"the assured configuration was dropped although the change reported success");
}
/**
* A change of the attributes published to the external changelog which reaches the domain
* while the import streams must leave the session to the import: the attributes are
* stored, and the session the import brings up next publishes them.
*/
@Test(timeOut = 180_000)
public void aChangeOfTheExternalChangelogAttributesLeavesTheSessionToTheImport() throws Exception
{
startDomain(domainCfg(AssuredType.NOT_ASSURED));
final String[] exported = exportedEntries();
startImportInto(exported.length);
final Thread listener = listenerThread();
assertNotNull(listener, "the import is running on no listener thread");
final SortedSet