opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java
@@ -1147,7 +1147,7 @@ */ pendingChanges = new PendingChanges(getGenerator(), this, new ShutdownSyncAnnouncer(dsrsShutdownSync, getBaseDN())); remotePendingChanges = new RemotePendingChanges(getServerState()); remotePendingChanges = new RemotePendingChanges(getServerState(), getServerId()); // listen for changes on the configuration configuration.addChangeListener(this); @@ -2986,9 +2986,8 @@ * Marks the specified message as the one currently processed by a replay thread. * * @param msg the message being processed * @return {@code false} if the change is not pending anymore, which happens when the * session was restarted after a failed replay while this message was waiting * in the replay queue: it is sent again, so this copy must not be replayed * @return {@code false} if the change is already committed, the domain has forgotten * it, the delivery was cancelled, or a retry has replaced this stale delivery */ boolean markInProgress(LDAPUpdateMsg msg) { @@ -4842,13 +4841,9 @@ * Asks for a session restart the way a replay thread on its way out does, without * running it. * <p> * Only there for the tests, which have no other way to leave a request standing at a * time of their choosing without a change released for it: the one a replay thread makes * on a domain whose session has no owner is made and run in one go, and one made under * an owner stands for a change which is listed, and which the owner forgets or the * checkpointer has delivered again. * Also used when a queue hand-off fails: the listener or replay thread must not * synchronously restart the session from its failure cleanup. */ @VisibleForTesting public void requestSessionRestart() { sessionRestarts.request(SessionRestart.NOW); @@ -5831,6 +5826,7 @@ state.clearInMemory(); state.loadState(strict); bringAStateWithNoChangeOfThisReplicaUpToTheData(strict); remotePendingChanges.resetReplayState(); getGenerator().adjust(state.getMaxCSN(getServerId())); forgetTheLocalChangesTheDataLacks(strict); @@ -8189,24 +8185,30 @@ { LDAPUpdateMsg msg = (LDAPUpdateMsg) updateMsg; // Put the UpdateMsg in the RemotePendingChanges list. if (!remotePendingChanges.putRemoteUpdate(msg)) final UpdateToReplay updateToReplay = new UpdateToReplay(msg, this); final RemotePendingChanges.Admission admission = remotePendingChanges.putRemoteUpdate(updateToReplay); if (admission == RemotePendingChanges.Admission.QUEUED_DUPLICATE) { // The queued copy completes the current session's delivery after replay, including // its error. An immediate completion here would grant extra credit and an early ack. incProcessedUpdates(); return false; } if (admission == RemotePendingChanges.Admission.DUPLICATE) { /* * A replay thread already owns this change, so this delivery is a duplicate: * ignore it. This happens when there are uncommitted changes in the queue and * session failover occurs causing a recovery of all changes since the current * committed server state. See OPENDJ-1115. * This change is already applied, owned by a replay thread, or committed * behind another pending change. Session failover can deliver any of these again * (OPENDJ-1115 and issue #1269). * * The copy which is already listed owns the change: it is the one which records * it in the ServerState once it really has been replayed. Report this delivery * The accepted copy is the one which records the change in the ServerState once * it really has been replayed, if it has not already done so. Report this delivery * as done - the window and the ack are per delivery - but as handled * asynchronously, so that the listener does not push the CSN to the ServerState * over a change which is still being replayed or is failing (issue #889). * * A change whose replay failed is not owned by anyone anymore, so this is not the * path it takes: putRemoteUpdate() takes this delivery over from the one which * failed and has it replayed again. * A released change has neither an owner nor a queued delivery, so this is not * the path it takes: putRemoteUpdate() accepts its next delivery for replay. */ if (logger.isTraceEnabled()) { @@ -8220,7 +8222,10 @@ // Put update message into the replay queue // (block until some place in the queue is available) final UpdateToReplay updateToReplay = new UpdateToReplay(msg, this); boolean queued = false; boolean handoffFailed = true; try { while (!isListenerShuttingDown()) { // loop until we can offer to the queue or shutdown was initiated @@ -8228,7 +8233,11 @@ { if (updateToReplayQueue.offer(updateToReplay, 1, TimeUnit.SECONDS)) { // successful offer to the queue, let's exit the loop queued = true; synchronized (updateToReplayQueue) { updateToReplayQueue.notifyAll(); } break; } } @@ -8238,6 +8247,22 @@ Thread.currentThread().interrupt(); } } handoffFailed = false; } finally { if (!queued) { // Cancel before comparing listener identity. If join completes, the next session // starts after this cancellation and can redeliver without an extra restart. If // join was interrupted, a newer session may already have refused the delivery. updateToReplay.cancel(false); if (handoffFailed || !isCurrentListenerThread()) { requestSessionRestart(); } } } return false; } opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PendingChange.java
@@ -38,9 +38,20 @@ */ private volatile UpdateMsg msg; /** * A remote delivery has been accepted and is waiting to be claimed from the replay * queue. It has no owner yet, but replacing it would move it behind newer changes. * Local pending changes do not use this flag. */ @GuardedBy("RemotePendingChanges.pendingChangesLock") private boolean queuedForReplay = true; @GuardedBy("RemotePendingChanges.pendingChangesLock") private boolean replayStarted; @GuardedBy("RemotePendingChanges.pendingChangesLock") private UpdateToReplay delivery; /** * The replay thread which owns this change: it is being replayed, or it waits for the * change it depends on. A remote change which no thread owns is one whose replay * failed and which the replication server is expected to deliver again. * change it depends on. A remote change which no thread owns is either queued for * replay or was released and may be taken over by another delivery. * <p> * The owner is kept rather than the bare fact that there is one, so that a change is * given back by the thread it was handed to and by nobody else: a release which arrives @@ -137,6 +148,43 @@ public void setMsg(LDAPUpdateMsg msg) { this.msg = msg; queuedForReplay = true; } /** * Returns whether a remote delivery is waiting to be claimed from the replay queue. * * @return {@code true} if the accepted delivery has not been dequeued or cancelled */ public boolean isQueuedForReplay() { return queuedForReplay && !isDeliveryCancelled(); } /** Installs a retry without changing this change's ordering or failure history. */ void setDelivery(UpdateToReplay delivery) { setMsg(delivery.getUpdateMessage()); this.delivery = delivery; } /** A failed queue hand-off can be cancelled without acquiring the pending lock. */ boolean isDeliveryCancelled() { return delivery != null && delivery.isCancelled(); } /** Marks a remote delivery as dequeued while its replay claim is being established. */ public void markDequeued() { queuedForReplay = false; replayStarted = true; } /** Keeps the in-progress monitor stable while a failed replay waits in the retry queue. */ boolean hasStartedReplay() { return replayStarted; } /** opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java
@@ -74,10 +74,10 @@ @GuardedBy("dependentChangesLock") private final SortedSet<PendingChange> dependentChanges = new TreeSet<>(); /** * {@code activeAndDependentChanges} also contains changes discovered to be dependent * on currently in progress changes. * Uncommitted changes, registered before enqueue. A queued change whose hand-off * fails must remain a predecessor: otherwise its DELETE can run before its ADD retry. */ private final ConcurrentSkipListSet<PendingChange> activeAndDependentChanges = new ConcurrentSkipListSet<>(); private final ConcurrentSkipListSet<PendingChange> replayDependencies; /** * The change each replay thread is replaying right now, read by the give-back on the way @@ -106,7 +106,7 @@ * races nothing for it. {@link #clear()} is the one writer of every entry, and it holds * both locks. */ private final ConcurrentMap<Thread, CSN> changeBeingReplayed = new ConcurrentHashMap<>(); private final ConcurrentMap<Thread, CSN> changeBeingReplayed; private final ReentrantReadWriteLock pendingChangesLock = new ReentrantReadWriteLock(true); private final ReentrantReadWriteLock.ReadLock pendingChangesReadLock = pendingChangesLock.readLock(); @@ -115,6 +115,16 @@ /** The ServerState that will be updated when LDAPUpdateMsg are fully replayed. */ private final ServerState state; private final int localServerId; /** Own-origin replay progress; local publication must not cover restore catch-up. */ @GuardedBy("pendingChangesLock") private CSN localReplayCSN; /** Whether a delivery needs replay or how its duplicate should be completed. */ enum Admission { ACCEPTED, QUEUED_DUPLICATE, DUPLICATE } /** * How many of the changes listed here have a replay failure recorded against them. @@ -137,7 +147,43 @@ */ public RemotePendingChanges(ServerState state) { this(state, -1); } RemotePendingChanges(ServerState state, int localServerId) { this(state, localServerId, new ConcurrentHashMap<Thread, CSN>()); } RemotePendingChanges(ServerState state, int localServerId, ConcurrentMap<Thread, CSN> changeBeingReplayed) { this(state, localServerId, changeBeingReplayed, new ConcurrentSkipListSet<PendingChange>()); } RemotePendingChanges(ServerState state, int localServerId, ConcurrentMap<Thread, CSN> changeBeingReplayed, ConcurrentSkipListSet<PendingChange> replayDependencies) { this.replayDependencies = replayDependencies; this.state = state; this.localServerId = localServerId; this.changeBeingReplayed = changeBeingReplayed; resetReplayState(); } /** Reloads the own-origin watermark only when the backend state is loaded. */ void resetReplayState() { pendingChangesWriteLock.lock(); try { localReplayCSN = state.getCSN(localServerId); } finally { pendingChangesWriteLock.unlock(); } } /** @@ -169,7 +215,23 @@ */ public int changesInProgressSize() { return activeAndDependentChanges.size(); pendingChangesReadLock.lock(); try { int count = 0; for (PendingChange change : replayDependencies) { if (change.hasStartedReplay()) { count++; } } return count; } finally { pendingChangesReadLock.unlock(); } } /** @@ -194,47 +256,82 @@ * Add a new LDAPUpdateMsg that was received from the replication server * to the pendingList. * <p> * A change which is already listed and which a replay thread owns - it is being * replayed, it waits for the change it depends on, or it has been replayed and waits * for the changes before it - is left alone: that copy knows what this one does not, * and replaying it again is exactly what the duplicate check is there to prevent * (OPENDJ-1115). * Changes covered by the applied ServerState are rejected even after they have left * the pending list. For this replica's own origin, only the state loaded from the backend * and subsequent replays count: a newer local publication does not prove that an older * change sent back after restore is in the data. A listed change is also left alone while its delivery is queued, * owned by a replay thread, or committed and waiting for earlier changes. Replacing a * queued delivery would move it behind newer changes: a DELETE could then be replayed * before its ADD, leaving the entry present once the ADD is replayed (issue #1269). * <p> * A change which is listed but which no replay thread owns is taken over by this * delivery: it is the one to replay, and the copy which may still wait in the replay * queue is dropped by {@link #markInProgress(LDAPUpdateMsg)}. That is a change whose * replay failed - it is deliberately left out of the ServerState, so the replication * server sends it again over the session which was restarted (issue #889) - and it is * also, harmlessly, a change which was listed a moment ago by a delivery no replay * thread has picked up yet: the two deliveries carry the same change, and the last one * listed is the one replayed. * Only a delivery which was released after failed or abandoned replay, or cancelled * before being enqueued, may be replaced. It is deliberately left out of the * ServerState so the replication server sends it again over the restarted session * (issue #889). Neither an owner nor a queued delivery remains to finish it. * <p> * The failures the change went through are kept, whichever delivery replays it: they * are what has this replica eventually give up on a change it can not apply. * * @param update The LDAPUpdateMsg that was received from the replication * server and that will be added to the pending list. * @return {@code false} if the update was already registered in the pending * changes and is owned by a replay thread. * @return {@code false} if the change is already applied or has a delivery which is * still queued, owned, or committed */ public boolean putRemoteUpdate(LDAPUpdateMsg update) { return putRemoteUpdate(new UpdateToReplay(update, null)) == Admission.ACCEPTED; } Admission putRemoteUpdate(UpdateToReplay delivery) { final LDAPUpdateMsg update = delivery.getUpdateMessage(); pendingChangesWriteLock.lock(); try { final CSN csn = update.getCSN(); if (csn.getServerId() == localServerId ? localReplayCSN != null && localReplayCSN.compareTo(csn) >= 0 : state.cover(csn)) { return Admission.DUPLICATE; } final PendingChange listed = pendingChanges.get(csn); if (listed == null) { pendingChanges.put(csn, new PendingChange(csn, null, update)); return true; final PendingChange change = new PendingChange(csn, null, update); change.setDelivery(delivery); pendingChanges.put(csn, change); try { replayDependencies.add(change); } catch (RuntimeException | Error e) { // No other admission sees this partial registration while the write lock is held. // Request recovery before rollback: removing a skip-list node can also fail. delivery.cancel(); pendingChanges.remove(csn); replayDependencies.remove(change); throw e; } return Admission.ACCEPTED; } if (listed.isCommitted() || listed.isOwned()) { return false; return Admission.DUPLICATE; } listed.setMsg(update); return true; if (listed.isQueuedForReplay()) { // Keep the queue position, but complete for the latest delivery's assured mode. // This changes in-memory ack metadata only; the received message's cached wire // encoding is stale and must not be reused to publish this message. final LDAPUpdateMsg accepted = listed.getLDAPUpdateMsg(); accepted.setAssured(update.isAssured()); accepted.setAssuredMode(update.getAssuredMode()); accepted.setSafeDataLevel(update.getSafeDataLevel()); return Admission.QUEUED_DUPLICATE; } listed.setDelivery(delivery); return Admission.ACCEPTED; } finally { @@ -278,7 +375,7 @@ curChange.setCommitted(true); curChange.setOwner(null); changeBeingReplayed.remove(Thread.currentThread(), csn); activeAndDependentChanges.remove(curChange); replayDependencies.remove(curChange); final Iterator<PendingChange> it = pendingChanges.values().iterator(); while (it.hasNext()) @@ -291,6 +388,10 @@ if (pendingChange.getMsg().contributesToDomainState()) { state.update(pendingChange.getCSN()); if (pendingChange.getCSN().getServerId() == localServerId) { localReplayCSN = pendingChange.getCSN(); } } if (pendingChange.getReplayFailures() > 0) { @@ -504,7 +605,7 @@ { pendingChanges.clear(); dependentChanges.clear(); activeAndDependentChanges.clear(); replayDependencies.clear(); changeBeingReplayed.clear(); failingChanges = 0; } @@ -520,12 +621,8 @@ * * @param msg * the message whose change is being replayed * @return {@code false} if this message is not the delivery which is listed as * pending, which happens when the session was restarted after a failed replay * while this message was still waiting in the replay queue: the replication * server delivered the change again and that delivery took over from this one, * or the domain was disabled and forgot the change, so this copy must not be * replayed. * @return {@code false} if the change is already committed, the domain has forgotten * it, the delivery was cancelled, or this is a stale delivery which a retry has replaced */ public boolean markInProgress(LDAPUpdateMsg msg) { @@ -533,18 +630,19 @@ try { final PendingChange change = pendingChanges.get(msg.getCSN()); if (change == null || change.isCommitted() || change.getLDAPUpdateMsg() != msg) if (change == null || change.isCommitted() || change.isDeliveryCancelled() || change.getLDAPUpdateMsg() != msg) { return false; } // This delivery has left the queue. If an allocation below fails before it gets // an owner, a later delivery must be able to take it over. change.markDequeued(); /* * Listed as being replayed before it is owned, and not the other way round: the * caller enters the replay - where the give-back on the way out lives - once this * returns, so nothing which allocates must run between the owner being stamped and * that. A change listed here without an owner is the state a failed replay leaves * behind, and the dependency checks which read this set do not read the owner. * The dependency was registered before enqueue, so a failed claim still holds back * its successors. Allocate the thread index before stamping the owner: the caller * reaches replay's give-back only once this method returns. */ activeAndDependentChanges.add(change); changeBeingReplayed.put(Thread.currentThread(), change.getCSN()); change.setOwner(Thread.currentThread()); return true; @@ -836,7 +934,7 @@ boolean hasDependencies = false; final DN targetDN = op.getEntryDN(); for (PendingChange pendingChange : activeAndDependentChanges) for (PendingChange pendingChange : replayDependencies) { if (pendingChange.getCSN().isNewerThanOrEqualTo(csn)) { @@ -910,7 +1008,7 @@ boolean hasDependencies = false; final DN targetDN = change.getLDAPUpdateMsg().getDN(); for (PendingChange pendingChange : activeAndDependentChanges) for (PendingChange pendingChange : replayDependencies) { if (pendingChange.getCSN().isNewerThanOrEqualTo(csn)) { @@ -977,7 +1075,7 @@ boolean hasDependencies = false; final DN targetDN = change.getLDAPUpdateMsg().getDN(); for (PendingChange pendingChange : activeAndDependentChanges) for (PendingChange pendingChange : replayDependencies) { if (pendingChange.getCSN().isNewerThanOrEqualTo(csn)) { @@ -1052,7 +1150,7 @@ boolean hasDependencies = false; final DN targetDN = op.getEntryDN(); for (PendingChange pendingChange : activeAndDependentChanges) for (PendingChange pendingChange : replayDependencies) { if (pendingChange.getCSN().isNewerThanOrEqualTo(csn)) { opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/ReplayThread.java
@@ -74,6 +74,10 @@ public void shutdown() { shutdown.set(true); synchronized (updateToReplayQueue) { updateToReplayQueue.notifyAll(); } } /** @@ -143,30 +147,54 @@ { LDAPReplicationDomain domain; LDAPUpdateMsg updateMsg; UpdateToReplay delivery = null; try { synchronized (updateToReplayQueue) { if (shutdown.get()) { break; } UpdateToReplay updateToreplay = updateToReplayQueue.poll(1L, TimeUnit.SECONDS); if (updateToreplay == null) if (updateToReplayQueue.isEmpty()) { updateToReplayQueue.wait(1000L); } if (shutdown.get()) { break; } // Retain the head before removing it. LinkedBlockingQueue.poll() can throw // OOME after unlinking it (signalNotFull takes another lock on JDK 11/17). // Every consumer holds this monitor, so peek and poll refer to the same item. delivery = updateToReplayQueue.peek(); if (delivery == null) { continue; } // Find replication domain for that update message and mark it as "in progress" updateMsg = updateToreplay.getUpdateMessage(); domain = updateToreplay.getReplicationDomain(); updateToReplayQueue.poll(); updateMsg = delivery.getUpdateMessage(); domain = delivery.getReplicationDomain(); if (!domain.markInProgress(updateMsg)) { /* * The domain restarted its session after a failed replay while this * message was waiting here, so it does not know about this change * anymore: the replication server sends it again over the new session. * This delivery was cancelled, replaced, committed or forgotten. The * domain counted the stale copy; only its accepted retry may replay it. */ continue; } } } catch (Throwable t) { if (delivery != null) { // No pending-changes lock is needed: the failed claim may itself have // run out of memory while trying to acquire that lock. delivery.cancel(); } throw t; } finally { switchQueueLock.unlock(); opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/UpdateToReplay.java
@@ -13,21 +13,22 @@ * * Copyright 2006-2008 Sun Microsystems, Inc. * Portions Copyright 2015 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.plugin; import org.opends.server.replication.protocol.LDAPUpdateMsg; /** * This is a bag class to hold an update to replay in the queue of updates to * be replayed by the replay threads. * It associates an update message to replay with the matching * ReplicationDomain. * A delivery in the shared replay queue, associating an update with its domain. * Cancellation belongs to this delivery, so an enqueue or claim failure can release * it without acquiring the pending-changes lock or cancelling a newer retry. */ public class UpdateToReplay { private LDAPUpdateMsg updateMessage; private LDAPReplicationDomain replicationDomain; private final LDAPUpdateMsg updateMessage; private final LDAPReplicationDomain replicationDomain; private volatile boolean cancelled; /** * Construct the object associating the update message with the replication @@ -44,6 +45,30 @@ } /** * Releases a failed hand-off without a lock or allocation, even on an OOME road. * A stale cancellation affects only this delivery, never the retry which replaced it. */ void cancel() { cancel(true); } /** Cancels this delivery, optionally requesting another session. */ void cancel(boolean requestRestart) { cancelled = true; if (requestRestart && replicationDomain != null) { replicationDomain.requestSessionRestart(); } } boolean isCancelled() { return cancelled; } /** * Getter for update message. * @return The update message */ opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationDomain.java
@@ -3599,6 +3599,12 @@ return tmp == null || tmp.isShutdownInitiated(); } /** Returns whether the calling thread is still the registered listener for this session. */ protected final boolean isCurrentListenerThread() { return Thread.currentThread() == listenerThread; } /** * Returns the generation of the session of this domain: bumped by * {@link #disableService()} and {@link #enableService()} under opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/DuplicateReplayTest.java
New file @@ -0,0 +1,746 @@ /* * 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.replication.plugin; import static org.opends.server.TestCaseUtils.*; import static org.opends.server.core.DirectoryServer.*; import static org.testng.Assert.*; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; import java.util.TreeSet; import java.util.concurrent.CountDownLatch; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.server.config.meta.ReplicationDomainCfgDefn.IsolationPolicy; import org.opends.server.api.DirectoryThread; import org.opends.server.core.AddOperation; import org.opends.server.protocols.internal.InternalClientConnection; import org.opends.server.replication.ReplicationTestCase; import org.opends.server.replication.common.AssuredMode; import org.opends.server.replication.common.CSN; import org.opends.server.replication.common.CSNGenerator; import org.opends.server.replication.protocol.AckMsg; 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.ReplSessionSecurity; import org.opends.server.replication.protocol.ReplicationMsg; import org.opends.server.replication.service.ReplicationBroker; import org.opends.server.replication.service.ReplicationDomain; import org.opends.server.types.Entry; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; /** Tests duplicate delivery across reconnects against the real replay and backend (issue #1269). */ @SuppressWarnings("javadoc") public class DuplicateReplayTest extends ReplicationTestCase { private static final String ENTRY_UUID = "c9cb8c3c-615a-4122-865d-50323aaaed48"; private DN baseDN; private LDAPReplicationDomain domain; private TestSynchronousReplayQueue queue; private Entry entry; private String parentUUID; private CSN addCSN; private CSN deleteCSN; private OutOfMemoryError enqueueFailure; private OutOfMemoryError pollFailure; private boolean failBeforePoll; private Runnable beforeEnqueue; private boolean refuseEnqueue; private Runnable onEmpty; private Runnable afterPoll; @BeforeMethod public void setUpLocal() throws Exception { baseDN = DN.valueOf(TEST_ROOT_DN_STRING); initializeTestBackend(true); enqueueFailure = null; pollFailure = null; failBeforePoll = false; beforeEnqueue = null; refuseEnqueue = false; onEmpty = null; afterPoll = null; queue = new TestSynchronousReplayQueue() { @Override public boolean offer(UpdateToReplay update, long timeout, TimeUnit unit) throws InterruptedException { if (beforeEnqueue != null) { beforeEnqueue.run(); } if (enqueueFailure != null) { throw enqueueFailure; } return !refuseEnqueue && super.offer(update, timeout, unit); } @Override public UpdateToReplay poll(long timeout, TimeUnit unit) { return poll(); } @Override public UpdateToReplay poll() { if (pollFailure != null && failBeforePoll) { throw pollFailure; } final UpdateToReplay result = super.poll(); if (pollFailure != null) { throw pollFailure; } if (afterPoll != null) { afterPoll.run(); } return result; } @Override public boolean isEmpty() { final boolean empty = super.isEmpty(); if (empty && onEmpty != null) { onEmpty.run(); } return empty; } }; final DomainFakeCfg conf = new DomainFakeCfg(baseDN, 1, new TreeSet<String>()); conf.setIsolationPolicy(IsolationPolicy.ACCEPT_ALL_UPDATES); domain = MultimasterReplication.createNewDomain(conf, queue); domain.start(); entry = makeEntry("dn: cn=duplicate," + baseDN, "objectClass: top", "objectClass: person", "cn: duplicate", "sn: duplicate", "entryUUID: " + ENTRY_UUID); parentUUID = getEntryUUID(baseDN); final CSNGenerator generator = new CSNGenerator(201, 0); addCSN = generator.newCSN(); deleteCSN = generator.newCSN(); } @AfterMethod public void tearDown() throws Exception { MultimasterReplication.deleteDomain(baseDN); } @Test public void appliedAddDeliveredAgainAfterDeleteDoesNotResurrectEntry() throws Exception { domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName())); assertEquals(getEntryUUID(entry.getName()), ENTRY_UUID); domain.processUpdate(deleteMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName())); assertTrue(domain.getServerState().cover(deleteCSN)); // A new session can deliver an old change after it has left the pending list. assertFalse(domain.processUpdate(addMsg())); replayQueuedMessages(); assertFalse(entryExists(entry.getName()), "an applied ADD was replayed after its DELETE"); assertEquals(processedUpdates(), 3, "each delivery must be counted, including the duplicate"); } @Test public void queuedDuplicateAddDoesNotMoveTheOriginalBehindDelete() throws Exception { domain.processUpdate(addMsg()); domain.processUpdate(deleteMsg()); final int completions = completedUpdates(); // Pause replay until the duplicate has arrived: no timing or reconnect race is needed. assertFalse(domain.processUpdate(addMsg())); assertEquals(completedUpdates(), completions, "a queued duplicate must not return credit before replay"); assertFalse(domain.getServerState().cover(addCSN), "discarding a duplicate must not advance state"); assertFalse(domain.getServerState().cover(deleteCSN)); replayQueuedMessages(); assertEquals(completedUpdates(), completions + 2, "one completion per replayed change"); assertFalse(entryExists(entry.getName()), "the queued ADD moved behind its DELETE"); assertTrue(domain.getServerState().cover(deleteCSN)); assertEquals(processedUpdates(), 3, "each delivery must be counted, including the duplicate"); } @Test public void deliveryAbandonedBeforeEnqueueCanBeRetried() throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); recovering.set(true); try { domain.disableService(); domain.processUpdate(addMsg()); assertTrue(queue.isEmpty()); assertFalse(domain.getServerState().cover(addCSN)); domain.enableService(); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName()), "a delivery abandoned before enqueue was refused on retry"); assertTrue(domain.getServerState().cover(addCSN)); } finally { restarts.clear(); recovering.set(false); } } @Test public void failedEnqueueCanBeRetried() throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); recovering.set(true); try { enqueueFailure = new OutOfMemoryError("enqueue allocation failed"); try { domain.processUpdate(addMsg()); fail("the injected allocation failure did not reach enqueue"); } catch (OutOfMemoryError e) { assertSame(e, enqueueFailure); } finally { enqueueFailure = null; } assertTrue(queue.isEmpty()); assertFalse(domain.getServerState().cover(addCSN)); assertTrue(restarts.isPending(), "a failed enqueue must request redelivery"); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName()), "a failed enqueue was refused on retry"); assertTrue(domain.getServerState().cover(addCSN)); } finally { restarts.clear(); recovering.set(false); } } @Test public void ownChangeSentBeforeANewerLocalPublishIsStillReplayed() throws Exception { // Model the incoming backlog after restoring a backup that predates these changes. final CSNGenerator generator = new CSNGenerator(1, 0); addCSN = generator.newCSN(); deleteCSN = generator.newCSN(); domain.publish(deleteMsg()); assertTrue(domain.getServerState().cover(addCSN)); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName()), "local publication must not hide a missing own-origin change"); domain.processUpdate(deleteMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName())); domain.processUpdate(addMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName()), "own-origin replay also needs duplicate suppression"); } @Test public void backendReloadResetsTheOwnReplayWatermark() throws Exception { final CSNGenerator generator = new CSNGenerator(1, 0); addCSN = generator.newCSN(); deleteCSN = generator.newCSN(); domain.processUpdate(addMsg()); domain.processUpdate(deleteMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName())); domain.disable(); // Restore the persisted state of a backup taken before the ADD. Its entry is absent, // as it is in this backend after DELETE; no history on that entry can cover the ADD. ((PersistentServerState) domainField("state")).clear(); domain.enable(); assertFalse(domain.getServerState().cover(addCSN)); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName()), "backend reload retained the pre-restore replay watermark"); } private int completedUpdates() throws Exception { final Field brokerField = ReplicationDomain.class.getDeclaredField("broker"); brokerField.setAccessible(true); final Object broker = brokerField.get(domain); final Field counter = ReplicationBroker.class.getDeclaredField("updateDoneCount"); counter.setAccessible(true); return counter.getInt(broker); } @DataProvider public Object[][] queueFailurePositions() { return new Object[][] { { false }, { true } }; } @Test(dataProvider = "queueFailurePositions") public void failureAroundQueueRemovalDoesNotLoseTheChange(boolean beforeRemoval) throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); recovering.set(true); // Keep the checkpointer from consuming the request under test. try { domain.processUpdate(addMsg()); domain.processUpdate(deleteMsg()); failBeforePoll = beforeRemoval; pollFailure = new OutOfMemoryError("dequeue failed"); try { new ReplayThread(queue, new ReentrantLock()).run(); fail("the injected dequeue failure did not escape"); } catch (OutOfMemoryError e) { assertSame(e, pollFailure); } finally { pollFailure = null; } assertEquals(queue.size(), beforeRemoval ? 2 : 1); assertTrue(restarts.isPending(), "a lost queue delivery must be requested again"); replayQueuedMessages(); // DELETE must wait for the cancelled ADD, not commit NO_SUCH_OBJECT. domain.processUpdate(addMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName()), "DELETE overtook the retry of the removed ADD"); assertTrue(domain.getServerState().cover(deleteCSN)); } finally { restarts.clear(); recovering.set(false); } } @Test public void cancellationAfterANewerDeliveryWasRefusedRequestsAnotherSession() throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); recovering.set(true); try { enqueueFailure = new OutOfMemoryError("old listener cannot finish enqueue"); beforeEnqueue = () -> { // A newer session redelivers while the old listener is still in offer(). assertFalse(domain.processUpdate(addMsg())); assertTrue(queue.isEmpty()); beforeEnqueue = null; final OutOfMemoryError oldFailure = enqueueFailure; enqueueFailure = null; try { domain.processUpdate(deleteMsg()); } finally { enqueueFailure = oldFailure; } }; try { domain.processUpdate(addMsg()); fail("enqueue must fail"); } catch (OutOfMemoryError e) { assertSame(e, enqueueFailure); } finally { beforeEnqueue = null; enqueueFailure = null; } assertTrue(restarts.isPending(), "the newer session already missed its chance to retry"); replayQueuedMessages(); domain.processUpdate(addMsg()); replayQueuedMessages(); assertFalse(entryExists(entry.getName()), "DELETE overtook the cancelled ADD's retry"); assertTrue(domain.getServerState().cover(deleteCSN)); } finally { restarts.clear(); recovering.set(false); } } @DataProvider public Object[][] listenerStops() { return new Object[][] { { false }, { true } }; } @Test(dataProvider = "listenerStops") public void shutdownCancellationRestartsOnlyAfterAnIncompleteJoin(boolean interruptJoin) throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); final CountDownLatch offering = new CountDownLatch(1); final CountDownLatch releaseOffer = new CountDownLatch(1); final CountDownLatch stopping = new CountDownLatch(1); final AtomicReference<Throwable> listenerFailure = new AtomicReference<>(); final Field listenerField = ReplicationDomain.class.getDeclaredField("listenerThread"); listenerField.setAccessible(true); recovering.set(true); domain.disableService(); refuseEnqueue = true; beforeEnqueue = () -> { offering.countDown(); await(releaseOffer); }; // Use the real listener slot and disableService/join, but supply the incoming change // directly so the interleaving does not need a replication server or a full 10000-slot queue. final DirectoryThread listener = new DirectoryThread(() -> { try { domain.processUpdate(addMsg()); } catch (Throwable t) { listenerFailure.set(t); } }, "held duplicate-replay listener"); final FutureTask<Void> stop = new FutureTask<>(() -> { domain.disableService(); return null; }); final Thread stopper = new Thread(stop, "duplicate-replay service stop"); try { listenerField.set(domain, listener); listener.start(); assertTrue(offering.await(10, TimeUnit.SECONDS)); domain.setServiceStopHook(stopping::countDown); stopper.start(); assertTrue(stopping.await(10, TimeUnit.SECONDS)); if (interruptJoin) { stopper.interrupt(); stop.get(10, TimeUnit.SECONDS); domain.setServiceStopHook(null); domain.enableService(); domain.processUpdate(addMsg()); // The new session refuses the still-live old delivery. assertTrue(queue.isEmpty()); domain.disableService(); restarts.clear(); // A restart before cancellation cannot be enough. } else { // Taking sessionLock would deadlock with join; wait on the thread's own flag. final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); while (!listener.isShutdownInitiated() && System.nanoTime() < deadline) { Thread.yield(); } assertTrue(listener.isShutdownInitiated()); } releaseOffer.countDown(); stop.get(10, TimeUnit.SECONDS); listener.join(10000); assertFalse(listener.isAlive()); assertNull(listenerFailure.get()); assertEquals(restarts.isPending(), interruptJoin, "a completed join already precedes redelivery; a detached listener needs a fresh restart"); refuseEnqueue = false; beforeEnqueue = null; domain.enableService(); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName())); } finally { releaseOffer.countDown(); stopper.join(10000); listener.join(10000); domain.setServiceStopHook(null); restarts.clear(); recovering.set(false); } } @Test public void failedHandoffByRegisteredListenerRequestsRestart() throws Exception { final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); final AtomicReference<Throwable> listenerFailure = new AtomicReference<>(); final Field listenerField = ReplicationDomain.class.getDeclaredField("listenerThread"); listenerField.setAccessible(true); recovering.set(true); domain.disableService(); enqueueFailure = new OutOfMemoryError("registered listener enqueue failed"); final DirectoryThread listener = new DirectoryThread(() -> { try { domain.processUpdate(addMsg()); } catch (Throwable t) { listenerFailure.set(t); } }, "failed duplicate-replay listener"); try { listenerField.set(domain, listener); listener.start(); listener.join(10000); assertFalse(listener.isAlive()); assertSame(listenerFailure.get(), enqueueFailure); assertTrue(restarts.isPending(), "failure needs recovery even while the listener is registered"); enqueueFailure = null; domain.disableService(); domain.enableService(); domain.processUpdate(addMsg()); replayQueuedMessages(); assertTrue(entryExists(entry.getName())); } finally { listener.join(10000); enqueueFailure = null; restarts.clear(); recovering.set(false); } } @Test public void replayConsumerWakesForAnEmptyQueueAndDrainsItsBacklog() throws Exception { final CountDownLatch empty = new CountDownLatch(1); final CountDownLatch removed = new CountDownLatch(2); final CountDownLatch replayed = new CountDownLatch(1); final ReplayThread consumer = new ReplayThread(queue, new ReentrantLock()); onEmpty = () -> { empty.countDown(); if (domain.getServerState().cover(deleteCSN)) { replayed.countDown(); } }; afterPoll = removed::countDown; try { consumer.start(); assertTrue(empty.await(10, TimeUnit.SECONDS)); synchronized (queue) { // The consumer observed an empty queue while holding this monitor. Acquiring it // proves it has now entered wait(). Deliver both changes before it can wake up. domain.processUpdate(addMsg()); domain.processUpdate(deleteMsg()); } assertTrue(removed.await(750, TimeUnit.MILLISECONDS), "notification must wake the consumer and a non-empty queue must not wait again"); // poll() runs before replay. Stopping at the second removal could abandon DELETE. assertTrue(replayed.await(10, TimeUnit.SECONDS), "the consumer did not commit its backlog"); } finally { consumer.shutdown(); consumer.join(10000); onEmpty = null; afterPoll = null; } assertFalse(consumer.isAlive()); assertFalse(entryExists(entry.getName())); assertTrue(domain.getServerState().cover(deleteCSN)); } private static void await(CountDownLatch latch) { try { assertTrue(latch.await(10, TimeUnit.SECONDS), "controlled hand-off timed out"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); } } private Object domainField(String name) throws Exception { final Field field = LDAPReplicationDomain.class.getDeclaredField(name); field.setAccessible(true); return field.get(domain); } @DataProvider public Object[][] assuredDeliveries() { return new Object[][] { { false, true, false }, { true, true, false }, { true, false, false }, { false, true, true }, { true, true, true }, { true, false, true } }; } @Test(dataProvider = "assuredDeliveries") public void queuedRedeliveryAcknowledgesOnlyTheReplayResult( boolean firstAssured, boolean lastAssured, boolean failReplay) throws Exception { final Field brokerField = ReplicationDomain.class.getDeclaredField("broker"); brokerField.setAccessible(true); final ReplicationBroker originalBroker = (ReplicationBroker) brokerField.get(domain); final List<AckMsg> acks = new ArrayList<>(); // A non-started broker retains real completion accounting but records the wire ack. // No domain is passed to its constructor, so it registers no second monitor. final ReplicationBroker recordingBroker = new ReplicationBroker(null, domain.getServerState(), new DomainFakeCfg(baseDN, 1, new TreeSet<String>()), new ReplSessionSecurity()) { @Override public byte getRsGroupId() { return domain.getGroupId(); } @Override public void publish(ReplicationMsg msg) { if (msg instanceof AckMsg) { acks.add((AckMsg) msg); } } }; brokerField.set(domain, recordingBroker); final AtomicBoolean recovering = (AtomicBoolean) domainField("replayFailureRecovery"); final SessionRestartRequests restarts = (SessionRestartRequests) domainField("sessionRestarts"); recovering.set(true); final OutOfMemoryError failure = new OutOfMemoryError("cannot construct replay operation"); try { final AddMsg original = failReplay ? new AddMsg(addCSN, entry.getName(), ENTRY_UUID, parentUUID, entry.getObjectClasses(), entry.getUserAttributes(), entry.getOperationalAttributes()) { @Override public AddOperation createOperation(InternalClientConnection connection, DN newDN) { throw failure; } } : addMsg(); original.setAssured(firstAssured); original.setAssuredMode(AssuredMode.SAFE_DATA_MODE); domain.processUpdate(original); for (int i = 0; i < 2; i++) { final AddMsg duplicate = addMsg(); duplicate.setAssured(lastAssured); duplicate.setAssuredMode(AssuredMode.SAFE_READ_MODE); domain.processUpdate(duplicate); assertTrue(acks.isEmpty(), "queued redelivery must not acknowledge unapplied data"); assertEquals(completedUpdates(), 0); } try { replayQueuedMessages(); assertFalse(failReplay, "the injected replay failure must escape"); } catch (OutOfMemoryError e) { assertTrue(failReplay); assertSame(e, failure); } assertEquals(completedUpdates(), 1, "only the accepted replay returns window credit"); assertEquals(acks.size(), lastAssured ? 1 : 0); if (lastAssured) { assertEquals(acks.get(0).getCSN(), addCSN); assertEquals(acks.get(0).hasReplayError(), failReplay); } assertEquals(entryExists(entry.getName()), !failReplay); } finally { brokerField.set(domain, originalBroker); restarts.clear(); recovering.set(false); } } private AddMsg addMsg() { return new AddMsg(addCSN, entry.getName(), ENTRY_UUID, parentUUID, entry.getObjectClasses(), entry.getUserAttributes(), entry.getOperationalAttributes()); } private DeleteMsg deleteMsg() { return new DeleteMsg(entry.getName(), deleteCSN, ENTRY_UUID); } private void replayQueuedMessages() throws Exception { while (!queue.isEmpty()) { final LDAPUpdateMsg msg = queue.take().getUpdateMessage(); if (domain.markInProgress(msg)) { domain.replay(msg, new AtomicBoolean(false)); } } } private int processedUpdates() throws Exception { // The monitor counter is package-private in service; this domain has no RS to register a monitor. final Method getter = ReplicationDomain.class.getDeclaredMethod("getNumProcessedUpdates"); getter.setAccessible(true); return (Integer) getter.invoke(domain); } } opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/NamingConflictTest.java
@@ -147,11 +147,13 @@ Entry entry = createAndAddEntry("simultaneousModrdnConflict"); String entryUUID = getEntryUUID(entry.getName()); // generate two consecutive CSN that will be used in backward order // Changes from different origins can arrive in reverse CSN order. Older changes // from the same origin would already be covered by its applied ServerState. CSN csn1 = gen.newCSN(); CSN csn2 = gen.newCSN(); CSN csn2 = new CSN(csn1.getTime() + 1, 0, 202); replayMsg(modDnMsg(entry, entryUUID, parentUUID, csn2, "uid=simultaneous2")); assertFalse(domain.getServerState().cover(csn1)); // This MODIFY DN uses an older DN and should therefore be cancelled at replay time. replayMsg(modDnMsg(entry, entryUUID, parentUUID, csn1, "uid=simulatneouswrong")); @@ -287,10 +289,11 @@ final String entryUUID = getEntryUUID(entry.getName()); final int givenUpBefore = failedReplayedUpdates(); // Two consecutive CSNs, replayed in the reverse order. // Reverse order across origins must still reach naming-conflict resolution. final CSN older = gen.newCSN(); final CSN newer = gen.newCSN(); final CSN newer = new CSN(older.getTime() + 1, 0, 202); replayMsg(modDnMsg(entry, entryUUID, parentUUID, newer, "cn=renamedAfter")); assertFalse(domain.getServerState().cover(older)); replayMsg(modDnMsg(entry, entryUUID, parentUUID, older, "cn=renamedBefore")); assertTrue(entryExists(DN.valueOf("cn=renamedAfter," + TEST_ROOT_DN_STRING)), opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RemotePendingChangesTest.java
@@ -19,9 +19,14 @@ import static org.testng.Assert.*; import java.util.NoSuchElementException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import org.forgerock.opendj.ldap.DN; import org.opends.server.DirectoryServerTestCase; @@ -33,6 +38,7 @@ import org.opends.server.replication.protocol.LDAPUpdateMsg; import org.opends.server.replication.protocol.ModifyDNMsg; import org.testng.annotations.BeforeClass; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; /** @@ -68,6 +74,264 @@ assertEquals(pendingChanges.getQueueSize(), 0); } @Test public void appliedChangesAreRejectedAfterLeavingThePendingList() throws Exception { final ServerState state = new ServerState(); final RemotePendingChanges pendingChanges = new RemotePendingChanges(state); final CSNGenerator generator = new CSNGenerator(SERVER_ID, 0); final CSN first = generator.newCSN(); final CSN last = generator.newCSN(); assertTrue(pendingChanges.putRemoteUpdate(deleteMsg(first, "uuid-1"))); pendingChanges.commit(first); assertTrue(pendingChanges.putRemoteUpdate(deleteMsg(last, "uuid-2"))); pendingChanges.commit(last); assertFalse(pendingChanges.putRemoteUpdate(deleteMsg(first, "uuid-1"))); assertFalse(pendingChanges.putRemoteUpdate(deleteMsg(last, "uuid-2"))); assertEquals(pendingChanges.getQueueSize(), 0); // The watermark belongs to the originating server, not to all servers at once. final CSN anotherOrigin = new CSN(first.getTime(), first.getSeqnum(), SERVER_ID + 1); assertTrue(pendingChanges.putRemoteUpdate(deleteMsg(anotherOrigin, "uuid-3"))); } @Test public void duplicateDoesNotReplaceAQueuedDelivery() throws Exception { final ServerState state = new ServerState(); final RemotePendingChanges pendingChanges = new RemotePendingChanges(state); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final DeleteMsg original = deleteMsg(csn, "uuid-1"); final DeleteMsg duplicate = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(original)); assertFalse(pendingChanges.putRemoteUpdate(duplicate)); assertFalse(pendingChanges.markInProgress(duplicate)); assertTrue(pendingChanges.markInProgress(original)); assertTrue(state.isEmpty(), "discarding a duplicate must not record the queued change as replayed"); pendingChanges.commit(csn); assertTrue(state.cover(csn)); } @Test public void duplicateDoesNotReplaceAQueuedRetry() throws Exception { final RemotePendingChanges pendingChanges = new RemotePendingChanges(new ServerState()); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final DeleteMsg original = deleteMsg(csn, "uuid-1"); final DeleteMsg retry = deleteMsg(csn, "uuid-1"); final DeleteMsg duplicate = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(original)); assertTrue(pendingChanges.markInProgress(original)); assertTrue(pendingChanges.replayFailed(csn)); assertTrue(pendingChanges.putRemoteUpdate(retry)); assertEquals(pendingChanges.changesInProgressSize(), 1, "a queued retry retains its in-progress count"); assertFalse(pendingChanges.putRemoteUpdate(duplicate)); assertFalse(pendingChanges.markInProgress(original)); assertFalse(pendingChanges.markInProgress(duplicate)); assertTrue(pendingChanges.markInProgress(retry)); } @Test public void interruptedClaimDoesNotLeaveTheDeliveryMarkedAsQueued() throws Exception { final OutOfMemoryError failure = new OutOfMemoryError("claim allocation failed"); final AtomicBoolean failClaim = new AtomicBoolean(true); final RemotePendingChanges pendingChanges = new RemotePendingChanges(new ServerState(), -1, new ConcurrentHashMap<Thread, CSN>() { @Override public CSN put(Thread thread, CSN csn) { if (failClaim.getAndSet(false)) { throw failure; } return super.put(thread, csn); } }); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final DeleteMsg original = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(original)); try { pendingChanges.markInProgress(original); fail("the injected allocation failure did not reach the claim"); } catch (OutOfMemoryError e) { assertSame(e, failure); } final DeleteMsg retry = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(retry), "the dequeued delivery has no owner to finish it"); assertTrue(pendingChanges.markInProgress(retry)); } @DataProvider public Object[][] registrationFailures() { return new Object[][] { { false }, { true } }; } @Test(dataProvider = "registrationFailures") public void failedDependencyRegistrationCancelsDeliveryBeforeRollback(boolean failRollback) throws Exception { final OutOfMemoryError registrationFailure = new OutOfMemoryError("dependency registration failed"); final OutOfMemoryError rollbackFailure = new OutOfMemoryError("dependency rollback failed"); final AtomicBoolean failRegistration = new AtomicBoolean(true); final ServerState state = new ServerState(); final RemotePendingChanges pending = new RemotePendingChanges(state, -1, new ConcurrentHashMap<>(), new ConcurrentSkipListSet<PendingChange>() { @Override public boolean add(PendingChange change) { if (failRegistration.getAndSet(false)) { // Also cover a set insertion that throws after linking its element. super.add(change); throw registrationFailure; } return super.add(change); } @Override public boolean remove(Object change) { if (failRollback) { throw rollbackFailure; } return super.remove(change); } }); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final UpdateToReplay delivery = new UpdateToReplay(deleteMsg(csn, "uuid-1"), null); try { pending.putRemoteUpdate(delivery); fail("registration failure must escape admission"); } catch (OutOfMemoryError e) { assertSame(e, failRollback ? rollbackFailure : registrationFailure); } assertTrue(delivery.isCancelled(), "rollback failure must not skip delivery cancellation"); assertFalse(state.cover(csn)); if (!failRollback) { assertEquals(pending.getQueueSize(), 0); final DeleteMsg retry = deleteMsg(csn, "uuid-1"); assertTrue(pending.putRemoteUpdate(retry), "failed registration must not wedge the next delivery"); assertTrue(pending.markInProgress(retry)); pending.commit(csn); assertTrue(state.cover(csn)); } } @Test public void lateEnqueueCancellationDoesNotReleaseAnotherDeliveryOrItsOwner() throws Exception { final RemotePendingChanges pendingChanges = new RemotePendingChanges(new ServerState()); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final DeleteMsg original = deleteMsg(csn, "uuid-1"); final DeleteMsg retry = deleteMsg(csn, "uuid-1"); final UpdateToReplay oldDelivery = new UpdateToReplay(original, null); final UpdateToReplay newDelivery = new UpdateToReplay(retry, null); assertEquals(pendingChanges.putRemoteUpdate(oldDelivery), RemotePendingChanges.Admission.ACCEPTED); oldDelivery.cancel(); assertEquals(pendingChanges.putRemoteUpdate(newDelivery), RemotePendingChanges.Admission.ACCEPTED); oldDelivery.cancel(); assertFalse(pendingChanges.putRemoteUpdate(deleteMsg(csn, "uuid-1")), "cancelling the previous delivery must not release its queued replacement"); assertTrue(pendingChanges.markInProgress(retry)); newDelivery.cancel(); assertFalse(pendingChanges.putRemoteUpdate(deleteMsg(csn, "uuid-1")), "enqueue cancellation must not take a delivery away from its replay owner"); assertTrue(pendingChanges.replayFailed(csn)); assertTrue(pendingChanges.putRemoteUpdate(deleteMsg(csn, "uuid-1"))); } @Test public void resettingStateAllowsPreviouslyAppliedChangesToBeReplayed() throws Exception { final ServerState state = new ServerState(); final RemotePendingChanges pendingChanges = new RemotePendingChanges(state); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); assertTrue(pendingChanges.putRemoteUpdate(deleteMsg(csn, "uuid-1"))); pendingChanges.commit(csn); // Disabling a domain clears pending changes, but retains its applied state. pendingChanges.clear(); assertFalse(pendingChanges.putRemoteUpdate(deleteMsg(csn, "uuid-1"))); // A restore/import may reset that state so the same changes are needed again. state.clear(); final DeleteMsg redelivered = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(redelivered)); assertTrue(pendingChanges.markInProgress(redelivered)); } @Test public void failedClaimBeforeTakingThePendingLockReleasesItsDelivery() throws Exception { final RemotePendingChanges pending = new RemotePendingChanges(new ServerState()); final CSN csn = new CSNGenerator(SERVER_ID, 0).newCSN(); final DeleteMsg original = deleteMsg(csn, "uuid-1"); final OutOfMemoryError failure = new OutOfMemoryError("pending lock allocation failed"); // Inject between dequeue and claim, before RemotePendingChanges can acquire its lock. final UpdateToReplay delivery = new UpdateToReplay(original, null) { @Override public LDAPReplicationDomain getReplicationDomain() { throw failure; } }; assertEquals(pending.putRemoteUpdate(delivery), RemotePendingChanges.Admission.ACCEPTED); final LinkedBlockingQueue<UpdateToReplay> queue = new LinkedBlockingQueue<>(); queue.add(delivery); try { new ReplayThread(queue, new ReentrantLock()).run(); fail("the failed claim must end the replay thread"); } catch (OutOfMemoryError e) { assertSame(e, failure); } assertTrue(queue.isEmpty()); assertFalse(pending.markInProgress(original), "a cancelled delivery must not acquire ownership"); final DeleteMsg retry = deleteMsg(csn, "uuid-1"); assertTrue(pending.putRemoteUpdate(retry), "the failed claim must not block redelivery"); assertTrue(pending.markInProgress(retry)); } @Test public void ownReplayWatermarkSurvivesClearAndResetsWithBackendState() throws Exception { final ServerState state = new ServerState(); final CSNGenerator generator = new CSNGenerator(SERVER_ID, 0); final CSN first = generator.newCSN(); final CSN later = generator.newCSN(); final RemotePendingChanges pending = new RemotePendingChanges(state, SERVER_ID); state.update(later); // A local publication does not prove that first was replayed. assertTrue(pending.putRemoteUpdate(deleteMsg(first, "uuid-1"))); pending.commit(first); pending.clear(); assertFalse(pending.putRemoteUpdate(deleteMsg(first, "uuid-1"))); state.clear(); pending.resetReplayState(); assertTrue(pending.putRemoteUpdate(deleteMsg(first, "uuid-1"))); } /** * A change which is not committed must hold back the ServerState, even when the * changes which follow it have been replayed: the replication server resumes from the @@ -180,9 +444,8 @@ } /** * The replication server sends the change again over the new session while the message * of the previous delivery may still be waiting in the replay queue: only the delivery * which is listed as pending is replayed, or the same change would be applied twice. * A failed delivery must not claim the change after the replication server has sent * a replacement over the new session: only the replacement may replay it. */ @Test public void markInProgressRejectsThePreviousDeliveryOfAChange() throws Exception @@ -193,7 +456,8 @@ final DeleteMsg newDelivery = deleteMsg(csn, "uuid-1"); assertTrue(pendingChanges.putRemoteUpdate(previousDelivery)); pendingChanges.replayFailed(csn); assertTrue(pendingChanges.markInProgress(previousDelivery)); assertTrue(pendingChanges.replayFailed(csn)); assertTrue(pendingChanges.putRemoteUpdate(newDelivery), "the next delivery must be replayed"); assertFalse(pendingChanges.markInProgress(previousDelivery),