+ * The bound is asked of the driver rather than of the session, because a pooled connection
+ * cannot carry a session setting - {@code CachedConnection.close()} only rolls back, so a
+ * {@code statement_timeout} of one operation would apply to whoever borrows the connection
+ * next - and it is applied in two layers, since the first one is not answered everywhere:
+ * {@code setQueryTimeout} cancels the statement and keeps the connection, while the socket read
+ * timeout behind it ends the wait even when the cancel is not acted upon. Oracle needs that
+ * second layer: a session blocked in a row-lock enqueue does not process the break its driver
+ * sends, so the timeout is armed and never arrives (the container suites cover it). That second
+ * layer belongs to the connection rather than to the statement, so it is arbitrated between the
+ * statements running on one - see {@link Backstop}.
+ */
+ private
+ * So the value armed is the loosest of the bounds of the statements in flight, and a statement
+ * with no bound of its own takes it off for as long as it runs: this backstop exists to end a
+ * wait nothing else would end, never to cut a statement that was told it may take as long as it
+ * needs. What the connection carried before is put back when the last of them is through.
+ */
+ private static final class Backstop {
+ /** Bounds of the statements in flight, in milliseconds and by count, the loosest last. */
+ final TreeMap
+ * The batches of such a cursor are bulk statements however ordinary they look: nobody is
+ * waiting on the walk, and on mssql it is not even a walk along an index - {@code k} is a
+ * {@code varbinary(max)} there, which cannot be an index key, so every batch is a scan and
+ * a sort of the table. Bounding those as entry reads aborted an export or a rebuild that
+ * ran to the end before this bound existed.
+ */
+ @Override
+ public Cursor
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/ID2Entry.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/ID2Entry.java
index 72bbded077..9347029659 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/ID2Entry.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/ID2Entry.java
@@ -382,7 +382,11 @@ void afterOpen(WriteableTransaction txn, boolean createOnDemand) throws StorageR
{
// Make sure the tree is there and readable, even if the storage is READ_ONLY.
// Would be nice if there were a better way...
- try (final Cursor
+ * A storage engine that bounds how long a statement may take must not bound such a walk as it
+ * bounds the work of a client operation: what this legitimately takes follows the size of the
+ * tree, and cutting it short fails an administrative task that would otherwise have run to the
+ * end. An engine with no such bound - every one but the JDBC backend - answers this exactly as
+ * {@link #openCursor(TreeName)} does.
+ *
+ * @param treeName
+ * the tree name
+ * @return a new cursor
+ */
+ default Cursor" - the bulk class - is reachable:
+ // AbstractTwoPhaseImportStrategy clears every tree before an import writes to it
+ try (final Importer importer = storage.startImport()) {
+ importer.clearTree(tree);
+ }
+ }
+ });
+ }
+
+ private interface BlockedOperation {
+ void run(JDBCStorage storage, TreeName tree) throws Exception;
+ }
+
+ /**
+ * Whether the failure the operation gave up with is the one its bound produced: the message of
+ * a statement classified as having reached its bound names the property that bounded it, and it
+ * arrives wrapped in whatever the storage throws to its caller.
+ */
+ private static boolean namesTheBound(Throwable failure, JDBCStorage.StatementBound bound) {
+ for (Throwable t = failure; t != null && t != t.getCause(); t = t.getCause()) {
+ if (t.getMessage() != null && t.getMessage().contains(bound.property)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** How far under its bound a statement may report the cancel, the timer of a driver being coarse. */
+ private static final long CLOCK_SLACK_MILLIS = 250;
+
+ /**
+ * Runs the given operation while another session holds every row of the tree in an uncommitted
+ * transaction, with only the property of the given class bounding it: the operation must give
+ * up inside that bound instead of waiting for a lock that is never released.
+ */
+ private void assertBoundedWhileRowsAreLocked(String treeId, JDBCStorage.StatementBound bound, BlockedOperation blocked)
+ throws Exception {
+ final int boundSeconds = 5;
+ final JDBCStorage storage = new JDBCStorage(createBackendCfg(), null);
+ final TreeName tree = new TreeName(treeId, "tree");
+ try {
+ storage.open(AccessMode.READ_WRITE);
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.openTree(tree, true);
+ txn.put(tree, key(1), value(1));
+ }
+ });
+ // another session takes an exclusive lock on every row of the table and keeps it: the
+ // same statement clearTree() issues, so it is known to parse on all four dialects
+ try (final Connection blocker = DriverManager.getConnection(getJdbcUrl())) {
+ blocker.setAutoCommit(false);
+ try (final Statement lock = blocker.createStatement()) {
+ lock.executeUpdate("delete from " + storage.getTableName(tree));
+ }
+ // only the class under test is bounded, so a pass through the other one cannot
+ // be mistaken for the bound working
+ for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
+ System.setProperty(each.property, each == bound ? Integer.toString(boundSeconds) : "0");
+ }
+ // the monotonic clock, which is what timedOut() measures the bound with: a step of
+ // the wall clock can neither lengthen nor shorten what the assertions below allow
+ final long startedAt = System.nanoTime();
+ Exception failure = null;
+ try {
+ blocked.run(storage, tree);
+ fail("the operation must give up while the rows it needs are locked");
+ } catch (Exception expected) {
+ failure = expected; // the bound was reached and the transaction rolled back
+ }
+ final long elapsed = (System.nanoTime() - startedAt) / 1000000L;
+ // The failure has to be the one the bound produces, not any failure at all: an
+ // operation that fell over at once for an unrelated reason would otherwise pass
+ // this test at t=0. timedOut() names the property in the message of everything it
+ // classifies as reaching the bound.
+ assertTrue(namesTheBound(failure, bound), "gave up with " + stackTraceToSingleLineString(failure)
+ + ", which does not name " + bound.property);
+ // And it has to arrive at the bound rather than at something else that happens to
+ // end the wait inside a generous ceiling: with the bound deleted, mysql would still
+ // come back after its own innodb_lock_wait_timeout of 50 s, and the assertion has
+ // to fail then. Oracle is given the second layer as well - a session blocked in a
+ // row-lock enqueue does not act on the break its driver sends, so the wait there
+ // ends at the socket read timeout, which is the bound plus its margin.
+ final long ceilingSeconds = getJdbcUrl().startsWith("jdbc:oracle")
+ ? boundSeconds + JDBCStorage.BACKSTOP_MARGIN_SECONDS + 10 : boundSeconds * 4L;
+ // with a little slack under the bound: a driver keeps its timer in whole seconds and
+ // may report the cancel a few milliseconds before the bound is arithmetically due
+ assertTrue(elapsed >= boundSeconds * 1000L - CLOCK_SLACK_MILLIS,
+ "gave up after " + elapsed + " ms, before its bound of "
+ + boundSeconds + " s: something other than the bound ended the wait");
+ assertTrue(elapsed < ceilingSeconds * 1000L, "gave up only after " + elapsed + " ms, past the "
+ + ceilingSeconds + " s this bound of " + boundSeconds + " s allows");
+ blocker.rollback();
+ }
+ } finally {
+ for (final JDBCStorage.StatementBound each : JDBCStorage.StatementBound.values()) {
+ System.clearProperty(each.property);
+ }
+ try {
+ storage.write(new WriteOperation() {
+ @Override
+ public void run(WriteableTransaction txn) throws Exception {
+ txn.deleteTree(tree);
+ }
+ });
+ } catch (Exception ignored) {}
+ storage.close();
+ }
+ }
+
/**
* Forward repositioning inside the already-fetched batch must be served from the buffer without SQL,
* and batch sizes must grow from "fetchsize.initial" to "fetchsize" on sequential reads (#860).
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ID2EntryTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ID2EntryTest.java
new file mode 100644
index 0000000000..b1633152a9
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ID2EntryTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.forgerock.opendj.ldap.ByteString;
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.backends.pluggable.spi.Cursor;
+import org.opends.server.backends.pluggable.spi.TreeName;
+import org.opends.server.backends.pluggable.spi.WriteableTransaction;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
+public class ID2EntryTest extends DirectoryServerTestCase
+{
+ /**
+ * The read that checks the tree is there when a backend opens asks for a bulk cursor. Its first
+ * batch carries no key to seek on, so a storage engine sees a walk of the whole tree - on the
+ * JDBC backend against SQL Server, a scan and a sort of it, {@code k} being a
+ * {@code varbinary(max)} that cannot be an index key - and this runs once per base DN on every
+ * open, outside the try/catch of {@code BackendImpl.openBackend()}. Bounded as the work of a
+ * client operation, a large backend would stop opening at all (#877).
+ */
+ @Test
+ public void testTheReadThatOpensTheTreeAsksForABulkCursor() throws Exception
+ {
+ final TreeName name = new TreeName("dc=example,dc=com", "id2entry");
+ final WriteableTransaction txn = mock(WriteableTransaction.class);
+ @SuppressWarnings("unchecked")
+ final Cursor