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