Skip to content

feat: add app-level indy A/B benches to the performance workflow - #16169

Open
jamesfredley wants to merge 5 commits into
9.0.xfrom
feat/app-bench-ci
Open

feat: add app-level indy A/B benches to the performance workflow#16169
jamesfredley wants to merge 5 commits into
9.0.xfrom
feat/app-bench-ci

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Purpose

Teach CI to measure app-level indy vs classic callsite, not just JMH micros.

This does not change the Grails plugin indy default. That remains #16165.

What landed

  • AppHttpBench helper + unit tests (rejects empty sample sets, clamps fork count)
  • Gated AppBench* specs for latency, app1, and gsp-layout
  • gsp-layout now depends on grails-testing-support-http-client
  • Native Gradle orchestrator: ./gradlew :grails-benchmarks:appIndyBench
  • benchmark.yml app-bench job on the performance label calls that single task (warmup 80 / samples 300 / forks 2)
  • No PowerShell, shell, or Python runner

Local usage

./gradlew :grails-benchmarks:appIndyBench

Optional: -PappBenchWarmup, -PappBenchSamples, -PappBenchForks, -PappBenchOutDir.

Local numbers already posted on #16165

Same harness, same settings: latency 0.98x, app1 0.99x, no clear change. gsp-layout was skipped there because that module lacked the HTTP client dep; this PR adds it.

Test plan

  • AppHttpBenchSpec
  • AppIndyBenchSpec
  • CI performance label runs the new job

Land the gated HTTP harness (latency, app1, gsp-layout) and run it
from benchmark.yml when a PR has the performance label. Compare
-PgrailsIndy=false vs true on HEAD. Does not change the plugin default.

Assisted-by: claude-code:claude-opus-5
Copilot AI lite review requested due to automatic review settings August 18, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the performance-labeled CI workflow to run app-level HTTP benchmarks comparing Groovy indy vs classic callsite dispatch, emitting JMH-compatible JSON so results can be compared with the existing :grails-benchmarks:jmhCompare tooling.

Changes:

  • Add AppHttpBench helper (and unit tests) to measure HTTP request latency and write JMH-shaped JSON output.
  • Add gated AppBench* integration specs in grails-test-examples (latency, app1, gsp-layout) and wire a shared Gradle test configuration to pass bench properties.
  • Add a new app-bench GitHub Actions job (runs noindy then indy, then publishes a comparison report and artifacts) and a local PowerShell runner.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/bench/AppHttpBench.groovy New helper to measure request timings and emit JMH-compatible JSON entries.
grails-testing-support-http-client/src/test/groovy/org/apache/grails/testing/http/client/bench/AppHttpBenchSpec.groovy Unit tests for the new bench helper behavior.
grails-test-examples/latency/src/integration-test/groovy/latencyapp/AppBenchFastPingSpec.groovy New gated integration bench for a simple controller render path.
grails-test-examples/latency/build.gradle Applies shared app-bench Gradle wiring to pass bench properties to tests.
grails-test-examples/app1/src/integration-test/groovy/functionaltests/AppBenchInterceptorDemoSpec.groovy New gated integration bench for interceptor + controller path.
grails-test-examples/app1/build.gradle Applies shared app-bench Gradle wiring to pass bench properties to tests.
grails-test-examples/gsp-layout/src/integration-test/groovy/org/example/grails/layout/AppBenchDemoRenderTextSpec.groovy New gated integration bench for GSP layout app request path.
grails-test-examples/gsp-layout/build.gradle Adds HTTP client support dependency for integration tests + applies app-bench wiring.
gradle/app-bench-config.gradle New shared Gradle config to set system properties and disable caching when benches are enabled.
grails-benchmarks/scripts/run-app-indy-bench.ps1 Local runner to execute noindy/indy benches and compare results.
.github/workflows/benchmark.yml Adds app-bench job gated by the performance label and publishes comparison artifacts/summary.
Suppressed comments (2)

grails-benchmarks/scripts/run-app-indy-bench.ps1:96

  • This script is invoked via pwsh (cross-platform), but it calls gradlew.bat, which will fail on non-Windows systems. Use gradlew on Unix-like systems.
    & "$root\gradlew.bat" '-q' ':grails-benchmarks:jmhCompare' `

grails-benchmarks/scripts/run-app-indy-bench.ps1:115

  • This script is invoked via pwsh (cross-platform), but it calls gradlew.bat, which will fail on non-Windows systems. Use gradlew on Unix-like systems.
& "$root\gradlew.bat" '-q' ':grails-benchmarks:jmhCompare' `

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread gradle/app-bench-config.gradle Outdated
Comment thread grails-benchmarks/scripts/run-app-indy-bench.ps1 Outdated
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 30.1177%. Comparing base (51b1528) to head (bb53ead).

Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##                9.0.x     #16169   +/-   ##
=============================================
  Coverage     30.1177%   30.1177%           
  Complexity        519        519           
