| New file |
| | |
| | | /* |
| | | * 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 java.util.concurrent.TimeUnit.*; |
| | | |
| | | import static org.opends.server.util.CollectionUtils.*; |
| | | import static org.opends.server.util.StaticUtils.*; |
| | | import static org.testng.Assert.*; |
| | | |
| | | import java.util.SortedSet; |
| | | import java.util.concurrent.CountDownLatch; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | |
| | | import org.forgerock.opendj.ldap.DN; |
| | | import org.opends.server.TestCaseUtils; |
| | | import org.opends.server.api.DirectoryThread; |
| | | import org.opends.server.backends.MemoryBackend; |
| | | import org.opends.server.core.ModifyOperation; |
| | | import org.opends.server.replication.ReplicationTestCase; |
| | | import org.opends.server.replication.common.CSN; |
| | | import org.opends.server.replication.server.ReplServerFakeConfiguration; |
| | | import org.opends.server.replication.server.ReplicationServer; |
| | | import org.opends.server.types.DirectoryException; |
| | | import org.opends.server.types.Entry; |
| | | import org.opends.server.util.TestTimer; |
| | | import org.opends.server.util.TestTimer.CallableVoid; |
| | | import org.testng.annotations.AfterMethod; |
| | | import org.testng.annotations.BeforeMethod; |
| | | import org.testng.annotations.Test; |
| | | |
| | | /** |
| | | * Tests the state checkpointer of a {@link LDAPReplicationDomain} against a backend which |
| | | * fails the write of the replication state. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | public class ServerStateFlushTest extends ReplicationTestCase |
| | | { |
| | | private static final String BACKEND_ID = "serverStateFlushTest"; |
| | | private static final String BASE_DN_STRING = "o=serverStateFlushTest"; |
| | | private static final int DS_ID = 1; |
| | | private static final int RS_ID = 601; |
| | | |
| | | /** How long a test waits for something the checkpointer does on its own, in seconds. */ |
| | | private static final int CHECKPOINT_TIMEOUT_IN_SECS = 20; |
| | | |
| | | private DN baseDN; |
| | | private StateWriteFailureBackend backend; |
| | | private ReplicationServer replicationServer; |
| | | private LDAPReplicationDomain domain; |
| | | private Thread checkpointer; |
| | | private boolean domainDeleted; |
| | | |
| | | /** What the backend does with a write of the replication state instead of writing it. */ |
| | | private enum StateWrite |
| | | { |
| | | /** Write it, the way the backend normally would. */ |
| | | SUCCEEDS, |
| | | /** Throw a {@link RuntimeException} the way an unwrapped driver failure would. */ |
| | | THROWS_RUNTIME_EXCEPTION, |
| | | /** Throw an {@link Error}, which no {@code catch} of the checkpointer can be expected to hold. */ |
| | | THROWS_ERROR, |
| | | /** Block until the test releases it, the way a write to an unresponsive storage would. */ |
| | | BLOCKS; |
| | | } |
| | | |
| | | /** A memory backend whose write of the replication state fails on demand. */ |
| | | private static final class StateWriteFailureBackend extends MemoryBackend |
| | | { |
| | | private final DN stateEntryDN; |
| | | private volatile StateWrite stateWrite = StateWrite.SUCCEEDS; |
| | | private final AtomicInteger failedStateWrites = new AtomicInteger(); |
| | | /** |
| | | * Those of {@link #failedStateWrites} the checkpointer made once it was asked to stop: |
| | | * the write its wake-up triggers, and the last one it runs on its way out. |
| | | */ |
| | | private final AtomicInteger failedStateWritesAfterShutdown = new AtomicInteger(); |
| | | private final CountDownLatch blockedWriteStarted = new CountDownLatch(1); |
| | | private final CountDownLatch blockedWriteReleased = new CountDownLatch(1); |
| | | |
| | | private StateWriteFailureBackend(DN stateEntryDN) |
| | | { |
| | | this.stateEntryDN = stateEntryDN; |
| | | } |
| | | |
| | | /** |
| | | * Not synchronized, unlike the method it overrides: a write which blocks must not hold |
| | | * the monitor of the backend, or every other operation on it would block with it. |
| | | */ |
| | | @Override |
| | | public void replaceEntry(Entry oldEntry, Entry newEntry, ModifyOperation modifyOperation) |
| | | throws DirectoryException |
| | | { |
| | | if (stateEntryDN.equals(newEntry.getName())) |
| | | { |
| | | switch (stateWrite) |
| | | { |
| | | case THROWS_RUNTIME_EXCEPTION: |
| | | recordFailedStateWrite(); |
| | | throw new IllegalStateException("injected failure of a replication state write"); |
| | | case THROWS_ERROR: |
| | | recordFailedStateWrite(); |
| | | throw new OutOfMemoryError("injected failure of a replication state write"); |
| | | case BLOCKS: |
| | | blockedWriteStarted.countDown(); |
| | | try |
| | | { |
| | | blockedWriteReleased.await(); |
| | | } |
| | | catch (InterruptedException e) |
| | | { |
| | | Thread.currentThread().interrupt(); |
| | | return; |
| | | } |
| | | break; |
| | | default: |
| | | break; |
| | | } |
| | | } |
| | | super.replaceEntry(oldEntry, newEntry, modifyOperation); |
| | | } |
| | | |
| | | private void recordFailedStateWrite() |
| | | { |
| | | failedStateWrites.incrementAndGet(); |
| | | final Thread writer = Thread.currentThread(); |
| | | if (writer instanceof DirectoryThread && ((DirectoryThread) writer).isShutdownInitiated()) |
| | | { |
| | | failedStateWritesAfterShutdown.incrementAndGet(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @BeforeMethod |
| | | public void setUpDomain() throws Exception |
| | | { |
| | | baseDN = DN.valueOf(BASE_DN_STRING); |
| | | |
| | | backend = new StateWriteFailureBackend(baseDN); |
| | | backend.setBackendID(BACKEND_ID); |
| | | backend.setBaseDNs(baseDN); |
| | | backend.configureBackend(null, TestCaseUtils.getServerContext()); |
| | | backend.openBackend(); |
| | | TestCaseUtils.getServerContext().getBackendConfigManager().registerLocalBackend(backend); |
| | | backend.addEntry(createEntry(baseDN), null); |
| | | |
| | | replicationServer = new ReplicationServer(new ReplServerFakeConfiguration( |
| | | TestCaseUtils.findFreePort(), "serverStateFlushTestDb", 0, RS_ID, 0, 100, null)); |
| | | |
| | | final SortedSet<String> replServers = newTreeSet("localhost:" + replicationServer.getReplicationPort()); |
| | | domain = MultimasterReplication.createNewDomain(new DomainFakeCfg(baseDN, DS_ID, replServers)); |
| | | domainDeleted = false; |
| | | domain.start(); |
| | | |
| | | checkpointer = checkpointerOf(domain); |
| | | assertNotNull(checkpointer, "the state checkpointer of the domain was not started"); |
| | | assertTrue(checkpointer.isAlive(), "the state checkpointer of the domain is not running"); |
| | | } |
| | | |
| | | /** |
| | | * Takes back what {@link #setUpDomain()} put in place, one resource at a time: a setup |
| | | * which failed halfway through must not leave a backend or a domain behind for the next |
| | | * test of the class to trip over. |
| | | * <p> |
| | | * Bounded on its own: the {@code timeOut} of a test covers the test alone, and for a test |
| | | * which leaves the domain to this method, the shutdown under test runs here - a shutdown |
| | | * which hangs must fail the test rather than hold the whole run. The bound has room for |
| | | * the budget the shutdown gives the checkpointer and for the wait for the thread below. |
| | | */ |
| | | @AfterMethod(timeOut = 90000) |
| | | public void tearDownDomain() throws Exception |
| | | { |
| | | if (backend != null) |
| | | { |
| | | // Let go of a write the test left blocked, and of the checkpointer waiting on it, |
| | | // before the backend it is writing to is taken away. |
| | | backend.stateWrite = StateWrite.SUCCEEDS; |
| | | backend.blockedWriteReleased.countDown(); |
| | | } |
| | | if (domain != null && !domainDeleted) |
| | | { |
| | | // Stops the checkpointer within a tick: waiting for it before this would spend the |
| | | // whole wait on a thread which is running by design. |
| | | deleteDomain(); |
| | | } |
| | | domain = null; |
| | | if (checkpointer != null) |
| | | { |
| | | checkpointer.join(SECONDS.toMillis(CHECKPOINT_TIMEOUT_IN_SECS)); |
| | | checkpointer = null; |
| | | } |
| | | if (replicationServer != null) |
| | | { |
| | | remove(replicationServer); |
| | | replicationServer = null; |
| | | } |
| | | if (backend != null) |
| | | { |
| | | backend.finalizeBackend(); |
| | | TestCaseUtils.getServerContext().getBackendConfigManager().deregisterLocalBackend(backend); |
| | | backend = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A write of the state which throws must not stop the checkpointer: the state stays unsaved, |
| | | * and the next checkpoint writes it. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void checkpointerKeepsCheckpointingAfterAStateWriteThatThrows() throws Exception |
| | | { |
| | | backend.stateWrite = StateWrite.THROWS_RUNTIME_EXCEPTION; |
| | | final CSN csn = newCSN(); |
| | | domain.getServerState().update(csn); |
| | | |
| | | waitForFailedStateWrites(1); |
| | | |
| | | backend.stateWrite = StateWrite.SUCCEEDS; |
| | | checkEntryHasAttributeValue(baseDN, "ds-sync-state", csn.toString(), CHECKPOINT_TIMEOUT_IN_SECS, |
| | | "the checkpointer did not write the state after a write which threw"); |
| | | } |
| | | |
| | | /** |
| | | * Shutting the domain down must not wait forever for a checkpointer whose writes all |
| | | * throw, and the checkpointer must hold the exception of the last write it runs on its |
| | | * way out as it does the others: that write used to end the thread before it reported |
| | | * that it was done. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void shutdownCompletesWhenEveryStateWriteThrows() throws Exception |
| | | { |
| | | backend.stateWrite = StateWrite.THROWS_RUNTIME_EXCEPTION; |
| | | domain.getServerState().update(newCSN()); |
| | | |
| | | waitForFailedStateWrites(1); |
| | | |
| | | // A thread which lets an exception out is dead too, so isAlive() alone can not tell the |
| | | // last write being held from the thread dying of it. The handler of the thread outranks |
| | | // the one of its group, which would log the exception and raise an alert instead. |
| | | final AtomicReference<Throwable> uncaught = new AtomicReference<>(); |
| | | checkpointer.setUncaughtExceptionHandler((t, e) -> uncaught.set(e)); |
| | | |
| | | deleteDomain(); |
| | | |
| | | assertFalse(checkpointer.isAlive(), "the state checkpointer is still running after the shutdown"); |
| | | assertNull(uncaught.get(), "the last state write let an exception out of the checkpointer"); |
| | | assertTrue(backend.failedStateWritesAfterShutdown.get() > 0, |
| | | "the checkpointer did not write the state on its way out"); |
| | | } |
| | | |
| | | /** |
| | | * Shutting the domain down must not wait forever for a checkpointer which is gone: an |
| | | * {@link Error} kills the thread whatever it catches. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void shutdownCompletesWhenTheCheckpointerDiedOfAnError() throws Exception |
| | | { |
| | | backend.stateWrite = StateWrite.THROWS_ERROR; |
| | | domain.getServerState().update(newCSN()); |
| | | |
| | | waitForCheckpointerToDie(); |
| | | |
| | | deleteDomain(); |
| | | } |
| | | |
| | | /** |
| | | * Shutting the domain down must not wait forever for a checkpointer whose write does not |
| | | * come back. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void shutdownCompletesWhileAStateWriteIsStuck() throws Exception |
| | | { |
| | | backend.stateWrite = StateWrite.BLOCKS; |
| | | domain.getServerState().update(newCSN()); |
| | | |
| | | assertTrue(backend.blockedWriteStarted.await(CHECKPOINT_TIMEOUT_IN_SECS, SECONDS), |
| | | "the checkpointer did not start the state write the test blocks on"); |
| | | |
| | | deleteDomain(); |
| | | } |
| | | |
| | | private CSN newCSN() |
| | | { |
| | | return new CSN(System.currentTimeMillis(), 1, DS_ID); |
| | | } |
| | | |
| | | private void deleteDomain() |
| | | { |
| | | domainDeleted = true; |
| | | MultimasterReplication.deleteDomain(baseDN); |
| | | } |
| | | |
| | | private void waitForFailedStateWrites(final int count) throws Exception |
| | | { |
| | | newTimer().repeatUntilSuccess(new CallableVoid() |
| | | { |
| | | @Override |
| | | public void call() throws Exception |
| | | { |
| | | assertTrue(backend.failedStateWrites.get() >= count, |
| | | "the state write of the checkpointer did not fail " + count + " time(s)"); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | private void waitForCheckpointerToDie() throws Exception |
| | | { |
| | | newTimer().repeatUntilSuccess(new CallableVoid() |
| | | { |
| | | @Override |
| | | public void call() throws Exception |
| | | { |
| | | assertFalse(checkpointer.isAlive(), "the state checkpointer did not die of the injected error"); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | private TestTimer newTimer() |
| | | { |
| | | return new TestTimer.Builder() |
| | | .maxSleep(CHECKPOINT_TIMEOUT_IN_SECS, SECONDS) |
| | | .sleepTimes(100, MILLISECONDS) |
| | | .toTimer(); |
| | | } |
| | | |
| | | private static Thread checkpointerOf(LDAPReplicationDomain domain) |
| | | { |
| | | final String name = |
| | | "Replica DS(" + domain.getServerId() + ") state checkpointer for domain \"" + domain.getBaseDN() + "\""; |
| | | final ThreadGroup group = DirectoryThread.DIRECTORY_THREAD_GROUP; |
| | | final Thread[] threads = new Thread[group.activeCount() * 2 + 10]; |
| | | final int count = group.enumerate(threads, true); |
| | | for (int i = 0; i < count; i++) |
| | | { |
| | | if (name.equals(threads[i].getName())) |
| | | { |
| | | return threads[i]; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | } |