/*
* 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.plugins;
import static java.util.concurrent.TimeUnit.*;
import java.util.ArrayList;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.LocalizedIllegalArgumentException;
import org.forgerock.opendj.config.server.ConfigException;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.server.config.server.PluginCfg;
import org.opends.server.api.plugin.DirectoryServerPlugin;
import org.opends.server.api.plugin.PluginResult;
import org.opends.server.api.plugin.PluginType;
import org.opends.server.types.OperationType;
import org.opends.server.types.operation.PluginOperation;
import org.opends.server.types.operation.PreParseAddOperation;
import org.opends.server.types.operation.PreParseDeleteOperation;
import org.opends.server.types.operation.PreParseModifyDNOperation;
import org.opends.server.types.operation.PreParseModifyOperation;
/**
* A plugin which parks an operation at the pre-parse plugin point until the test releases
* it, so that a test can hold a thread inside {@code Operation.run()} for as long as it
* takes to do something else.
*
* The pre-parse point is the one the replayed operations of a replication domain go
* through - the pre-operation plugins are not invoked for synchronization operations - so
* this is how a test holds a replay thread between the moment it starts applying a change
* and the moment the change reaches the backend.
*
* A pause is registered for one operation type on one entry: this plugin is enabled for
* the whole unit test suite and for the internal operations too, so a pause which parked
* every operation of a type would park whatever else the server happens to be doing - and
* report that as the operation the test is waiting for. The park is bounded all the same:
* a test which never releases costs {@link #MAX_PAUSE_IN_MS} rather than a server thread.
*/
public class PausePreParsePlugin extends DirectoryServerPlugin
{
/**
* How long an operation is parked when nothing releases it. A test which forgets to
* release, or which fails before it could, must not leave a server thread parked for
* the rest of the run.
*/
public static final long MAX_PAUSE_IN_MS = 60000;
/** One registered pause: the entry it applies to, and what reports and releases it. */
private static final class Pause
{
/** The entry whose operations are parked. */
private final DN target;
/** Counted down by the first operation which reaches the pause. */
private final CountDownLatch reached = new CountDownLatch(1);
/** Counted down when the test releases the parked operations. */
private final CountDownLatch released = new CountDownLatch(1);
/** How many operations are parked here right now. */
private final AtomicInteger parked = new AtomicInteger();
private Pause(DN target)
{
this.target = target;
}
}
/** The pauses registered per operation type. */
private static final Map pauses = new ConcurrentHashMap<>();
/**
* Creates a new instance of this Directory Server plugin. Every plugin must implement a
* default constructor (it is the only one that will be used to create plugins defined in
* the configuration), and every plugin constructor must call super() as its
* first element.
*/
public PausePreParsePlugin()
{
super();
}
@Override
public void initializePlugin(Set pluginTypes, PluginCfg configuration) throws ConfigException
{
// This plugin may only be used as a pre-parse plugin.
for (PluginType t : pluginTypes)
{
switch (t)
{
case PRE_PARSE_ADD:
case PRE_PARSE_DELETE:
case PRE_PARSE_MODIFY:
case PRE_PARSE_MODIFY_DN:
// This is fine.
break;
default:
throw new ConfigException(
LocalizableMessage.raw("Invalid plugin type " + t + " for the pause pre-parse plugin."));
}
}
}
@Override
public void finalizePlugin()
{
/*
* A pause which outlived the test which registered it - one whose test timed out
* before its finally could run - must not park the operations of the tests which
* follow. This runs when the plugin is taken away, which is when the test server is
* stopped or restarted.
*/
for (OperationType operation : new ArrayList<>(pauses.keySet()))
{
release(operation);
}
}
@Override
public PluginResult.PreParse doPreParse(PreParseAddOperation addOperation)
{
pauseInternal(addOperation, addOperation.getRawEntryDN());
return PluginResult.PreParse.continueOperationProcessing();
}
@Override
public PluginResult.PreParse doPreParse(PreParseDeleteOperation deleteOperation)
{
pauseInternal(deleteOperation, deleteOperation.getRawEntryDN());
return PluginResult.PreParse.continueOperationProcessing();
}
@Override
public PluginResult.PreParse doPreParse(PreParseModifyOperation modifyOperation)
{
pauseInternal(modifyOperation, modifyOperation.getRawEntryDN());
return PluginResult.PreParse.continueOperationProcessing();
}
@Override
public PluginResult.PreParse doPreParse(PreParseModifyDNOperation modifyDNOperation)
{
pauseInternal(modifyDNOperation, modifyDNOperation.getRawEntryDN());
return PluginResult.PreParse.continueOperationProcessing();
}
/**
* Parks the operation if a pause is registered for its type and its entry, and reports
* that it reached the pause so that the test knows the thread is now inside
* {@code Operation.run()}.
*
* @param operation the operation which is being processed
* @param rawEntryDN the entry it is on, as the request carries it
*/
private void pauseInternal(PluginOperation operation, ByteString rawEntryDN)
{
final Pause pause = pauses.get(operation.getOperationType());
if (pause == null || !pause.target.equals(parseOrNull(rawEntryDN)))
{
return;
}
/*
* Counted before the pause is reported as reached, not after: a test which is told
* that an operation got here goes on to read parkedCount(), and a count incremented
* afterwards would leave that read racing this thread being scheduled.
*/
pause.parked.incrementAndGet();
pause.reached.countDown();
try
{
pause.released.await(MAX_PAUSE_IN_MS, MILLISECONDS);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
finally
{
pause.parked.decrementAndGet();
}
}
/** Returns the DN the request carries, or {@code null} when it does not parse. */
private static DN parseOrNull(ByteString rawEntryDN)
{
if (rawEntryDN == null)
{
return null;
}
try
{
return DN.valueOf(rawEntryDN.toString());
}
catch (LocalizedIllegalArgumentException e)
{
// Not the entry any pause is registered for, whatever it was meant to be.
return null;
}
}
/**
* Registers a pause: the operations of the given type on the given entry are parked at
* the pre-parse plugin point until {@link #release(OperationType)} is called.
*
* @param operation the type of operation to park
* @param target the entry whose operations are parked
*/
public static void pause(OperationType operation, DN target)
{
final Pause replaced = pauses.put(operation, new Pause(target));
if (replaced != null)
{
// Whatever was parked on the pause this one replaces would stay parked for the rest
// of MAX_PAUSE_IN_MS: the thread which registered it is not coming back for it.
replaced.released.countDown();
}
}
/**
* Waits until an operation reached the pause registered for the given operation type.
*
* @param operation the type of operation which was registered
* @param timeout how long to wait
* @param unit the unit of the timeout
* @return {@code true} when an operation reached the pause, {@code false} when the wait
* timed out
* @throws IllegalStateException when no pause is registered for that operation type,
* which is a caller waiting for something nothing can report rather than an
* operation which is slow to come
* @throws InterruptedException when the wait was interrupted
*/
public static boolean awaitPaused(OperationType operation, long timeout, TimeUnit unit)
throws InterruptedException
{
final Pause pause = pauses.get(operation);
if (pause == null)
{
/*
* Told apart from the timeout, and loudly: a pause registered for another operation
* type - the whole of the mistake - would otherwise be reported as the operation
* never coming, after the caller waited its whole budget out for it.
*/
throw new IllegalStateException(
"no pause is registered for " + operation + ": nothing can park on it, and nothing"
+ " will report that it did");
}
return pause.reached.await(timeout, unit);
}
/**
* Returns how many operations are parked right now by the pause registered for the given
* operation type.
*
* A test which took something down while an operation was parked reads this to say that
* it really did come down without waiting for it: the pause is only released by the test
* itself, so an operation which is still parked here never finished.
*
* To be read before the pause is released, and not after: the count is decremented by the
* parked thread itself, on its way out, so a release does not bring it back to zero by the
* time it returns.
*
* @param operation the type of operation which was registered
* @return the number of operations parked right now, 0 when no pause is registered
*/
public static int parkedCount(OperationType operation)
{
final Pause pause = pauses.get(operation);
return pause != null ? pause.parked.get() : 0;
}
/**
* Releases the operations parked by the pause registered for the given operation type,
* and deregisters it so that the operations which follow are not parked. Does nothing
* when no pause is registered, so that a test can release in a {@code finally} whatever
* happened.
*
* @param operation the type of operation which was registered
*/
public static void release(OperationType operation)
{
final Pause pause = pauses.remove(operation);
if (pause != null)
{
pause.released.countDown();
}
}
}