opendj-server-legacy/src/main/java/org/opends/server/core/AuthenticatedUsers.java
@@ -13,21 +13,22 @@ * * 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.CopyOnWriteArraySet; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.concurrent.ConcurrentHashMap; 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 +49,17 @@ * 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. * <BR><BR> * 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. Each value is * a concurrent set with O(1) add/remove, so many connections authenticating as * the same user (e.g. an application service account) do not degrade: a * copy-on-write set here would copy the whole connection array under the bin * lock on every bind. 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 +69,7 @@ * The mapping between authenticated user DNs and the associated client * connection objects. */ private final DITCacheMap<CopyOnWriteArraySet<ClientConnection>> userMap; /** Lock to protect internal data structures. */ private final ReentrantReadWriteLock lock; private final ConcurrentHashMap<DN, Set<ClientConnection>> userMap; /** Dummy configuration DN. */ private static final String CONFIG_DN = "cn=Authenticated Users,cn=config"; @@ -75,8 +84,7 @@ // 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 +99,19 @@ */ public void put(DN userDN, ClientConnection clientConnection) { lock.writeLock().lock(); try // The add must happen inside compute(), under the same bin lock as // remove(): with computeIfAbsent(..).add(..) a concurrent remove() could // unmap the set between the two calls and the connection would be // registered in a set no longer reachable from the map. userMap.compute(userDN, (dn, connectionSet) -> { CopyOnWriteArraySet<ClientConnection> connectionSet = userMap.get(userDN); if (connectionSet == null) { connectionSet = new CopyOnWriteArraySet<>(); connectionSet.add(clientConnection); userMap.put(userDN, connectionSet); connectionSet = ConcurrentHashMap.newKeySet(); } else { connectionSet.add(clientConnection); } } finally { lock.writeLock().unlock(); } connectionSet.add(clientConnection); return connectionSet; }); } @@ -124,23 +126,11 @@ */ public void remove(DN userDN, ClientConnection clientConnection) { lock.writeLock().lock(); try userMap.computeIfPresent(userDN, (k, connectionSet) -> { CopyOnWriteArraySet<ClientConnection> 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; }); } @@ -156,42 +146,27 @@ * @return The set of client connections authenticated as the specified user, * or {@code null} if there are none. */ public CopyOnWriteArraySet<ClientConnection> get(DN userDN) public Set<ClientConnection> 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<CopyOnWriteArraySet<ClientConnection>> 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<Set<ClientConnection>> arraySet = removeSubtree(entryDN); for (CopyOnWriteArraySet<ClientConnection> connectionSet : arraySet) for (Set<ClientConnection> connectionSet : arraySet) { for (ClientConnection conn : connectionSet) { @@ -202,54 +177,52 @@ 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<Set<ClientConnection>> removeSubtree(DN baseDN) { lock.readLock().lock(); try Set<Set<ClientConnection>> removed = new HashSet<>(); for (Iterator<Map.Entry<DN, Set<ClientConnection>>> it = userMap.entrySet().iterator(); it.hasNext();) { return !userMap.containsSubtree(entryDN); Map.Entry<DN, Set<ClientConnection>> entry = it.next(); if (entry.getKey().isSubordinateOrEqualTo(baseDN)) { removed.add(entry.getValue()); it.remove(); } } finally { lock.readLock().unlock(); } 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. Set<ClientConnection> connectionSet = userMap.get(oldEntry.getName()); if (connectionSet != null) { CopyOnWriteArraySet<ClientConnection> 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 +232,7 @@ 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 +243,71 @@ // 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<Set<ClientConnection>> arraySet = removeSubtree(oldEntry.getName()); for (Set<ClientConnection> connectionSet : arraySet) { final Set<CopyOnWriteArraySet<ClientConnection>> arraySet = new HashSet<>(); userMap.removeSubtree(oldEntry.getName(), arraySet); for (CopyOnWriteArraySet<ClientConnection> connectionSet : arraySet) DN authNDN = null; DN authZDN = null; DN newAuthNDN = null; DN newAuthZDN = null; Set<ClientConnection> newAuthNSet = null; Set<ClientConnection> newAuthZSet = null; for (ClientConnection conn : connectionSet) { DN authNDN = null; DN authZDN = null; DN newAuthNDN = null; DN newAuthZDN = null; CopyOnWriteArraySet<ClientConnection> newAuthNSet = null; CopyOnWriteArraySet<ClientConnection> 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); } } if (newAuthNDN != null && authNDN != null && authNDN.isSubordinateOrEqualTo(oldEntry.getName())) { if (newAuthNSet == null) { newAuthNSet = new CopyOnWriteArraySet<>(); } conn.getAuthenticationInfo().setAuthenticationDN(newAuthNDN); newAuthNSet.add(conn); } if (newAuthZDN != null && authZDN != null && authZDN.isSubordinateOrEqualTo(oldEntry.getName())) { 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 (authZDN == null) { userMap.put(newAuthNDN, newAuthNSet); authZDN = conn.getAuthenticationInfo().getAuthorizationDN(); try { newAuthZDN = authZDN.rename(oldDN, newDN); } catch (Exception e) { // Should not happen. logger.traceException(e); } } if (newAuthZDN != null && newAuthZSet != null) if (newAuthNDN != null && authNDN != null && authNDN.isSubordinateOrEqualTo(oldEntry.getName())) { userMap.put(newAuthZDN, newAuthZSet); if (newAuthNSet == null) { newAuthNSet = ConcurrentHashMap.newKeySet(); } conn.getAuthenticationInfo().setAuthenticationDN(newAuthNDN); newAuthNSet.add(conn); } if (newAuthZDN != null && authZDN != null && authZDN.isSubordinateOrEqualTo(oldEntry.getName())) { if (newAuthZSet == null) { newAuthZSet = ConcurrentHashMap.newKeySet(); } 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(); } } opendj-server-legacy/src/test/java/org/opends/server/core/BindLatencyBenchmarkTestCase.java
New file @@ -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. * <p> * The benchmark opens {@code bind.bench.threads} persistent LDAP connections * (one per thread), each repeatedly performing a simple BIND as a <em>random</em> * 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. * <p> * 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. * <p> * The benchmark is <strong>disabled by default</strong> so that it never runs as * part of the normal test suite. Enable it explicitly, e.g.: * <pre> * 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 * </pre> * 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<Worker> workers = new ArrayList<>(THREADS); final List<Thread> 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(); } } } opendj-server-legacy/src/test/java/org/opends/server/types/PrivilegeTestCase.java
@@ -27,7 +27,6 @@ import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CopyOnWriteArraySet; import org.forgerock.opendj.ldap.AttributeDescription; import org.forgerock.opendj.ldap.ByteString; @@ -2397,7 +2396,7 @@ { conn.bind("cn=Test User,o=test", "password"); CopyOnWriteArraySet<ClientConnection> connections = DirectoryServer Set<ClientConnection> connections = DirectoryServer .getAuthenticatedUsers().get(DN.valueOf("cn=Test User,o=test")); assertNotNull(connections);