/* * 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 2009-2010 Sun Microsystems, Inc. * Portions Copyright 2013-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.plugin; import static org.assertj.core.api.Assertions.*; 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.ArrayList; import java.util.List; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicBoolean; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.RDN; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.server.config.meta.ReplicationDomainCfgDefn.IsolationPolicy; import org.opends.server.TestCaseUtils; import org.opends.server.core.DirectoryServer; import org.opends.server.core.ModifyDNOperation; import org.opends.server.core.ModifyOperationBasis; import org.opends.server.plugins.ShortCircuitPlugin; import org.opends.server.replication.ReplicationTestCase; import org.opends.server.replication.common.CSN; import org.opends.server.replication.common.CSNGenerator; import org.opends.server.replication.protocol.AddMsg; import org.opends.server.replication.protocol.DeleteMsg; import org.opends.server.replication.protocol.LDAPUpdateMsg; import org.opends.server.replication.protocol.ModifyDNMsg; import org.opends.server.replication.protocol.ModifyMsg; import org.opends.server.replication.protocol.OperationContext; import org.opends.server.replication.protocol.UpdateMsg; import org.opends.server.types.Entry; import org.opends.server.types.OperationType; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; /** Test the naming conflict resolution code. */ @SuppressWarnings("javadoc") public class NamingConflictTest extends ReplicationTestCase { private static final AtomicBoolean SHUTDOWN = new AtomicBoolean(false); /** The monitor attributes which count the naming conflicts a domain solved, and did not. */ private static final String RESOLVED_NAMING_CONFLICTS = "resolved-naming-conflicts"; private static final String UNRESOLVED_NAMING_CONFLICTS = "unresolved-naming-conflicts"; private DN baseDN; private LDAPReplicationDomain domain; private CSNGenerator gen; private TestSynchronousReplayQueue queue; /** * The result code to put back in {@code ds-cfg-server-error-result-code}, or * {@code null} when this test did not change it. *

* The setting is server-wide, so it is put back by {@link #tearDown()} rather than by * the test which changed it: a method the harness kills on its timeout, or interrupts * inside a replay, would otherwise leave every later method of this class replaying * against a result code it never asked for. */ private Integer serverErrorResultCodeToRestore; @BeforeMethod public void setUpLocal() throws Exception { baseDN = DN.valueOf(TEST_ROOT_DN_STRING); TestCaseUtils.initializeTestBackend(true); queue = new TestSynchronousReplayQueue(); final DomainFakeCfg conf = new DomainFakeCfg(baseDN, 1, new TreeSet()); conf.setIsolationPolicy(IsolationPolicy.ACCEPT_ALL_UPDATES); domain = MultimasterReplication.createNewDomain(conf, queue); domain.start(); gen = new CSNGenerator(201, 0); } @AfterMethod public void tearDown() throws Exception { try { MultimasterReplication.deleteDomain(baseDN); } finally { if (serverErrorResultCodeToRestore != null) { final int resultCode = serverErrorResultCodeToRestore; serverErrorResultCodeToRestore = null; setServerErrorResultCode(resultCode); } } } /** * Test for issue 3402 : test, that a modrdn that is older than an other * modrdn but that is applied later is ignored. * * In this test, the local server act both as an LDAP server and * a replicationServer that are inter-connected. * * The test creates an other session to the replicationServer using * directly the ReplicationBroker API. * It then uses this session to simulate conflicts and therefore * test the naming conflict resolution code. */ @Test public void simultaneousModrdnConflict() throws Exception { String parentUUID = getEntryUUID(baseDN); Entry entry = createAndAddEntry("simultaneousModrdnConflict"); String entryUUID = getEntryUUID(entry.getName()); // generate two consecutive CSN that will be used in backward order CSN csn1 = gen.newCSN(); CSN csn2 = gen.newCSN(); replayMsg(modDnMsg(entry, entryUUID, parentUUID, csn2, "uid=simultaneous2")); // This MODIFY DN uses an older DN and should therefore be cancelled at replay time. replayMsg(modDnMsg(entry, entryUUID, parentUUID, csn1, "uid=simulatneouswrong")); // Expect the conflict resolution assertFalse(entryExists(entry.getName()), "The modDN conflict was not resolved as expected."); } private ModifyDNMsg modDnMsg(Entry entry, String entryUUID, String parentUUID, CSN csn, String newRDN) throws Exception { return new ModifyDNMsg(entry.getName(), csn, entryUUID, parentUUID, false, TEST_ROOT_DN_STRING, newRDN); } /** * Test case for [Issue 910]: a naming conflict which * {@code solveNamingConflict(ModifyDNOperation)} solves must still be solved while * {@code ds-cfg-server-error-result-code} is set to one of the result codes conflict * resolution owns. *

* That setting is a plain integer which is not validated as a result code, so it can be * one of them. Here it is {@code UNWILLING_TO_PERFORM}, which is what a ModifyDN whose * new superior is - on this replica - a subordinate of the entry being moved comes back * with, and only conflict resolution can turn such a change into an operation which * applies: it resolves both DNs again from the entryUUIDs the message carries. Reading * the code as a failure of the server would take the change away from it - the message * would never be rewritten, so no attempt would apply any better than the first - and * the change would be retried in place, delivered again and finally given up on, with * the entry left where it was. *

* {@code UpdateOperationTest.changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetried} * covers the other half: a change which fails with that same code and which conflict * resolution can not solve is retried as the failure of the server it is. */ @Test public void modifyDnConflictIsSolvedWhileTheServerErrorCodeIsOneOfTheConflictCodes() throws Exception { final Entry entry = createAndAddEntry("modDnOnConflictingServerErrorCode"); final String entryUUID = getEntryUUID(entry.getName()); final Entry newParent = TestCaseUtils.addEntry( "dn: ou=newParent," + TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: organizationalUnit", "ou: newParent"); final String newParentUUID = getEntryUUID(newParent.getName()); // Remembered for tearDown() rather than restored here: the code which is in force, // which is the defined default unless the suite configured another one, and never a // value hardcoded by this test. serverErrorResultCodeToRestore = getServerContext().getCoreConfigManager().getServerErrorResultCode().intValue(); setServerErrorResultCode(ResultCode.UNWILLING_TO_PERFORM.intValue()); /* * The new superior as the master knew it: a DN which, here, is a subordinate of the * entry being moved - as it would be after that parent was renamed on this replica. * The operation reports UNWILLING_TO_PERFORM for as long as the message carries that * DN - ERR_MODDN_NEW_SUPERIOR_IN_SUBTREE, and the memory backend answers the same on * a new superior which is not in it - so the change is applied only if conflict * resolution gets to rewrite the message with the DNs the entryUUIDs resolve to here. */ final String staleNewSuperior = "ou=newParent," + entry.getName(); final CSN csn = gen.newCSN(); replayMsg(new ModifyDNMsg(entry.getName(), csn, entryUUID, newParentUUID, false, staleNewSuperior, entry.getName().rdn().toString())); final DN resolvedDN = newParent.getName().child(entry.getName().rdn()); assertTrue(entryExists(resolvedDN), "the naming conflict was not solved: no entry at " + resolvedDN); assertFalse(entryExists(entry.getName()), "the entry was not moved by the replayed ModifyDN"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); } /** * Test case for [Issue 955]: a ModifyDN whose entry and whose new superior are both * gone from this replica is a conflict between a delete and this ModifyDN, and it is * solved as such rather than left to be delivered again until this replica gives up on * it. *

* Neither entryUUID the message carries resolves to a DN here, so conflict resolution * has no new superior to move the entry under - and no entry to move either. The entry * having been deleted settles what the ModifyDN was trying to do, which is what makes * the change resolved: an entry which is not in the database can not be marked as * conflicting, and marking it is what used to be attempted first, on the DN of an entry * which is not there. */ @Test public void modifyDnOnAnEntryAndANewSuperiorWhichAreBothGone() throws Exception { final Entry entry = createAndAddEntry("modDnOnEntryAndNewSuperiorBothGone"); final String entryUUID = getEntryUUID(entry.getName()); final Entry newSuperior = TestCaseUtils.addEntry( "dn: ou=newSuperiorBothGone," + TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: organizationalUnit", "ou: newSuperiorBothGone"); final String newSuperiorUUID = getEntryUUID(newSuperior.getName()); // Both entries are deleted on this replica while the ModifyDN is on its way. TestCaseUtils.deleteEntry(newSuperior.getName()); TestCaseUtils.deleteEntry(entry.getName()); final CSN csn = gen.newCSN(); replayMsg(new ModifyDNMsg(entry.getName(), csn, entryUUID, newSuperiorUUID, false, newSuperior.getName().toString(), entry.getName().rdn().toString())); assertFalse(entryExists(entry.getName()), "the deleted entry was brought back by the replayed ModifyDN"); assertTrue(domain.getServerState().cover(csn), "a ModifyDN which the delete of its entry has settled must be recorded as replayed"); } /** * Test case for [Issue 956]: conflict resolution reads the data with a search of the * entryUUID, and a search which did not run is no evidence about the data - the entry * it did not report is not an entry which was deleted. *

* The change replayed here was made on the master under a DN this replica does not * have: the entry lives under another one, the way it does after a rename which was * replayed first, and only the entryUUID search finds it. So the change is applied * only if that search is given another chance once the storage serves it again. * Reading its failure as "the entry has been deleted" answers NOTHING_TO_DO, which * records the change as replayed and loses it for good - the replication server never * sends a change this replica reports itself past. */ @Test public void modifyIsRetriedWhileTheEntryUUIDSearchCanNotRun() throws Exception { final Entry entry = createAndAddEntry("modifyWhoseSearchCanNotRun"); final String entryUUID = getEntryUUID(entry.getName()); final String phoneNumber = "01 02 45"; // The DN the change carries is the one the entry had on the master. Here the entry // is the one which was just added, which only the entryUUID search finds. final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final CSN csn = gen.newCSN(); /* * The storage does not serve the search for the first attempts and serves it after * them: a failure which lasts less than the attempts made in place, the way a * backend which is being rebuilt or a connection which was lost does. The short * circuit is put in force right before the replay and dropped right after it - it * applies to every search of this server while it is registered, and the replay * here runs on this thread. */ ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 2); try { replayMsg(new ModifyMsg(csn, staleDN, generatemods("telephonenumber", phoneNumber), entryUUID)); assertShortCircuitSpentBy(2); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } final Entry replayedEntry = DirectoryServer.getEntry(entry.getName()); assertEquals(replayedEntry.parseAttribute("telephonenumber").asString(), phoneNumber, "the change was not applied: a search which could not run was read as a deleted entry"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); } /** * Test case for [Issue 956]: the entryUUID searches which check a replayed Add for a * conflict read the data the same way, and a search which did not run is no evidence * about it either. The first of them checks whether the Add was replayed here already. *

* The Add is delivered a second time - the replication server sends again what a * replica does not report itself past - and the entry was renamed here since it was * added: only the entryUUID search finds it. An entry that search did not report is * not an entry which is not there: reading it that way adds the entry a second time, * under its former DN, and the data holds one entryUUID twice. */ @Test public void addIsNotReplayedTwiceWhileTheEntryUUIDSearchCanNotRun() throws Exception { final Entry entry = createAndAddEntry("addWhoseSearchCanNotRun"); final String entryUUID = getEntryUUID(entry.getName()); final RDN renamedRDN = RDN.valueOf("cn=renamedAfterTheAdd"); final ModifyDNOperation rename = getRootConnection().processModifyDN(entry.getName(), renamedRDN, true); assertEquals(rename.getResultCode(), ResultCode.SUCCESS); final CSN csn = gen.newCSN(); // The first attempt fails its first search; the second attempt has it served. ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 1); try { replayMsg(addMsg(entry, csn, getEntryUUID(baseDN), entryUUID)); assertShortCircuitSpentBy(1); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertFalse(entryExists(entry.getName()), "the entry was added a second time: a search which could not run was read as an " + "Add which was not replayed here yet"); assertTrue(entryExists(baseDN.child(renamedRDN)), "the renamed entry is gone"); assertTrue(domain.getServerState().cover(csn), "a change which is in the data must be recorded as replayed"); } /** * Test case for [Issue 956]: the search which checks that the parent of a replayed * Add is still the one the change was made under fails, and the one before it - the * check that the Add was not replayed here already - ran. *

* A parent that search did not report is not a parent which was deleted: the Add is * attempted again once the storage serves the search, and no naming conflict is * counted for a search which read nothing. Reading it as a parent which is gone hands * the Add to conflict resolution as the naming conflict it is not - and renames the * entry under the base DN as a conflicting entry, a divergence which is left for an * administrator to repair by hand, when the search conflict resolution makes fails as * well. */ @Test public void addIsRetriedWhileTheParentEntryUUIDSearchCanNotRun() throws Exception { final Entry parent = addParentEntry("addWhoseParentSearchCanNotRun"); final String parentUUID = getEntryUUID(parent.getName()); final Entry child = makeChildEntry("addedWhileTheParentSearchFailed", parent.getName()); final CSN csn = gen.newCSN(); final long resolvedConflicts = getMonitorAttrValue(baseDN, RESOLVED_NAMING_CONFLICTS); final long unresolvedConflicts = getMonitorAttrValue(baseDN, UNRESOLVED_NAMING_CONFLICTS); /* * The first search of the first attempt - the check for an Add replayed already - is * let through, the parent check right after it fails, and every search after that * one is served. A parent search read as a parent which is gone hands the Add to * conflict resolution, whose own search finds the parent where it was and rewrites * the message to the DN the Add already carries: the entry lands where it should * either way, and what tells the two apart is the naming conflict counted for a * search which read nothing. */ ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 1, 1); try { replayMsg(addMsg(child, csn, parentUUID, "1c3c2c4d-2b5e-4b9f-8a7c-3d5e6f7a8b9c")); assertShortCircuitSpentBy(2); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertTrue(entryExists(child.getName()), "the entry was not added under its parent: a parent search which could not run " + "was read as a parent which is gone"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); assertEquals(getMonitorAttrValue(baseDN, RESOLVED_NAMING_CONFLICTS), resolvedConflicts, "a search which did not run is not a naming conflict which was resolved"); assertEquals(getMonitorAttrValue(baseDN, UNRESOLVED_NAMING_CONFLICTS), unresolvedConflicts, "a search which did not run is not a naming conflict which could not be resolved"); } /** * Test case for [Issue 956]: the search conflict resolution reads the data with once a * replayed Add failed on a genuine conflict fails, and the checks before the Add ran. *

* The parent of the entry was renamed here, so the Add carries a DN which is not * where the parent is anymore: a conflict which is solved by adding the entry under * the parent's current DN, once the search which finds that DN runs. A search which * did not run is not a parent which is gone, and the conflict is counted once, when * it is solved - not for the search which read nothing. */ @Test public void addIsRetriedWhileTheConflictResolutionSearchCanNotRun() throws Exception { final Entry parent = addParentEntry("addWhoseConflictSearchCanNotRun"); final String parentUUID = getEntryUUID(parent.getName()); final Entry child = makeChildEntry("addedWhileTheConflictSearchFailed", parent.getName()); final String entryUUID = "2d4d3d5e-3c6f-4ca0-9b8d-4e6f7a8b9cad"; final CSN csn = gen.newCSN(); final RDN renamedParentRDN = RDN.valueOf("ou=renamedBeforeTheAdd"); final ModifyDNOperation renameParent = getRootConnection().processModifyDN(parent.getName(), renamedParentRDN, true); assertEquals(renameParent.getResultCode(), ResultCode.SUCCESS); final DN expectedDN = baseDN.child(renamedParentRDN).child(child.getName().rdn()); final long resolvedConflicts = getMonitorAttrValue(baseDN, RESOLVED_NAMING_CONFLICTS); final long unresolvedConflicts = getMonitorAttrValue(baseDN, UNRESOLVED_NAMING_CONFLICTS); /* * The two searches which check the Add before it runs are let through - they find * the parent under its new DN, which is what fails the Add on a conflict - and the * search conflict resolution then reads the data with is the one which fails. The * next attempt has every search served. */ ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 2, 1); try { replayMsg(addMsg(child, csn, parentUUID, entryUUID)); assertShortCircuitSpentBy(3); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertTrue(entryExists(expectedDN), "the entry was not added under the current DN of its parent: a search which could " + "not run was read as a parent which is gone"); // A parent read as gone puts the entry under the base DN as a conflicting entry, with // its entryUUID added to its RDN. assertFalse(entryExists(DN.valueOf( "entryuuid=" + entryUUID + "+" + child.getName().rdn() + "," + TEST_ROOT_DN_STRING)), "the entry was renamed under the base DN as a conflicting entry"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); assertEquals(getMonitorAttrValue(baseDN, RESOLVED_NAMING_CONFLICTS), resolvedConflicts + 1, "the renamed parent is one naming conflict, solved once the search ran"); assertEquals(getMonitorAttrValue(baseDN, UNRESOLVED_NAMING_CONFLICTS), unresolvedConflicts, "a search which did not run is not a naming conflict which could not be resolved"); } /** * Test case for [Issue 956]: a replayed Delete reads the data with the same search * once it failed on a conflict, and rides on the same retry. *

* The entry was renamed here, so the Delete carries a DN which is not the entry's * anymore, and only the entryUUID search finds it. Reading the search's failure as an * entry which was deleted already answers NOTHING_TO_DO: the entry stays, and the * change is recorded as replayed. */ @Test public void deleteIsRetriedWhileTheEntryUUIDSearchCanNotRun() throws Exception { final Entry entry = createAndAddEntry("deleteWhoseSearchCanNotRun"); final String entryUUID = getEntryUUID(entry.getName()); final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final CSN csn = gen.newCSN(); ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 2); try { replayMsg(new DeleteMsg(staleDN, csn, entryUUID)); assertShortCircuitSpentBy(2); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertFalse(entryExists(entry.getName()), "the entry was not deleted: a search which could not run was read as an entry " + "which was deleted already"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); } /** * Test case for [Issue 956]: a replayed Modify DN reads the data with the same search * once it failed on a conflict, and rides on the same retry. *

* {@code solveNamingConflict(ModifyDNOperation)} declares {@code throws Exception} * rather than the search failure alone, so this is the one path where the failure * reaches the replay loop through a declaration which does not name it. */ @Test public void modifyDnIsRetriedWhileTheEntryUUIDSearchCanNotRun() throws Exception { final Entry entry = createAndAddEntry("modifyDnWhoseSearchCanNotRun"); final String entryUUID = getEntryUUID(entry.getName()); final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final RDN newRDN = RDN.valueOf("cn=renamedWhileTheSearchFailed"); final CSN csn = gen.newCSN(); ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 2); try { replayMsg(new ModifyDNMsg(staleDN, csn, entryUUID, null, false, null, newRDN.toString())); assertShortCircuitSpentBy(2); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertTrue(entryExists(baseDN.child(newRDN)), "the entry was not renamed: a search which could not run was read as an entry " + "which is not in the data anymore"); assertFalse(entryExists(entry.getName()), "the entry kept its former DN"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); } /** * Test case for [Issue 956], the half which closes [Issue 889] for this path: a * change whose entryUUID search never runs is left out of the ServerState once the * attempts in place are spent, so that the replication server sends it again. *

* The result code of every attempt is the conflict the operation failed on, which the * exhaustion exit does not read as a failure of the server: without the attempt itself * telling that its search did not run, the exit reads the attempts as conflict * resolution rewriting an operation which keeps failing, and skips the change - the * CSN is committed, and a change which is not in the data is never asked for again. */ @Test public void modifyIsLeftOutOfTheServerStateWhenTheEntryUUIDSearchNeverRuns() throws Exception { final Entry entry = createAndAddEntry("modifyWhoseSearchNeverRuns"); final String entryUUID = getEntryUUID(entry.getName()); final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final CSN csn = gen.newCSN(); // No number of times: every attempt in place fails its search. ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue()); try { replayMsg(new ModifyMsg(csn, staleDN, generatemods("telephonenumber", "01 02 45"), entryUUID)); assertTrue(ShortCircuitPlugin.getShortCircuitCount(OperationType.SEARCH, "PreParse") >= LDAPReplicationDomain.IN_PLACE_REPLAY_ATTEMPTS, "every attempt in place must have made its search"); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertFalse(domain.getServerState().cover(csn), "a change whose search never ran is not in the data and must not advance the ServerState"); assertThat(DirectoryServer.getEntry(entry.getName()).getAllAttributes("telephonenumber")) .as("the change was applied to the entry the searches never found").isEmpty(); /* * The result code of the last attempt is the conflict the operation failed on, which * says nothing of the search: the line which reports the change must carry the search * which did not run, which is the one thing that names the entryUUID it was made for. */ final List reports = exhaustionExitRecordsOf(csn); assertThat(reports).as("the change was not reported once the attempts in place were spent") .isNotEmpty(); assertThat(reports) .as("the exhaustion exit reports the error of the operation, not the search which did not run") .allMatch(record -> record.contains(entryUUID)); } /** * Test case for [Issue 956]: the exhaustion exit reports the attempt which spent the * last of the attempts in place, not an earlier one which ended on a search conflict * resolution could not run. *

* The first attempt reaches the data, fails on the conflict, and its search does not * run; the server then refuses every attempt after it before the data is reached. Each * of these is a failure of the server, and the change is left out of the ServerState * either way - what the line which reports it carries is the question. Its result * code is the last attempt's, and so must be the error next to it: a line which reads * the server refusing the operation next to a search which did not run names two * causes, and the operator chases the wrong one. */ @Test public void theExhaustionExitReportsTheAttemptWhichSpentTheLastOfThem() throws Exception { final Entry entry = createAndAddEntry("modifyWhoseLastAttemptIsRefused"); final String entryUUID = getEntryUUID(entry.getName()); final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final CSN csn = gen.newCSN(); /* * The first attempt is let through to the data and fails on the conflict of the * stale DN; its search is the one the short circuit stops. The attempts after it are * refused before they reach the data, the way a backend which went offline after the * first attempt refuses them: no search is made on any of them. */ ShortCircuitPlugin.registerShortCircuit( OperationType.SEARCH, "PreParse", ResultCode.UNAVAILABLE.intValue(), 1); // The replayed operation only, named by its CSN: see the flush below. ShortCircuitPlugin.registerShortCircuit(OperationType.MODIFY, "PreParse", ResultCode.UNAVAILABLE.intValue(), 1, LDAPReplicationDomain.IN_PLACE_REPLAY_ATTEMPTS - 1, op -> csn.equals(OperationContext.getCSN(op))); try { /* * The ServerState flush thread saves the state with a Modify of the base entry on * its tick, which the add above made dirty: a tick which lands among the attempts in * place is a Modify the short circuit meets like any other. Made here rather than * left to the tick, so that the case says what it does about it every time instead * of once in a while: that Modify is not the replayed operation, and the short * circuit must neither let it through in place of the first attempt nor refuse it * in place of a later one. */ flushLikeTheStateFlushThread(); assertEquals(ShortCircuitPlugin.getShortCircuitCount(OperationType.MODIFY, "PreParse"), 0, "the Modify of the state flush thread was counted against the short circuit"); replayMsg(new ModifyMsg(csn, staleDN, generatemods("telephonenumber", "01 02 45"), entryUUID)); assertTrue(ShortCircuitPlugin.getShortCircuitCount(OperationType.SEARCH, "PreParse") >= 1, "the first attempt must have made its search"); assertTrue(ShortCircuitPlugin.getShortCircuitCount(OperationType.MODIFY, "PreParse") >= LDAPReplicationDomain.IN_PLACE_REPLAY_ATTEMPTS, "every attempt in place must have been made"); } finally { ShortCircuitPlugin.deregisterShortCircuit(OperationType.MODIFY, "PreParse"); ShortCircuitPlugin.deregisterShortCircuit(OperationType.SEARCH, "PreParse"); } assertFalse(domain.getServerState().cover(csn), "a change the server kept refusing is not in the data and must not advance the ServerState"); final List reports = exhaustionExitRecordsOf(csn); assertThat(reports).as("the change was not reported once the attempts in place were spent") .isNotEmpty(); // The last attempt was refused before it reached the data and made no search: a line // which names the entryUUID reports the search of an earlier attempt next to its result. assertThat(reports).as("the exhaustion exit reports an attempt other than the last one") .allMatch(record -> !record.contains(entryUUID)); } /** * Test case for [Issue 956]: the base entry of the domain replayed into a replica * which has none. *

* Two empty replicas share the generation ID of an empty backend, so no initialization * is needed and the first change replayed is the base entry itself. The searches which * check that Add for a conflict run under the base DN, which the backend serves and * has no entry for: they answer NO_SUCH_OBJECT, which is the answer of a backend which * is offline as well. Here the search ran and nothing is below a base entry which is * not there, so the Add must go through rather than be retried until the give-up * budget skips it as a change this replica can not apply. */ @Test public void baseEntryIsAddedToAnEmptyReplica() throws Exception { // The backend, without its base entry. TestCaseUtils.initializeTestBackend(false); final Entry base = TestCaseUtils.makeEntry( "dn: " + TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: organization", "o: test"); final CSN csn = gen.newCSN(); replayMsg(addMsg(base, csn, null, "7c1a0d2e-4b6f-4c8a-9e1d-3f5b7a9c1e2d")); assertTrue(entryExists(base.getName()), "the base entry of an empty replica must land: its searches found nothing, they did not fail"); assertTrue(domain.getServerState().cover(csn), "a change which was applied must be recorded as replayed"); } /** * Test case for [Issue 956]: the entryUUID a change carries is looked up as a value, * not read as a filter. *

* The entryUUID comes off the wire and nothing validates it as one. Built into a * filter string, a value which does not parse as a filter is a search which never * runs - a permanent condition retried as a transient one, for as long as the change * is asked for, until the give-up budget skips it and raises an alert. Looked up as a * value, such an entryUUID names no entry, which is what a search which ran and found * nothing says: the change is resolved as one on an entry which is not in the data. */ @Test public void anEntryUUIDWhichIsNotOneNamesNoEntry() throws Exception { final Entry entry = createAndAddEntry("modifyWhoseEntryUUIDIsNotOne"); // A value no filter string parses: the backslash escapes nothing. final String entryUUID = getEntryUUID(entry.getName()) + "\\"; final DN staleDN = DN.valueOf("cn=movedAway," + TEST_ROOT_DN_STRING); final CSN csn = gen.newCSN(); replayMsg(new ModifyMsg(csn, staleDN, generatemods("telephonenumber", "01 02 45"), entryUUID)); assertTrue(domain.getServerState().cover(csn), "a change on an entry which is not in the data is a conflict which is resolved, and recorded"); assertThat(DirectoryServer.getEntry(entry.getName()).getAllAttributes("telephonenumber")) .as("a change on an entryUUID which is not one was applied to an entry").isEmpty(); } /** * The records of the error log which report the provided change once its attempts in * place were spent. The record the error logger writes carries the id of the message * rather than its text. */ private static List exhaustionExitRecordsOf(CSN csn) { final String exhaustionExit = "msgID=" + ERR_ERROR_REPLAYING_OPERATION.ordinal(); final List reports = new ArrayList<>(); for (String record : TestCaseUtils.ERROR_TEXT_WRITER.getMessages()) { if (record.contains(exhaustionExit) && record.contains(csn.toString())) { reports.add(record); } } return reports; } /** * Asserts that the searches a short circuit was registered over were made - the ones * let through before it and the ones it applied to - and that the search after them * was made as well. *

* The count includes the searches let through once the short circuit was spent, so a * count past what it was registered over says that the budget was used and that the * search after it ran. Read before the short circuit is deregistered, which drops the * count with it. * * @param searchesRegisteredOver the searches let through before the short circuit plus * the ones it applied to */ private void assertShortCircuitSpentBy(int searchesRegisteredOver) { assertTrue( ShortCircuitPlugin.getShortCircuitCount(OperationType.SEARCH, "PreParse") > searchesRegisteredOver, "the short circuit must have been spent by the attempts in place"); } private Entry addParentEntry(String ou) throws Exception { return TestCaseUtils.addEntry( "dn: ou=" + ou + "," + TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: organizationalUnit", "ou: " + ou); } private Entry makeChildEntry(String cn, DN parentDN) throws Exception { return TestCaseUtils.makeEntry( "dn: cn=" + cn + "," + parentDN, "objectClass: top", "objectClass: person", "cn: " + cn, "sn: Amar"); } /** * Test that when a previous conflict is resolved because * a delete operation has removed one of the conflicting entries * the other conflicting entry is correctly renamed to its original name. */ @Test public void conflictCleaningDelete() throws Exception { Entry entry = createAndAddEntry("conflictCleaningDelete"); // Add the first entry String parentUUID = getEntryUUID(baseDN); CSN csn1 = gen.newCSN(); // Now try to add the same entry with same DN but a different // unique ID though the replication replayMsg(addMsg(entry, csn1, parentUUID, "c9cb8c3c-615a-4122-865d-50323aaaed48")); // Now delete the first entry that was added at the beginning TestCaseUtils.deleteEntry(entry.getName()); // Expect the conflict resolution : the second entry should now // have been renamed with the original DN. Entry resultEntry = DirectoryServer.getEntry(entry.getName()); assertNotNull(resultEntry, "The conflict was not cleared"); assertEquals(getEntryUUID(resultEntry.getName()), "c9cb8c3c-615a-4122-865d-50323aaaed48", "The wrong entry has been renamed"); assertThat(resultEntry.getAllAttributes(LDAPReplicationDomain.DS_SYNC_CONFLICT)).isEmpty(); } private AddMsg addMsg(Entry entry, CSN csn, String parentUUID, String childUUID) { return new AddMsg(csn, entry.getName(), childUUID, parentUUID, entry.getObjectClasses(), entry.getUserAttributes(), null); } /** * Test that when a previous conflict is resolved because * a MODDN operation has removed one of the conflicting entries * the other conflicting entry is correctly renamed to its original name. */ @Test public void conflictCleaningMODDN() throws Exception { Entry entry = createAndAddEntry("conflictCleaningDelete"); String parentUUID = getEntryUUID(baseDN); CSN csn1 = gen.newCSN(); // Now try to add the same entry with same DN but a different // unique ID though the replication replayMsg(addMsg(entry, csn1, parentUUID, "c9cb8c3c-615a-4122-865d-50323aaaed48")); // Now delete the first entry that was added at the beginning ModifyDNOperation modDNOperation = getRootConnection().processModifyDN(entry.getName(), RDN.valueOf("cn=foo"), false); assertEquals(modDNOperation.getResultCode(), ResultCode.SUCCESS); // Expect the conflict resolution : the second entry should now // have been renamed with the original DN. Entry resultEntry = DirectoryServer.getEntry(entry.getName()); assertNotNull(resultEntry, "The conflict was not cleared"); assertEquals(getEntryUUID(resultEntry.getName()), "c9cb8c3c-615a-4122-865d-50323aaaed48", "The wrong entry has been renamed"); assertThat(resultEntry.getAllAttributes(LDAPReplicationDomain.DS_SYNC_CONFLICT)).isEmpty(); } /** * Makes the Modify the ServerState flush thread makes on its tick: an internal * synchronization Modify of the base entry, which is not synchronized itself. The * attribute is a harmless one rather than ds-sync-state, which is the flush thread's to * write. */ private void flushLikeTheStateFlushThread() { final ModifyOperationBasis op = new ModifyOperationBasis(getRootConnection(), nextOperationID(), nextMessageID(), null, baseDN, generatemods("description", "written on the tick of the flush thread")); op.setInternalOperation(true); op.setSynchronizationOperation(true); op.setDontSynchronize(true); op.run(); assertEquals(op.getResultCode(), ResultCode.SUCCESS, op.getErrorMessage().toString()); } private Entry createAndAddEntry(String commonName) throws Exception { // @formatter:off return TestCaseUtils.addEntry( "dn: cn=" + commonName + ", " + TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "uid: user.1", "description: This is the description for Aaccf Amar.", "st: NC", "postalAddress: Aaccf Amar$17984 Thirteenth Street$Rockford, NC 85762", "mail: user.1@example.com", "cn: Aaccf Amar", "l: Rockford", "street: 17984 Thirteenth Street", "employeeNumber: 1", "sn: Amar", "givenName: Aaccf", "postalCode: 85762", "userPassword: password", "initials: AA"); // @formatter:on } /** * Tests for issue 3891 * S1 S2 * ADD uid=xx,ou=parent,... [SUBTREE] DEL ou=parent, ... * * 1/ removeParentConflict1 (on S1) * - t1(csn1) ADD uid=xx,ou=parent,... * - t2(csn2) replay SUBTREE DEL ou=parent, .... * => No conflict : expect the parent entry & subtree to be deleted * * 2/ removeParentConflict2 (on S1) * - t1(csn1) ADD uid=xx,ou=parent,... * - replay t2(csn2) DEL ou=parent, .... * => Conflict and no automatic resolution: expect * - the child entry to be renamed under root entry * - the parent entry to be deleted * * 3/ removeParentConflict3 (on S2) * - t2(csn2) DEL or SUBTREE DEL ou=parent, .... * - t1(csn1) replay ADD uid=xx,ou=parent,... * => Conflict and no automatic resolution: expect * - the child entry to be renamed under root entry * */ @Test public void removeParentConflict1() throws Exception { Entry parentEntry = createParentEntry(); Entry childEntry = createChildEntry(); TestCaseUtils.addEntry(parentEntry); TestCaseUtils.addEntry(childEntry); String parentUUID = getEntryUUID(parentEntry.getName()); CSN csn2 = gen.newCSN(); DeleteMsg delMsg = new DeleteMsg(parentEntry.getName(), csn2, parentUUID); delMsg.setSubtreeDelete(true); replayMsg(delMsg); // Expect the subtree to be deleted and no conflict entry created assertFalse(entryExists(parentEntry.getName()), "DEL subtree on parent was not processed as expected."); assertFalse(entryExists(parentEntry.getName()), "DEL subtree on parent was not processed as expected."); } @Test public void removeParentConflict2() throws Exception { Entry parentEntry = createParentEntry(); Entry childEntry = createChildEntry(); TestCaseUtils.addEntry(parentEntry); TestCaseUtils.addEntry(childEntry); String parentUUID = getEntryUUID(parentEntry.getName()); String childUUID = getEntryUUID(childEntry.getName()); CSN csn2 = gen.newCSN(); DeleteMsg delMsg = new DeleteMsg(parentEntry.getName(), csn2, parentUUID); // NOT SUBTREE replayMsg(delMsg); // Expect the parent entry to be deleted assertFalse(entryExists(parentEntry.getName()), "Parent entry expected to be deleted : " + parentEntry.getName()); // Expect the child entry to be moved as conflict entry under the root // entry of the suffix DN childDN = DN.valueOf("entryuuid=" + childUUID + "+cn=child,o=test"); assertTrue(entryExists(childDN), "Child entry conflict exist with DN=" + childDN); } @Test public void removeParentConflict3() throws Exception { Entry parentEntry = createParentEntry(); Entry childEntry = createChildEntry(); TestCaseUtils.addEntry(parentEntry); String parentUUID = getEntryUUID(parentEntry.getName()); TestCaseUtils.deleteEntry(parentEntry); CSN csn1 = gen.newCSN(); // Create and publish an update message to add the child entry. String childUUID = "44444444-4444-4444-4444-444444444444"; AddMsg addMsg = new AddMsg( csn1, childEntry.getName(), childUUID, parentUUID, childEntry.getObjectClassAttribute(), childEntry.getAllAttributes(), null); // Put the message in the replay queue replayMsg(addMsg); // Expect the parent entry to be deleted assertFalse(entryExists(parentEntry.getName()), "Parent entry exists "); // Expect the child entry to be moved as conflict entry under the root // entry of the suffix DN childDN = DN.valueOf("entryuuid=" + childUUID + "+cn=child,o=test"); assertTrue(entryExists(childDN), "Child entry conflict exist with DN=" + childDN); } private Entry createParentEntry() throws Exception { return TestCaseUtils.makeEntry( "dn: ou=rpConflict, "+ TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: organizationalUnit"); } private Entry createChildEntry() throws Exception { // @formatter:off return TestCaseUtils.makeEntry( "dn: cn=child, ou=rpConflict,"+ TEST_ROOT_DN_STRING, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "uid: user.1", "description: This is the description for Aaccf Amar.", "st: NC", "postalAddress: Aaccf Amar$17984 Thirteenth Street$Rockford, NC 85762", "mail: user.1@example.com", "cn: Aaccf Amar", "l: Rockford", "street: 17984 Thirteenth Street", "employeeNumber: 1", "sn: Amar", "givenName: Aaccf", "postalCode: 85762", "userPassword: password", "initials: AA"); // @formatter:on } private void replayMsg(UpdateMsg updateMsg) throws InterruptedException { domain.processUpdate(updateMsg); LDAPUpdateMsg ldapUpdate = queue.take().getUpdateMessage(); domain.markInProgress(ldapUpdate); domain.replay(ldapUpdate, SHUTDOWN); } }