Skip to content

feat: enable Groovy invokedynamic by default on Grails 9 - #16165

Open
jamesfredley wants to merge 6 commits into
9.0.xfrom
feat/indy-default-groovy6
Open

feat: enable Groovy invokedynamic by default on Grails 9#16165
jamesfredley wants to merge 6 commits into
9.0.xfrom
feat/indy-default-groovy6

Conversation

@jamesfredley

Copy link
Copy Markdown
Contributor

Purpose

Enable Groovy invokedynamic by default on Grails 9.

Grails 7 disabled indy because Groovy 4 paid a 2-8x penalty (#15293). Grails 9 is on Groovy 6. Earlier A/B work on perf/indy-vs-callsite-g6 showed:

  • microbenchmarks: positive for indy
  • app-level HTTP benches: flat to slightly negative

That is still a large improvement over the old 2-8x hit. This PR flips the default so generated apps match Groovy 6's preferred dispatch. The performance label is set so CI JMH can measure this default against 9.0.x.

What changed

  • grails { indy } convention is now true
  • Opt out: grails { indy = false }
  • CompilePlugin honors -PgrailsIndy for framework modules (same toggle CI already uses)
  • Non-indy still needs groovy-callsite (BOM-managed)
  • whatsNew.adoc documents the default and the opt-out

Not in this PR

The full app-bench harness from perf/indy-vs-callsite-g6 is not copied here. That branch is 3.7k lines and stale vs current 9.0.x. CI JMH via the performance label is the measurement path.

Test plan

  • GrailsExtensionSpec default-on and opt-out
  • CompilePluginIndySpec absent / true / false
  • CI performance JMH vs 9.0.x

Grails 7 turned indy off because Groovy 4 paid a 2-8x penalty (#15293).
Grails 9 is on Groovy 6, where microbenchmarks were already positive and
app-level results were flat to slightly negative. Make indy the default
so generated apps match Groovy 6's preferred dispatch.

Opt out with grails { indy = false }. Non-indy still needs groovy-callsite,
which the BOM already manages. Framework modules honor -PgrailsIndy so CI
and JMH can still A/B both modes.

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

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 flips the default Groovy invokedynamic (“indy”) setting to enabled for Grails 9 builds, aligning generated applications with Groovy 6’s preferred dynamic dispatch mode while preserving an opt-out path and CI/framework-module toggling.

Changes:

  • Set the grails { indy } convention default to true and update plugin logging accordingly.
  • Add build-logic support for -PgrailsIndy to control indy in framework modules, including adding org.apache.groovy:groovy-callsite when indy is forced off.
  • Add/extend tests and documentation covering the new default and the opt-out configuration.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/core/GrailsExtensionSpec.groovy Adds tests asserting indy default-on and opt-out behavior in the Gradle extension.
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy Changes indy default handling to enabled and updates informational logging.
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy Sets the indy convention default to true and updates associated Javadoc.
grails-doc/src/en/guide/introduction/whatsNew.adoc Documents indy being enabled by default and how to disable it.
build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginIndySpec.groovy Adds coverage for -PgrailsIndy behavior in framework-module compilation configuration.
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy Honors -PgrailsIndy for framework modules and adds groovy-callsite when indy is disabled.

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

Comment thread grails-doc/src/en/guide/introduction/whatsNew.adoc
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

JMH Shard Pairing

Complete shard pairs used: 2

JMH Benchmark Report

Regressions: 0
Improvements: 1
Runner health: worst ruler deviation: 1.9% in shard-a.json (MemoryRulerBenchmark.allocateAndCopyArray (mode=avgt))
Ruler benchmarks are excluded from verdicts and group summaries; runner health is a stability check, not a calibration factor.
Ruler movements: shard-a.json: CpuRulerBenchmark.integerArithmetic (mode=avgt): 1.00x, shard-a.json: MemoryRulerBenchmark.allocateAndCopyArray (mode=avgt): 1.02x, shard-b.json: CpuRulerBenchmark.integerArithmetic (mode=avgt): 1.00x, shard-b.json: MemoryRulerBenchmark.allocateAndCopyArray (mode=avgt): 0.99x

Group geometric means are descriptive only, not verdicts.

Group Descriptive geomean speedup n
databinding 1.00x 2
gsp 1.00x 2
interceptors 1.06x 2
urlmappings 0.99x 3
views 1.10x 2
Per-benchmark results
Benchmark Base score Head score Speedup Verdict Allocation delta (ADVISORY)
views.ViewTemplateRenderingBenchmark.renderMarkupTemplate (mode=avgt) 737 ns/op 645 ns/op 1.14x IMPROVED -90 B/op (-8.2%)
databinding.SimpleDataBinderBenchmark.bindFlatMap (mode=avgt) 7.9e+03 ns/op 7.85e+03 ns/op 1.01x no clear change -180 B/op (-4.5%)
databinding.SimpleDataBinderBenchmark.bindMapWithTypeConversion (mode=avgt) 8.09e+03 ns/op 8.13e+03 ns/op 0.99x no clear change +90 B/op (+2.3%)
gsp.GroovyPageParserBenchmark.parseSmallTemplate (mode=avgt) 7.91e+03 ns/op 7.89e+03 ns/op 1.00x no clear change -18 B/op (-0.0%)
gsp.GroovyPageParserBenchmark.parseTemplateWithTagsAndExpressions (mode=avgt) 3.15e+04 ns/op 3.18e+04 ns/op 0.99x no clear change -22 B/op (-0.0%)
interceptors.UrlMappingMatcherBenchmark.matchUriPattern (mode=avgt) 168 ns/op 150 ns/op 1.12x no clear change -24 B/op (-11.3%)
interceptors.UrlMappingMatcherBenchmark.rejectNonMatchingUriPattern (mode=avgt) 44.2 ns/op 44.4 ns/op 0.99x no clear change ~0 B/op
ruler.CpuRulerBenchmark.integerArithmetic (mode=avgt) 1.84e+03 ns/op 1.84e+03 ns/op 1.00x ruler - excluded ~0 B/op
ruler.MemoryRulerBenchmark.allocateAndCopyArray (mode=avgt) 487 ns/op 484 ns/op 1.01x ruler - excluded ~0 B/op
urlmappings.UrlMappingsBenchmark.matchColdVariedKeys (mode=avgt) 1.52e+03 ns/op 1.57e+03 ns/op 0.96x no clear change -6 B/op (-0.4%)
urlmappings.UrlMappingsBenchmark.matchWarmCache (mode=avgt) 5.52 ns/op 5.53 ns/op 1.00x no clear change ~0 B/op
urlmappings.UrlMappingsBenchmark.reverseMappingAndCreateRelativeUrl (mode=avgt) 135 ns/op 134 ns/op 1.00x no clear change ~0 B/op
views.ViewTemplateRenderingBenchmark.renderJsonTemplate (mode=avgt) 6.44e+03 ns/op 6.1e+03 ns/op 1.06x no clear change -188 B/op (-0.9%)

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 14.28571% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.1171%. Comparing base (cd062bf) to head (ccce1ba).

Files with missing lines Patch % Lines
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 6 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                9.0.x     #16165        +/-   ##
==================================================
+ Coverage     53.1116%   53.1171%   +0.0055%     
+ Complexity      19362      19340        -22     
==================================================
  Files            2081       2081                
  Lines           99018      99019         +1     
  Branches        17375      17390        +15     
==================================================
+ Hits            52590      52596         +6     
- Misses          38867      38880        +13     
+ Partials         7561       7543        -18     
Files with missing lines Coverage Δ
...g/grails/gradle/plugin/core/GrailsExtension.groovy 58.6956% <100.0000%> (+4.3478%) ⬆️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)

... and 9 files with indirect coverage changes

🚀 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.

-PgrailsIndy=false is how CI compiles applications without indy. Wiring
that property through CompilePlugin forced every framework module onto
classic callsites and added org.apache.groovy:groovy-callsite with no
version, so those jobs failed with "Could not find groovy-callsite:.".

Leave framework compilation on Groovy's default. App indy remains
controlled by grails { indy }. Clarify the docs so default-on does not
read like a required setting.

Assisted-by: claude-code:claude-opus-5
@codeconsole

Copy link
Copy Markdown
Contributor

Indy Whiplash?

I agree with this change, but I wonder if we should consider turning indy on in the final release of 8? I think it is counter intuitive to go against Groovy defaults and turn it opposite to normal compiler behavior. At the very least we should consider not changing downstream plugin behavior and perhaps enforce plugins indy true?

The following should be fully understood prior to 8.0.0:

  1. Benchmark for an app with Grails compiled indy false, plugins false, and app false. (Current 8 defaults)
  2. Benchmark for an app with Grails compiled indy true, plugins false, and app true.
  3. Benchmark for an app with Grails compiled indy true, plugins true, and app false. (Unknown?)
  4. Benchmark for an app with Grails compiled indy true, plugins true, and app true (default Groovy behavior)

Current state of Grails 8

indy OFF (29): grails-cache, grails-fields, grails-geb, grails-mail, grails-redis, grails-scaffolding, grails-views-gson, grails-views-markup, grails-undertow/plugin, grails-gsp/{plugin,grails-layout,grails-sitemesh3}, grails-data-hibernate5/{grails-plugin,dbmigration}, grails-data-hibernate7/{grails-plugin,dbmigration}, grails-data-mongodb/grails-plugin, grails-data-graphql/plugin, all 11 grails-spring-security/* plugin modules.

indy ON (17, Groovy 5 default): grails-core, grails-controllers, grails-domain-class, grails-url-mappings, grails-interceptors, grails-services, grails-databinding, grails-converters, grails-codecs, grails-mimetypes, grails-i18n, grails-datasource, grails-micronaut, grails-rest-transforms, grails-async/plugin, grails-events/plugin, grails-data-neo4j/grails-plugin.

MiscSpec is identical to 8.0.x and passed on 9.0.x when the plugin
defaulted to indy=false. With indy on by default, Map-as-FilterChain
and session[name] are fragile Groovy dispatch. Use a real FilterChain
and HttpSession.getAttribute instead.

Assisted-by: claude-code:claude-opus-5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

@codeconsole only holdup is Groovy 5 indy is not as fast as Groovy 6 indy.

MiscSpec failed only when the security plugin compiled with indy on.
The session dump already contained Username=admin. with(auth) {
contains(...) } does not dispatch to String.contains under Groovy 6
indy. Direct pageSource.contains already passed. Call contains on the
string instead and snapshot pageSource once per assertion block.

Assisted-by: claude-code:claude-opus-5
The same dispatch bug as MiscSpec. ACL and UI functional specs call
with(pageSource) { contains(...) }, which does not hit String.contains
when compiled with invokedynamic. Use pageSource.contains instead.

Assisted-by: claude-code:claude-opus-5
UserSimpleSpec failed because with(page.rolesTab) { hasEnabledRole(...) }
dispatched to Geb DefaultNavigator under Groovy 6 invokedynamic. Call
the tab methods on page.rolesTab instead.

Assisted-by: claude-code:claude-opus-5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

App-level indy vs callsite (local A/B)

Ran the existing perf/indy-vs-callsite-g6 HTTP harness on this PR's tree (ccce1baf53 plus an uncommitted harness overlay). Same SHA, -PgrailsIndy=false (base) vs -PgrailsIndy=true (head).

Machine: Windows, JDK 21, warmup 80 / samples 300 / forks 2.

App Endpoint noindy indy Speedup Verdict
latency GET /fast/ping 4.42 ms/op 4.53 ms/op 0.98x no clear change
app1 GET /interceptorDemo/one 9.20 ms/op 9.27 ms/op 0.99x no clear change
gsp-layout GET /demo/renderText n/a n/a skipped: module has no grails-testing-support-http-client dep

This matches the earlier story: app-level is flat to slightly negative, not the old 2-8x Groovy 4 penalty.

A follow-up 9.0.x PR will land the harness (including the missing gsp-layout dep) and teach benchmark.yml to run this A/B on the performance label.

@testlens-app

testlens-app Bot commented Aug 19, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: ccce1ba
▶️ Tests: 71817 executed
⚪️ Checks: 80/80 completed


Learn more about TestLens at testlens.app.

contains('93')
contains('95')
}
pageSource.contains('75')

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.

Why are we removing with { } in these assertion blocks? This is a supported feature of spock that @matrei had added: https://spockframework.org/spock/docs/2.5-SNAPSHOT/all_in_one.html#_using_with_for_expectations

@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.

let's remove teh mass test changes that removed the with usage in tests?

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

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants