From d6260b31ed6880f6ab7b0aa71214bbb7a9d7b7e7 Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Mon, 22 Jun 2026 08:30:48 +0000
Subject: [PATCH] Add Performance workflow benchmarking OpenDJ vs OpenLDAP

---
 .github/workflows/performance.yml |  208 +++++++++++++++++
 .github/performance/summary.sh    |  144 ++++++++++++
 .github/performance/benchmark.jmx |  332 +++++++++++++++++++++++++++
 .github/performance/people.ldif   |   17 +
 4 files changed, 701 insertions(+), 0 deletions(-)

diff --git a/.github/performance/benchmark.jmx b/.github/performance/benchmark.jmx
new file mode 100644
index 0000000..a20b94a
--- /dev/null
+++ b/.github/performance/benchmark.jmx
@@ -0,0 +1,332 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
+  <hashTree>
+    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="OpenDJ vs OpenLDAP - LDAP benchmark" enabled="true">
+      <stringProp name="TestPlan.comments">Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/ADD WITHOUT DELETE) reuse the cached admin connection. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.</stringProp>
+      <boolProp name="TestPlan.functional_mode">false</boolProp>
+      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
+      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
+      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
+        <collectionProp name="Arguments.arguments"/>
+      </elementProp>
+      <stringProp name="TestPlan.user_define_classpath"></stringProp>
+    </TestPlan>
+    <hashTree>
+      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="LDAP Thread Group" enabled="true">
+        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
+        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
+          <boolProp name="LoopController.continue_forever">false</boolProp>
+          <stringProp name="LoopController.loops">-1</stringProp>
+        </elementProp>
+        <stringProp name="ThreadGroup.num_threads">${__P(threads,256)}</stringProp>
+        <stringProp name="ThreadGroup.ramp_time">${__P(rampup,0)}</stringProp>
+        <boolProp name="ThreadGroup.scheduler">true</boolProp>
+        <stringProp name="ThreadGroup.duration">${__P(duration,600)}</stringProp>
+        <stringProp name="ThreadGroup.delay"></stringProp>
+      </ThreadGroup>
+      <hashTree>
+        <!-- Admin connection: bound once per thread, cached in ldapContexts[thread], reused by all
+             data operations. Labelled ADMIN_CONNECT and excluded from metrics. -->
+        <OnceOnlyController guiclass="OnceOnlyControllerGui" testclass="OnceOnlyController" testname="Once Only - admin connect" enabled="true"/>
+        <hashTree>
+          <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="ADMIN_CONNECT" enabled="true">
+            <stringProp name="servername">${__P(host,localhost)}</stringProp>
+            <stringProp name="port">${__P(port,1389)}</stringProp>
+            <stringProp name="rootdn">ou=People,${__P(basedn,dc=example,dc=com)}</stringProp>
+            <stringProp name="scope">2</stringProp>
+            <stringProp name="countlimit"></stringProp>
+            <stringProp name="timelimit"></stringProp>
+            <stringProp name="attributes"></stringProp>
+            <stringProp name="return_object">false</stringProp>
+            <stringProp name="deref_aliases">false</stringProp>
+            <stringProp name="connection_timeout">60000</stringProp>
+            <stringProp name="parseflag">false</stringProp>
+            <stringProp name="secure">false</stringProp>
+            <stringProp name="user_dn">${__P(adminbinddn,cn=Directory Manager)}</stringProp>
+            <stringProp name="user_pw">${__P(adminbindpw,password)}</stringProp>
+            <stringProp name="comparedn"></stringProp>
+            <stringProp name="comparefilt"></stringProp>
+            <stringProp name="modddn"></stringProp>
+            <stringProp name="newdn"></stringProp>
+            <stringProp name="test">bind</stringProp>
+          </LDAPExtSampler>
+          <hashTree/>
+        </hashTree>
+
+        <!-- ADD (admin connection): create the per-thread user entry. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="ADD" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">add</stringProp>
+          <stringProp name="base_entry_dn">cn=user_${__threadNum}</stringProp>
+          <elementProp name="arguments" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
+            <collectionProp name="Arguments.arguments">
+              <elementProp name="sn" elementType="Argument">
+                <stringProp name="Argument.name">sn</stringProp>
+                <stringProp name="Argument.value">user_${__threadNum}</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="mail" elementType="Argument">
+                <stringProp name="Argument.name">mail</stringProp>
+                <stringProp name="Argument.value">user_${__threadNum}@test.com</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">top</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">inetOrgPerson</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">organizationalPerson</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">person</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+            </collectionProp>
+          </elementProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- SEARCH (admin connection). -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="SEARCH" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit">0</stringProp>
+          <stringProp name="timelimit">0</stringProp>
+          <stringProp name="attributes">cn:dn:objectClass</stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">search</stringProp>
+          <stringProp name="search"></stringProp>
+          <stringProp name="searchfilter">(sn=user_${__threadNum})</stringProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- COMPARE (admin connection). -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="COMPARE" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn">cn=user_${__threadNum}</stringProp>
+          <stringProp name="comparefilt">mail=user_${__threadNum}@test.com</stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">compare</stringProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- MODIFY (admin connection): rename sn and set userPassword so the user can authenticate. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="MODIFY" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">modify</stringProp>
+          <stringProp name="base_entry_dn">cn=user_${__threadNum}</stringProp>
+          <elementProp name="ldaparguments" elementType="LDAPArguments" guiclass="LDAPArgumentsPanel" testclass="LDAPArguments" testname="LDAP Extended Request Defaults" enabled="true">
+            <collectionProp name="Arguments.arguments">
+              <elementProp name="sn" elementType="LDAPArgument">
+                <stringProp name="Argument.name">sn</stringProp>
+                <stringProp name="Argument.value">rename_${__threadNum}</stringProp>
+                <stringProp name="Argument.opcode">replace</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="userPassword" elementType="LDAPArgument">
+                <stringProp name="Argument.name">userPassword</stringProp>
+                <stringProp name="Argument.value">${__P(benchpw,benchPass1)}</stringProp>
+                <stringProp name="Argument.opcode">replace</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+            </collectionProp>
+          </elementProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- BIND (measured user authentication): single bind/unbind on its own connection,
+             does NOT disturb the cached admin connection. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="BIND" enabled="true">
+          <stringProp name="servername">${__P(host,localhost)}</stringProp>
+          <stringProp name="port">${__P(port,1389)}</stringProp>
+          <stringProp name="rootdn">ou=People,${__P(basedn,dc=example,dc=com)}</stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout">60000</stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn">cn=user_${__threadNum},ou=People,${__P(basedn,dc=example,dc=com)}</stringProp>
+          <stringProp name="user_pw">${__P(benchpw,benchPass1)}</stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">sbind</stringProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- DELETE (admin connection): remove the per-thread user entry. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="DELETE" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">delete</stringProp>
+          <stringProp name="delete">cn=user_${__threadNum}</stringProp>
+        </LDAPExtSampler>
+        <hashTree/>
+
+        <!-- ADD WITHOUT DELETE (admin connection): accumulation; unique RDN via __UUID. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="ADD WITHOUT DELETE" enabled="true">
+          <stringProp name="servername"></stringProp>
+          <stringProp name="port"></stringProp>
+          <stringProp name="rootdn"></stringProp>
+          <stringProp name="scope">2</stringProp>
+          <stringProp name="countlimit"></stringProp>
+          <stringProp name="timelimit"></stringProp>
+          <stringProp name="attributes"></stringProp>
+          <stringProp name="return_object">false</stringProp>
+          <stringProp name="deref_aliases">false</stringProp>
+          <stringProp name="connection_timeout"></stringProp>
+          <stringProp name="parseflag">false</stringProp>
+          <stringProp name="secure">false</stringProp>
+          <stringProp name="user_dn"></stringProp>
+          <stringProp name="user_pw"></stringProp>
+          <stringProp name="comparedn"></stringProp>
+          <stringProp name="comparefilt"></stringProp>
+          <stringProp name="modddn"></stringProp>
+          <stringProp name="newdn"></stringProp>
+          <stringProp name="test">add</stringProp>
+          <stringProp name="base_entry_dn">cn=user_${__threadNum}_${__UUID}</stringProp>
+          <elementProp name="arguments" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
+            <collectionProp name="Arguments.arguments">
+              <elementProp name="sn" elementType="Argument">
+                <stringProp name="Argument.name">sn</stringProp>
+                <stringProp name="Argument.value">user_${__threadNum}</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="mail" elementType="Argument">
+                <stringProp name="Argument.name">mail</stringProp>
+                <stringProp name="Argument.value">user_${__threadNum}@test.com</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">top</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">inetOrgPerson</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">organizationalPerson</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+              <elementProp name="objectClass" elementType="Argument">
+                <stringProp name="Argument.name">objectClass</stringProp>
+                <stringProp name="Argument.value">person</stringProp>
+                <stringProp name="Argument.metadata">=</stringProp>
+              </elementProp>
+            </collectionProp>
+          </elementProp>
+        </LDAPExtSampler>
+        <hashTree/>
+      </hashTree>
+    </hashTree>
+  </hashTree>
+</jmeterTestPlan>
diff --git a/.github/performance/people.ldif b/.github/performance/people.ldif
new file mode 100644
index 0000000..d6a3500
--- /dev/null
+++ b/.github/performance/people.ldif
@@ -0,0 +1,17 @@
+# 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.
+dn: ou=People,dc=example,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: People
diff --git a/.github/performance/summary.sh b/.github/performance/summary.sh
new file mode 100644
index 0000000..ecac86c
--- /dev/null
+++ b/.github/performance/summary.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+# 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.
+#
+# Render an OpenDJ-vs-OpenLDAP LDAP benchmark report (versions + comparison table +
+# comparative Mermaid charts) to stdout — intended to be appended to $GITHUB_STEP_SUMMARY.
+#
+# Usage:
+#   summary.sh <openldap_statistics.json> <opendj_statistics.json> \
+#              <openldap_version> <opendj_version> [openldap_image] [opendj_image]
+#
+# The admin connection bind is labelled ADMIN_CONNECT in the plan and is intentionally
+# skipped here, so it never pollutes the per-operation comparison.
+set -euo pipefail
+
+OL_JSON="${1:?openldap statistics.json required}"
+DJ_JSON="${2:?opendj statistics.json required}"
+OL_VER="${3:-unknown}"
+DJ_VER="${4:-unknown}"
+OL_IMG="${5:-}"
+DJ_IMG="${6:-}"
+
+# Operations to compare, in workflow order. ADMIN_CONNECT and Total are excluded.
+OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "ADD WITHOUT DELETE")
+
+# m <file> <label> <field>  -> numeric value (0 if absent), rounded to 1 decimal.
+m() { jq -r --arg l "$2" --arg f "$3" '((.[$l][$f]) // 0) | (.*10 | round / 10)' "$1"; }
+# mi <file> <label> <field> -> integer value (0 if absent).
+mi() { jq -r --arg l "$2" --arg f "$3" '((.[$l][$f]) // 0) | round' "$1"; }
+
+echo "## 🔬 LDAP Benchmark — OpenDJ vs OpenLDAP"
+echo ""
+
+# ---------------------------------------------------------------- Versions
+echo "### Versions"
+echo ""
+echo "| Server | Version | Image |"
+echo "|---|---|---|"
+echo "| **OpenLDAP** | \`${OL_VER}\` | \`${OL_IMG:-n/a}\` |"
+echo "| **OpenDJ** | \`${DJ_VER}\` | \`${DJ_IMG:-n/a}\` |"
+echo ""
+
+# ---------------------------------------------------------------- Totals
+ol_tot_tp=$(m  "$OL_JSON" Total throughput)
+dj_tot_tp=$(m  "$DJ_JSON" Total throughput)
+ol_tot_n=$(mi  "$OL_JSON" Total sampleCount)
+dj_tot_n=$(mi  "$DJ_JSON" Total sampleCount)
+ol_tot_e=$(mi  "$OL_JSON" Total errorCount)
+dj_tot_e=$(mi  "$DJ_JSON" Total errorCount)
+ol_tot_mean=$(m "$OL_JSON" Total meanResTime)
+dj_tot_mean=$(m "$DJ_JSON" Total meanResTime)
+
+echo "### Totals (all operations, ADMIN_CONNECT excluded by the plan label)"
+echo ""
+echo "| Server | Throughput (ops/s) | Mean (ms) | Samples | Errors |"
+echo "|---|--:|--:|--:|--:|"
+echo "| **OpenLDAP** | ${ol_tot_tp} | ${ol_tot_mean} | ${ol_tot_n} | ${ol_tot_e} |"
+echo "| **OpenDJ** | ${dj_tot_tp} | ${dj_tot_mean} | ${dj_tot_n} | ${dj_tot_e} |"
+echo ""
+
+# ---------------------------------------------------------------- Per-op table
+echo "### Per-operation comparison"
+echo ""
+echo "| Operation | ops/s OpenLDAP | ops/s OpenDJ | mean ms OpenLDAP | mean ms OpenDJ | p99 ms OpenLDAP | p99 ms OpenDJ | err OL | err DJ |"
+echo "|---|--:|--:|--:|--:|--:|--:|--:|--:|"
+for op in "${OPS[@]}"; do
+  printf '| %s | %s | %s | %s | %s | %s | %s | %s | %s |\n' \
+    "$op" \
+    "$(m  "$OL_JSON" "$op" throughput)"   "$(m  "$DJ_JSON" "$op" throughput)" \
+    "$(m  "$OL_JSON" "$op" meanResTime)"  "$(m  "$DJ_JSON" "$op" meanResTime)" \
+    "$(mi "$OL_JSON" "$op" pct3ResTime)"  "$(mi "$DJ_JSON" "$op" pct3ResTime)" \
+    "$(mi "$OL_JSON" "$op" errorCount)"   "$(mi "$DJ_JSON" "$op" errorCount)"
+done
+echo ""
+
+# ---------------------------------------------------------------- Chart helpers
+# Build a Mermaid list "x, y, z" of values for all OPS from <file> <field>, via <m|mi>.
+series() { # <fn> <file> <field>
+  local fn="$1" file="$2" field="$3" out="" v
+  for op in "${OPS[@]}"; do
+    v=$("$fn" "$file" "$op" "$field")
+    out+="${out:+, }${v}"
+  done
+  printf '%s' "$out"
+}
+# x-axis with every label quoted (labels contain spaces).
+xaxis() {
+  local out="" op
+  for op in "${OPS[@]}"; do out+="${out:+, }\"${op}\""; done
+  printf '%s' "$out"
+}
+
+XAXIS="$(xaxis)"
+
+# ---------------------------------------------------------------- Throughput chart
+echo "### Comparative chart — throughput per operation (ops/s, higher is better)"
+echo ""
+echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo ""
+echo '```mermaid'
+echo "xychart-beta"
+echo "    title \"Throughput per operation (ops/s) — OpenLDAP vs OpenDJ\""
+echo "    x-axis [${XAXIS}]"
+echo "    y-axis \"ops/s\""
+echo "    bar [$(series m "$OL_JSON" throughput)]"
+echo "    bar [$(series m "$DJ_JSON" throughput)]"
+echo '```'
+echo ""
+
+# ---------------------------------------------------------------- Latency chart
+echo "### Comparative chart — mean latency per operation (ms, lower is better)"
+echo ""
+echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo ""
+echo '```mermaid'
+echo "xychart-beta"
+echo "    title \"Mean latency per operation (ms) — OpenLDAP vs OpenDJ\""
+echo "    x-axis [${XAXIS}]"
+echo "    y-axis \"ms\""
+echo "    bar [$(series m "$OL_JSON" meanResTime)]"
+echo "    bar [$(series m "$DJ_JSON" meanResTime)]"
+echo '```'
+echo ""
+
+# ---------------------------------------------------------------- Caveats
+echo "### Notes"
+echo ""
+echo "- \`BIND\` is the measured **user authentication** (\`test=sbind\`, single bind/unbind on its"
+echo "  own connection) as \`cn=user_<n>,ou=People\` with the password set by \`MODIFY\`. The admin"
+echo "  connection bind (\`ADMIN_CONNECT\`) is cached once per thread and excluded from these results."
+echo "- Default password storage scheme differs by server (OpenDJ PBKDF2/Salted-SHA vs OpenLDAP"
+echo "  SSHA); this is a legitimate part of authentication cost."
+echo "- Full interactive JMeter HTML dashboards are attached as the \`jmeter-reports\` artifact."
diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
new file mode 100644
index 0000000..77e1d09
--- /dev/null
+++ b/.github/workflows/performance.yml
@@ -0,0 +1,208 @@
+# 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.
+name: Performance
+
+# LDAP benchmark: OpenDJ vs OpenLDAP.
+# Runs manually (workflow_dispatch) and automatically after the "Release" workflow completes.
+# Starts the latest OpenLDAP and OpenDJ from Docker (image tags overridable via inputs),
+# benchmarks OpenLDAP first then OpenDJ with Apache JMeter, and writes versions + comparison
+# table + comparative charts to the job summary. Full JMeter HTML dashboards are uploaded as
+# the "jmeter-reports" artifact.
+
+on:
+  workflow_dispatch:
+    inputs:
+      openldap_image:
+        description: "OpenLDAP Docker image"
+        default: "osixia/openldap:latest"
+      opendj_image:
+        description: "OpenDJ Docker image"
+        default: "openidentityplatform/opendj:latest"
+      threads:
+        description: "Concurrent JMeter threads"
+        default: "256"
+      duration:
+        description: "Test duration per server (seconds)"
+        default: "600"
+      rampup:
+        description: "Ramp-up period (seconds)"
+        default: "0"
+      jmeter_version:
+        description: "Apache JMeter version"
+        default: "5.6.3"
+  workflow_run:
+    workflows: ["Release"]
+    types: [completed]
+
+permissions:
+  contents: read
+
+concurrency:
+  group: performance-${{ github.ref }}
+  cancel-in-progress: true
+
+defaults:
+  run:
+    shell: bash
+
+jobs:
+  benchmark:
+    # Manual runs always proceed; chained runs only after a successful Release.
+    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+    runs-on: ubuntu-latest
+    env:
+      # `${{ inputs.X || 'default' }}` so workflow_run (which carries no inputs) falls back.
+      OPENLDAP_IMAGE: ${{ inputs.openldap_image || 'osixia/openldap:latest' }}
+      OPENDJ_IMAGE: ${{ inputs.opendj_image || 'openidentityplatform/opendj:latest' }}
+      THREADS: ${{ inputs.threads || '256' }}
+      DURATION: ${{ inputs.duration || '600' }}
+      RAMPUP: ${{ inputs.rampup || '0' }}
+      JMETER: ${{ inputs.jmeter_version || '5.6.3' }}
+      BENCHPW: benchPass1
+      BASEDN: dc=example,dc=com
+      # Exclude the cached admin connection bind from the dashboard / statistics.json.
+      SAMPLE_FILTER: '^(?!ADMIN_CONNECT).*'
+      HEAP: -Xms1g -Xmx2g
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Cache JMeter
+        uses: actions/cache@v4
+        with:
+          path: ~/jmeter
+          key: jmeter-${{ env.JMETER }}
+
+      - name: Install tooling (ldap-utils + JMeter)
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y ldap-utils
+          if [ ! -x "$HOME/jmeter/apache-jmeter-$JMETER/bin/jmeter" ]; then
+            mkdir -p "$HOME/jmeter"
+            curl -fsSL "https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-$JMETER.tgz" -o /tmp/jmeter.tgz
+            tar -xzf /tmp/jmeter.tgz -C "$HOME/jmeter"
+          fi
+          echo "JMETER_BIN=$HOME/jmeter/apache-jmeter-$JMETER/bin/jmeter" >> "$GITHUB_ENV"
+
+      - name: Start OpenLDAP
+        run: |
+          docker run -d --name openldap -p 2389:389 \
+            -e LDAP_ORGANISATION="Example" \
+            -e LDAP_DOMAIN="example.com" \
+            -e LDAP_ADMIN_PASSWORD="password" \
+            -e LDAP_TLS=false \
+            "$OPENLDAP_IMAGE"
+
+      - name: Wait for OpenLDAP + seed ou=People
+        run: |
+          for i in $(seq 1 90); do
+            if ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
+                 -b "$BASEDN" -s base dn >/dev/null 2>&1; then
+              echo "OpenLDAP is up"; break
+            fi
+            sleep 2
+          done
+          ldapadd -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
+            -f .github/performance/people.ldif || true
+
+      - name: Capture OpenLDAP version
+        run: |
+          ver="$(docker exec openldap sh -c '/usr/sbin/slapd -VV 2>&1 || slapd -VV 2>&1' | head -1)"
+          echo "OpenLDAP: $ver"
+          {
+            echo "OPENLDAP_VER<<EOF"
+            echo "$ver"
+            echo "EOF"
+          } >> "$GITHUB_ENV"
+
+      - name: Benchmark OpenLDAP
+        run: |
+          rm -rf openldap openldap.jtl
+          HEAP="$HEAP" "$JMETER_BIN" -n -t .github/performance/benchmark.jmx \
+            -Jhost=localhost -Jport=2389 \
+            -Jbasedn="$BASEDN" \
+            -Jadminbinddn="cn=admin,$BASEDN" -Jadminbindpw=password \
+            -Jbenchpw="$BENCHPW" \
+            -Jthreads="$THREADS" -Jduration="$DURATION" -Jrampup="$RAMPUP" \
+            -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
+            -l openldap.jtl -e -o openldap
+
+      - name: Start OpenDJ
+        run: |
+          docker run -d --name opendj -p 1389:1389 \
+            -e ROOT_PASSWORD=password \
+            -e BASE_DN="$BASEDN" \
+            -e ADD_BASE_ENTRY=--addBaseEntry \
+            "$OPENDJ_IMAGE"
+
+      - name: Wait for OpenDJ + seed ou=People
+        run: |
+          for i in $(seq 1 90); do
+            if ldapsearch -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
+                 -b "$BASEDN" -s base dn >/dev/null 2>&1; then
+              echo "OpenDJ is up"; break
+            fi
+            sleep 2
+          done
+          ldapadd -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
+            -f .github/performance/people.ldif || true
+
+      - name: Capture OpenDJ version
+        run: |
+          ver="$(ldapsearch -x -LLL -H ldap://localhost:1389 -D 'cn=Directory Manager' -w password \
+                  -b '' -s base fullVendorVersion 2>/dev/null | sed -n 's/^fullVendorVersion: //p')"
+          [ -n "$ver" ] || ver="$OPENDJ_IMAGE"
+          echo "OpenDJ: $ver"
+          {
+            echo "OPENDJ_VER<<EOF"
+            echo "$ver"
+            echo "EOF"
+          } >> "$GITHUB_ENV"
+
+      - name: Benchmark OpenDJ
+        run: |
+          rm -rf opendj opendj.jtl
+          HEAP="$HEAP" "$JMETER_BIN" -n -t .github/performance/benchmark.jmx \
+            -Jhost=localhost -Jport=1389 \
+            -Jbasedn="$BASEDN" \
+            -Jadminbinddn="cn=Directory Manager" -Jadminbindpw=password \
+            -Jbenchpw="$BENCHPW" \
+            -Jthreads="$THREADS" -Jduration="$DURATION" -Jrampup="$RAMPUP" \
+            -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
+            -l opendj.jtl -e -o opendj
+
+      # ---------------------------------------------------------------- Report
+      - name: Build job summary
+        run: |
+          bash .github/performance/summary.sh \
+            openldap/statistics.json opendj/statistics.json \
+            "$OPENLDAP_VER" "$OPENDJ_VER" \
+            "$OPENLDAP_IMAGE" "$OPENDJ_IMAGE" >> "$GITHUB_STEP_SUMMARY"
+
+      - name: Upload JMeter reports
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: jmeter-reports
+          path: |
+            openldap/
+            opendj/
+            *.jtl
+          if-no-files-found: warn
+
+      - name: Container logs + cleanup
+        if: always()
+        run: |
+          docker logs openldap 2>&1 | tail -n 100 || true
+          docker logs opendj 2>&1 | tail -n 100 || true
+          docker rm -f openldap opendj || true

--
Gitblit v1.10.0