newAuthZSet = null;
- for (ClientConnection conn : connectionSet)
+ if (authNDN == null)
{
- if (authNDN == null)
+ authNDN = conn.getAuthenticationInfo().getAuthenticationDN();
+ try
{
- authNDN = conn.getAuthenticationInfo().getAuthenticationDN();
- try
- {
- newAuthNDN = authNDN.rename(oldDN, newDN);
- }
- catch (Exception e)
- {
- // Should not happen.
- logger.traceException(e);
- }
+ newAuthNDN = authNDN.rename(oldDN, newDN);
}
- if (authZDN == null)
+ catch (Exception e)
{
- authZDN = conn.getAuthenticationInfo().getAuthorizationDN();
- try
- {
- newAuthZDN = authZDN.rename(oldDN, newDN);
- }
- catch (Exception e)
- {
- // Should not happen.
- logger.traceException(e);
- }
+ // Should not happen.
+ logger.traceException(e);
}
- if (newAuthNDN != null && authNDN != null && authNDN.isSubordinateOrEqualTo(oldEntry.getName()))
+ }
+ if (authZDN == null)
+ {
+ authZDN = conn.getAuthenticationInfo().getAuthorizationDN();
+ try
{
- if (newAuthNSet == null)
- {
- newAuthNSet = new CopyOnWriteArraySet<>();
- }
- conn.getAuthenticationInfo().setAuthenticationDN(newAuthNDN);
- newAuthNSet.add(conn);
+ newAuthZDN = authZDN.rename(oldDN, newDN);
}
- if (newAuthZDN != null && authZDN != null && authZDN.isSubordinateOrEqualTo(oldEntry.getName()))
+ catch (Exception e)
{
- if (newAuthZSet == null)
- {
- newAuthZSet = new CopyOnWriteArraySet<>();
- }
- conn.getAuthenticationInfo().setAuthorizationDN(newAuthZDN);
- newAuthZSet.add(conn);
+ // Should not happen.
+ logger.traceException(e);
}
}
- if (newAuthNDN != null && newAuthNSet != null)
+ if (newAuthNDN != null && authNDN != null && authNDN.isSubordinateOrEqualTo(oldEntry.getName()))
{
- userMap.put(newAuthNDN, newAuthNSet);
+ if (newAuthNSet == null)
+ {
+ newAuthNSet = new CopyOnWriteArraySet<>();
+ }
+ conn.getAuthenticationInfo().setAuthenticationDN(newAuthNDN);
+ newAuthNSet.add(conn);
}
- if (newAuthZDN != null && newAuthZSet != null)
+ if (newAuthZDN != null && authZDN != null && authZDN.isSubordinateOrEqualTo(oldEntry.getName()))
{
- userMap.put(newAuthZDN, newAuthZSet);
+ if (newAuthZSet == null)
+ {
+ newAuthZSet = new CopyOnWriteArraySet<>();
+ }
+ conn.getAuthenticationInfo().setAuthorizationDN(newAuthZDN);
+ newAuthZSet.add(conn);
}
}
- }
- finally
- {
- lock.writeLock().unlock();
+ if (newAuthNDN != null && newAuthNSet != null)
+ {
+ userMap.put(newAuthNDN, newAuthNSet);
+ }
+ if (newAuthZDN != null && newAuthZSet != null)
+ {
+ userMap.put(newAuthZDN, newAuthZSet);
+ }
}
return PostResponse.continueOperationProcessing();
}
}
-
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/core/BindLatencyBenchmarkTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/core/BindLatencyBenchmarkTestCase.java
new file mode 100644
index 0000000000..cb6ea2b172
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/core/BindLatencyBenchmarkTestCase.java
@@ -0,0 +1,403 @@
+/*
+ * 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.core;
+
+import java.io.File;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+
+import org.opends.server.DirectoryServerTestCase;
+import org.opends.server.TestCaseUtils;
+import org.opends.server.tools.RemoteConnection;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+/**
+ * Server side latency micro-benchmark for the simple BIND operation under high
+ * concurrency.
+ *
+ * The benchmark opens {@code bind.bench.threads} persistent LDAP connections
+ * (one per thread), each repeatedly performing a simple BIND as a random
+ * one of {@code bind.bench.users} provisioned users for
+ * {@code bind.bench.durationSeconds} seconds, and reports the observed server
+ * side latency distribution (mean / p50 / p90 / p99 / max) and throughput.
+ *
+ * Many connections binding as different users is the canonical high-concurrency
+ * authentication workload and is the scenario that stresses the per-bind
+ * bookkeeping done in {@code ClientConnection.setAuthenticationInfo()} /
+ * {@code AuthenticatedUsers}, whose user map is a concurrent map so that binds
+ * for different users do not serialize on a single global lock.
+ *
+ * The benchmark is disabled by default so that it never runs as
+ * part of the normal test suite. Enable it explicitly, e.g.:
+ *
+ * JAVA_HOME=<jdk11> mvn -P precommit -pl opendj-server-legacy verify \
+ * -Dit.test=BindLatencyBenchmarkTestCase -DfailIfNoTests=false \
+ * -Dbind.bench=true -Dbind.bench.threads=200 \
+ * -Dbind.bench.durationSeconds=120 -Dbind.bench.label=before
+ *
+ * Results are printed to stdout and also written to
+ * {@code target/bind-bench-result-<label>.txt} (stdout may be suppressed
+ * during the test run).
+ */
+@SuppressWarnings("javadoc")
+public class BindLatencyBenchmarkTestCase extends DirectoryServerTestCase
+{
+ private static final String PASSWORD = "password";
+
+ /** Whether the benchmark is enabled (it is skipped otherwise). */
+ private static final boolean ENABLED = Boolean.getBoolean("bind.bench");
+ private static final int THREADS = Integer.getInteger("bind.bench.threads", 200);
+ /**
+ * Number of distinct users to provision and bind as. Each bind picks a random
+ * user, so binds spread across users (and, on the server, across the concurrent
+ * {@code AuthenticatedUsers} map) - this is what exposes per-bind lock
+ * contention. Defaults to the thread count.
+ */
+ private static final int USERS = Integer.getInteger("bind.bench.users", THREADS);
+
+ private static String userDN(int i)
+ {
+ return "uid=bench.user." + i + ",o=test";
+ }
+ private static final int DURATION_SECONDS = Integer.getInteger("bind.bench.durationSeconds", 120);
+ private static final int WARMUP_SECONDS = Integer.getInteger("bind.bench.warmupSeconds", 10);
+ private static final String LABEL = System.getProperty("bind.bench.label", "run");
+ private static final String HOST = System.getProperty("bind.bench.host", "127.0.0.1");
+
+ private volatile boolean running = true;
+ private volatile boolean recording;
+
+ @BeforeClass
+ public void setUp() throws Exception
+ {
+ TestCaseUtils.startServer();
+ TestCaseUtils.initializeTestBackend(true);
+ for (int i = 0; i < USERS; i++)
+ {
+ TestCaseUtils.addEntry(
+ "dn: " + userDN(i),
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "uid: bench.user." + i,
+ "givenName: Bench",
+ "sn: User " + i,
+ "cn: Bench User " + i,
+ "userPassword: " + PASSWORD);
+ }
+ }
+
+ @Test
+ public void benchmarkConcurrentBind() throws Exception
+ {
+ if (!ENABLED)
+ {
+ // Keep the regular test suite fast: the benchmark only runs when
+ // explicitly requested with -Dbind.bench=true.
+ System.out.println("BindLatencyBenchmarkTestCase skipped (set -Dbind.bench=true to run).");
+ return;
+ }
+
+ final int port = TestCaseUtils.getServerLdapPort();
+ final CountDownLatch ready = new CountDownLatch(THREADS);
+ final CountDownLatch startGate = new CountDownLatch(1);
+
+ final List workers = new ArrayList<>(THREADS);
+ final List threads = new ArrayList<>(THREADS);
+ for (int i = 0; i < THREADS; i++)
+ {
+ Worker w = new Worker(i, HOST, port, ready, startGate);
+ workers.add(w);
+ Thread t = new Thread(w, "bind-bench-" + i);
+ threads.add(t);
+ t.start();
+ }
+
+ // Wait until every worker has its connection ready, then release them all together.
+ assertTrue(ready.await(60, TimeUnit.SECONDS), "workers failed to connect in time");
+ startGate.countDown();
+
+ // Warm up (let JIT settle) without recording, then measure for the requested duration.
+ Thread.sleep(TimeUnit.SECONDS.toMillis(WARMUP_SECONDS));
+ long measureStart = System.nanoTime();
+ recording = true;
+ Thread.sleep(TimeUnit.SECONDS.toMillis(DURATION_SECONDS));
+ recording = false;
+ long measureEnd = System.nanoTime();
+ running = false;
+
+ for (Thread t : threads)
+ {
+ t.join(TimeUnit.SECONDS.toMillis(60));
+ }
+
+ // Aggregate results.
+ LatencyHistogram total = new LatencyHistogram();
+ long ops = 0;
+ long errors = 0;
+ for (Worker w : workers)
+ {
+ total.mergeFrom(w.hist);
+ ops += w.ops;
+ errors += w.errors;
+ }
+
+ double elapsedSeconds = (measureEnd - measureStart) / 1_000_000_000.0;
+ double throughput = ops / elapsedSeconds;
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n================ BIND latency benchmark [").append(LABEL).append("] ================\n");
+ sb.append(String.format(Locale.ROOT, "threads : %d%n", THREADS));
+ sb.append(String.format(Locale.ROOT, "measured duration : %.1f s (warmup %d s)%n", elapsedSeconds, WARMUP_SECONDS));
+ sb.append(String.format(Locale.ROOT, "bind operations : %d%n", ops));
+ sb.append(String.format(Locale.ROOT, "errors : %d%n", errors));
+ sb.append(String.format(Locale.ROOT, "throughput : %,.0f binds/s%n", throughput));
+ sb.append(String.format(Locale.ROOT, "latency mean : %.3f ms%n", total.meanMillis()));
+ sb.append(String.format(Locale.ROOT, "latency p50 : %.3f ms%n", total.percentileMillis(50.0)));
+ sb.append(String.format(Locale.ROOT, "latency p90 : %.3f ms%n", total.percentileMillis(90.0)));
+ sb.append(String.format(Locale.ROOT, "latency p99 : %.3f ms%n", total.percentileMillis(99.0)));
+ sb.append(String.format(Locale.ROOT, "latency p99.9 : %.3f ms%n", total.percentileMillis(99.9)));
+ sb.append(String.format(Locale.ROOT, "latency max : %.3f ms%n", total.maxMillis()));
+ sb.append("=========================================================================\n");
+ String report = sb.toString();
+
+ System.out.println(report);
+ writeReport(report);
+
+ // Basic sanity checks - this is a measurement, not a pass/fail gate.
+ assertEquals(errors, 0L, "some BIND operations failed");
+ assertTrue(ops > 0, "no BIND operations were recorded");
+ }
+
+ private void writeReport(String report)
+ {
+ String buildDir = System.getProperty("org.opends.server.BuildDir", "target");
+ File out = new File(buildDir, "bind-bench-result-" + LABEL + ".txt");
+ try (PrintWriter pw = new PrintWriter(out, "UTF-8"))
+ {
+ pw.print(report);
+ }
+ catch (Exception e)
+ {
+ System.out.println("Could not write benchmark report to " + out + ": " + e);
+ }
+ System.out.println("Benchmark report written to " + out.getAbsolutePath());
+ }
+
+ /** A single benchmark worker: owns one connection and binds in a tight loop. */
+ private final class Worker implements Runnable
+ {
+ private final int id;
+ private final String host;
+ private final int port;
+ private final CountDownLatch ready;
+ private final CountDownLatch startGate;
+ final LatencyHistogram hist = new LatencyHistogram();
+ long ops;
+ long errors;
+
+ Worker(int id, String host, int port, CountDownLatch ready, CountDownLatch startGate)
+ {
+ this.id = id;
+ this.host = host;
+ this.port = port;
+ this.ready = ready;
+ this.startGate = startGate;
+ }
+
+ @Override
+ public void run()
+ {
+ RemoteConnection conn = null;
+ try
+ {
+ conn = new RemoteConnection(host, port);
+ ready.countDown();
+ startGate.await();
+
+ while (running)
+ {
+ String dn = userDN(ThreadLocalRandom.current().nextInt(USERS));
+ long start = System.nanoTime();
+ try
+ {
+ conn.bind(dn, PASSWORD);
+ }
+ catch (Throwable t)
+ {
+ errors++;
+ conn = reconnect(conn);
+ continue;
+ }
+ long elapsed = System.nanoTime() - start;
+ if (recording)
+ {
+ hist.record(elapsed);
+ ops++;
+ }
+ }
+ }
+ catch (Throwable t)
+ {
+ errors++;
+ System.out.println("worker " + id + " aborted: " + t);
+ }
+ finally
+ {
+ close(conn);
+ }
+ }
+
+ private RemoteConnection reconnect(RemoteConnection old)
+ {
+ close(old);
+ try
+ {
+ return new RemoteConnection(host, port);
+ }
+ catch (Exception e)
+ {
+ return null;
+ }
+ }
+
+ private void close(RemoteConnection conn)
+ {
+ if (conn != null)
+ {
+ try
+ {
+ conn.close();
+ }
+ catch (Exception ignored)
+ {
+ // best effort
+ }
+ }
+ }
+ }
+
+ /**
+ * Compact log-linear latency histogram (HdrHistogram style, ~16 sub-buckets per
+ * power of two) with bounded memory and full dynamic range. Values are stored in
+ * microseconds; reporting is in milliseconds.
+ */
+ static final class LatencyHistogram
+ {
+ private static final int SUB_BITS = 4;
+ private static final int SUB_COUNT = 1 << SUB_BITS; // 16
+ private static final int SIZE = 512;
+
+ private final long[] counts = new long[SIZE];
+ private long count;
+ private long sumNanos;
+ private long maxNanos;
+
+ void record(long nanos)
+ {
+ long micros = (nanos + 500) / 1000;
+ counts[bucketIndex(micros)]++;
+ count++;
+ sumNanos += nanos;
+ if (nanos > maxNanos)
+ {
+ maxNanos = nanos;
+ }
+ }
+
+ void mergeFrom(LatencyHistogram other)
+ {
+ for (int i = 0; i < SIZE; i++)
+ {
+ counts[i] += other.counts[i];
+ }
+ count += other.count;
+ sumNanos += other.sumNanos;
+ if (other.maxNanos > maxNanos)
+ {
+ maxNanos = other.maxNanos;
+ }
+ }
+
+ static int bucketIndex(long micros)
+ {
+ if (micros < SUB_COUNT)
+ {
+ return (int) Math.max(0, micros);
+ }
+ int m = 63 - Long.numberOfLeadingZeros(micros); // floor(log2(micros))
+ int sub = (int) ((micros - (1L << m)) >> (m - SUB_BITS));
+ int idx = SUB_COUNT + (m - SUB_BITS) * SUB_COUNT + sub;
+ return Math.min(idx, SIZE - 1);
+ }
+
+ private static long bucketMidpointMicros(int idx)
+ {
+ if (idx < SUB_COUNT)
+ {
+ return idx;
+ }
+ int j = idx - SUB_COUNT;
+ int m = SUB_BITS + j / SUB_COUNT;
+ int sub = j % SUB_COUNT;
+ long lower = (1L << m) + ((long) sub << (m - SUB_BITS));
+ long width = 1L << (m - SUB_BITS);
+ return lower + width / 2;
+ }
+
+ double meanMillis()
+ {
+ return count == 0 ? 0.0 : (sumNanos / (double) count) / 1_000_000.0;
+ }
+
+ double maxMillis()
+ {
+ return maxNanos / 1_000_000.0;
+ }
+
+ double percentileMillis(double percentile)
+ {
+ if (count == 0)
+ {
+ return 0.0;
+ }
+ long target = (long) Math.ceil(percentile / 100.0 * count);
+ if (target < 1)
+ {
+ target = 1;
+ }
+ long cumulative = 0;
+ for (int i = 0; i < SIZE; i++)
+ {
+ cumulative += counts[i];
+ if (cumulative >= target)
+ {
+ return bucketMidpointMicros(i) / 1000.0;
+ }
+ }
+ return maxMillis();
+ }
+ }
+}