/*
* 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.backends.pluggable;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Field;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.backends.VerifyConfig;
import org.opends.server.backends.pluggable.spi.AccessMode;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.Storage;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteableTransaction;
import org.opends.server.core.ServerContext;
import org.opends.server.crypto.CryptoSuite;
import org.opends.server.types.LDIFExportConfig;
import org.testng.annotations.Test;
/**
* Which cursor the walks of a whole tree ask for (#877).
*
* {@code ReadableTransaction.openBulkCursor()} is a {@code default} answering exactly as
* {@code openCursor()} does, so every storage engine but the JDBC backend behaves the same either
* way and a call site turned back into {@code openCursor()} would compile, run, and stay invisible
* everywhere - while on the JDBC backend it would take the bound of a client operation, two
* minutes by default, and abort the walk of a tree larger than that. Some of these walks have
* nobody at a command line to see it happen: the load of the compressed schema and the read that
* checks id2entry is there, both on the path of {@code start-ds}, and the generation ID a
* replicated domain computes for itself the first time it starts, which is an export of the whole
* of id2entry.
*
* Each test below pins one such call site twice: that the bulk cursor is what it asks for, and
* that it asks for no cursor of an operation at all. The second half only bites where the walk
* really runs its body, so a walk whose fixture holds a record - {@code iterateDN2ID} - is given
* one: with an empty tree the loop stops on its first step and every {@code never()} below it
* passes on a run that reached nothing.
*
* Six call sites are pinned that way - {@code ExportJob}, the id2entry, dn2id and VLV walks of
* {@code VerifyJob}, and both trees of {@code PersistentCompressedSchema} - together with the
* children count each row of the dn2id walk reads and the total the progress report of a verify
* is sized with, the latter pinned one hop above its cursor. Three more are held by other means:
* {@code ID2Entry.afterOpen()} has {@code ID2EntryTest}, the override that gives the class its
* meaning has {@code JDBCStatementBoundTestCase}, and {@code VerifyJob.iterateID2ChildrenCount()}
* cannot be reverted at all, since {@code ID2ChildrenCount} exposes no cursor but the bulk one and
* the revert would not compile. The last three tests pin a delegation rather than its caller, which
* is all that is available for them.
*
* What none of this covers is the single-row {@code ReadableTransaction.read()} these same walks
* make - {@code id2entry.get()} once per row of dn2id and of a VLV index - which has no bulk form
* in the SPI at all and takes the class of the transaction it is made through. That is a gap of
* the SPI rather than of a call site: a read by primary key is not the scan a cursor batch is, so
* what it risks is a lock wait rather than a walk cut short.
*
* Three call sites are left uncovered and are named here rather than passed over: the attribute
* index of {@code verify-index} ({@code VerifyJob.iterateAttrIndex}), whose {@code MatchingRuleIndex}
* is {@code final} and so cannot be handed to this suite, and the two of {@code BackendStat}. All
* three walk a tree only on the command line of an operator.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public class BulkCursorTest extends DirectoryServerTestCase
{
private final TreeName id2entryName = new TreeName("dc=example,dc=com", "id2entry");
@SuppressWarnings("unchecked")
private static Cursor emptyCursor()
{
return mock(Cursor.class); // next() answers false, so the walk stops on its first step
}
/** A cursor over a single record, so that the body of a walk really runs once. */
@SuppressWarnings("unchecked")
private static Cursor cursorOver(ByteString key, ByteString value)
{
final Cursor cursor = mock(Cursor.class);
when(cursor.next()).thenReturn(true, false);
when(cursor.getKey()).thenReturn(key);
when(cursor.getValue()).thenReturn(value);
return cursor;
}
/** A transaction whose every cursor is the empty one above, whichever kind is asked for. */
private static ReadableTransaction transactionWithEmptyCursors()
{
final ReadableTransaction txn = mock(ReadableTransaction.class);
when(txn.openBulkCursor(any(TreeName.class))).thenReturn(emptyCursor());
when(txn.openCursor(any(TreeName.class))).thenReturn(emptyCursor());
return txn;
}
/**
* Gives a mock of a tree the name it would have been constructed with.
* {@code AbstractTree.getName()} is {@code public final} over a private field, so mockito cannot
* stub it and the instance it builds - whose constructor never runs - answers {@code null}. A
* production call of {@code openBulkCursor(index.getName())} then passes {@code null}, which
* {@code any(TreeName.class)} happily matches under the mockito pinned here: the assertion would
* accept a walk of any tree at all, the wrong one included.
*/
private static T named(T tree, TreeName name) throws Exception
{
final Field field = AbstractTree.class.getDeclaredField("name");
field.setAccessible(true);
field.set(tree, name);
return tree;
}
/**
* An {@code export-ldif} walks the whole of id2entry, and so does the generation ID a replicated
* domain computes for itself the first time it starts - {@code LDAPReplicationDomain
* .computeGenerationId()} exports the backend to compute it, with no client operation waiting on
* it and no operator watching it fail.
*/
@Test
public void testAnExportWalksId2entryWithABulkCursor() throws Exception
{
final ReadableTransaction txn = transactionWithEmptyCursors();
final EntryContainer entryContainer = mock(EntryContainer.class);
when(entryContainer.getID2Entry()).thenReturn(new ID2Entry(id2entryName, new DataConfig.Builder().build()));
// LDIFExportConfig is final and nothing is written here, the walk stopping on its first step
new ExportJob(new LDIFExportConfig(new ByteArrayOutputStream())).exportContainer(txn, entryContainer);
verify(txn).openBulkCursor(id2entryName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/** {@code verify-index} walks id2entry whole, checking every entry against the indexes. */
@Test
public void testAVerifyWalksId2entryWithABulkCursor() throws Exception
{
final ReadableTransaction txn = transactionWithEmptyCursors();
final VerifyJob job = new VerifyJob(mock(RootContainer.class), mock(VerifyConfig.class));
job.id2entry = new ID2Entry(id2entryName, new DataConfig.Builder().build());
job.iterateID2Entry(txn);
verify(txn).openBulkCursor(id2entryName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* And it walks dn2id whole as well, rebuilding the children counts as it goes - which is a read
* of the children count tree per DN, inside that walk and belonging to it. Read as a client
* operation those would put the bound of an entry read over a verify nobody is waiting on, once
* for every DN of the backend, so the tree here holds a record: with an empty one the walk stops
* before its first row and the assertions below hold whatever the counters do.
*/
@Test
public void testAVerifyWalksDn2idAndItsChildrenCountsWithBulkCursors() throws Exception
{
final TreeName dn2idName = new TreeName("dc=example,dc=com", "dn2id");
final TreeName id2childrenCountName = new TreeName("dc=example,dc=com", "id2childrencount");
final ReadableTransaction txn = transactionWithEmptyCursors();
// built before it is handed over: stubbing it inside the argument of thenReturn() would open a
// stubbing while the one it belongs to is still unfinished, which mockito refuses
final Cursor dn2idRows =
cursorOver(ByteString.valueOfUtf8("dc=example,dc=com"), ByteString.valueOfLong(1));
when(txn.openBulkCursor(dn2idName)).thenReturn(dn2idRows);
final VerifyJob job = new VerifyJob(mock(RootContainer.class), mock(VerifyConfig.class));
job.dn2id = new DN2ID(dn2idName, DN.valueOf("dc=example,dc=com"));
// read for the one row above, and answered with nothing: the entry it points at is not what
// this suite is about, and a missing one is counted as an error rather than thrown
job.id2entry = new ID2Entry(id2entryName, new DataConfig.Builder().build());
job.id2childrenCount = new ID2ChildrenCount(id2childrenCountName);
job.iterateDN2ID(txn);
verify(txn).openBulkCursor(dn2idName);
verify(txn).openBulkCursor(id2childrenCountName); // the count of the DN the walk just passed
verify(txn, never()).openCursor(any(TreeName.class));
}
/** A VLV index of a verify is walked whole too, key by key. */
@Test
public void testAVerifyWalksAVlvIndexWithABulkCursor() throws Exception
{
final TreeName vlvIndexName = new TreeName("dc=example,dc=com", "vlv.people");
final ReadableTransaction txn = transactionWithEmptyCursors();
final VerifyJob job = new VerifyJob(mock(RootContainer.class), mock(VerifyConfig.class));
job.iterateVLVIndex(txn, named(mock(VLVIndex.class), vlvIndexName), true);
verify(txn).openBulkCursor(vlvIndexName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* The compressed schema is loaded by walking both of its trees whole, while the backend opens:
* {@code RootContainer.open()} constructs it before a single client operation can run, and a
* walk cut short there is a backend that does not open at all.
*/
@Test
public void testTheCompressedSchemaIsLoadedWithBulkCursors() throws Exception
{
final WriteableTransaction txn = mock(WriteableTransaction.class);
// both trees are there and empty: loadTrees() walks only a tree that exists (#873), and the
// record counts a mock answers with leave nothing to migrate from the legacy pair
when(txn.treeExists(any(TreeName.class))).thenReturn(true);
when(txn.openBulkCursor(any(TreeName.class))).thenReturn(emptyCursor());
when(txn.openCursor(any(TreeName.class))).thenReturn(emptyCursor());
new PersistentCompressedSchema(mock(ServerContext.class), "bulkCursorTest", mock(Storage.class), txn,
AccessMode.READ_ONLY);
verify(txn, times(2)).openBulkCursor(any(TreeName.class)); // the object classes and the attributes
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* An index walked whole - by {@code verify-index} or by {@code dbtest} - asks the transaction
* for a bulk cursor, while {@code Index.openCursor()} stays what an operation evaluating a
* filter takes. This pins the delegation rather than either of its call sites: the index a
* verify walks is a {@code MatchingRuleIndex}, which is {@code final} and cannot be handed to a
* mock, and the one {@code dbtest} walks is chosen inside {@code BackendStat}.
*/
@Test
public void testAnIndexWalkedWholeAsksForABulkCursor() throws Exception
{
final TreeName indexName = new TreeName("dc=example,dc=com", "cn.caseIgnoreMatch");
final ReadableTransaction txn = transactionWithEmptyCursors();
final CryptoSuite cryptoSuite = mock(CryptoSuite.class);
when(cryptoSuite.isEncrypted()).thenReturn(false);
final DefaultIndex index =
new DefaultIndex(indexName, mock(State.class), 5, mock(EntryContainer.class), cryptoSuite);
index.openBulkCursor(txn);
verify(txn).openBulkCursor(indexName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* The children counts are walked whole by {@code verify-index} through {@code ShardedCounter}.
* The delegation again, its one call site - {@code VerifyJob.iterateID2ChildrenCount()} - being
* held by the compiler instead: {@code ID2ChildrenCount} exposes no cursor but this one, so a
* revert to {@code openCursor} does not compile.
*/
@Test
public void testTheChildrenCountsAreWalkedWithABulkCursor() throws Exception
{
final TreeName id2childrenCountName = new TreeName("dc=example,dc=com", "id2childrencount");
final ReadableTransaction txn = transactionWithEmptyCursors();
new ID2ChildrenCount(id2childrenCountName).openBulkCursor(txn);
verify(txn).openBulkCursor(id2childrenCountName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* The record check inside that walk is bulk as well: {@code VerifyJob.iterateID2ChildrenCount()}
* asks it once per record of the children count tree, so a cursor of a client operation there is
* the same hazard as one over the tree itself. The delegation again - that walk is private, and
* {@code containsEntryID} has no other caller to keep an operation-class form for.
*/
@Test
public void testTheRecordCheckOfAWholeTreeWalkAsksForABulkCursor() throws Exception
{
final ReadableTransaction txn = transactionWithEmptyCursors();
new ID2Entry(id2entryName, new DataConfig.Builder().build()).containsEntryID(txn, new EntryID(1));
verify(txn).openBulkCursor(id2entryName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/**
* And a count read outside such a walk is a client operation, which the bulk read above must not
* quietly turn into: an LDAP search asking for {@code numSubordinates} reads one, and there a
* bound of a client operation is exactly what it should take.
*/
@Test
public void testAChildrenCountOfAClientOperationStaysAnOperation() throws Exception
{
final TreeName id2childrenCountName = new TreeName("dc=example,dc=com", "id2childrencount");
final ReadableTransaction txn = transactionWithEmptyCursors();
new ID2ChildrenCount(id2childrenCountName).getCount(txn, new EntryID(1));
verify(txn).openCursor(id2childrenCountName);
verify(txn, never()).openBulkCursor(any(TreeName.class));
}
/**
* The total is one such count read on one key, so it takes the class of whoever asks: the walk
* that reads it to size its progress report, or the client operation that reads the same total.
*/
@Test
public void testTheTotalCountOfAWholeTreeWalkAsksForABulkCursor() throws Exception
{
final TreeName id2childrenCountName = new TreeName("dc=example,dc=com", "id2childrencount");
final ReadableTransaction txn = transactionWithEmptyCursors();
new ID2ChildrenCount(id2childrenCountName).getTotalCount(txn, true);
verify(txn).openBulkCursor(id2childrenCountName);
verify(txn, never()).openCursor(any(TreeName.class));
}
/** And {@code cn=monitor} reading the same total is a client operation. */
@Test
public void testTheTotalCountOfAClientOperationStaysAnOperation() throws Exception
{
final TreeName id2childrenCountName = new TreeName("dc=example,dc=com", "id2childrencount");
final ReadableTransaction txn = transactionWithEmptyCursors();
new ID2ChildrenCount(id2childrenCountName).getTotalCount(txn);
verify(txn).openCursor(id2childrenCountName);
verify(txn, never()).openBulkCursor(any(TreeName.class));
}
/**
* The count a verify reads to size its progress report belongs to the walk it measures. Its
* three siblings - the record counts of dn2id, of the children count tree and of a VLV index -
* are bulk by the tree they count, and this one was the branch left reading as a client
* operation: it is also the only one a plain {@code verify-index} reaches, the other three
* being the {@code --clean} path.
*
* Pinned on the container rather than on a cursor, that read being one hop further down:
* {@code getNumberOfEntriesInBaseDN0} to {@code ID2ChildrenCount.getTotalCount} to the cursor
* the two tests above pin.
*/
@Test
public void testTheProgressCountOfAVerifyIsReadAsPartOfItsWalk() throws Exception
{
final DN baseDN = DN.valueOf("dc=example,dc=com");
final VerifyConfig verifyConfig = mock(VerifyConfig.class);
when(verifyConfig.getBaseDN()).thenReturn(baseDN);
final EntryContainer entryContainer = mock(EntryContainer.class);
final RootContainer rootContainer = mock(RootContainer.class);
when(rootContainer.getEntryContainer(baseDN)).thenReturn(entryContainer);
final ReadableTransaction txn = transactionWithEmptyCursors();
// false: the entry iterator, which is what a verify-index runs unless it was given --clean
new VerifyJob(rootContainer, verifyConfig).new ProgressTask(false, txn);
verify(entryContainer).getNumberOfEntriesInBaseDN0(txn, true);
verify(entryContainer, never()).getNumberOfEntriesInBaseDN0(txn);
}
}