/*
* 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]".
*
* Portions 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.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.opends.server.backends.pluggable.State.IndexFlag.TRUSTED;
import static org.opends.server.backends.pluggable.SuffixContainer.STATE_INDEX_NAME;
import static org.opends.server.util.CollectionUtils.newTreeSet;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Set;
import java.util.SortedSet;
import org.forgerock.opendj.config.server.ConfigChangeResult;
import org.forgerock.opendj.config.server.ConfigException;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
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.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
import org.opends.server.backends.pdb.PDBStorage;
import org.opends.server.backends.pluggable.State.IndexFlag;
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.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 BackendImpl#applyConfigurationChange} survives a replay of its
* {@link WriteOperation}. {@link Storage#write(WriteOperation)} may replay the operation after a
* transaction conflict, so every side effect it performs must either be transactional or be
* idempotent - see OpenDJ issue #907.
*
* 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. That loop keeps one storage implementation - and with it its cache
* of PersistIt exchanges - across every attempt, which a second call would not.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public class ReplayedConfigChangeTest extends DirectoryServerTestCase
{
private static final String BACKEND_ID = "ReplayedConfigChangeTest";
private static final DN KEPT = DN.valueOf("dc=b907a,dc=com");
private static final DN REMOVED = DN.valueOf("dc=b907b,dc=com");
private static final DN ADDED = DN.valueOf("dc=b907c,dc=com");
private ServerContext serverContext;
private AttributeType cnType;
@BeforeClass
public void startServer() throws Exception
{
TestCaseUtils.startServer();
serverContext = TestCaseUtils.getServerContext();
cnType = serverContext.getSchema().getAttributeType("cn");
}
/**
* These tests are 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 to use that DN.
*/
@AfterMethod
public void deregisterLeftoverBaseDNs()
{
for (DN baseDN : new DN[] { KEPT, REMOVED, ADDED })
{
try
{
serverContext.getBackendConfigManager().deregisterBaseDN(baseDN);
}
catch (Exception alreadyGone)
{
// Which is what the test should have left behind.
}
}
}
/**
* A base DN removal whose transaction conflicts before it touches the storage must be replayed
* without reporting a failure against the base DN it has already deregistered.
*/
@Test
public void removalIsReplayableWhenTheTransactionConflictsBeforeAnyStorageAccess() throws Exception
{
final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED));
try
{
final RootContainer rootContainer = backend.getRootContainer();
assertThat(rootContainer.getBaseDNs()).contains(REMOVED);
backend.storage.conflictAtFirstStorageAccess(1);
final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT)));
assertThat(backend.storage.attempts()).isEqualTo(2);
assertThat(ccr.getMessages()).isEmpty();
assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
assertThat(rootContainer.getBaseDNs()).doesNotContain(REMOVED);
assertThat(backend.getBaseDNs()).doesNotContain(REMOVED);
assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(REMOVED)).isNull();
}
finally
{
backend.finalizeBackend();
}
}
/**
* A base DN addition whose transaction conflicts at commit time must be replayed, so that what
* the entry container it opens writes ends up committed rather than discarded by the rollback.
*/
@Test
public void additionIsReplayableWhenTheTransactionConflictsAtCommitTime() throws Exception
{
final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
try
{
final RootContainer rootContainer = backend.getRootContainer();
assertThat(rootContainer.getBaseDNs()).doesNotContain(ADDED);
backend.storage.conflictAtCommit(1);
final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED)));
assertThat(backend.storage.attempts()).isEqualTo(2);
assertThat(ccr.getMessages()).isEmpty();
assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
assertThat(rootContainer.getBaseDNs()).contains(ADDED);
assertThat(backend.getBaseDNs()).contains(ADDED);
assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(ADDED)).isSameAs(backend);
// Everything the newly opened entry container wrote belongs to the rolled back transaction,
// so the storage has to be asked, not the entry container which remembers writing it.
final EntryContainer ec = rootContainer.getEntryContainer(ADDED);
final TreeName cnIndex = ec.getAttributeIndex(cnType).getNameToIndexes().values().iterator().next().getName();
assertThat(rootContainer.getStorage().listTrees()).contains(cnIndex);
assertThat(persistedFlags(rootContainer, ec, cnIndex)).contains(TRUSTED);
// The entry container the rolled back attempt opened registered five configuration listeners,
// which only its close() takes back, so the replay has to give it up before opening another.
verify(backend.configuredWith, times(1)).removePluggableChangeListener(any());
}
finally
{
backend.finalizeBackend();
}
}
/**
* The trees of a removed base DN are deleted by the operation itself, so a replay deletes trees a
* rolled back attempt had already deleted. This is the case which reaches the storage, and it
* removes and adds a base DN at once because that is what an operator editing the configuration
* does.
*/
@Test
public void aRemovalAndAnAdditionInOneChangeSurviveRepeatedReplay() throws Exception
{
final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED));
try
{
final RootContainer rootContainer = backend.getRootContainer();
final Set removedTrees = treesOf(rootContainer.getEntryContainer(REMOVED));
assertThat(rootContainer.getStorage().listTrees()).containsAll(removedTrees);
// More than one conflict, because the contract is that the operation is replayed until it
// succeeds rather than that it survives a single replay.
backend.storage.conflictAtCommit(2);
final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED)));
assertThat(backend.storage.attempts()).isEqualTo(3);
assertThat(ccr.getMessages()).isEmpty();
assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
assertThat(rootContainer.getBaseDNs()).contains(KEPT, ADDED).doesNotContain(REMOVED);
assertThat(backend.getBaseDNs()).contains(KEPT, ADDED).doesNotContain(REMOVED);
final Set storedTrees = rootContainer.getStorage().listTrees();
assertThat(storedTrees).doesNotContainAnyElementsOf(removedTrees);
assertThat(storedTrees).containsAll(treesOf(rootContainer.getEntryContainer(ADDED)));
assertThat(storedTrees).containsAll(treesOf(rootContainer.getEntryContainer(KEPT)));
}
finally
{
backend.finalizeBackend();
}
}
/**
* A failure the storage engine does not replay must leave the backend as it was and say which
* base DNs the change was about, since the failure itself never names them.
*/
@Test
public void aFailureWhichIsNotReplayedAppliesNothingAndNamesTheBaseDNs() throws Exception
{
final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED));
try
{
final RootContainer rootContainer = backend.getRootContainer();
final Set removedTrees = treesOf(rootContainer.getEntryContainer(REMOVED));
backend.storage.failWithoutReplay();
final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED)));
assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS);
assertThat(ccr.getMessages().toString()).contains(REMOVED.toString()).contains(ADDED.toString());
// Nothing was registered, nothing was deregistered, and the rollback put the trees back.
assertThat(rootContainer.getBaseDNs()).contains(REMOVED).doesNotContain(ADDED);
assertThat(backend.getBaseDNs()).contains(REMOVED).doesNotContain(ADDED);
assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(REMOVED)).isSameAs(backend);
assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(ADDED)).isNull();
assertThat(rootContainer.getStorage().listTrees()).containsAll(removedTrees);
}
finally
{
backend.finalizeBackend();
}
}
private static Set treesOf(EntryContainer ec)
{
final Set names = new HashSet<>();
for (Tree tree : ec.listTrees())
{
names.add(tree.getName());
}
return names;
}
/** Reads back the flags an index was given when it was opened, as they are stored. */
private static EnumSet persistedFlags(RootContainer rootContainer, EntryContainer ec, TreeName index)
throws Exception
{
final State state = new State(new TreeName(ec.getTreePrefix(), STATE_INDEX_NAME));
return rootContainer.getStorage().read(txn -> state.getIndexFlags(txn, index));
}
private ReplayingBackend openBackend(SortedSet baseDNs) throws Exception
{
final ReplayingBackend backend = new ReplayingBackend();
backend.setBackendID(BACKEND_ID);
backend.configuredWith = backendCfg(baseDNs);
backend.configureBackend(backend.configuredWith, serverContext);
// Start from a pristine on-disk state so that a previous run cannot mask the defect.
backend.storage.removeStorageFiles();
backend.openBackend();
return backend;
}
private PDBBackendCfg backendCfg(SortedSet baseDNs) throws ConfigException
{
final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class);
when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"));
when(cfg.getBackendId()).thenReturn(BACKEND_ID);
when(cfg.getDBDirectory()).thenReturn(BACKEND_ID);
when(cfg.getDBDirectoryPermissions()).thenReturn("755");
when(cfg.getDBCacheSize()).thenReturn(0L);
when(cfg.getDBCachePercent()).thenReturn(20);
when(cfg.getBaseDN()).thenReturn(baseDNs);
when(cfg.listBackendIndexes()).thenReturn(new String[] { "cn" });
when(cfg.listBackendVLVIndexes()).thenReturn(new String[0]);
final BackendIndexCfg indexCfg = mock(BackendIndexCfg.class);
when(indexCfg.getIndexType()).thenReturn(newTreeSet(IndexType.EQUALITY));
when(indexCfg.getAttribute()).thenReturn(cnType);
when(indexCfg.getIndexEntryLimit()).thenReturn(4000);
when(indexCfg.getSubstringLength()).thenReturn(6);
when(cfg.getBackendIndex("cn")).thenReturn(indexCfg);
return cfg;
}
/** A backend whose storage makes the next write operation conflict, and so be replayed. */
private static final class ReplayingBackend extends BackendImpl
{
private ReplayingStorage storage;
/** The configuration the entry containers register their listeners with. */
private PDBBackendCfg configuredWith;
@Override
protected Storage configureStorage(PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException
{
storage = new ReplayingStorage(new PDBStorage(cfg, serverContext));
return storage;
}
}
/** A failure which no storage engine replays, unlike {@link RollbackException}. */
private static final class UnreplayableFailure extends Exception
{
private static final long serialVersionUID = 1L;
}
/**
* Decorates a {@link Storage} so that the next {@link Storage#write(WriteOperation)} conflicts a
* given number of times before it is let through. The conflict is raised from within the single
* {@code write} the delegate is asked for, so the delegate's own retry loop performs the replay.
*/
private static final class ReplayingStorage implements Storage
{
/** Where the conflict is raised, which decides how much of the operation has run. */
private enum ConflictPoint
{
/** As soon as the operation first touches the transaction, before it has changed anything. */
FIRST_STORAGE_ACCESS,
/** Once the operation has run to completion, as a conflict reported by {@code commit()}. */
COMMIT,
/** Once the operation has run to completion, as a failure which is not replayed at all. */
NO_REPLAY
}
private final Storage delegate;
private ConflictPoint conflictPoint;
private int conflictsLeft;
private int attempts;
ReplayingStorage(Storage delegate)
{
this.delegate = delegate;
}
void conflictAtFirstStorageAccess(int conflicts)
{
arm(ConflictPoint.FIRST_STORAGE_ACCESS, conflicts);
}
void conflictAtCommit(int conflicts)
{
arm(ConflictPoint.COMMIT, conflicts);
}
void failWithoutReplay()
{
arm(ConflictPoint.NO_REPLAY, 1);
}
private void arm(ConflictPoint where, int conflicts)
{
conflictPoint = where;
conflictsLeft = conflicts;
attempts = 0;
}
/** How many times the armed operation was run, the first attempt included. */
int attempts()
{
return attempts;
}
@Override
public void write(final WriteOperation writeOperation) throws Exception
{
final ConflictPoint armed = conflictPoint;
if (armed == null)
{
delegate.write(writeOperation);
return;
}
conflictPoint = null;
// A single call, so that the replay is the delegate's own and keeps whatever the delegate
// holds for the duration of a write, rather than starting afresh as a second call would.
delegate.write(new WriteOperation()
{
@Override
public void run(WriteableTransaction txn) throws Exception
{
attempts++;
if (conflictsLeft-- <= 0)
{
writeOperation.run(txn);
return;
}
if (armed == ConflictPoint.FIRST_STORAGE_ACCESS)
{
writeOperation.run(new ConflictingTransaction());
return;
}
writeOperation.run(txn);
if (armed == ConflictPoint.NO_REPLAY)
{
throw new UnreplayableFailure();
}
throw new RollbackException();
}
});
}
@Override
public Importer startImport() throws ConfigException
{
return delegate.startImport();
}
@Override
public void open(AccessMode accessMode) throws Exception
{
delegate.open(accessMode);
}
@Override
public T read(ReadOperation readOperation) throws Exception
{
return delegate.read(readOperation);
}
@Override
public void removeStorageFiles()
{
delegate.removeStorageFiles();
}
@Override
public StorageStatus getStorageStatus()
{
return delegate.getStorageStatus();
}
@Override
public boolean supportsBackupAndRestore()
{
return delegate.supportsBackupAndRestore();
}
@Override
public void createBackup(BackupConfig backupConfig) throws DirectoryException
{
delegate.createBackup(backupConfig);
}
@Override
public void removeBackup(BackupDirectory backupDirectory, String backupID) throws DirectoryException
{
delegate.removeBackup(backupDirectory, backupID);
}
@Override
public void restoreBackup(RestoreConfig restoreConfig) throws DirectoryException
{
delegate.restoreBackup(restoreConfig);
}
@Override
public Set listTrees()
{
return delegate.listTrees();
}
@Override
public void close()
{
delegate.close();
}
}
/** A transaction which conflicts as soon as it is used, without ever reaching the storage. */
private static final class ConflictingTransaction implements WriteableTransaction
{
private static RollbackException conflict()
{
return new RollbackException();
}
@Override
public void openTree(TreeName name, boolean createOnDemand)
{
throw conflict();
}
@Override
public void deleteTree(TreeName name)
{
throw conflict();
}
@Override
public void put(TreeName treeName, ByteSequence key, ByteSequence value)
{
throw conflict();
}
@Override
public boolean update(TreeName treeName, ByteSequence key, UpdateFunction f)
{
throw conflict();
}
@Override
public boolean delete(TreeName treeName, ByteSequence key)
{
throw conflict();
}
@Override
public ByteString read(TreeName treeName, ByteSequence key)
{
throw conflict();
}
@Override
public Cursor openCursor(TreeName treeName)
{
throw conflict();
}
@Override
public long getRecordCount(TreeName treeName)
{
throw conflict();
}
@Override
public boolean treeExists(TreeName treeName)
{
throw conflict();
}
}
}