From 4a77a1f6a2f1c8ddd53c1bed792d56a46d6bf456 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 25 Jun 2026 20:46:15 +0300 Subject: [PATCH 1/2] Fix duplicate SNMP connection handler entries in packaged config.ldif The `snmp` profile assembles the server template config.ldif in two antrun steps: `copy-config-ldif` copies the pristine resource/config/config.ldif into target/template/config, and `generate-config-ldif` appends config.snmp.ldif to it with `concat append="true"`. The copy used Ant's default `overwrite="false"`, so on an incremental build (without `clean`) the already-populated target file was newer than the source and was not refreshed, while the concat appended the SNMP fragment again on every build. Repeated builds therefore accumulated several `cn=SNMP Connection Handler,cn=Connection Handlers,cn=config` entries, and `setup` then failed with "Entry Already Exists ... multiple times". Add `overwrite="true"` to the config.ldif copy so the SNMP fragment is always appended to a freshly copied base file, making config.ldif generation idempotent regardless of incremental builds. --- opendj-server-legacy/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendj-server-legacy/pom.xml b/opendj-server-legacy/pom.xml index 9d53b1d0bc..53024af3db 100644 --- a/opendj-server-legacy/pom.xml +++ b/opendj-server-legacy/pom.xml @@ -1107,7 +1107,7 @@ generate-resources - + From fe11ac3722a8988ce7023811b97e4aa38b27d3b2 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 25 Jun 2026 22:25:17 +0300 Subject: [PATCH 2/2] Remove per-bind global lock contention in AuthenticatedUsers Every successful bind (and unbind) registers/deregisters the client connection in the global AuthenticatedUsers map via ClientConnection.setAuthenticationInfo(). The map was a DITCacheMap guarded by a single ReentrantReadWriteLock, so put()/remove() serialized on one write lock that is taken on the hot path of every authentication. Under high concurrency this is a real bottleneck: with 200 connections binding as different users, roughly half of the worker threads were parked acquiring that lock (AuthenticatedUsers.put/remove -> WriteLock.lock), confirmed by jstack sampling, even though the connections target different users and never actually conflict. Replace the DITCacheMap + global lock with a ConcurrentHashMap>: - put/remove/get are now lock-free at the map level (bin-granularity locking via computeIfAbsent/computeIfPresent); binds for different users no longer serialize. - The post-response handlers that react to changes of authenticated user entries are adjusted accordingly: - modify uses an exact-DN lookup (O(1)) - a modify never affects descendants, so no subtree scan is needed; - delete and modify-DN keep subtree semantics via a single key-set pass (removeSubtree), guarded by an isEmpty() fast-exit; the redundant pre-check method operationDoesNotTargetAuthenticatedUser is removed. Public behaviour and the get() return type (CopyOnWriteArraySet) are unchanged. Measured on a packaged JE server (5000 users, 200 threads, persistent connections rebinding as random distinct users): throughput 18.4k -> ~23k binds/s (+~25%) mean 11.0 -> ~8.5 ms p50 9.5 -> ~6.5 ms workers parked on AuthenticatedUsers lock: ~half -> 0 Add BindLatencyBenchmarkTestCase: an opt-in concurrent BIND latency / throughput benchmark (N threads, distinct users, persistent connections) used to capture the before/after numbers. It is disabled by default and only runs with -Dbind.bench=true, so it never slows the normal test suite. --- .../server/core/AuthenticatedUsers.java | 252 +++++------ .../core/BindLatencyBenchmarkTestCase.java | 403 ++++++++++++++++++ 2 files changed, 503 insertions(+), 152 deletions(-) create mode 100644 opendj-server-legacy/src/test/java/org/opends/server/core/BindLatencyBenchmarkTestCase.java diff --git a/opendj-server-legacy/src/main/java/org/opends/server/core/AuthenticatedUsers.java b/opendj-server-legacy/src/main/java/org/opends/server/core/AuthenticatedUsers.java index 33461b6513..4653d84014 100644 --- a/opendj-server-legacy/src/main/java/org/opends/server/core/AuthenticatedUsers.java +++ b/opendj-server-legacy/src/main/java/org/opends/server/core/AuthenticatedUsers.java @@ -13,21 +13,23 @@ * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2011-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.core; import java.util.EnumSet; import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.locks.ReentrantReadWriteLock; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.ResultCode; import org.opends.server.api.ClientConnection; -import org.opends.server.api.DITCacheMap; import org.opends.server.api.plugin.InternalDirectoryServerPlugin; import org.opends.server.api.plugin.PluginResult.PostResponse; import org.opends.server.types.DisconnectReason; @@ -48,6 +50,13 @@ * This class also provides a mechanism for detecting changes to authenticated * user entries and notifying the corresponding client connections so that they * can update their cached versions. + *

