From cc7e0c3353f9b541880438d442a43da149aae81e Mon Sep 17 00:00:00 2001 From: James Fredley Date: Tue, 18 Aug 2026 16:25:33 -0400 Subject: [PATCH 1/6] build(ci): bound concurrent JVMs on the macOS runner org.gradle.jvmargs sizes the Gradle daemon only. Test forks are separate child JVMs that take their heap from maxHeapSize in gradle/test-config.gradle, so a job's configured heap is the daemon -Xmx plus the concurrent test forks times the per-fork heap. Those two numbers live in different files and have never been reasoned about together. The concurrent fork count is not maxParallelForks. With org.gradle.parallel=true several Test tasks run at once, so the live JVM count is bounded by Gradle's global worker pool, which defaults to the CPU count. On the 4-CPU, ~16 GB Linux and Windows runners that floor is 5G + 4x768m = 8G and fits. On the 3-CPU, ~7 GB macOS runner it is 5G + 3x768m = 7.25G and does not. Cap --max-workers on the macOS leg, since that is what actually limits concurrent test and compiler JVMs, and keep maxTestParallel alongside it so no single task exceeds the same cap. Both are passed through a new runner_arguments matrix key that is undefined, and therefore empty, for every other entry. The daemon stays at 5 GB: groovydoc is what needs it, and shrinking it would trade a memory problem for a slower build. Document the arithmetic next to org.gradle.jvmargs as a simplified configured-heap floor, explicitly excluding metaspace, native memory and the forked compiler workers that CompilePlugin gives their own -Xmx2G, so it is not mistaken for a true peak. This changes concurrency only. No test is added, removed, skipped or weakened. Assisted-by: claude-code:claude-opus-5 --- .github/workflows/gradle.yml | 11 +++++++++++ gradle.properties | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 39f808c509c..9d6f57fa36c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -142,6 +142,16 @@ jobs: java: 21 job_name: macOS JDK 21 gradle_task: 'build :grails-shell-cli:installDist groovydoc' + # The macOS runner has ~7 GB of RAM and 3 CPUs, against ~16 GB and 4 CPUs on the + # Linux and Windows runners, while org.gradle.jvmargs still asks for a 5 GB daemon + # (groovydoc needs it). Capping only maxTestParallel would not help: that is a + # per-Test-task limit, and with org.gradle.parallel=true several projects' test + # tasks run at once, so the number of live forks is bounded by Gradle's global + # worker pool - which defaults to the 3 CPUs here. --max-workers is therefore the + # setting that actually limits concurrent test and compiler JVMs; maxTestParallel + # is kept alongside it so no single task exceeds that cap either. This reduces + # memory pressure on the smallest runner rather than proving the job fits. + runner_arguments: '--max-workers=2 -PmaxTestParallel=2' cache_writer: true - os: windows-latest java: 25 @@ -204,6 +214,7 @@ jobs: -PonlyCoreTests -PskipCodeStyle ${{ matrix.shard_arguments }} + ${{ matrix.runner_arguments }} - name: "🗄️ Save dependency jar cache" if: ${{ success() && matrix.cache_writer && steps.dependency-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 diff --git a/gradle.properties b/gradle.properties index b49b31f28fd..e7e55b6e02b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -88,4 +88,23 @@ org.gradle.daemon=true #org.gradle.configureondemand=true # Note: groovydoc requires almost a doubling of this memory; if it could run in a process isolation, we could reduce this # This is a future TODO see groovydoc-tool-rewrite branch for experiementations with this +# +# This -Xmx sizes the Gradle DAEMON only. Test forks are separate child JVMs and get their +# own heap from maxHeapSize in gradle/test-config.gradle (768m on CI, 1024m locally), so a +# rough lower bound on a job's configured heap is: +# +# daemon -Xmx + (concurrent test forks x per-fork maxHeapSize) +# +# "Concurrent test forks" is NOT maxParallelForks. With org.gradle.parallel=true several +# Test tasks run at once, so the live fork count is bounded by Gradle's global worker pool +# (--max-workers, defaulting to the CPU count). +# +# This is a simplified CONFIGURED-HEAP budget, not a true peak: it excludes metaspace and +# other native memory, the Gradle client, and forked Java/Groovy compiler workers (which +# CompilePlugin gives their own -Xmx2G). Treat it as a floor when sizing a runner. +# +# On the 4-CPU / ~16 GB Linux and Windows runners that floor is 5G + 4x768m = 8G, which +# fits. On the 3-CPU / ~7 GB macOS runner it is 5G + 3x768m = 7.25G, which does not - so +# .github/workflows/gradle.yml caps --max-workers there to reduce memory pressure, rather +# than shrinking this daemon and slowing groovydoc. org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G From 8dfa3636da797ad5c92724f78e3cef638cc7bff8 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 19 Aug 2026 19:41:25 -0400 Subject: [PATCH 2/6] build: disable Groovy invokedynamic for the Grails 8 compile Groovy 5 defaults indy on. Only modules that apply the Grails Gradle plugin inherited grails.indy=false, so published framework artifacts were mixed. Centralize indy=false in CompilePlugin and apply gradle/groovy-indy.gradle from the grails-core, grails-gradle, and grails-forge builds. CI can still opt in with -PgrailsIndy=true. See #15293 Assisted-by: Sisyphus:grok-4.6 --- build-logic/docs-core/build.gradle | 7 ++ build-logic/plugins/build.gradle | 7 ++ .../grails/buildsrc/CompilePlugin.groovy | 7 ++ .../grails/buildsrc/CompilePluginSpec.groovy | 67 +++++++++++++++++++ build.gradle | 2 + gradle/grails-extension-gradle-config.gradle | 2 + gradle/groovy-indy.gradle | 34 ++++++++++ grails-forge/build.gradle | 2 + grails-gradle/build.gradle | 2 + 9 files changed, 130 insertions(+) create mode 100644 gradle/groovy-indy.gradle diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index d3459b33310..c52c7515dff 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -71,6 +71,13 @@ sourceSets { } } +// docs-core does not apply org.apache.grails.buildsrc.compile. Keep the same +// Grails 8 default (indy off) as CompilePlugin. See #15293. +tasks.withType(GroovyCompile).configureEach { + groovyOptions.optimizationOptions.indy = project.hasProperty('grailsIndy') && + Boolean.parseBoolean(project.property('grailsIndy') as String) +} + def docFilesJar = tasks.register('docFilesJar', Jar) docFilesJar.configure {Jar it -> it.description = 'Package up files used for generating documentation.' diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index e6d687e1f84..b7c64b86a3a 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -57,6 +57,13 @@ tasks.named('test') { useJUnitPlatform() } +// This project compiles CompilePlugin itself, so it cannot apply that plugin. +// Keep the same Grails 8 default (indy off) as CompilePlugin. See #15293. +tasks.withType(GroovyCompile).configureEach { + groovyOptions.optimizationOptions.indy = project.hasProperty('grailsIndy') && + Boolean.parseBoolean(project.property('grailsIndy') as String) +} + gradlePlugin { plugins { register('compilePlugin') { diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy index 149c2cf1114..c32e8773c41 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy @@ -36,6 +36,7 @@ import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.external.javadoc.StandardJavadocDocletOptions +import static org.apache.grails.buildsrc.GradleUtils.lookupProperty import static org.apache.grails.buildsrc.GradleUtils.lookupPropertyByType @CompileStatic @@ -113,6 +114,12 @@ class CompilePlugin implements Plugin { it.groovyOptions.encoding = StandardCharsets.UTF_8.name() // Preserve method parameter names in Groovy/Java classes for IDE parameter hints & bean reflection metadata. it.groovyOptions.parameters = true + // Grails 8 keeps invokedynamic off. Groovy 5's compiler default is indy=true, + // which is a large runtime regression for dynamic Groovy (see #15293). Modules + // that do not apply the Grails Gradle plugin would otherwise inherit that + // default. Grails 9 / Groovy 6 can flip this. CI can still opt in with + // -PgrailsIndy=true (same property as grails-extension-gradle-config.gradle). + it.groovyOptions.optimizationOptions.put('indy', lookupProperty(project, 'grailsIndy', false)) // encoding needs to be the same since it's different across platforms it.options.encoding = StandardCharsets.UTF_8.name() it.options.fork = true diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy index a7677964086..88789e16504 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy @@ -21,14 +21,21 @@ package org.apache.grails.buildsrc import org.gradle.api.Project import org.gradle.api.tasks.compile.GroovyCompile import org.gradle.testfixtures.ProjectBuilder +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification import spock.lang.TempDir +import java.nio.file.Path + class CompilePluginSpec extends Specification { @TempDir File projectDir + @TempDir + Path testProjectDir + void 'the hand-authored auto-configuration imports file is a compiler input'() { given: Project project = ProjectBuilder.builder().withProjectDir(projectDir).build() @@ -46,4 +53,64 @@ class CompilePluginSpec extends Specification { compileGroovy.inputs.files.files.count { it.canonicalFile == importsFile.canonicalFile } == 1 } + def setup() { + testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('.asf.yaml').toFile().text = '' + def configScript = testProjectDir.resolve('gradle/groovy-compile-configscript.groovy').toFile() + configScript.parentFile.mkdirs() + configScript.text = '' + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'groovy' + id 'org.apache.grails.buildsrc.compile' + } + + ext { + javaVersion = 21 + grailsVersion = '8.0.0-SNAPSHOT' + formattedBuildDate = '2026-01-01' + } + + repositories { + mavenCentral() + } + + tasks.register('printIndy') { + def compileTask = tasks.named('compileGroovy', org.gradle.api.tasks.compile.GroovyCompile) + def testCompileTask = tasks.named('compileTestGroovy', org.gradle.api.tasks.compile.GroovyCompile) + doLast { + println "MAIN_INDY=\${compileTask.get().groovyOptions.optimizationOptions.indy}" + println "TEST_INDY=\${testCompileTask.get().groovyOptions.optimizationOptions.indy}" + } + } + """ + } + + def "disables invokedynamic on GroovyCompile tasks by default"() { + when: + def result = runPrintIndy() + + then: + result.task(':printIndy').outcome == TaskOutcome.SUCCESS + result.output.contains('MAIN_INDY=false') + result.output.contains('TEST_INDY=false') + } + + def "enables invokedynamic when grailsIndy is true"() { + when: + def result = runPrintIndy('-PgrailsIndy=true') + + then: + result.task(':printIndy').outcome == TaskOutcome.SUCCESS + result.output.contains('MAIN_INDY=true') + result.output.contains('TEST_INDY=true') + } + + private def runPrintIndy(String... extraArgs) { + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(['printIndy', '--stacktrace'] + (extraArgs as List)) + .withPluginClasspath() + .build() + } } diff --git a/build.gradle b/build.gradle index 61f25361f56..b8fa7f63e52 100644 --- a/build.gradle +++ b/build.gradle @@ -109,6 +109,8 @@ final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentP subprojects { + apply from: rootProject.layout.projectDirectory.file('gradle/groovy-indy.gradle') + tasks.withType(Test).configureEach { testTask -> testTask.jvmArgumentProviders.add(new ActiveProcessorCountArgumentProvider( Runtime.runtime.availableProcessors(), gradle.startParameter.maxWorkerCount)) diff --git a/gradle/grails-extension-gradle-config.gradle b/gradle/grails-extension-gradle-config.gradle index d491c3f2e55..bb3cc52e28e 100644 --- a/gradle/grails-extension-gradle-config.gradle +++ b/gradle/grails-extension-gradle-config.gradle @@ -35,6 +35,8 @@ grails { // Allow CI to toggle Groovy invokedynamic (indy) via -PgrailsIndy=true // This enables testing functional tests with both indy enabled and disabled. // See: https://github.com/apache/grails-core/issues/15321 + // Framework modules that do not apply this plugin inherit the same default + // from org.apache.grails.buildsrc.compile (CompilePlugin). if (project.hasProperty('grailsIndy')) { indy = Boolean.parseBoolean(project.property('grailsIndy') as String) } diff --git a/gradle/groovy-indy.gradle b/gradle/groovy-indy.gradle new file mode 100644 index 00000000000..33fc608be87 --- /dev/null +++ b/gradle/groovy-indy.gradle @@ -0,0 +1,34 @@ +/* + * 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 + * + * https://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. + */ + +// Grails 8 keeps invokedynamic off. Groovy 5's compiler default is indy=true, +// which is a large runtime regression for dynamic Groovy (see #15293). +// Applied from each independent build (grails-core, grails-gradle, grails-forge) +// so modules that never apply the Grails Gradle plugin still inherit this. +// Grails 9 / Groovy 6 can flip the default. CI can still opt in with -PgrailsIndy=true. +boolean grailsIndyEnabled = false +if (project.hasProperty('grailsIndy')) { + grailsIndyEnabled = Boolean.parseBoolean(project.property('grailsIndy') as String) +} + +project.pluginManager.withPlugin('groovy') { + project.tasks.withType(org.gradle.api.tasks.compile.GroovyCompile).configureEach { compileTask -> + compileTask.groovyOptions.optimizationOptions.indy = grailsIndyEnabled + } +} diff --git a/grails-forge/build.gradle b/grails-forge/build.gradle index a149ee6e110..81570aaa0b7 100644 --- a/grails-forge/build.gradle +++ b/grails-forge/build.gradle @@ -83,6 +83,8 @@ allprojects { } subprojects { + apply from: rootProject.layout.projectDirectory.file('../gradle/groovy-indy.gradle') + configurations.configureEach { resolutionStrategy { def cacheHours = isCiBuild || isReproducibleBuild ? 0 : 24 diff --git a/grails-gradle/build.gradle b/grails-gradle/build.gradle index bc42c184e2c..43b1ee10b4b 100644 --- a/grails-gradle/build.gradle +++ b/grails-gradle/build.gradle @@ -72,6 +72,8 @@ final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentP } subprojects { + apply from: rootProject.layout.projectDirectory.file('../gradle/groovy-indy.gradle') + tasks.withType(Test).configureEach { testTask -> testTask.jvmArgumentProviders.add(new ActiveProcessorCountArgumentProvider( Runtime.runtime.availableProcessors(), gradle.startParameter.maxWorkerCount)) From 085c0d2886484a6538fc30a01c851ccb14fdf146 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 19 Aug 2026 20:49:24 -0400 Subject: [PATCH 3/6] fix: stop count() log.debug hitting GORM methodMissing With indy off, log.debug inside GormStaticApi.count()'s session callback was dispatched as Domain.debug(...). Capture the @Slf4j logger in a local first. Also trim -PgrailsIndy the same way as CompilePlugin and apply the shared groovy-indy script from build-logic. Assisted-by: Sisyphus:grok-4.6 --- build-logic/docs-core/build.gradle | 5 +---- build-logic/plugins/build.gradle | 5 +---- .../apache/grails/buildsrc/CompilePluginSpec.groovy | 10 ++++++++++ gradle/grails-extension-gradle-config.gradle | 2 +- gradle/groovy-indy.gradle | 2 +- .../org/grails/datastore/gorm/GormStaticApi.groovy | 11 ++++++++--- .../grails/datastore/gorm/GormStaticApiSpec.groovy | 11 +++++++++++ 7 files changed, 33 insertions(+), 13 deletions(-) diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index c52c7515dff..647d93c2872 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -73,10 +73,7 @@ sourceSets { // docs-core does not apply org.apache.grails.buildsrc.compile. Keep the same // Grails 8 default (indy off) as CompilePlugin. See #15293. -tasks.withType(GroovyCompile).configureEach { - groovyOptions.optimizationOptions.indy = project.hasProperty('grailsIndy') && - Boolean.parseBoolean(project.property('grailsIndy') as String) -} +apply from: layout.projectDirectory.file('../../gradle/groovy-indy.gradle') def docFilesJar = tasks.register('docFilesJar', Jar) docFilesJar.configure {Jar it -> diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index b7c64b86a3a..f48baadfa7d 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -59,10 +59,7 @@ tasks.named('test') { // This project compiles CompilePlugin itself, so it cannot apply that plugin. // Keep the same Grails 8 default (indy off) as CompilePlugin. See #15293. -tasks.withType(GroovyCompile).configureEach { - groovyOptions.optimizationOptions.indy = project.hasProperty('grailsIndy') && - Boolean.parseBoolean(project.property('grailsIndy') as String) -} +apply from: layout.projectDirectory.file('../../gradle/groovy-indy.gradle') gradlePlugin { plugins { diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy index 88789e16504..4ca4004cb90 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy @@ -106,6 +106,16 @@ class CompilePluginSpec extends Specification { result.output.contains('TEST_INDY=true') } + def "trims whitespace when parsing grailsIndy"() { + when: + def result = runPrintIndy('-PgrailsIndy= true ') + + then: + result.task(':printIndy').outcome == TaskOutcome.SUCCESS + result.output.contains('MAIN_INDY=true') + result.output.contains('TEST_INDY=true') + } + private def runPrintIndy(String... extraArgs) { GradleRunner.create() .withProjectDir(testProjectDir.toFile()) diff --git a/gradle/grails-extension-gradle-config.gradle b/gradle/grails-extension-gradle-config.gradle index bb3cc52e28e..dfc3cdb977c 100644 --- a/gradle/grails-extension-gradle-config.gradle +++ b/gradle/grails-extension-gradle-config.gradle @@ -38,6 +38,6 @@ grails { // Framework modules that do not apply this plugin inherit the same default // from org.apache.grails.buildsrc.compile (CompilePlugin). if (project.hasProperty('grailsIndy')) { - indy = Boolean.parseBoolean(project.property('grailsIndy') as String) + indy = project.property('grailsIndy').toString().trim().toBoolean() } } \ No newline at end of file diff --git a/gradle/groovy-indy.gradle b/gradle/groovy-indy.gradle index 33fc608be87..88ab6271296 100644 --- a/gradle/groovy-indy.gradle +++ b/gradle/groovy-indy.gradle @@ -24,7 +24,7 @@ // Grails 9 / Groovy 6 can flip the default. CI can still opt in with -PgrailsIndy=true. boolean grailsIndyEnabled = false if (project.hasProperty('grailsIndy')) { - grailsIndyEnabled = Boolean.parseBoolean(project.property('grailsIndy') as String) + grailsIndyEnabled = project.property('grailsIndy').toString().trim().toBoolean() } project.pluginManager.withPlugin('groovy') { diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy index 8a3930560de..e3f56c8eebb 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy @@ -377,15 +377,20 @@ class GormStaticApi extends AbstractGormApi implements GormAllOperations def query = session.createQuery(persistentClass) query.projections().count() def res = query.singleResult() - log.debug('Query singleResult returned {}', res) + logger.debug('Query singleResult returned {}', res) res instanceof Number ? ((Number)res).intValue() : 0 } as SessionCallback) - log.debug('count() result is {}', result) + logger.debug('count() result is {}', result) return result } diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy index 91a7439d4bc..62ec8c72ccc 100644 --- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormStaticApiSpec.groovy @@ -100,6 +100,17 @@ class GormStaticApiSpec extends Specification { api.executeQualified(ConnectionSource.DEFAULT, { Session session -> 'ran' }) == 'ran' } + void "count() does not dispatch log.debug through methodMissing"() { + given: + def api = new GormStaticApi(GormStaticApiThing, datastore, []) + + when: + Integer n = api.count() + + then: + n == 0 + } + void "getGormDynamicFinders returns the finders the api was constructed with"() { given: def finder = Stub(org.grails.datastore.gorm.finders.FinderMethod) From c4e794fc6d16d550a0c8398929a2a3cbcb587842 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 20 Aug 2026 15:48:52 -0400 Subject: [PATCH 4/6] fix: invoke Map constructors without Class.newInstance under indy-off Class.newInstance(Map) is not selected under @CompileStatic when invokedynamic is disabled. Use InvokerHelper.invokeConstructorOf with an explicit Object[] so nested Map-constructor types still bind. Also avoid `null as boolean` in the Map-constructor test fixture, which Groovy 5 throws on without indy after unbindable properties are filtered from constructor arguments. Assisted-by: Sisyphus:grok-4.6 --- .../grails/databinding/SimpleDataBinder.groovy | 15 ++++++++++++++- .../databinding/GrailsWebDataBinderSpec.groovy | 3 ++- .../web/databinding/GrailsWebDataBinder.groovy | 3 ++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy index c84fe8b77b2..33846aee051 100755 --- a/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy +++ b/grails-databinding-core/src/main/groovy/grails/databinding/SimpleDataBinder.groovy @@ -29,6 +29,7 @@ import groovy.transform.CompileStatic import groovy.transform.TypeCheckingMode import groovy.xml.slurpersupport.GPathResult import org.codehaus.groovy.reflection.CachedMethod +import org.codehaus.groovy.runtime.InvokerHelper import grails.databinding.converters.FormattedValueConverter import grails.databinding.converters.ValueConverter @@ -430,12 +431,24 @@ class SimpleDataBinder implements DataBinder { try { instance = referencedType.getDeclaredConstructor().newInstance() } catch (NoSuchMethodException | IllegalAccessException ignored) { - return referencedType.newInstance(values) + return newInstanceFromMapArguments(referencedType, values) } bind(instance, new SimpleMapDataBindingSource(values), listener) instance } + /** + * Invoke a {@code Map} constructor without calling Groovy's + * {@code Class.newInstance(Map)}. Under {@code @CompileStatic} with + * invokedynamic disabled that extension is not selected, so nested + * objects with only a Map constructor are left unbound. + */ + protected Object newInstanceFromMapArguments(Class referencedType, Map values) { + // Pass an Object[] so CompileStatic cannot treat the Map as named + // arguments or coerce it to a multi-arg constructor signature. + InvokerHelper.invokeConstructorOf(referencedType, new Object[] { values }) + } + @CompileStatic(TypeCheckingMode.SKIP) protected initializeArray(obj, String propertyName, Class arrayType, int index) { Object[] array = obj[propertyName] diff --git a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy index 6130502bf22..0f761a363e6 100644 --- a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy +++ b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy @@ -2254,7 +2254,8 @@ class SecureMapConstructorValue implements Validateable { SecureMapConstructorValue(Map values) { name = values.name - admin = values.admin as boolean + // Groovy 5 without invokedynamic throws on `null as boolean`. + admin = Boolean.TRUE.equals(values.admin) } static constraints = { diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 765fdcc292e..a36cc37072c 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -651,7 +651,8 @@ class GrailsWebDataBinder extends SimpleDataBinder { if (value instanceof Map) { if (isBindAllIncludeList(includeList) || !DataBindingUtils.isDenyByDefaultEnabled()) { - return referencedType.newInstance(filterUnbindableMapConstructorArguments(referencedType, (Map) value)) + return newInstanceFromMapArguments(referencedType, + filterUnbindableMapConstructorArguments(referencedType, (Map) value)) } if (DataBindingUtils.isGeneratedBindingIncludeList(bindingIncludeList.get())) { warnAboutMissingNoArgConstructor(referencedType) From 505542f121404d4d3ed9552d25b1cb1953028641 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 2 Sep 2026 18:40:05 -0400 Subject: [PATCH 5/6] build: keep Grails 8 indy-off only in CompilePlugin Honor review: drop groovy-indy.gradle and subprojects applies. Unpublished build-logic uses Gradle's Groovy default. --- build-logic/docs-core/build.gradle | 4 --- build-logic/plugins/build.gradle | 4 --- .../grails/buildsrc/CompilePlugin.groovy | 6 ++-- build.gradle | 2 -- gradle/grails-extension-gradle-config.gradle | 3 +- gradle/groovy-indy.gradle | 34 ------------------- grails-forge/build.gradle | 2 -- grails-gradle/build.gradle | 2 -- 8 files changed, 4 insertions(+), 53 deletions(-) delete mode 100644 gradle/groovy-indy.gradle diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index 647d93c2872..d3459b33310 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -71,10 +71,6 @@ sourceSets { } } -// docs-core does not apply org.apache.grails.buildsrc.compile. Keep the same -// Grails 8 default (indy off) as CompilePlugin. See #15293. -apply from: layout.projectDirectory.file('../../gradle/groovy-indy.gradle') - def docFilesJar = tasks.register('docFilesJar', Jar) docFilesJar.configure {Jar it -> it.description = 'Package up files used for generating documentation.' diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index f48baadfa7d..e6d687e1f84 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -57,10 +57,6 @@ tasks.named('test') { useJUnitPlatform() } -// This project compiles CompilePlugin itself, so it cannot apply that plugin. -// Keep the same Grails 8 default (indy off) as CompilePlugin. See #15293. -apply from: layout.projectDirectory.file('../../gradle/groovy-indy.gradle') - gradlePlugin { plugins { register('compilePlugin') { diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy index c32e8773c41..4eb47d7dedc 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy @@ -114,9 +114,9 @@ class CompilePlugin implements Plugin { it.groovyOptions.encoding = StandardCharsets.UTF_8.name() // Preserve method parameter names in Groovy/Java classes for IDE parameter hints & bean reflection metadata. it.groovyOptions.parameters = true - // Grails 8 keeps invokedynamic off. Groovy 5's compiler default is indy=true, - // which is a large runtime regression for dynamic Groovy (see #15293). Modules - // that do not apply the Grails Gradle plugin would otherwise inherit that + // Grails 8 keeps invokedynamic off for published artifacts. Groovy 5's + // compiler default is indy=true, which is a large runtime regression for + // dynamic Groovy (see #15293). Unpublished build-logic uses Gradle's // default. Grails 9 / Groovy 6 can flip this. CI can still opt in with // -PgrailsIndy=true (same property as grails-extension-gradle-config.gradle). it.groovyOptions.optimizationOptions.put('indy', lookupProperty(project, 'grailsIndy', false)) diff --git a/build.gradle b/build.gradle index b8fa7f63e52..61f25361f56 100644 --- a/build.gradle +++ b/build.gradle @@ -109,8 +109,6 @@ final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentP subprojects { - apply from: rootProject.layout.projectDirectory.file('gradle/groovy-indy.gradle') - tasks.withType(Test).configureEach { testTask -> testTask.jvmArgumentProviders.add(new ActiveProcessorCountArgumentProvider( Runtime.runtime.availableProcessors(), gradle.startParameter.maxWorkerCount)) diff --git a/gradle/grails-extension-gradle-config.gradle b/gradle/grails-extension-gradle-config.gradle index dfc3cdb977c..1b8a5b67aa0 100644 --- a/gradle/grails-extension-gradle-config.gradle +++ b/gradle/grails-extension-gradle-config.gradle @@ -35,8 +35,7 @@ grails { // Allow CI to toggle Groovy invokedynamic (indy) via -PgrailsIndy=true // This enables testing functional tests with both indy enabled and disabled. // See: https://github.com/apache/grails-core/issues/15321 - // Framework modules that do not apply this plugin inherit the same default - // from org.apache.grails.buildsrc.compile (CompilePlugin). + // Published framework modules inherit the same default from CompilePlugin. if (project.hasProperty('grailsIndy')) { indy = project.property('grailsIndy').toString().trim().toBoolean() } diff --git a/gradle/groovy-indy.gradle b/gradle/groovy-indy.gradle deleted file mode 100644 index 88ab6271296..00000000000 --- a/gradle/groovy-indy.gradle +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 - * - * https://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. - */ - -// Grails 8 keeps invokedynamic off. Groovy 5's compiler default is indy=true, -// which is a large runtime regression for dynamic Groovy (see #15293). -// Applied from each independent build (grails-core, grails-gradle, grails-forge) -// so modules that never apply the Grails Gradle plugin still inherit this. -// Grails 9 / Groovy 6 can flip the default. CI can still opt in with -PgrailsIndy=true. -boolean grailsIndyEnabled = false -if (project.hasProperty('grailsIndy')) { - grailsIndyEnabled = project.property('grailsIndy').toString().trim().toBoolean() -} - -project.pluginManager.withPlugin('groovy') { - project.tasks.withType(org.gradle.api.tasks.compile.GroovyCompile).configureEach { compileTask -> - compileTask.groovyOptions.optimizationOptions.indy = grailsIndyEnabled - } -} diff --git a/grails-forge/build.gradle b/grails-forge/build.gradle index 81570aaa0b7..a149ee6e110 100644 --- a/grails-forge/build.gradle +++ b/grails-forge/build.gradle @@ -83,8 +83,6 @@ allprojects { } subprojects { - apply from: rootProject.layout.projectDirectory.file('../gradle/groovy-indy.gradle') - configurations.configureEach { resolutionStrategy { def cacheHours = isCiBuild || isReproducibleBuild ? 0 : 24 diff --git a/grails-gradle/build.gradle b/grails-gradle/build.gradle index 43b1ee10b4b..bc42c184e2c 100644 --- a/grails-gradle/build.gradle +++ b/grails-gradle/build.gradle @@ -72,8 +72,6 @@ final class ActiveProcessorCountArgumentProvider implements CommandLineArgumentP } subprojects { - apply from: rootProject.layout.projectDirectory.file('../gradle/groovy-indy.gradle') - tasks.withType(Test).configureEach { testTask -> testTask.jvmArgumentProviders.add(new ActiveProcessorCountArgumentProvider( Runtime.runtime.availableProcessors(), gradle.startParameter.maxWorkerCount)) From 854938f029512272cc47407312f3fc470e874f0c Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 4 Sep 2026 13:48:56 -0400 Subject: [PATCH 6/6] fix: include runtime jars on groovydoc classpath Hibernate 7 publishes jboss-logging as a runtime-only transitive. Groovydoc Class.forName's referenced types, so :grails-data-hibernate7-dbmigration-core:groovydoc failed in CI (Forge/e2e publish) with NoClassDefFoundError: org/jboss/logging/Logger. --- .../buildsrc/GroovydocEnhancerPlugin.groovy | 15 ++++-- .../GroovydocEnhancerPluginSpec.groovy | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy index 38ff02cf863..8c87a887ac3 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy @@ -75,6 +75,14 @@ class GroovydocEnhancerPlugin implements Plugin { if (project.configurations.names.contains('documentation')) { it.groovyClasspath = project.configurations.getByName('documentation') } + // Groovydoc Class.forName's referenced types against this classpath. Compile + // classpath is not enough: Hibernate 7 (and similar libraries) publish logging + // APIs such as jboss-logging as runtime-only transitives, and loading those + // classes without the jar fails with NoClassDefFoundError. + def runtimeClasspath = project.configurations.findByName('runtimeClasspath') + if (runtimeClasspath != null) { + it.classpath = it.classpath ? it.classpath.plus(runtimeClasspath) : runtimeClasspath + } } } @@ -110,9 +118,10 @@ class GroovydocEnhancerPlugin implements Plugin { // Groovydoc resolves references to types outside the documented sources with // Class.forName against its own classloader; anything it cannot load becomes a - // link to a page that was never generated. Adding the documented sources' - // compile classpath lets those types resolve, at which point the 'links' - // below turn them into external javadoc URLs. + // link to a page that was never generated. The groovydoc classpath includes + // compile and runtime dependencies so types such as Hibernate (which need + // runtime-only jars like jboss-logging) can load; the 'links' below then turn + // those types into external javadoc URLs. def antClasspath = gdoc.classpath ? classpath.plus(gdoc.classpath) : classpath project.ant.taskdef( diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy new file mode 100644 index 00000000000..c0c29cb7686 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy @@ -0,0 +1,54 @@ +/* + * 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 + * + * https://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.grails.buildsrc + +import org.gradle.api.Project +import org.gradle.api.tasks.javadoc.Groovydoc +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification +import spock.lang.TempDir + +class GroovydocEnhancerPluginSpec extends Specification { + + @TempDir + File projectDir + + void 'groovydoc classpath includes runtime-only jars that Class.forName needs'() { + given: 'a groovy project whose runtime-only jar is not on the compile classpath' + Project project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.extensions.extraProperties.set('javaVersion', 21) + project.pluginManager.apply('groovy') + project.pluginManager.apply(GroovydocEnhancerPlugin) + + File compileOnlyJar = new File(projectDir, 'compile-only.jar') + File runtimeOnlyJar = new File(projectDir, 'runtime-only.jar') + compileOnlyJar.bytes = [] as byte[] + runtimeOnlyJar.bytes = [] as byte[] + project.dependencies.add('compileOnly', project.files(compileOnlyJar)) + project.dependencies.add('runtimeOnly', project.files(runtimeOnlyJar)) + + when: 'the groovydoc task classpath is resolved' + Groovydoc groovydoc = project.tasks.named('groovydoc', Groovydoc).get() + Set groovydocFiles = groovydoc.classpath.files + + then: 'runtime-only jars are visible to groovydoc alongside compile-only jars' + groovydocFiles.any { it.name == runtimeOnlyJar.name } + groovydocFiles.any { it.name == compileOnlyJar.name } + } +}