This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 3dc947b24d feat(utest): JMH observability NoOp zero-allocation
benchmark (TODO-116)
3dc947b24d is described below
commit 3dc947b24d4119def28278c5d31f1db8f3ac3e4d
Author: James Bognar <[email protected]>
AuthorDate: Thu May 28 17:30:46 2026 -0400
feat(utest): JMH observability NoOp zero-allocation benchmark (TODO-116)
Add a -Pmicrobench-gated JMH harness under juneau-utest/src/jmh/java/
with ObservabilityNoopBenchmark asserting the RestOpInvoker
observability block is zero-allocation when MetricsRecorder.NOOP and
TracerHook.NOOP are resolved. gc.alloc.rate.norm ≈ 0 B/op recorded in
perf-baseline.txt [observability] section. New scripts/microbench.py
wrapper. Default mvn test build untouched (profile-gated).
---
juneau-utest/README.md | 68 +++++++
juneau-utest/jmh-results/.gitkeep | 0
.../jmh-results/observability-2026-05-28.json | 150 ++++++++++++++
juneau-utest/perf-baseline.txt | 16 ++
juneau-utest/pom.xml | 51 +++++
.../observability/ObservabilityNoopBenchmark.java | 167 +++++++++++++++
juneau-utest/test-run-history.tsv | 1 +
pom.xml | 3 +
scripts/microbench.py | 224 +++++++++++++++++++++
9 files changed, 680 insertions(+)
diff --git a/juneau-utest/README.md b/juneau-utest/README.md
new file mode 100644
index 0000000000..0a43cf0f19
--- /dev/null
+++ b/juneau-utest/README.md
@@ -0,0 +1,68 @@
+# juneau-utest
+
+Apache Juneau unit + integration test module.
+
+## Running Tests
+
+```bash
+# Full clean build + all tests (recommended before a push)
+python3 scripts/test.py --full
+
+# Tests only (skip Maven compile)
+python3 scripts/test.py --test-only
+
+# Single module with Maven
+mvn -f juneau-utest/pom.xml test
+```
+
+## Benchmarks
+
+JMH micro-benchmarks live in `src/test/java/.../microbench/` and are
**excluded from the normal
+test run** (Surefire only picks up `**/*Test.class`). Run them explicitly via
the wrapper script
+or Maven directly.
+
+### Observability NoOp benchmark (TODO-116)
+
+Asserts the `RestOpInvoker` observability hot path — `MetricsRecorder` +
`TracerHook` resolved
+to their NoOp singletons — allocates **zero objects per invocation**.
+
+**Run via script (recommended):**
+
+```bash
+python3 scripts/microbench.py observability
+```
+
+The script compiles the benchmark, runs JMH with the GC allocation profiler
(`-prof gc`), reads
+the JSON output, and asserts `gc.alloc.rate.norm ≤ 8 bytes/op`. Exit code 0 =
pass.
+
+**Custom threshold:**
+
+```bash
+python3 scripts/microbench.py observability --threshold 0
+```
+
+**Run directly via Maven:**
+
+```bash
+mvn -f juneau-utest/pom.xml -Pmicrobench test-compile exec:java \
+
-Dexec.mainClass=org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark
+```
+
+**JSON results** are written to
`juneau-utest/jmh-results/observability-YYYY-MM-DD.json`.
+
+### Performance baseline
+
+The recorded baseline lives in `juneau-utest/perf-baseline.txt` under the
`[observability]`
+section. To update after an intentional regression is accepted:
+
+1. Run `python3 scripts/microbench.py observability` and note the
`gc.alloc.rate.norm` value.
+2. Edit the `[observability]` section in `perf-baseline.txt` manually.
+
+### Adding new benchmarks
+
+1. Place the class in `src/test/java/org/apache/juneau/microbench/<area>/`.
+2. Annotate with `@Benchmark`, `@State`, `@BenchmarkMode`, etc.
+3. Add a `public static void main(String[] args)` using `OptionsBuilder` with
`forks(0)`
+ (required for the `exec:java` invocation to work).
+4. Add a subcommand to `scripts/microbench.py`.
+5. Document in this file and in `perf-baseline.txt`.
diff --git a/juneau-utest/jmh-results/.gitkeep
b/juneau-utest/jmh-results/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/juneau-utest/jmh-results/observability-2026-05-28.json
b/juneau-utest/jmh-results/observability-2026-05-28.json
new file mode 100644
index 0000000000..5350dd643e
--- /dev/null
+++ b/juneau-utest/jmh-results/observability-2026-05-28.json
@@ -0,0 +1,150 @@
+[
+ {
+ "jmhVersion" : "1.37",
+ "benchmark" :
"org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark.observabilityNoopBlock",
+ "mode" : "avgt",
+ "threads" : 1,
+ "forks" : 0,
+ "jvm" :
"/Library/Java/JavaVirtualMachines/sfdc-jdk-zulu-25.0.2.0.101_50-macos_aarch64/bin/java",
+ "jvmArgs" : [
+ "--enable-native-access=ALL-UNNAMED",
+
"-Dclassworlds.conf=/opt/homebrew/Cellar/maven/3.9.14/libexec/bin/m2.conf",
+ "-Dmaven.home=/opt/homebrew/Cellar/maven/3.9.14/libexec",
+
"-Dlibrary.jansi.path=/opt/homebrew/Cellar/maven/3.9.14/libexec/lib/jansi-native",
+
"-Dmaven.multiModuleProjectDirectory=/Users/james.bognar/git/apache/juneau"
+ ],
+ "jdkVersion" : "25.0.2.0.101",
+ "vmName" : "OpenJDK 64-Bit Server VM",
+ "vmVersion" : "25.0.2.0.101+1-LTS",
+ "warmupIterations" : 5,
+ "warmupTime" : "1 s",
+ "warmupBatchSize" : 1,
+ "measurementIterations" : 5,
+ "measurementTime" : "1 s",
+ "measurementBatchSize" : 1,
+ "primaryMetric" : {
+ "score" : 19.974406132802557,
+ "scoreError" : 0.24253322304403113,
+ "scoreConfidence" : [
+ 19.731872909758525,
+ 20.216939355846588
+ ],
+ "scorePercentiles" : {
+ "0.0" : 19.93103822935356,
+ "50.0" : 19.94582955269426,
+ "90.0" : 20.083049845543115,
+ "95.0" : 20.083049845543115,
+ "99.0" : 20.083049845543115,
+ "99.9" : 20.083049845543115,
+ "99.99" : 20.083049845543115,
+ "99.999" : 20.083049845543115,
+ "99.9999" : 20.083049845543115,
+ "100.0" : 20.083049845543115
+ },
+ "scoreUnit" : "ns/op",
+ "rawData" : [
+ [
+ 19.94582955269426,
+ 20.083049845543115,
+ 19.93742554638398,
+ 19.974687490037866,
+ 19.93103822935356
+ ]
+ ]
+ },
+ "secondaryMetrics" : {
+ "gc.alloc.rate" : {
+ "score" : 0.006495300240889219,
+ "scoreError" : 4.3399379074270775E-5,
+ "scoreConfidence" : [
+ 0.006451900861814948,
+ 0.00653869961996349
+ ],
+ "scorePercentiles" : {
+ "0.0" : 0.006476149625941684,
+ "50.0" : 0.006497432327447318,
+ "90.0" : 0.006505980167042568,
+ "95.0" : 0.006505980167042568,
+ "99.0" : 0.006505980167042568,
+ "99.9" : 0.006505980167042568,
+ "99.99" : 0.006505980167042568,
+ "99.999" : 0.006505980167042568,
+ "99.9999" : 0.006505980167042568,
+ "100.0" : 0.006505980167042568
+ },
+ "scoreUnit" : "MB/sec",
+ "rawData" : [
+ [
+ 0.006505980167042568,
+ 0.006499625263923176,
+ 0.006497432327447318,
+ 0.00649731382009135,
+ 0.006476149625941684
+ ]
+ ]
+ },
+ "gc.alloc.rate.norm" : {
+ "score" : 1.3605628564026167E-4,
+ "scoreError" : 2.12679433548717E-6,
+ "scoreConfidence" : [
+ 1.339294913047745E-4,
+ 1.3818307997574884E-4
+ ],
+ "scorePercentiles" : {
+ "0.0" : 1.3536226325704166E-4,
+ "50.0" : 1.3608313471316226E-4,
+ "90.0" : 1.3688811183918653E-4,
+ "95.0" : 1.3688811183918653E-4,
+ "99.0" : 1.3688811183918653E-4,
+ "99.9" : 1.3688811183918653E-4,
+ "99.99" : 1.3688811183918653E-4,
+ "99.999" : 1.3688811183918653E-4,
+ "99.9999" : 1.3688811183918653E-4,
+ "100.0" : 1.3688811183918653E-4
+ },
+ "scoreUnit" : "B/op",
+ "rawData" : [
+ [
+ 1.3608313471316226E-4,
+ 1.3688811183918653E-4,
+ 1.3584676025215047E-4,
+ 1.3610115813976747E-4,
+ 1.3536226325704166E-4
+ ]
+ ]
+ },
+ "gc.count" : {
+ "score" : 0.0,
+ "scoreError" : "NaN",
+ "scoreConfidence" : [
+ 0.0,
+ 0.0
+ ],
+ "scorePercentiles" : {
+ "0.0" : 0.0,
+ "50.0" : 0.0,
+ "90.0" : 0.0,
+ "95.0" : 0.0,
+ "99.0" : 0.0,
+ "99.9" : 0.0,
+ "99.99" : 0.0,
+ "99.999" : 0.0,
+ "99.9999" : 0.0,
+ "100.0" : 0.0
+ },
+ "scoreUnit" : "counts",
+ "rawData" : [
+ [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ]
+ ]
+ }
+ }
+ }
+]
+
+
diff --git a/juneau-utest/perf-baseline.txt b/juneau-utest/perf-baseline.txt
index e175a7397f..213fcdf5b7 100644
--- a/juneau-utest/perf-baseline.txt
+++ b/juneau-utest/perf-baseline.txt
@@ -23,3 +23,19 @@
60 # core; as of 2026-05-27; tolerance ±20%; set by FINISHED-139.
3 # container.springboot; as of 2026-05-27; tolerance ±20%; set by
FINISHED-139.
1 # container.jetty; as of 2026-05-27; tolerance ±20%; set by FINISHED-139.
+
+# ─── [observability]
──────────────────────────────────────────────────────────
+#
+# Baseline for the JMH ObservabilityNoopBenchmark (TODO-116).
+# Metric: gc.alloc.rate.norm = bytes allocated per benchmark invocation.
+# Assertion threshold: ≤ 8 bytes/op (see scripts/microbench.py --threshold).
+#
+# To re-baseline: run `python3 scripts/microbench.py observability`,
+# read the reported gc.alloc.rate.norm, and update the line below.
+#
+# Measured on: developer M1 Pro laptop (Apple M1 Pro, Zulu JDK 25, forks=0).
+# Note: forks=0 means the benchmark shares the JVM with Maven (exec:java).
+# Numbers are still valid for allocation assertions since
gc.alloc.rate.norm
+# is deterministic per invocation modulo JIT warm-up.
+#
+observability-noop-alloc-bytes-per-op = 0.000 # as of 2026-05-28; set by
FINISHED-116.
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index 275552d82b..bd4ca16572 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -544,6 +544,57 @@
</dependency>
</dependencies>
+ <profiles>
+ <!--
+ Micro-benchmark profile. Not activated by default;
benchmarks are run out-of-band
+ by `scripts/microbench.py` or manually:
+
+ mvn -f juneau-utest/pom.xml -Pmicrobench
test-compile exec:java \
+
-Dexec.mainClass=org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark
+
+ Use -f rather than -pl to avoid the .mvn/maven.config
also-make flag causing
+ exec:java to run on the root project before
juneau-utest is compiled.
+
+ JMH deps (jmh-core + jmh-generator-annprocess) are
already declared in test scope
+ above so they are available without this profile. The
profile contributes only the
+ exec-maven-plugin configuration to simplify the
invocation.
+ -->
+ <profile>
+ <id>microbench</id>
+ <build>
+ <plugins>
+ <!--
+ Force full annotation
processing so jmh-generator-annprocess generates
+ META-INF/BenchmarkList.
Without this, maven-compiler-plugin 3.13+ uses
+ incremental mode which skips
annotation processors on unchanged sources.
+ -->
+ <plugin>
+
<groupId>org.apache.maven.plugins</groupId>
+
<artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <proc>full</proc>
+ </configuration>
+ </plugin>
+ <plugin>
+
<groupId>org.codehaus.mojo</groupId>
+
<artifactId>exec-maven-plugin</artifactId>
+ <version>3.3.0</version>
+ <configuration>
+ <!--
+ Use
test-compile scope so all JMH and project test deps are on the
+ classpath. The
caller runs `mvn test-compile exec:java` to compile
+ first; the exec
plugin then launches the benchmark main() method.
+ -->
+
<classpathScope>test</classpathScope>
+ <!-- Default benchmark;
override with -Dexec.mainClass=... for others. -->
+
<mainClass>org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark</mainClass>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+
<build>
<plugins>
<plugin>
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
new file mode 100644
index 0000000000..b7ab2244b1
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microbench.observability;
+
+import java.nio.file.*;
+import java.time.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.rest.metrics.*;
+import org.apache.juneau.rest.tracing.*;
+import org.openjdk.jmh.annotations.*;
+import org.openjdk.jmh.infra.*;
+import org.openjdk.jmh.profile.GCProfiler;
+import org.openjdk.jmh.results.format.*;
+import org.openjdk.jmh.runner.*;
+import org.openjdk.jmh.runner.options.*;
+
+/**
+ * JMH micro-benchmark asserting the observability hot path in {@link
org.apache.juneau.rest.RestOpInvoker}
+ * is zero-allocation when both {@link MetricsRecorder} and {@link TracerHook}
resolve to their
+ * NoOp singletons.
+ *
+ * <p>
+ * Mirrors the observability block inside {@code
RestOpInvoker.invoke(opSession, observable=true)}:
+ * </p>
+ * <pre class='bjava'>
+ * MetricsRecorder recorder = NoOpMetricsRecorder.INSTANCE;
+ * TracerHook tracer = NoOpTracerHook.INSTANCE;
+ * long startNanos = System.nanoTime();
+ * Scope scope = tracer.startSpan(<jk>null</jk>); <jc>// NoOp
ignores request arg</jc>
+ * <jk>try</jk> {
+ * scope.setStatusCode(200);
+ * } <jk>finally</jk> {
+ * scope.close();
+ * var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+ * recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE, 200,
elapsed, <jk>null</jk>);
+ * }
+ * </pre>
+ *
+ * <p>
+ * Expected result: {@code gc.alloc.rate.norm} ≤ 8 bytes/op (zero under
typical HotSpot
+ * escape-analysis; the 8-byte epsilon absorbs JIT-on-warmup noise).
+ *
+ * <h5 class='section'>Running:</h5>
+ * <pre class='bconsole'>
+ * $ python3 scripts/microbench.py observability
+ *
+ * <jc># Or directly:</jc>
+ * $ mvn -pl juneau-utest -Pmicrobench test-compile exec:java \
+ *
-Dexec.mainClass=org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark
+ * </pre>
+ *
+ * @since 9.5.0
+ * @see MetricsRecorder
+ * @see TracerHook
+ * @see NoOpMetricsRecorder
+ * @see NoOpTracerHook
+ */
+@State(org.openjdk.jmh.annotations.Scope.Benchmark)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+// Class-level @Fork(0) is overridden by OptionsBuilder.forks() in main();
kept at 1 so IDE
+// runners that auto-discover benchmarks get proper fork isolation.
+@Fork(1)
+public class ObservabilityNoopBenchmark {
+
+ private static final String OP_NAME =
"org.apache.juneau.rest.BenchResource.get()";
+ private static final String HTTP_METHOD = "GET";
+ private static final String URI_TEMPLATE = "/bench";
+ private static final int STATUS_CODE = 200;
+
+ // Interface-typed to match the production RestOpInvoker call pattern
(virtual dispatch,
+ // no static devirtualisation by the benchmarking harness).
+ private MetricsRecorder recorder;
+ private TracerHook tracer;
+
+ /**
+ * Initialises the NoOp singleton references once per benchmark trial.
+ */
+ @Setup(Level.Trial)
+ public void setup() {
+ recorder = NoOpMetricsRecorder.INSTANCE;
+ tracer = NoOpTracerHook.INSTANCE;
+ }
+
+ /**
+ * Exercises the observability hot path using NoOp singletons.
+ *
+ * <p>
+ * The {@link Blackhole} parameter prevents the JIT from discarding the
scope reference after
+ * {@link Scope#close()} — keeping the virtual-dispatch chain alive for
accurate allocation
+ * measurement.
+ *
+ * @param bh JMH blackhole to prevent dead-code elimination.
+ */
+ @Benchmark
+ public void observabilityNoopBlock(Blackhole bh) {
+ long startNanos = System.nanoTime();
+ // NoOpTracerHook.startSpan() ignores the request arg; null is
safe on the NoOp path.
+ @SuppressWarnings("java:S2637")
+ var scope = tracer.startSpan(null);
+ try {
+ scope.setStatusCode(STATUS_CODE);
+ } finally {
+ scope.close();
+ var elapsed = Duration.ofNanos(System.nanoTime() -
startNanos);
+ recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE,
STATUS_CODE, elapsed, null);
+ }
+ bh.consume(scope);
+ }
+
+ /**
+ * Benchmark entry point.
+ *
+ * <p>
+ * Runs the benchmark with the GC allocation profiler ({@code -prof
gc}) and writes
+ * results to {@code
juneau-utest/jmh-results/observability-YYYY-MM-DD.json}.
+ * Use {@code python3 scripts/microbench.py observability} for the
recommended invocation,
+ * which reads the JSON output and asserts the allocation threshold.
+ *
+ * @param args Unused; JMH options are hard-coded for reproducibility.
+ * @throws RunnerException If JMH fails to run the benchmark.
+ */
+ public static void main(String[] args) throws RunnerException {
+ var dateTag = LocalDate.now().toString();
+ var resultDir = Paths.get("juneau-utest", "jmh-results");
+ try {
+ Files.createDirectories(resultDir);
+ } catch (Exception e) {
+ // Best-effort; JMH will fail with a clear message if
the path is unwritable.
+ }
+ var jsonPath = resultDir.resolve("observability-" + dateTag +
".json").toString();
+
+ var opt = new OptionsBuilder()
+
.include(ObservabilityNoopBenchmark.class.getSimpleName())
+ .warmupIterations(5)
+ .warmupTime(TimeValue.seconds(1))
+ .measurementIterations(5)
+ .measurementTime(TimeValue.seconds(1))
+ // forks(0): run in the same JVM as exec:java to avoid
the forked process inheriting
+ // an incomplete java.class.path (exec-maven-plugin
loads via its own ClassLoader,
+ // not via the system class path, so ForkedMain is
unavailable to child processes).
+ .forks(0)
+ .addProfiler(GCProfiler.class)
+ .resultFormat(ResultFormatType.JSON)
+ .result(jsonPath)
+ .build();
+
+ new Runner(opt).run();
+ }
+}
diff --git a/juneau-utest/test-run-history.tsv
b/juneau-utest/test-run-history.tsv
index f5fbcbe60c..e1cfd1da41 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -50,3 +50,4 @@ timestamp git_sha branch tests_run failures
errors skipped surefire_sec wall_sec
2026-05-28T20:05:34Z 60e4b5a8e813 master 125889 0 0 21
136
2026-05-28T20:22:45Z 5e454b3a7988 master 125891 0 0 21
141
2026-05-28T20:45:00Z 007293082747 master 125891 0 0 21
146
+2026-05-28T21:29:51Z b0dc55154f95 master 125891 0 0 21
143
diff --git a/pom.xml b/pom.xml
index 1e3ad91fea..da2d88b958 100644
--- a/pom.xml
+++ b/pom.xml
@@ -169,6 +169,9 @@
<!-- Performance baseline data
file - plain text metrics, no license header syntax -->
<exclude>**/perf-baseline.txt</exclude>
<exclude>**/test-run-history.tsv</exclude>
+
+ <!-- JMH micro-benchmark output
directory - generated result data + .gitkeep placeholder, no license header
syntax -->
+ <exclude>**/jmh-results/**</exclude>
</excludes>
<consoleOutput>true</consoleOutput>
<!-- Note: useEclipseDefaultExcludes
does not work for subprojects. See RAT-107 -->
diff --git a/scripts/microbench.py b/scripts/microbench.py
new file mode 100755
index 0000000000..ba0a164486
--- /dev/null
+++ b/scripts/microbench.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+#
***************************************************************************************************************************
+# * Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file
+# * distributed with this work for additional information regarding copyright
ownership. The ASF licenses this file
+# * to you under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance
+# * with the License. You may obtain a copy of the License at
+# *
+# * http://www.apache.org/licenses/LICENSE-2.0
+# *
+# * Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an
+# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
express or implied. See the License for the
+# * specific language governing permissions and limitations under the License.
+#
***************************************************************************************************************************
+"""
+JMH micro-benchmark runner for Apache Juneau.
+
+Usage:
+ python3 scripts/microbench.py observability [options]
+
+Subcommands:
+ observability Run ObservabilityNoopBenchmark and assert
gc.alloc.rate.norm ≤ threshold.
+
+Options:
+ --threshold <bytes> Max allowed gc.alloc.rate.norm in bytes/op (default:
8).
+ --verbose, -v Stream full Maven output instead of tail-only.
+ --help, -h Show this message.
+
+Exit codes:
+ 0 Benchmark passed (alloc rate within threshold).
+ 1 Benchmark failed (alloc rate exceeded threshold or Maven error).
+
+The benchmark compiles and runs via:
+ mvn -pl juneau-utest -Pmicrobench test-compile exec:java
+
+JSON output is written to
juneau-utest/jmh-results/observability-YYYY-MM-DD.json.
+The gc.alloc.rate.norm secondary metric (bytes allocated per operation) is
read from
+that file to assert the zero-allocation contract.
+
+To record the baseline in juneau-utest/perf-baseline.txt after a clean run:
+ 1. Run this script and note the reported alloc bytes/op.
+ 2. Update the [observability] section in perf-baseline.txt manually.
+"""
+
+import argparse
+import glob
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).parent.parent
+UTEST_DIR = REPO_ROOT / "juneau-utest"
+JMH_RESULTS_DIR = UTEST_DIR / "jmh-results"
+
+# Benchmark class name → subcommand mapping
+BENCHMARK_CLASSES = {
+ "observability":
"org.apache.juneau.microbench.observability.ObservabilityNoopBenchmark",
+}
+
+# Default alloc threshold in bytes/op. Tighten to 0 once confirmed
empirically.
+DEFAULT_THRESHOLD_BYTES = 8.0
+
+
+def run_benchmark(main_class: str, verbose: bool) -> tuple[int, str]:
+ """Compile and run the benchmark via Maven, returning (exit_code,
combined_output).
+
+ Uses ``-f juneau-utest/pom.xml`` instead of ``-pl juneau-utest`` to avoid
the
+ ``--also-make`` in ``.mvn/maven.config`` causing exec:java to also run on
the root project.
+ Upstream deps are expected to be installed in ~/.m2 (i.e. ``mvn install``
was run recently).
+ """
+ cmd = (
+ f"mvn -f juneau-utest/pom.xml -Pmicrobench test-compile exec:java "
+ f"-Dexec.mainClass={main_class}"
+ )
+ print(f"Running: {cmd}")
+ print("-" * 80)
+ result = subprocess.run(
+ cmd,
+ shell=True,
+ cwd=str(REPO_ROOT),
+ capture_output=not verbose,
+ text=True,
+ )
+ if verbose:
+ combined = ""
+ else:
+ combined = (result.stdout or "") + (result.stderr or "")
+ lines = combined.splitlines()
+ # Print the tail so JMH benchmark output is visible
+ tail = lines[-80:] if len(lines) > 80 else lines
+ print("\n".join(tail))
+ return result.returncode, combined
+
+
+def find_latest_json(prefix: str) -> Path | None:
+ """Return the most-recently-modified JSON file matching the prefix
pattern."""
+ JMH_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
+ pattern = str(JMH_RESULTS_DIR / f"{prefix}-*.json")
+ matches = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
+ return Path(matches[0]) if matches else None
+
+
+def parse_alloc_rate_norm(json_path: Path) -> float | None:
+ """
+ Extract gc.alloc.rate.norm from a JMH JSON result file.
+
+ The GC profiler emits a secondary metric named
+ ``<benchmark>.gc.alloc.rate.norm`` with score in B/op.
+ Returns None if the metric is not found (e.g. the profiler was not active).
+ """
+ try:
+ with open(json_path) as f:
+ results = json.load(f)
+ except (json.JSONDecodeError, OSError) as e:
+ print(f"❌ Could not read JMH result file {json_path}: {e}")
+ return None
+
+ # JMH JSON: list of benchmark result objects, each may have a
+ # "secondaryMetrics" dict with keys like "·gc.alloc.rate.norm".
+ for entry in results:
+ secondary = entry.get("secondaryMetrics", {})
+ for key, metric in secondary.items():
+ if "gc.alloc.rate.norm" in key:
+ score = metric.get("score")
+ if score is not None:
+ return float(score)
+ return None
+
+
+def cmd_observability(args: argparse.Namespace) -> int:
+ """Run the observability NoOp benchmark and assert the allocation
threshold."""
+ threshold = args.threshold
+ main_class = BENCHMARK_CLASSES["observability"]
+
+ print("=" * 70)
+ print("🔬 ObservabilityNoopBenchmark — NoOp hot path allocation assertion")
+ print(f" Threshold: ≤ {threshold} bytes/op (gc.alloc.rate.norm)")
+ print("=" * 70)
+
+ t0 = time.monotonic()
+ exit_code, _ = run_benchmark(main_class, args.verbose)
+ elapsed = time.monotonic() - t0
+
+ if exit_code != 0:
+ print(f"\n❌ Benchmark run failed (Maven exit code {exit_code})")
+ return 1
+
+ print(f"\n✅ Benchmark completed in {elapsed:.1f}s — reading results...")
+
+ json_path = find_latest_json("observability")
+ if json_path is None:
+ print(f"❌ No result JSON found in {JMH_RESULTS_DIR}")
+ return 1
+
+ alloc = parse_alloc_rate_norm(json_path)
+ if alloc is None:
+ print(
+ f"❌ gc.alloc.rate.norm not found in {json_path}.\n"
+ " Make sure the benchmark was compiled with -prof gc enabled in
main()."
+ )
+ return 1
+
+ print(f"\n📊 gc.alloc.rate.norm = {alloc:.3f} bytes/op (threshold ≤
{threshold})")
+ print(f" Results written to: {json_path}")
+
+ if alloc <= threshold:
+ print(f"\n✅ PASS — allocation rate {alloc:.3f} B/op is within
threshold {threshold} B/op")
+ print(
+ "\nTo update perf-baseline.txt, add or refresh the [observability]
section:\n"
+ f" observability-noop-alloc-bytes-per-op = {alloc:.3f}"
+ )
+ return 0
+ else:
+ print(
+ f"\n❌ FAIL — allocation rate {alloc:.3f} B/op exceeds threshold
{threshold} B/op\n"
+ " The observability NoOp path is allocating unexpectedly.\n"
+ " Check for:\n"
+ " • varargs array creation in MetricsRecorder.record() or
TracerHook.startSpan()\n"
+ " • Duration.ofNanos() not being elided by escape analysis\n"
+ " • A recent refactor introducing allocations on the hot
path\n"
+ " Re-run with --verbose for full JMH output."
+ )
+ return 1
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="JMH micro-benchmark runner for Apache Juneau.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__,
+ )
+ parser.add_argument(
+ "--verbose", "-v",
+ action="store_true",
+ help="Stream full Maven output (default: tail-only).",
+ )
+ sub = parser.add_subparsers(dest="subcommand", required=True)
+
+ obs = sub.add_parser(
+ "observability",
+ help="Assert the RestOpInvoker observability NoOp block allocates ≤
threshold bytes/op.",
+ )
+ obs.add_argument(
+ "--threshold",
+ type=float,
+ default=DEFAULT_THRESHOLD_BYTES,
+ metavar="BYTES",
+ help=f"Max allowed gc.alloc.rate.norm in bytes/op (default:
{DEFAULT_THRESHOLD_BYTES}).",
+ )
+
+ args = parser.parse_args()
+
+ if args.subcommand == "observability":
+ return cmd_observability(args)
+
+ parser.print_help()
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())