/* * 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.backends.pluggable; import static org.assertj.core.api.Assertions.assertThat; import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; import static org.mockito.Mockito.any; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opends.server.util.CollectionUtils.newTreeSet; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.SortedSet; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.config.server.ConfigurationChangeListener; import org.forgerock.opendj.ldap.ByteSequence; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.schema.AttributeType; import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType; import org.forgerock.opendj.server.config.server.BackendIndexCfg; import org.forgerock.opendj.server.config.server.PDBBackendCfg; import org.forgerock.opendj.server.config.server.PluggableBackendCfg; import org.mockito.ArgumentCaptor; import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; import org.opends.server.backends.pdb.PDBStorage; import org.opends.server.backends.pluggable.spi.AccessMode; import org.opends.server.backends.pluggable.spi.Cursor; import org.opends.server.backends.pluggable.spi.Importer; import org.opends.server.backends.pluggable.spi.ReadOperation; import org.opends.server.backends.pluggable.spi.Storage; import org.opends.server.backends.pluggable.spi.StorageRuntimeException; import org.opends.server.backends.pluggable.spi.StorageStatus; import org.opends.server.backends.pluggable.spi.TreeName; import org.opends.server.backends.pluggable.spi.UpdateFunction; import org.opends.server.backends.pluggable.spi.WriteOperation; import org.opends.server.backends.pluggable.spi.WriteableTransaction; import org.opends.server.core.ServerContext; import org.opends.server.types.BackupConfig; import org.opends.server.types.BackupDirectory; import org.opends.server.types.DirectoryException; import org.opends.server.types.RestoreConfig; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import com.persistit.exception.RollbackException; /** * Tests that {@link RootContainer#open(AccessMode)} survives a replay of its {@link WriteOperation}. * {@link Storage#write(WriteOperation)} may replay the operation after a transaction conflict, and * {@code RootContainer.open} opens and registers the entry container of every base DN inside a * single one of them, so every side effect that write performs must either be transactional or be * idempotent - see OpenDJ issue #896. *
* The conflict is raised from inside the operation as the {@link RollbackException} PersistIt itself
* raises, so that the replay is driven by {@code PDBStorage.write}'s own retry loop rather than by a
* second call to it, as {@link ReplayedConfigChangeTest} does for the sibling path.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public class ReplayedOpenTest extends DirectoryServerTestCase
{
private static final String BACKEND_ID = "ReplayedOpenTest";
/** Opened and registered first, since the base DNs are opened in the order of the sorted set. */
private static final DN FIRST = DN.valueOf("dc=b896a,dc=com");
/** Opened while the first one is already registered, which is where a conflict reaches the bug. */
private static final DN SECOND = DN.valueOf("dc=b896b,dc=com");
private ServerContext serverContext;
private AttributeType cnType;
/** The configuration of the cn index, which the attribute index of every base DN registers with. */
private BackendIndexCfg indexCfg;
@BeforeClass
public void startServer() throws Exception
{
TestCaseUtils.startServer();
serverContext = TestCaseUtils.getServerContext();
cnType = serverContext.getSchema().getAttributeType("cn");
}
/**
* These tests open a backend which is designed to fail, and a failing one can leave a base DN
* behind in the server wide registry, where it would outlive the test and break the next one.
*/
@AfterMethod
public void deregisterLeftoverBaseDNs()
{
for (DN baseDN : new DN[] { FIRST, SECOND })
{
try
{
serverContext.getBackendConfigManager().deregisterBaseDN(baseDN);
}
catch (Exception alreadyGone)
{
// Which is what the test should have left behind.
}
}
}
/**
* The case the report is written from: the conflict is raised while the second base DN is being
* opened, so the replay meets the first one already registered by the attempt it replaces.
*/
@Test
public void openIsReplayableWhenTheTransactionConflictsWhileTheSecondBaseDNIsOpened() throws Exception
{
final ReplayingBackend backend = newBackend(newTreeSet(FIRST, SECOND));
boolean opened = false;
try
{
backend.storage.conflictAtTreesOf(SECOND, 1);
backend.openBackend();
opened = true;
assertThat(backend.storage.attempts()).isEqualTo(2);
final RootContainer rootContainer = backend.getRootContainer();
assertThat(rootContainer.getBaseDNs()).containsOnly(FIRST, SECOND);
// Each base DN is answered with a container of its own, rather than with the one an ancestor
// registered, and the trees of both are there to be read.
for (DN baseDN : new DN[] { FIRST, SECOND })
{
final EntryContainer ec = rootContainer.getEntryContainer(baseDN);
assertThat((Object) ec.getBaseDN()).isEqualTo(baseDN);
assertThat(rootContainer.getStorage().listTrees()).containsAll(treesOf(ec));
}
// What the attempt which was replaced opened is off the backend configuration, and the live
// pair is on it: SECOND of that attempt was given up by its own close(), raised from
// EntryContainer.open's own catch, and FIRST by the replay, which begins by unregistering and
// closing what the attempt it replaces had registered. Counting the removals would say neither
// which containers were given back nor that the adds balance: one of the two takes off a
// listener SECOND had never added, the conflict being raised at its id2entry, before
// EntryContainer.open registers anything.
assertThat(entryContainersRegisteredOn(backend.configuredWith))
.containsOnly(rootContainer.getEntryContainer(FIRST), rootContainer.getEntryContainer(SECOND));
// The same balance one level down: a container given back without the listener each of its
// indexes registered would satisfy the assertion above.
assertThat(indexesRegisteredOn(indexCfg))
.containsOnly(rootContainer.getEntryContainer(FIRST).getAttributeIndex(cnType),
rootContainer.getEntryContainer(SECOND).getAttributeIndex(cnType));
}
finally
{
close(backend, opened);
}
}
/**
* A conflict raised once the operation has run to completion, before the commit, replays an
* operation which ran to completion, so every base DN of the backend is registered by the
* attempt the replay replaces. One base DN would be enough to reach that, and
* {@code FailedBackendOpenTest.aReplayedOpenLeavesOneSetOfEntryContainers} drives the same
* conflict with one; two are used here so that the case differs from the one above only in where
* the conflict was raised, and so that the give-back has more than one container to walk.
*/
@Test
public void openIsReplayableWhenTheTransactionConflictsAtCommitTime() throws Exception
{
final ReplayingBackend backend = newBackend(newTreeSet(FIRST, SECOND));
boolean opened = false;
try
{
backend.storage.conflictAtCommit(1);
backend.openBackend();
opened = true;
assertThat(backend.storage.attempts()).isEqualTo(2);
final RootContainer rootContainer = backend.getRootContainer();
assertThat(rootContainer.getBaseDNs()).containsOnly(FIRST, SECOND);
// The two entry containers the rolled back attempt opened registered five configuration
// listeners each, and one more per index, which only their close() takes back, so the replay
// has to give both up before it opens another pair: what is left of the two attempts is the
// live pair alone.
assertThat(entryContainersRegisteredOn(backend.configuredWith))
.containsOnly(rootContainer.getEntryContainer(FIRST), rootContainer.getEntryContainer(SECOND));
assertThat(indexesRegisteredOn(indexCfg))
.containsOnly(rootContainer.getEntryContainer(FIRST).getAttributeIndex(cnType),
rootContainer.getEntryContainer(SECOND).getAttributeIndex(cnType));
}
finally
{
close(backend, opened);
}
}
/**
* A failure the storage engine does not replay ends the open, and {@code RootContainer}'s own
* give-back is the only one there is: {@code BackendImpl.newRootContainer} drops the root
* container without closing it, so what the attempt had opened by then - both entry containers,
* the root container's own listener and the storage - is handed back by
* {@code RootContainer.giveUpAfterFailedOpen} or not at all.
*/
@Test
public void anOpenWhichIsNotReplayedGivesUpTheEntryContainersItOpened() throws Exception
{
final ReplayingBackend backend = newBackend(newTreeSet(FIRST, SECOND));
try
{
backend.storage.failWithoutReplay();
try
{
backend.openBackend();
throw new AssertionError("the open was expected to fail");
}
catch (Exception expected)
{
// The failure itself is the caller's business; what it left behind is this test's.
}
// The failure is the one the engines do not replay, so the open ends at the first attempt:
// a replay would open a second pair of containers over the first.
assertThat(backend.storage.attempts()).isEqualTo(1);
// The two entry containers the attempt opened, and the root container's own listener, which
// goes with them since #993: nothing closes a root container which did not open.
verify(backend.configuredWith, times(3)).removePluggableChangeListener(any());
}
finally
{
close(backend, false);
}
}
private static Set
* A replayed open registers a container per base DN twice and gives the first set back, so what
* the case is about is which containers are left rather than how many calls were made: a removal
* says nothing on its own - an entry container whose open failed before it had registered
* anything is taken off all the same, by the {@code close()} of {@code EntryContainer.open}'s own
* catch.
*/
private static List