diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc index 6e5a78cf17..e2ccb13502 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-replication.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -1986,3 +1986,62 @@ You can therefore compare the current configuration with the earlier configurati +[#repl-repair-entry] +=== Repairing an Entry on One Replica + +Replication owns a few operational attributes: `entryUUID`, which identifies an entry across replicas, and `ds-sync-hist`, which records the changes to an entry for conflict resolution. Both are `NO-USER-MODIFICATION` in the schema, so a modify request that touches them is refused, even one from `cn=Directory Manager`. + +The replication repair request control (OID `1.3.6.1.4.1.26027.1.5.2`) lets an administrator write these attributes anyway. A request that carries the control changes the replica it is sent to, and that replica only: the change is not published to the replication servers, and it is not recorded in the history of the entry. Use it when the replicas already agree on the data, but an entry must be given specific values - for example, the `entryUUID` that another directory service assigned to it before a migration, and that client applications still hold. + +[#repair-entry-uuid] +.To Set the Entry UUID of an Entry +==== +. Apply the change to each replica in turn, while nothing else changes the entry: ++ + +[source, console] +---- +$ ldapmodify \ + --hostname opendj.example.com \ + --port 1389 \ + --bindDN "cn=Directory Manager" \ + --bindPassword password \ + --control 1.3.6.1.4.1.26027.1.5.2 +dn: cn=My Group,ou=Groups,dc=example,dc=com +changetype: modify +replace: entryUUID +entryUUID: 3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77 + +Processing MODIFY request for cn=My Group,ou=Groups,dc=example,dc=com +MODIFY operation successful for DN cn=My Group,ou=Groups,dc=example,dc=com +---- ++ +Send the control as not critical, which is what `--control` does unless told otherwise: the backend refuses a critical control it does not know before the replication plugin takes the control off the request. + +. Check that every replica now returns the same value: ++ + +[source, console] +---- +$ ldapsearch \ + --hostname opendj.example.com \ + --port 1389 \ + --bindDN "cn=Directory Manager" \ + --bindPassword password \ + --baseDN "cn=My Group,ou=Groups,dc=example,dc=com" \ + --searchScope base \ + "(objectClass=*)" \ + entryUUID +dn: cn=My Group,ou=Groups,dc=example,dc=com +entryUUID: 3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77 +---- + +==== + +[WARNING] +====== +A repair that reaches some replicas and not others leaves the topology inconsistent. A replica initialized from one that was not repaired takes the old values with it, and when replication later resolves a conflict on the entry by its `entryUUID`, the replicas no longer agree on which entry that is. +====== + +Once the replicas agree again, the entry replicates as any other: a change to it is published with the repaired `entryUUID`, and no reinitialization is needed. + diff --git a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc index af531d8858..fb7aa0b7b5 100644 --- a/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc +++ b/opendj-doc-generated-ref/src/main/asciidoc/reference/appendix-controls.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -197,6 +197,14 @@ Object Identifier: 1.3.6.1.4.1.26027.1.5.4 + OpenDJ specific, for using the bookmark cookie when reading the external change log. +[#replication-repair-request-control] +Replication Repair request control:: ++ +Object Identifier: 1.3.6.1.4.1.26027.1.5.2 + ++ +OpenDJ specific, for writing the attributes replication owns, such as `entryUUID` and `ds-sync-hist`, on the replica the request is sent to, and on that replica only: the change is neither published to the replication servers nor recorded in the history of the entry. Send it as not critical. See xref:../admin-guide/chap-replication.adoc#repl-repair-entry["Repairing an Entry on One Replica"] in the __Administration Guide__. + [#server-side-sort-request-control] Server-Side Sort request control:: + diff --git a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml index 5e88d567ee..0d1fa0fb74 100644 --- a/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml +++ b/opendj-doc-generated-ref/src/main/docbkx/admin-guide/appendix-controls.xml @@ -21,7 +21,7 @@ ! CCPL HEADER END ! ! Copyright 2011 ForgeRock AS - ! Portions copyright 2024 3A Systems,LLC. + ! Portions copyright 2024-2026 3A Systems,LLC. ! --> + + Replication Repair request control + + + LDAP controls + Replication repair + + Object Identifier: 1.3.6.1.4.1.26027.1.5.2 + OpenDJ specific, for writing the attributes replication owns, + such as entryUUID and ds-sync-hist, + on the replica the request is sent to, and on that replica only: the + change is neither published to the replication servers nor recorded in + the history of the entry. Send it as not critical. + + + Server Side Sort Request Control diff --git a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java index c338df86ac..0bff32c58c 100644 --- a/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java +++ b/opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/ReplicationRepairControlTest.java @@ -13,26 +13,122 @@ * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2015-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.plugin; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.opends.messages.ReplicationMessages.ERR_OPERATION_NOT_FOUND_IN_PENDING; +import static org.opends.server.TestCaseUtils.TEST_ROOT_DN_STRING; +import static org.opends.server.replication.plugin.ReplicationRepairRequestControl.OID_REPLICATION_REPAIR_CONTROL; +import static org.opends.server.types.NullOutputStream.nullPrintStream; +import static org.testng.Assert.assertEquals; + +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; + +import org.forgerock.opendj.ldap.Connection; +import org.forgerock.opendj.ldap.DN; +import org.forgerock.opendj.ldap.LDAPConnectionFactory; +import org.forgerock.opendj.ldap.LdapException; +import org.forgerock.opendj.ldap.ModificationType; +import org.forgerock.opendj.ldap.ResultCode; +import org.forgerock.opendj.ldap.SearchScope; +import org.forgerock.opendj.ldap.controls.GenericControl; +import org.forgerock.opendj.ldap.requests.ModifyRequest; +import org.forgerock.opendj.ldap.requests.Requests; +import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.opends.server.TestCaseUtils; import org.opends.server.replication.ReplicationTestCase; -import com.forgerock.opendj.ldap.tools.LDAPModify; +import org.opends.server.replication.protocol.LDAPUpdateMsg; +import org.opends.server.replication.protocol.ModifyMsg; +import org.opends.server.replication.protocol.ReplicationMsg; +import org.opends.server.replication.service.ReplicationBroker; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import static org.opends.server.types.NullOutputStream.nullPrintStream; -import static org.testng.Assert.*; -import static org.opends.server.TestCaseUtils.*; +import com.forgerock.opendj.ldap.tools.LDAPModify; +/** + * Tests the replication repair request control, which lets an administrator write the + * attributes replication owns - {@code entryUUID}, {@code ds-sync-hist} - on the replica the + * request is sent to, and on that replica only. + *

+ * The suffix is replicated and a broker listens on its replication server: what a repair + * publishes, if anything, is read from there. + */ +@SuppressWarnings("javadoc") public class ReplicationRepairControlTest extends ReplicationTestCase { + private static final String REPAIRED_DN = "cn=repair me," + TEST_ROOT_DN_STRING; + /** + * The record the error logger writes carries the id of the message rather than its text, so + * what is looked for here does not depend on the locale the tests run under. + */ + private static final String NOT_IN_PENDING = + "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal(); + + private ReplicationBroker broker; + private LDAPConnectionFactory factory; + private Connection connection; + + @Override + @BeforeClass(alwaysRun = true) + public void setUp() throws Exception + { + super.setUp(); + + final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING); + TestCaseUtils.initializeTestBackend(true); + + final int replServerPort = TestCaseUtils.findFreePort(); + final String replServerLdif = + "dn: cn=Replication Server, " + SYNCHRO_PLUGIN_DN + "\n" + + "objectClass: top\n" + + "objectClass: ds-cfg-replication-server\n" + + "cn: Replication Server\n" + + "ds-cfg-replication-port: " + replServerPort + "\n" + + "ds-cfg-replication-db-directory: ReplicationRepairControlTest\n" + + "ds-cfg-replication-server-id: 106\n"; + final String synchroServerLdif = + "dn: cn=replicationRepairControlTest, cn=domains, " + SYNCHRO_PLUGIN_DN + "\n" + + "objectClass: top\n" + + "objectClass: ds-cfg-replication-domain\n" + + "cn: replicationRepairControlTest\n" + + "ds-cfg-base-dn: " + baseDN + "\n" + + "ds-cfg-replication-server: localhost:" + replServerPort + "\n" + + "ds-cfg-server-id: 1\n" + + "ds-cfg-receive-status: true\n"; + configureReplication(replServerLdif, synchroServerLdif); + + broker = openReplicationSession(baseDN, 2, 100, replServerPort, 1000); + + factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort()); + connection = factory.getConnection(); + connection.bind("cn=Directory Manager", "password".toCharArray()); + } + + @AfterClass(alwaysRun = true) + public void tearDown() throws Exception + { + if (connection != null) + { + connection.close(); + } + if (factory != null) + { + factory.close(); + } + stop(broker); + } + @Test public void testRepairControl() throws Exception { - TestCaseUtils.initializeTestBackend(true); - // Test that we can't add an entry with the entryuuid attribute // without specifying the replication repair control. String path = TestCaseUtils.createTempFile( @@ -95,7 +191,7 @@ public void testRepairControl() "dn: uid=test.repair," + TEST_ROOT_DN_STRING + "\n" + "changetype: add\n" + "objectClass: top\n" + - "objectClass: person" + + "objectClass: person\n" + "objectClass: organizationalPerson\n" + "objectClass: inetOrgPerson\n" + "uid: test.repair\n" + @@ -117,5 +213,118 @@ public void testRepairControl() }; assertEquals(LDAPModify.run(nullPrintStream(), nullPrintStream(), args2), 0); + + // A repair is a change to this replica alone: the add was not published. + assertThat(nextUpdate()).as("the repaired add was published to the topology").isNull(); + } + + /** + * An administrator setting the entryUUID of an entry - to give it back the value another + * directory had, say - does it with the repair control, on every replica in turn: the + * change is not published, and replication does not treat it as a change of its own either. + */ + @Test + public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception + { + TestCaseUtils.addEntry( + "dn: " + REPAIRED_DN, + "objectClass: top", + "objectClass: person", + "sn: repair", + "cn: repair me"); + assertThat(nextUpdate()).as("the add of the entry to repair was not published").isNotNull(); + + final String repairedUUID = "d5b910d8-47cb-4ac0-9e5f-0f4a77de58d4"; + final String repairedNsUniqueId = "d5b910d8-47cb4ac0-9e5f0f4a-77de58d4"; + assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(repairedUUID); + + // Without the control, entryUUID is NO-USER-MODIFICATION for an administrator too. + try + { + connection.modify(repairRequest(false, repairedUUID, repairedNsUniqueId)); + throw new AssertionError("entryUUID was modified without the repair control"); + } + catch (LdapException e) + { + assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION); + } + + TestCaseUtils.ERROR_TEXT_WRITER.clear(); + connection.modify(repairRequest(true, repairedUUID, repairedNsUniqueId)); + + assertThat(attributeOfRepairedEntry("entryUUID")).isEqualTo(repairedUUID); + assertThat(attributeOfRepairedEntry("nsUniqueId")).isEqualTo(repairedNsUniqueId); + assertThat(nextUpdate()).as("the repair was published to the topology").isNull(); + // The change is not a replayed one either: replication is not left looking for it among + // the changes it was replaying. + final List records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages()); + assertThat(records).as("the repair was reported as a change missing from the pending list") + .noneMatch(record -> record.contains(NOT_IN_PENDING)); + } + + /** A repaired entry goes on replicating as any other, under its repaired entryUUID. */ + @Test(dependsOnMethods = "aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly") + public void aChangeAfterTheRepairIsPublishedUnderTheRepairedEntryUUID() throws Exception + { + connection.modify(Requests.newModifyRequest(REPAIRED_DN) + .addModification(ModificationType.REPLACE, "description", "changed after the repair")); + + final LDAPUpdateMsg published = nextUpdate(); + assertThat(published).as("a change after the repair was not published").isInstanceOf(ModifyMsg.class); + assertThat(published.getEntryUUID()).isEqualTo(attributeOfRepairedEntry("entryUUID")); + } + + private static ModifyRequest repairRequest(boolean withRepairControl, String entryUUID, String nsUniqueId) + { + final ModifyRequest request = Requests.newModifyRequest(REPAIRED_DN) + .addModification(ModificationType.REPLACE, "entryUUID", entryUUID) + .addModification(ModificationType.REPLACE, "nsUniqueId", nsUniqueId); + if (withRepairControl) + { + // Not critical: the control is taken off the request by the replication plugin, which + // runs after the backend has refused a critical control it does not know. + request.addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)); + } + return request; + } + + private String attributeOfRepairedEntry(String attribute) throws Exception + { + final SearchResultEntry entry = connection.searchSingleEntry( + Requests.newSearchRequest(REPAIRED_DN, SearchScope.BASE_OBJECT, "(objectClass=*)") + .addAttribute("*", "+")); + return entry.containsAttribute(attribute) ? entry.parseAttribute(attribute).asString() : null; + } + + /** + * The next update the replication server forwards, or {@code null} if none comes within a + * few seconds - long enough for a change published by the operation which just returned, + * which the replication server forwards as soon as it has it. + */ + private LDAPUpdateMsg nextUpdate() throws Exception + { + final long deadline = System.nanoTime() + SECONDS.toNanos(4); + while (deadline - System.nanoTime() > 0) + { + final ReplicationMsg msg; + try + { + msg = broker.receive(); + } + catch (SocketTimeoutException e) + { + // The broker reads under a timeout of its own, shorter than the budget here. + continue; + } + if (msg == null) + { + throw new AssertionError("the broker session is gone"); + } + if (msg instanceof LDAPUpdateMsg) + { + return (LDAPUpdateMsg) msg; + } + } + return null; } }