+ * The user map is a {@link ConcurrentHashMap}, so registering and deregistering + * a connection (which happens on every bind and unbind) is lock-free at the map + * level and only contends at the granularity of a single hash bin. The subtree + * operations triggered by changes to authenticated user entries (delete / + * modify / modify DN) are rare and scan the key set, which the concurrent map + * supports with weakly-consistent iteration. */ public class AuthenticatedUsers extends InternalDirectoryServerPlugin { @@ -57,10 +66,7 @@ public class AuthenticatedUsers extends InternalDirectoryServerPlugin * The mapping between authenticated user DNs and the associated client * connection objects. */ - private final DITCacheMap> userMap; - - /** Lock to protect internal data structures. */ - private final ReentrantReadWriteLock lock; + private final ConcurrentHashMap> userMap; /** Dummy configuration DN. */ private static final String CONFIG_DN = "cn=Authenticated Users,cn=config"; @@ -75,8 +81,7 @@ public AuthenticatedUsers() // can not be authenticated as a user that does not exist yet. POST_RESPONSE_MODIFY, POST_RESPONSE_MODIFY_DN, POST_RESPONSE_DELETE), true); - userMap = new DITCacheMap<>(); - lock = new ReentrantReadWriteLock(); + userMap = new ConcurrentHashMap<>(); DirectoryServer.registerInternalPlugin(this); } @@ -91,25 +96,7 @@ public AuthenticatedUsers() */ public void put(DN userDN, ClientConnection clientConnection) { - lock.writeLock().lock(); - try - { - CopyOnWriteArraySet connectionSet = userMap.get(userDN); - if (connectionSet == null) - { - connectionSet = new CopyOnWriteArraySet<>(); - connectionSet.add(clientConnection); - userMap.put(userDN, connectionSet); - } - else - { - connectionSet.add(clientConnection); - } - } - finally - { - lock.writeLock().unlock(); - } + userMap.computeIfAbsent(userDN, k -> new CopyOnWriteArraySet<>()).add(clientConnection); } @@ -124,23 +111,11 @@ public void put(DN userDN, ClientConnection clientConnection) */ public void remove(DN userDN, ClientConnection clientConnection) { - lock.writeLock().lock(); - try + userMap.computeIfPresent(userDN, (k, connectionSet) -> { - CopyOnWriteArraySet connectionSet = userMap.get(userDN); - if (connectionSet != null) - { - connectionSet.remove(clientConnection); - if (connectionSet.isEmpty()) - { - userMap.remove(userDN); - } - } - } - finally - { - lock.writeLock().unlock(); - } + connectionSet.remove(clientConnection); + return connectionSet.isEmpty() ? null : connectionSet; + }); } @@ -158,38 +133,23 @@ public void remove(DN userDN, ClientConnection clientConnection) */ public CopyOnWriteArraySet get(DN userDN) { - lock.readLock().lock(); - try - { - return userMap.get(userDN); - } - finally - { - lock.readLock().unlock(); - } + return userMap.get(userDN); } @Override public PostResponse doPostResponse(PostResponseDeleteOperation op) { final DN entryDN = op.getEntryDN(); - if (op.getResultCode() != ResultCode.SUCCESS || operationDoesNotTargetAuthenticatedUser(entryDN)) + if (op.getResultCode() != ResultCode.SUCCESS || userMap.isEmpty()) { return PostResponse.continueOperationProcessing(); } - // Identify any client connections that may be authenticated - // or authorized as the user whose entry has been deleted and terminate them - Set> arraySet = new HashSet<>(); - lock.writeLock().lock(); - try - { - userMap.removeSubtree(entryDN, arraySet); - } - finally - { - lock.writeLock().unlock(); - } + // Identify any client connections that may be authenticated or authorized as + // the user whose entry has been deleted (or, for a subtree delete, any user + // below it) and terminate them. A single removeSubtree pass both detects and + // collects the matches, so no separate pre-check scan is needed. + Set> arraySet = removeSubtree(entryDN); for (CopyOnWriteArraySet connectionSet : arraySet) { @@ -202,54 +162,52 @@ public PostResponse doPostResponse(PostResponseDeleteOperation op) return PostResponse.continueOperationProcessing(); } - private boolean operationDoesNotTargetAuthenticatedUser(final DN entryDN) + /** + * Removes and returns every connection set whose user DN is at or below the + * provided base DN. + */ + private Set> removeSubtree(DN baseDN) { - lock.readLock().lock(); - try - { - return !userMap.containsSubtree(entryDN); - } - finally + Set> removed = new HashSet<>(); + for (Iterator>> it = userMap.entrySet().iterator(); + it.hasNext();) { - lock.readLock().unlock(); + Map.Entry> entry = it.next(); + if (entry.getKey().isSubordinateOrEqualTo(baseDN)) + { + removed.add(entry.getValue()); + it.remove(); + } } + return removed; } @Override public PostResponse doPostResponse(PostResponseModifyOperation op) { final Entry oldEntry = op.getCurrentEntry(); - if (op.getResultCode() != ResultCode.SUCCESS || oldEntry == null - || operationDoesNotTargetAuthenticatedUser(oldEntry.getName())) + if (op.getResultCode() != ResultCode.SUCCESS || oldEntry == null) { return PostResponse.continueOperationProcessing(); } - // Identify any client connections that may be authenticated - // or authorized as the user whose entry has been modified - // and update them with the latest version of the entry - // including any virtual attributes. - lock.writeLock().lock(); - try + // A modify only changes the target entry itself, never its descendants, so an + // exact-DN lookup is sufficient (no subtree scan). Identify any client + // connections authenticated or authorized as that user and update them with + // the latest version of the entry, including any virtual attributes. + CopyOnWriteArraySet connectionSet = userMap.get(oldEntry.getName()); + if (connectionSet != null) { - CopyOnWriteArraySet connectionSet = userMap.get(oldEntry.getName()); - if (connectionSet != null) + Entry newEntry = null; + for (ClientConnection conn : connectionSet) { - Entry newEntry = null; - for (ClientConnection conn : connectionSet) + if (newEntry == null) { - if (newEntry == null) - { - newEntry = op.getModifiedEntry().duplicate(true); - } - conn.updateAuthenticationInfo(oldEntry, newEntry); + newEntry = op.getModifiedEntry().duplicate(true); } + conn.updateAuthenticationInfo(oldEntry, newEntry); } } - finally - { - lock.writeLock().unlock(); - } return PostResponse.continueOperationProcessing(); } @@ -259,7 +217,7 @@ public PostResponse doPostResponse(PostResponseModifyDNOperation op) final Entry oldEntry = op.getOriginalEntry(); final Entry newEntry = op.getUpdatedEntry(); if (op.getResultCode() != ResultCode.SUCCESS || oldEntry == null || newEntry == null - || operationDoesNotTargetAuthenticatedUser(oldEntry.getName())) + || userMap.isEmpty()) { return PostResponse.continueOperationProcessing(); } @@ -270,81 +228,71 @@ public PostResponse doPostResponse(PostResponseModifyDNOperation op) // Identify any client connections that may be authenticated // or authorized as the user whose entry has been modified // and update them with the latest version of the entry. - lock.writeLock().lock(); - try + final Set> arraySet = removeSubtree(oldEntry.getName()); + for (CopyOnWriteArraySet connectionSet : arraySet) { - final Set> arraySet = new HashSet<>(); - userMap.removeSubtree(oldEntry.getName(), arraySet); - for (CopyOnWriteArraySet connectionSet : arraySet) + DN authNDN = null; + DN authZDN = null; + DN newAuthNDN = null; + DN newAuthZDN = null; + CopyOnWriteArraySet newAuthNSet = null; + CopyOnWriteArraySet newAuthZSet = null; + for (ClientConnection conn : connectionSet) { - DN authNDN = null; - DN authZDN = null; - DN newAuthNDN = null; - DN newAuthZDN = null; - CopyOnWriteArraySet newAuthNSet = null; - CopyOnWriteArraySet 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(); + } + } +}