/* * 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(); } } }