=============================================
  Files              83         83           
  Lines            4758       4758           
  Branches          815        815           
=============================================
  Hits             1433       1433           
  Misses           3082       3082           
  Partials          243        243           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread grails-benchmarks/scripts/run-app-indy-bench.ps1 Outdated
Run app-level indy A/B benches through :grails-benchmarks:appIndyBench.
Nested Gradle is launched via the wrapper JAR, not gradlew or a shell
script. CI now calls that single task. AppHttpBench rejects empty
sample sets and clamps fork count.

Assisted-by: Sisyphus:grok-4.6
@jdaugherty

Copy link
Copy Markdown
Contributor

I asked Scott to fix this in his other PR, but 'grails-benchmarks' should either be under grails-test-examples or it should be considered a test project in the root build.gradle where we set testProjectsStartWith variable. I think Scott just moved it in his PR. FYI: his PR expands on this significantly.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following review was AI generated for more feedback:

Comment thread gradle/app-bench-config.gradle Outdated
APPS.each { App app ->
Path out = modeDir.resolve(app.name + '.json')
runner.run(options.projectDir, gradleArgs(options, app, indy, out))
if (!Files.isRegularFile(out)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is six sequential --no-daemon nested builds (app × mode), each paying full configuration of the 63-project build (functional-test-config forces evaluationDependsOn across subprojects) — hence the 180-minute CI timeout. Two builds — one per mode, passing all three task paths with per-task --tests filters — would do the same work at roughly a third of the cold-start/configuration cost. The single-file -PappBenchOut routing is the only blocker; per-app default out files (which AppHttpBench.outputPath already produces) would absorb it. A combined invocation should cap workers so the three integrationTests don't contaminate each other's latency samples.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in c17dc64. Left as six nested builds for this pass so each integrationTest keeps its own AppBenchOut and worker cap; combining three apps in one Gradle invocation is a follow-up because it needs per-task output routing to avoid sample contamination.

class AppIndyBench {

static final List<App> APPS = [
new App('latency', ':grails-test-examples-latency:integrationTest', 'latencyapp.AppBenchFastPingSpec'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This roster duplicates knowledge the example projects own (project path, spec FQCN) into a compiled class in another module, and each benched app must separately remember to apply gradle/app-bench-config.gradle — adding or renaming an app takes lockstep edits in three places with no drift detection, and a miss surfaces only as the late 'Missing result file' failure deep in a nested-build run. The specs already follow an AppBench* naming convention and every example app applies functional-test-config.gradle; discovery by convention (or folding the ~10 lines of sysprop wiring into functional-test-config.gradle) would keep the roster where the apps live.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in c17dc64. Roster stays explicit for now so the orchestrator fails fast on a missing named spec. Convention discovery (AppBench* plus folding wiring into functional-test-config) is a follow-up.

Move AppHttpBench into http-client test fixtures, report a t-interval
over fork means, emit a ruler-equivalent, and replace JSON output.
Treat grails-benchmarks as a test project. Interleave indy/noindy per
app, continue after nested failures with a fallback summary, forward
GRADLE_OPTS, and stop daemons before measurement.

Assisted-by: Sisyphus:grok-4.6
Made-with: Cursor
@jamesfredley

Copy link
Copy Markdown
Contributor Author

@jdaugherty Implemented in c17dc64. grails-benchmarks is now in root testProjectsStartWith next to grails-test-suite and grails-test-examples, so it is classified as a test project rather than a published module.

@testlens-app

testlens-app Bot commented Sep 4, 2026

Copy link
Copy Markdown

🚨 TestLens detected 6 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Failed Jobs without Test Failures

Validate GitHub Actions / Validate Actions
SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false)
Code Style / Core Projects
Code Style / Forge Projects
Groovy Snapshot Canary Build / Build Grails (shard 2)
Groovy Snapshot Canary Build / Build Grails (shard 0)
Groovy Snapshot Canary Build / Build Grails (shard 1)
End to End / End to End Tests (end-to-end build only)
CI / Spring Security Tests (Java 25, shard 1)
CI / Functional Tests (Java 25, indy=false)
CI / Spring Security Tests (Java 25)
CI / Neo4j Functional Tests (Java 25, indy=false)
CI / Hibernate7 Functional Tests (Java 21, indy=false, shard 2)
CI / Build Grails Forge (Java 21, indy=true)
CI / Build Grails-Core (Windows JDK 25 shard 1)
CI / Functional Tests (Java 21, indy=true)
CI / Hibernate7 Functional Tests (Java 25, indy=false, shard 1)
CI / Mongodb Functional Tests (Java 21, MongoDB 7.0, indy=true)
CI / Mongodb Functional Tests (Java 21, MongoDB 7.0, indy=false)
CI / Mongodb Functional Tests (Java 21, MongoDB 8.0, indy=false)
CI / Spring Security Tests (Java 21)
CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 1)
CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 0)
CI / Hibernate7 Functional Tests (Java 21, indy=true, shard 1)
CI / Neo4j Functional Tests (Java 21, indy=true)
CI / Spring Security Config Functional Tests (annotation)
CI / Build Grails-Core (Windows JDK 25 shard 0)
CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 build)
CI / Mongodb Functional Tests (Java 25, MongoDB 8.0, indy=false)
CI / Build Grails Forge (Java 21, indy=false)
CI / Functional Tests (Java 21, indy=false, shard 1)
CI / Spring Security Config Functional Tests (issue503)
CI / Build Grails Forge (Java 25, indy=false)
CI / Mongodb Functional Tests (Java 25, MongoDB 7.0, indy=false)
CI / Spring Security Config Functional Tests (basic)
CI / Spring Security Config Functional Tests (misc)
CI / Hibernate7 Functional Tests (Java 21, indy=false, shard 1)
CI / Spring Security Config Functional Tests (static)
CI / Functional Tests (Java 21, indy=false)
CI / Functional Tests (Java 21, indy=true, shard 1)
CI / Spring Security Config Functional Tests (bcrypt)
CI / Spring Security Config Functional Tests (requestmap)
CI / Hibernate7 Functional Tests (Java 25, indy=false)
CI / Hibernate5 Functional Tests (Java 21, indy=true)
CI / Redis Tests (25, 7.4)
CI / Spring Security CAS Functional Tests (casProxy)
CI / Spring Security Config Functional Tests (basicCacheUsers)
CI / Spring Security CAS Functional Tests (cas)
CI / Functional Tests (Java 25, indy=false, shard 1)
CI / Neo4j Functional Tests (Java 21, indy=false)
CI / Hibernate5 Functional Tests (Java 21, indy=false)
CI / Hibernate5 Functional Tests (Java 25, indy=false)
CI / Redis Tests (21, 7.4)
CI / Redis Tests (25, 8.0)
CI / Redis Tests (21, 8.0)
CI / Hibernate7 Functional Tests (Java 21, indy=false)
CI / Hibernate7 Functional Tests (Java 21, indy=true)
CI / Spring Security CAS Functional Tests (casNoSingleSignout)
CI / Spring Security Config Functional Tests (putWithParams)
Coverage / Coverage - grails-core (ubuntu-24.04)

Test Summary

CI / Build Grails-Core (Ubuntu JDK 21) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

CI / Build Grails-Core (Ubuntu JDK 25) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

CI / Build Grails-Core (Windows JDK 25 shard 2) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

CI / Build Grails-Core (macOS JDK 21) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 2) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

