/* * 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.atLeastOnce; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opends.messages.BackendMessages.ERR_CONFIG_INDEX_ATTRIBUTE_ALREADY_INDEXED; import static org.opends.messages.BackendMessages.NOTE_INDEX_ADD_REQUIRES_REBUILD; import static org.opends.messages.BackendMessages.WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES; 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.ArrayList; import java.util.Collection; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedSet; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.opendj.config.server.ConfigChangeResult; import org.forgerock.opendj.config.server.ConfigException; import org.forgerock.opendj.config.server.ConfigurationAddListener; 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.meta.BackendVLVIndexCfgDefn.Scope; import org.forgerock.opendj.server.config.server.BackendIndexCfg; import org.forgerock.opendj.server.config.server.BackendVLVIndexCfg; import org.forgerock.opendj.server.config.server.ErrorLogPublisherCfg; import org.forgerock.opendj.server.config.server.JEBackendCfg; import org.forgerock.opendj.server.config.server.PDBBackendCfg; import org.forgerock.opendj.server.config.server.PluggableBackendCfg; import org.mockito.ArgumentCaptor; import org.opends.messages.Severity; import org.opends.server.DirectoryServerTestCase; import org.opends.server.TestCaseUtils; import org.opends.server.backends.jeb.JEStorage; import org.opends.server.backends.pdb.PDBStorage; import org.opends.server.backends.pluggable.AttributeIndex.MatchingRuleIndex; 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.WriteOperation; import org.opends.server.backends.pluggable.spi.WriteableTransaction; import org.opends.server.core.AddOperation; import org.opends.server.core.ServerContext; import org.opends.server.loggers.ErrorLogPublisher; import org.opends.server.loggers.ErrorLogger; import org.opends.server.types.BackupConfig; import org.opends.server.types.BackupDirectory; import org.opends.server.types.DirectoryException; import org.opends.server.types.Entry; import org.opends.server.types.RestoreConfig; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; /** * Tests what a pluggable backend does with the trees of an index which is declared again after the * configuration naming them is gone. The name of an index tree is a pure function of the base DN, * the attribute and the index id, and opening an index creates its trees only if they are not * there, so an index added for an attribute a previous index of the same name served reopens * exactly the trees that one left behind - with their content and with the TRUSTED flag their * {@code state} records still carry. Neither belongs to the index being added: what those trees * hold is whatever the backend was told before the configuration stopped naming them, and every * entry written in between is missing from it - see OpenDJ issue #990. *

* The trees are left behind here by declaring them, filling them and then reopening the backend * from a configuration which no longer names them, which is what an index deleted while the * backend is disabled - or through an offline {@code dsconfig} - leaves behind: the entry * container registers the listener which would have deleted the trees only while it is open * ({@code EntryContainer}, its constructor and {@code close()}). A deletion the storage gives up on * and a stop between the deletion of the trees and the commit of the configuration change leave the * same state; #962 covers the first of those. *

* Every case runs over PDB and over JE. The two lock differently: JE write-locks the name of a * deleted tree until the transaction commits, and opening a tree of that name from the same * transaction - which JE does under a transaction of its own - waits for it forever, so a drop and * an open sharing one write never return there. The time limit is what makes that a failure rather * than a hang; under Maven {@code TestListener} replaces it with {@code org.opends.test.timeout}. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "pluggablebackend" }, sequential = true) public class IndexAddedOverLeftoverTreesTest extends DirectoryServerTestCase { private static final String BACKEND_ID = "IndexAddedOverLeftoverTreesTest"; private static final DN BASE_DN = DN.valueOf("dc=b990,dc=com"); private static final String VLV_INDEX_NAME = "b990vlv"; private static final long HANG_TIMEOUT_MS = 60_000; /** The storages the cases run over, each in a directory of its own. */ enum StorageKind { PDB { @Override PluggableBackendCfg newBackendCfg(String dbDirectory) { final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class); when(cfg.getDBDirectory()).thenReturn(dbDirectory); when(cfg.getDBDirectoryPermissions()).thenReturn("755"); when(cfg.getDBCacheSize()).thenReturn(0L); when(cfg.getDBCachePercent()).thenReturn(20); return cfg; } @Override Storage newStorage(PluggableBackendCfg cfg, ServerContext serverContext) throws ConfigException { return new PDBStorage((PDBBackendCfg) cfg, serverContext); } }, JE { @Override PluggableBackendCfg newBackendCfg(String dbDirectory) { final JEBackendCfg cfg = mockCfg(JEBackendCfg.class); when(cfg.getDBDirectory()).thenReturn(dbDirectory); when(cfg.getDBDirectoryPermissions()).thenReturn("755"); when(cfg.getDBCacheSize()).thenReturn(0L); when(cfg.getDBCachePercent()).thenReturn(20); when(cfg.getDBNumCleanerThreads()).thenReturn(2); when(cfg.getDBNumLockTables()).thenReturn(63); return cfg; } @Override Storage newStorage(PluggableBackendCfg cfg, ServerContext serverContext) throws ConfigException { return new JEStorage((JEBackendCfg) cfg, serverContext); } }; /** A configuration of this storage, with its files under the given directory. */ abstract PluggableBackendCfg newBackendCfg(String dbDirectory); abstract Storage newStorage(PluggableBackendCfg cfg, ServerContext serverContext) throws ConfigException; } private ServerContext serverContext; private AttributeType cnType; @BeforeClass public void startServer() throws Exception { TestCaseUtils.startServer(); serverContext = TestCaseUtils.getServerContext(); cnType = serverContext.getSchema().getAttributeType("cn"); } @DataProvider(name = "storages") public Object[][] storages() { return new Object[][] { { StorageKind.PDB }, { StorageKind.JE } }; } /** * A test which fails before it finalizes its backend leaves the base DN behind in the server wide * registry, where it would outlive the test and break the next one to use it. */ @AfterMethod public void deregisterLeftoverBaseDN() { try { serverContext.getBackendConfigManager().deregisterBaseDN(BASE_DN); } catch (Exception alreadyGone) { // Which is what a test that finalized its backend leaves behind. } } /** * The index the add opens is not the index whose trees are still there: it has indexed none of * the entries, so it must be untrusted and the operator must be told to rebuild it, exactly as * for any other index added to a backend which already holds entries - and told, in the change * result and in the error log, that content was discarded to get there. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedOverTheTreesLeftBehindIsNotTrusted(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); final ErrorLogPublisher errorLog = registerErrorLogCapture(); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); assertThat(backend.getRootContainer().getStorage().listTrees()) .as("the trees no configuration names any more").containsAll(backend.leftoverIndexTrees); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(ec.getAttributeIndex(cnType).isTrusted()) .as("an index trusted over the content of trees it did not fill").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal(), WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(renderedOf(ccr.getMessages())).as("the index and the base DN the report names") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.get("cn", BASE_DN).toString()); assertThat(warningOrdinalsLoggedTo(errorLog)) .as("the discard reported in the error log, not only in the change result") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); } finally { ErrorLogger.getInstance().removeLogPublisher(errorLog); backend.finalizeBackend(); } } /** * The flag alone does not make the answers right again: {@code DefaultIndex.get} hands back what * a key holds whenever it holds anything, whatever the flag says, and only a key with nothing * behind it answers "undefined" and sends the search to the entries. So the content of the * adopted trees has to go, or every key they hold goes on answering with the entries of another * index and misses everything written since. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedOverTheTreesLeftBehindDoesNotAnswerWithTheirContent(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); final Map keysHeld = keysHeldBy(storage, backend.leftoverIndexTrees); assertThat(keysHeld).as("the keys the trees left behind hold").isNotEmpty(); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ordinalsOf(ccr.getMessages())).contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); final AttributeIndex index = ec.getAttributeIndex(cnType); for (final MatchingRuleIndex opened : index.getNameToIndexes().values()) { final ByteString keyHeld = keysHeld.get(opened.getName()); if (keyHeld != null) { assertThat(answers(storage, opened, keyHeld)) .as("a key of " + opened.getName() + " answered out of the tree left behind").isFalse(); } } } finally { backend.finalizeBackend(); } } /** * The trees can be gone while their {@code state} records are not: on JDBC the drop of a table is * DDL which commits of its own accord, while the {@code state.deleteRecord} of the same * {@code closeAndDelete} belongs to the transaction, so a rollback after the drop restores the * records over tables which are already gone. The index added next creates empty trees and reads * TRUSTED out of those records - an empty index which every search believes. Nothing is * discarded on this road, so nothing says it was: the record goes, and the rebuild is asked for. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedOverAStateRecordWhoseTreesAreGoneIsNotTrusted(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { for (final TreeName leftover : backend.leftoverIndexTrees) { txn.deleteTree(leftover); } } }); for (final TreeName leftover : backend.leftoverIndexTrees) { assertThat(persistedFlags(backend, leftover)) .as("the state record left over the tree which is gone").contains(TRUSTED); } final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(ec.getAttributeIndex(cnType).isTrusted()) .as("an empty index trusted over a state record which outlived its trees").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal()); assertThat(ordinalsOf(ccr.getMessages())).as("content reported as discarded where no tree was") .doesNotContain(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); } finally { backend.finalizeBackend(); } } /** * {@code dropLeftovers} folds each id's result with {@code dropped |= ...} rather than the last one * assigned; the two only disagree when the ids themselves do, which every other case avoids by giving * all of them a leftover or none. Deleting one of the two trees {@code leaveTreesBehind} left - the * cn index has EQUALITY and SUBSTRING - ahead of the add leaves the other one real, so the fold and * the assignment can differ on whether the change discarded anything. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedOverOnlyOneOfTwoLeftoverTreesIsReportedDiscarded(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final Storage storage = backend.getRootContainer().getStorage(); final TreeName oneOfTwo = backend.leftoverIndexTrees.iterator().next(); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(oneOfTwo); } }); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())) .as("the other of the two ids still had a real tree to discard") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); } finally { backend.finalizeBackend(); } } /** * A VLV index adopts what it left behind in the same way, in its tree and in its counter. Both are * dropped, and so is the {@code state} record the next open of the backend would read the flag * out of. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aVlvIndexAddedOverTheTreesLeftBehindIsNotTrusted(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); final ErrorLogPublisher errorLog = registerErrorLogCapture(); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); assertThat(storage.listTrees()) .as("the VLV trees no configuration names any more") .contains(backend.leftoverVlvTree, backend.leftoverVlvCounterTree); assertThat(holdsAnything(storage, backend.leftoverVlvTree)).as("the VLV tree left behind").isTrue(); assertThat(holdsAnything(storage, backend.leftoverVlvCounterTree)).as("the VLV counter left behind").isTrue(); assertThat(persistedFlags(backend, backend.leftoverVlvTree)).contains(TRUSTED); final ConfigChangeResult ccr = vlvIndexAddListener(backend).applyConfigurationAdd(backend.vlvIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(ec.getVLVIndex(VLV_INDEX_NAME).isTrusted()) .as("a VLV index trusted over the content of trees it did not fill").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal(), WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(renderedOf(ccr.getMessages())).as("the index and the base DN the report names") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.get(VLV_INDEX_NAME, BASE_DN).toString()); assertThat(warningOrdinalsLoggedTo(errorLog)) .as("the discard reported in the error log, not only in the change result") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(holdsAnything(storage, backend.leftoverVlvTree)) .as("the VLV tree left behind still holds its content").isFalse(); assertThat(holdsAnything(storage, backend.leftoverVlvCounterTree)) .as("the VLV counter left behind still holds its count").isFalse(); assertThat(persistedFlags(backend, backend.leftoverVlvTree)) .as("the TRUSTED record outlived the add").doesNotContain(TRUSTED); } finally { ErrorLogger.getInstance().removeLogPublisher(errorLog); backend.finalizeBackend(); } } /** * The state record can outlive the VLV tree and its counter the same way it outlives an * attribute index's tree * ({@link #anIndexAddedOverAStateRecordWhoseTreesAreGoneIsNotTrusted(StorageKind)}): deleting * them by hand exercises the two {@code treeExists} guards in * {@code VLVIndex.dropLeftovers} on their false arm, which the fixture of the case above never * reaches. On JE, {@code deleteTree} of a tree which is not there is silent, so a guard removed * or inverted would say content was discarded where none was. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aVlvIndexAddedOverAStateRecordWhoseTreesAreGoneIsNotTrusted(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); storage.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { txn.deleteTree(backend.leftoverVlvTree); txn.deleteTree(backend.leftoverVlvCounterTree); } }); assertThat(persistedFlags(backend, backend.leftoverVlvTree)) .as("the state record left over the VLV tree which is gone").contains(TRUSTED); final ConfigChangeResult ccr = vlvIndexAddListener(backend).applyConfigurationAdd(backend.vlvIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(ec.getVLVIndex(VLV_INDEX_NAME).isTrusted()) .as("an empty VLV index trusted over a state record which outlived its trees").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal()); assertThat(ordinalsOf(ccr.getMessages())).as("content reported as discarded where no tree was") .doesNotContain(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); } finally { backend.finalizeBackend(); } } /** * An index type declared again for an attribute which is still indexed goes through * {@code AttributeIndex.applyConfigurationChange} rather than through the add listener, and opens * its tree the same way. Only the tree of the id declared again goes: the tree of the id the index * was serving all along keeps what it holds. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexTypeAddedOverTheTreeLeftBehindIsNotTrusted(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind, indexCfg(newTreeSet(IndexType.EQUALITY))); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); final AttributeIndex index = ec.getAttributeIndex(cnType); assertThat(index.isIndexed(IndexType.SUBSTRING)) .as("the index type this configuration no longer names").isFalse(); final Map keysHeld = keysHeldBy(storage, backend.leftoverIndexTrees); // The index ids are the matching rules' - the one the index serves now is the equality one. final Map servedBefore = new HashMap<>(index.getNameToIndexes()); assertThat(servedBefore).hasSize(1); final MatchingRuleIndex equality = servedBefore.values().iterator().next(); final ByteString equalityKey = keysHeld.get(equality.getName()); assertThat(equalityKey).as("a key the live equality tree holds").isNotNull(); final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING))); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(index.isIndexed(IndexType.SUBSTRING)).isTrue(); assertThat(index.isTrusted()) .as("an index type trusted over the content of the tree it did not fill").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal(), WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); final Map added = new HashMap<>(index.getNameToIndexes()); added.keySet().removeAll(servedBefore.keySet()); assertThat(added).hasSize(1); final MatchingRuleIndex substring = added.values().iterator().next(); // Named by its tree on this road, where the add listener names the index by its attribute. assertThat(renderedOf(ccr.getMessages())).as("the tree and the base DN the report names") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.get(substring.getName(), BASE_DN).toString()); assertThat(keysHeld).as("the substring tree left behind held a key").containsKey(substring.getName()); assertThat(answers(storage, substring, keysHeld.get(substring.getName()))) .as("a substring key answered out of the tree left behind").isFalse(); assertThat(answers(storage, equality, equalityKey)) .as("the live equality tree went with the leftover").isTrue(); } finally { backend.finalizeBackend(); } } /** * What the drop write found is reported from a {@code finally}, so a throw from the write which * opens the index - after the drop has committed and the content is gone for good - does not * swallow it: the change fails, and the change result and the error log still say what was * discarded. Neither storage engine can be made to throw on its own, so the failure is injected * through {@link RefusingStorage}: the drop write goes through, the open write is refused before * it runs. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aDiscardIsStillReportedWhenTheWriteWhichOpensTheIndexFails(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); final ErrorLogPublisher errorLog = registerErrorLogCapture(); try { final Storage storage = backend.getRootContainer().getStorage(); final int writesBefore = backend.storage.writes(); backend.storage.refuseWrite(2); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(backend.storage.writes()).as("the refused write was the second, and the last one made") .isEqualTo(writesBefore + 2); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the discard the failed change swallowed") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(warningOrdinalsLoggedTo(errorLog)).contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); for (final TreeName leftover : backend.leftoverIndexTrees) { assertThat(storage. read(txn -> txn.treeExists(leftover))) .as("the tree the report says was discarded: " + leftover).isFalse(); } } finally { ErrorLogger.getInstance().removeLogPublisher(errorLog); backend.finalizeBackend(); } } /** The VLV road reports from a {@code finally} the same way. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aVlvDiscardIsStillReportedWhenTheWriteWhichBuildsTheIndexFails(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); final ErrorLogPublisher errorLog = registerErrorLogCapture(); try { final Storage storage = backend.getRootContainer().getStorage(); final int writesBefore = backend.storage.writes(); backend.storage.refuseWrite(2); final ConfigChangeResult ccr = vlvIndexAddListener(backend).applyConfigurationAdd(backend.vlvIndexCfg); assertThat(backend.storage.writes()).as("the refused write was the second, and the last one made") .isEqualTo(writesBefore + 2); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the discard the failed change swallowed") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(warningOrdinalsLoggedTo(errorLog)).contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(storage. read(txn -> txn.treeExists(backend.leftoverVlvTree))) .as("the VLV tree the report says was discarded").isFalse(); assertThat(storage. read(txn -> txn.treeExists(backend.leftoverVlvCounterTree))) .as("the VLV counter the report says was discarded").isFalse(); } finally { ErrorLogger.getInstance().removeLogPublisher(errorLog); backend.finalizeBackend(); } } /** * And so does {@code AttributeIndex.applyConfigurationChange}, whose drop write is the first of * the writes a change which adds an index type makes, and whose open write is the second. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexTypeDiscardIsStillReportedWhenTheWriteWhichOpensItFails(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind, indexCfg(newTreeSet(IndexType.EQUALITY))); final ErrorLogPublisher errorLog = registerErrorLogCapture(); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); final AttributeIndex index = ec.getAttributeIndex(cnType); final Map keysHeld = keysHeldBy(storage, backend.leftoverIndexTrees); final MatchingRuleIndex equality = index.getNameToIndexes().values().iterator().next(); final Set substringTrees = new HashSet<>(backend.leftoverIndexTrees); substringTrees.remove(equality.getName()); assertThat(substringTrees).hasSize(1); final int writesBefore = backend.storage.writes(); backend.storage.refuseWrite(2); final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING))); assertThat(backend.storage.writes()).as("the refused write was the second, and the last one made") .isEqualTo(writesBefore + 2); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the discard the failed change swallowed") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(warningOrdinalsLoggedTo(errorLog)).contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); final TreeName substringTree = substringTrees.iterator().next(); assertThat(storage. read(txn -> txn.treeExists(substringTree))) .as("the tree the report says was discarded").isFalse(); assertThat(index.isIndexed(IndexType.SUBSTRING)).as("an index type the failed change declared").isFalse(); assertThat(answers(storage, equality, keysHeld.get(equality.getName()))) .as("the live equality tree went with the leftover").isTrue(); } finally { ErrorLogger.getInstance().removeLogPublisher(errorLog); backend.finalizeBackend(); } } /** * The drop write can fail at its own commit, once it has run and answered - a disk which is full * when the engine writes its log. JE and PDB roll the drop back with it: the trees are still * there, and so is the TRUSTED record the next open of the backend reads. The report stands all * the same, and says more than happened. It is kept that way on purpose: the configuration entry * is already written when the listener runs, so what comes next is not another add but that * open, which adopts the trees - and the rebuild the report asks for is what puts it right. On * JDBC the same failure comes after a DROP which committed on its own, and the report is the only * trace of it; a report made to wait for the commit would be silent there, in the one case the * drop exists for. Pinned so that moving it behind the commit is red here rather than silent * there. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aDiscardIsStillReportedWhenTheDropWriteFailsAtItsCommit(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final Storage storage = backend.getRootContainer().getStorage(); final int writesBefore = backend.storage.writes(); backend.storage.failWriteAfterItRan(1); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(backend.storage.writes()).as("the failed write was the first, and the last one made") .isEqualTo(writesBefore + 1); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the report of a drop the engine rolled back") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); for (final TreeName leftover : backend.leftoverIndexTrees) { assertThat(holdsAnything(storage, leftover)).as("the drop of " + leftover + " rolled back with the write") .isTrue(); assertThat(persistedFlags(backend, leftover)).as("the record of " + leftover).contains(TRUSTED); } } finally { backend.finalizeBackend(); } } /** * The VLV road keeps its report when the drop write fails at its commit, on the terms the case * above gives: the tree and its counter are back, and so is the TRUSTED record. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aVlvDiscardIsStillReportedWhenTheDropWriteFailsAtItsCommit(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind); try { final Storage storage = backend.getRootContainer().getStorage(); final int writesBefore = backend.storage.writes(); backend.storage.failWriteAfterItRan(1); final ConfigChangeResult ccr = vlvIndexAddListener(backend).applyConfigurationAdd(backend.vlvIndexCfg); assertThat(backend.storage.writes()).as("the failed write was the first, and the last one made") .isEqualTo(writesBefore + 1); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the report of a drop the engine rolled back") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(holdsAnything(storage, backend.leftoverVlvTree)).as("the VLV tree rolled back with the write") .isTrue(); assertThat(holdsAnything(storage, backend.leftoverVlvCounterTree)) .as("the VLV counter rolled back with the write").isTrue(); assertThat(persistedFlags(backend, backend.leftoverVlvTree)).as("the record of the VLV tree").contains(TRUSTED); } finally { backend.finalizeBackend(); } } /** * And so does {@code AttributeIndex.applyConfigurationChange}: the tree of the index type * declared again is back with its TRUSTED record, the change has not been applied, and the * report stands. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexTypeDiscardIsStillReportedWhenTheDropWriteFailsAtItsCommit(StorageKind kind) throws Exception { final LeftoverBackend backend = leaveTreesBehind(kind, indexCfg(newTreeSet(IndexType.EQUALITY))); try { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); final AttributeIndex index = ec.getAttributeIndex(cnType); final Map keysHeld = keysHeldBy(storage, backend.leftoverIndexTrees); final MatchingRuleIndex equality = index.getNameToIndexes().values().iterator().next(); final Set substringTrees = new HashSet<>(backend.leftoverIndexTrees); substringTrees.remove(equality.getName()); assertThat(substringTrees).hasSize(1); final TreeName substringTree = substringTrees.iterator().next(); final int writesBefore = backend.storage.writes(); backend.storage.failWriteAfterItRan(1); final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING))); assertThat(backend.storage.writes()).as("the failed write was the first, and the last one made") .isEqualTo(writesBefore + 1); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).as("the report of a drop the engine rolled back") .contains(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); assertThat(holdsAnything(storage, substringTree)).as("the substring tree rolled back with the write").isTrue(); assertThat(persistedFlags(backend, substringTree)).as("the record of the substring tree").contains(TRUSTED); assertThat(index.isIndexed(IndexType.SUBSTRING)).as("an index type the failed change declared").isFalse(); assertThat(answers(storage, equality, keysHeld.get(equality.getName()))) .as("the live equality tree went with the leftover").isTrue(); } finally { backend.finalizeBackend(); } } /** * An index added to a backend which holds no entry has nothing to index and nothing to adopt, so * it stays trusted and asks for nothing. Pins the upgrade {@code DefaultIndex.afterOpen} makes. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedToAnEmptyBackendIsTrustedAndAsksForNothing(StorageKind kind) throws Exception { final LeftoverBackend backend = openBackend(kind, true, null, null); try { final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(backend.getRootContainer().getEntryContainer(BASE_DN).getAttributeIndex(cnType).isTrusted()) .as("an index of a backend with nothing to index").isTrue(); assertThat(ccr.adminActionRequired()).isFalse(); assertThat(ccr.getMessages()).isEmpty(); } finally { backend.finalizeBackend(); } } /** * An index added to a backend which holds entries, with nothing left behind for it to adopt, is * untrusted over trees it created empty and asks to be rebuilt. Pins what the fix has to leave * alone: this is the outcome an index added over leftover trees has to reach as well, and nothing * was discarded to reach it here. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void anIndexAddedToANonEmptyBackendAsksForARebuild(StorageKind kind) throws Exception { final LeftoverBackend backend = openBackend(kind, true, null, null); try { addEntry(backend, "dn: " + BASE_DN, "objectClass: top", "objectClass: domain", "dc: b990"); final ConfigChangeResult ccr = indexAddListener(backend).applyConfigurationAdd(backend.cnIndexCfg); assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); assertThat(backend.getRootContainer().getEntryContainer(BASE_DN).getAttributeIndex(cnType).isTrusted()) .as("an index which has indexed none of the entries").isFalse(); assertThat(ccr.adminActionRequired()).isTrue(); assertThat(ordinalsOf(ccr.getMessages())).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal()); assertThat(ordinalsOf(ccr.getMessages())).as("content reported as discarded where nothing was left behind") .doesNotContain(WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES.ordinal()); } finally { backend.finalizeBackend(); } } /** * The entry container keys its indexes by the attribute type, which every name of the attribute * and its OID resolve to, while a configuration entry is named by whichever of them was typed. An * index declared for cn as commonName or as 2.5.4.3 is therefore a second index for an attribute * which is already indexed, naming the very trees the live index serves - and those must not go * the way of trees left behind. The add is refused instead, before the configuration is written * and again where it would have dropped them. */ @Test(dataProvider = "storages", timeOut = HANG_TIMEOUT_MS) public void aSecondIndexForAnAttributeAlreadyIndexedIsRefused(StorageKind kind) throws Exception { final LeftoverBackend backend = openBackend(kind, true, indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING)), null); try { addEntry(backend, "dn: " + BASE_DN, "objectClass: top", "objectClass: domain", "dc: b990"); addEntry(backend, "dn: cn=live," + BASE_DN, "objectClass: top", "objectClass: organizationalRole", "cn: live"); final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final Storage storage = backend.getRootContainer().getStorage(); final AttributeIndex live = ec.getAttributeIndex(cnType); assertThat(live.isTrusted()).isTrue(); final Map keysHeld = keysHeldBy(storage, treesOf(live)); assertThat(keysHeld).as("the keys the live index holds").isNotEmpty(); // What was typed names the configuration entry; the attribute type it resolves to is the one already indexed. final DN typedAs = DN.valueOf("ds-cfg-attribute=commonName,cn=Index," + backend.configuredWith.dn()); final BackendIndexCfg declaredAgain = indexCfg(newTreeSet(IndexType.EQUALITY)); when(declaredAgain.dn()).thenReturn(typedAs); final ConfigurationAddListener listener = indexAddListener(backend); // An index for an attribute which is not indexed yet is still accepted - pins the refusing arm above // against a mutant which refuses every add. final BackendIndexCfg fresh = indexCfg(newTreeSet(IndexType.EQUALITY)); when(fresh.getAttribute()).thenReturn(serverContext.getSchema().getAttributeType("sn")); final List reasons = new ArrayList<>(); assertThat(listener.isConfigurationAddAcceptable(fresh, reasons)).isTrue(); assertThat(reasons).isEmpty(); assertThat(listener.isConfigurationAddAcceptable(declaredAgain, reasons)).isFalse(); assertThat(ordinalsOf(reasons)).contains(ERR_CONFIG_INDEX_ATTRIBUTE_ALREADY_INDEXED.ordinal()); final ConfigChangeResult ccr = listener.applyConfigurationAdd(declaredAgain); assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); assertThat(ordinalsOf(ccr.getMessages())).contains(ERR_CONFIG_INDEX_ATTRIBUTE_ALREADY_INDEXED.ordinal()); assertThat(ec.getAttributeIndex(cnType)).as("the live index was replaced").isSameAs(live); assertThat(live.isTrusted()).isTrue(); for (final MatchingRuleIndex opened : live.getNameToIndexes().values()) { assertThat(answers(storage, opened, keysHeld.get(opened.getName()))) .as("a key of " + opened.getName() + " the live index held before").isTrue(); } } finally { backend.finalizeBackend(); } } private LeftoverBackend leaveTreesBehind(StorageKind kind) throws Exception { return leaveTreesBehind(kind, null); } /** * Opens a backend whose configuration names a cn index and a VLV index, fills them with an entry, * and reopens it from a configuration which names {@code reopenedWith} instead - null for a * configuration which names no index at all. The trees of everything it no longer names are left * behind, and the entry added afterwards is in none of them. */ private LeftoverBackend leaveTreesBehind(StorageKind kind, BackendIndexCfg reopenedWith) throws Exception { final LeftoverBackend indexed = openBackend(kind, true, indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING)), vlvIndexCfg()); final Set indexTrees; final TreeName vlvTree; final TreeName vlvCounterTree; try { addEntry(indexed, "dn: " + BASE_DN, "objectClass: top", "objectClass: domain", "dc: b990"); addEntry(indexed, "dn: cn=stale," + BASE_DN, "objectClass: top", "objectClass: organizationalRole", "cn: stale"); final EntryContainer ec = indexed.getRootContainer().getEntryContainer(BASE_DN); indexTrees = treesOf(ec.getAttributeIndex(cnType)); vlvTree = new TreeName(ec.getTreePrefix(), "vlv." + VLV_INDEX_NAME); vlvCounterTree = new TreeName(ec.getTreePrefix(), "counter.vlv." + VLV_INDEX_NAME); } finally { indexed.finalizeBackend(); } final LeftoverBackend reopened = openBackend(kind, false, reopenedWith, null); try { addEntry(reopened, "dn: cn=fresh," + BASE_DN, "objectClass: top", "objectClass: organizationalRole", "cn: fresh"); } catch (Exception e) { reopened.finalizeBackend(); throw e; } reopened.leftoverIndexTrees = indexTrees; reopened.leftoverVlvTree = vlvTree; reopened.leftoverVlvCounterTree = vlvCounterTree; return reopened; } /** The first key each of the given trees holds, for the trees which hold any. */ private static Map keysHeldBy(Storage storage, Set trees) throws Exception { final Map keys = new HashMap<>(); for (final TreeName tree : trees) { final ByteString key = storage.read(txn -> { try (Cursor cursor = txn.openCursor(tree)) { return cursor.next() ? cursor.getKey() : null; } }); if (key != null) { keys.put(tree, key); } } return keys; } private static boolean holdsAnything(Storage storage, TreeName tree) throws Exception { return storage.read(txn -> { try (Cursor cursor = txn.openCursor(tree)) { return cursor.next(); } }); } /** * Whether the index answers the key with a non-empty entry ID set, as a search reading it would get one. * {@code DefaultIndex.get} answers {@code newDefinedSet()} for a missing key while the index is trusted, so * {@code isDefined()} alone would pin the trusted flag rather than the content a leftover tree still holds. */ private static boolean answers(Storage storage, MatchingRuleIndex index, ByteString key) throws Exception { return storage.read(txn -> { final EntryIDSet ids = index.get(txn, key); return ids.isDefined() && ids.size() > 0; }); } /** Reads back the flags an index tree carries, as they are stored. */ private EnumSet persistedFlags(LeftoverBackend backend, TreeName index) throws Exception { final EntryContainer ec = backend.getRootContainer().getEntryContainer(BASE_DN); final State state = new State(new TreeName(ec.getTreePrefix(), STATE_INDEX_NAME)); return backend.getRootContainer().getStorage().read(txn -> state.getIndexFlags(txn, index)); } /** The messages a change carries, as the operator reads them: identity and arguments both. */ private static List renderedOf(Collection messages) { final List rendered = new ArrayList<>(); for (final LocalizableMessage message : messages) { rendered.add(message.toString()); } return rendered; } /** The messages a change carries, by identity rather than by their formatted text. */ private static Set ordinalsOf(Collection messages) { final Set ordinals = new HashSet<>(); for (final LocalizableMessage message : messages) { ordinals.add(message.ordinal()); } return ordinals; } /** * Registers a mock {@code ErrorLogPublisher}, enabled for every category and severity, so that * {@link #warningOrdinalsLoggedTo} can read back what {@code AttributeIndex.reportDiscardedLeftovers} * put in the error log - the half of it the change result alone does not exercise. The caller removes * it once done, from a {@code finally}: it would otherwise go on capturing messages other tests log. */ @SuppressWarnings("unchecked") private static ErrorLogPublisher registerErrorLogCapture() { final ErrorLogPublisher errorLog = mock(ErrorLogPublisher.class); when(errorLog.isEnabledFor(any(), any())).thenReturn(true); ErrorLogger.getInstance().addLogPublisher(errorLog); return errorLog; } /** The ordinals of the messages logged at {@link Severity#WARNING} through the given capture. */ private static Set warningOrdinalsLoggedTo(ErrorLogPublisher errorLog) { final ArgumentCaptor logged = ArgumentCaptor.forClass(LocalizableMessage.class); verify(errorLog, atLeastOnce()).log(any(), eq(Severity.WARNING), logged.capture(), any()); return ordinalsOf(logged.getAllValues()); } private static Set treesOf(AttributeIndex index) { final Set names = new HashSet<>(); for (final MatchingRuleIndex matchingRuleIndex : index.getNameToIndexes().values()) { names.add(matchingRuleIndex.getName()); } return names; } private static void addEntry(LeftoverBackend backend, String... ldifLines) throws Exception { final Entry entry = TestCaseUtils.makeEntry(ldifLines); backend.addEntry(entry, mock(AddOperation.class)); } @SuppressWarnings({ "unchecked", "rawtypes" }) private static ConfigurationAddListener indexAddListener(LeftoverBackend backend) throws ConfigException { final ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigurationAddListener.class); verify(backend.configuredWith).addBackendIndexAddListener(captor.capture()); return captor.getValue(); } @SuppressWarnings({ "unchecked", "rawtypes" }) private static ConfigurationAddListener vlvIndexAddListener(LeftoverBackend backend) throws ConfigException { final ArgumentCaptor captor = ArgumentCaptor.forClass(ConfigurationAddListener.class); verify(backend.configuredWith).addBackendVLVIndexAddListener(captor.capture()); return captor.getValue(); } /** * Opens a backend whose configuration names the given index and VLV index - null for one it does * not name, whose trees are then left to whatever is already in the storage. */ private LeftoverBackend openBackend(StorageKind kind, boolean pristine, BackendIndexCfg cnIndexCfg, BackendVLVIndexCfg vlvIndexCfg) throws Exception { final LeftoverBackend backend = new LeftoverBackend(kind); backend.setBackendID(BACKEND_ID); backend.cnIndexCfg = cnIndexCfg != null ? cnIndexCfg : indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.SUBSTRING)); backend.vlvIndexCfg = vlvIndexCfg != null ? vlvIndexCfg : vlvIndexCfg(); backend.configuredWith = backendCfg(kind, cnIndexCfg, vlvIndexCfg); backend.configureBackend(backend.configuredWith, serverContext); if (pristine) { // Start from a pristine on-disk state, so that a previous run cannot mask what this one leaves. backend.storage.removeStorageFiles(); } try { backend.openBackend(); } catch (Exception e) { // openBackend() opens the root container before it registers the base DNs and the monitor, so a failure in // any of those leaves the volume open and every following test failing here too. finalizeBackend() is no // use for it: closeBackend() deregisters the monitor before it closes the root container, and NPEs on the // monitor which was never registered. try { final RootContainer root = backend.getRootContainer(); if (root != null) { root.close(); } else { backend.storage.close(); } } catch (Exception cleanupFailure) { e.addSuppressed(cleanupFailure); } throw e; } return backend; } private PluggableBackendCfg backendCfg(StorageKind kind, BackendIndexCfg cnIndexCfg, BackendVLVIndexCfg vlvIndexCfg) throws ConfigException { final PluggableBackendCfg cfg = kind.newBackendCfg(BACKEND_ID + "." + kind); when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config")); when(cfg.getBackendId()).thenReturn(BACKEND_ID); when(cfg.getBaseDN()).thenReturn(newTreeSet(BASE_DN)); when(cfg.listBackendIndexes()).thenReturn(cnIndexCfg != null ? new String[] { "cn" } : new String[0]); when(cfg.getBackendIndex("cn")).thenReturn(cnIndexCfg); when(cfg.listBackendVLVIndexes()) .thenReturn(vlvIndexCfg != null ? new String[] { VLV_INDEX_NAME } : new String[0]); when(cfg.getBackendVLVIndex(VLV_INDEX_NAME)).thenReturn(vlvIndexCfg); return cfg; } private BackendIndexCfg indexCfg(SortedSet indexTypes) { final BackendIndexCfg cfg = mock(BackendIndexCfg.class); when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-attribute=cn,cn=Index,ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config")); when(cfg.getIndexType()).thenReturn(indexTypes); when(cfg.getAttribute()).thenReturn(cnType); when(cfg.getIndexEntryLimit()).thenReturn(4000); when(cfg.getSubstringLength()).thenReturn(6); return cfg; } private BackendVLVIndexCfg vlvIndexCfg() { final BackendVLVIndexCfg cfg = mock(BackendVLVIndexCfg.class); when(cfg.getName()).thenReturn(VLV_INDEX_NAME); when(cfg.getBaseDN()).thenReturn(BASE_DN); when(cfg.getScope()).thenReturn(Scope.WHOLE_SUBTREE); when(cfg.getFilter()).thenReturn("(objectClass=*)"); when(cfg.getSortOrder()).thenReturn("+cn"); return cfg; } /** A backend over the storage of the given kind, which keeps hold of the configuration its entry container registered with. */ private static final class LeftoverBackend extends BackendImpl { private final StorageKind kind; private RefusingStorage storage; /** The configuration the entry container registers its listeners with. */ private PluggableBackendCfg configuredWith; private BackendIndexCfg cnIndexCfg; private BackendVLVIndexCfg vlvIndexCfg; /** The trees of the indexes an earlier configuration named, which nothing names now. */ private Set leftoverIndexTrees; private TreeName leftoverVlvTree; private TreeName leftoverVlvCounterTree; private LeftoverBackend(StorageKind kind) { this.kind = kind; } @Override protected Storage configureStorage(PluggableBackendCfg cfg, ServerContext serverContext) throws ConfigException { storage = new RefusingStorage(kind.newStorage(cfg, serverContext)); return storage; } } /** A failure no storage engine replays, unlike a conflict. */ private static final class RefusedWrite extends Exception { private static final long serialVersionUID = 1L; RefusedWrite() { super("write refused by the test"); } } /** * Delegates to the storage of the engine, and refuses the one write a case arms: neither engine * can be made to fail a write on its own, and both are final. The refusal comes at one of two * points - before the operation runs, which is what a write the engine cannot begin looks like * to the caller, or once the operation has run and before it is committed, which is what a * failure at commit looks like: the engine rolls the operation back, and whatever the operation * noted on the way stands. Unarmed, it is the engine's storage. */ private static final class RefusingStorage implements Storage { private final Storage delegate; /** Which write, counted over the life of this storage, is armed; zero for none. */ private int armedWrite; private boolean afterItRan; private int writes; RefusingStorage(Storage delegate) { this.delegate = delegate; } /** Refuses the {@code nth} write asked for from now on, the next one being the first, before it runs. */ void refuseWrite(int nth) { armedWrite = writes + nth; afterItRan = false; } /** Fails the {@code nth} write asked for from now on once its operation has run, before it is committed. */ void failWriteAfterItRan(int nth) { armedWrite = writes + nth; afterItRan = true; } /** How many write operations this storage was asked for, refused or not. */ int writes() { return writes; } @Override public void write(final WriteOperation operation) throws Exception { writes++; if (writes != armedWrite) { delegate.write(operation); return; } armedWrite = 0; if (!afterItRan) { throw new RefusedWrite(); } delegate.write(new WriteOperation() { @Override public void run(WriteableTransaction txn) throws Exception { operation.run(txn); throw new RefusedWrite(); } }); } @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(); } } }