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())

Reply via email to