SiteMesh 2 Compatibility / SiteMesh 2 Core Tests (Java 21) > :grails-taglib:test

Test Runs Flakiness
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() 0% 🟢

🏷️ Commit: bb53ead
▶️ Tests: 28456 executed
⚪️ Checks: 90/90 completed

Test Failures

GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in CI / Build Grails-Core (Ubuntu JDK 21))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in CI / Build Grails-Core (Ubuntu JDK 25))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in CI / Build Grails-Core (Windows JDK 25 shard 2))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in CI / Build Grails-Core (macOS JDK 21))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in CI / Build Grails-Core Rerunning all Tasks (Ubuntu JDK 21 shard 2))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true
GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter() (:grails-taglib:test in SiteMesh 2 Compatibility / SiteMesh 2 Core Tests (Java 21))
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:158)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:139)
	at org.junit.jupiter.api.AssertFalse.failNotFalse(AssertFalse.java:69)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:41)
	at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
	at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:246)
	at org.grails.taglib.GroovyPageAttributesTests.testAssigningGspTagSyntaxCallInvokesTheSetter(GroovyPageAttributesTests.groovy:134)
expected actual
false true

Rerun Controls

Select tests to mute in this pull request:

  • GroovyPageAttributesTests > testAssigningGspTagSyntaxCallInvokesTheSetter()

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@jdaugherty

Copy link
Copy Markdown
Contributor

FYI: Scott's PR was merged prior to this, so we'll want to move this to grails-test-examples/benchmarks too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants