diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ab1c33c0900..64c3fa224cd 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 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 c424c1cd1b8..69c3194af3f 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 @@ -37,6 +37,7 @@ import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.api.tasks.testing.Test import org.gradle.external.javadoc.StandardJavadocDocletOptions +import static org.apache.grails.buildsrc.GradleUtils.lookupProperty import static org.apache.grails.buildsrc.GradleUtils.lookupPropertyByType @CompileStatic @@ -114,6 +115,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 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)) // 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/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/CompilePluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/CompilePluginSpec.groovy index a7677964086..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 @@ -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,74 @@ 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') + } + + 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()) + .withArguments(['printIndy', '--stacktrace'] + (extraArgs as List)) + .withPluginClasspath() + .build() + } } 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 } + } +} diff --git a/gradle.properties b/gradle.properties index d5f3bb2fe05..856337f0f82 100644 --- a/gradle.properties +++ b/gradle.properties @@ -95,6 +95,26 @@ 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. +# # grails8-groovy6-canary: carry Spock's compile-time Groovy version-check opt-out on the build JVM so # the forked gson/gsp view compiler (AbstractGroovyTemplateCompileTask) can propagate it; Spock's global # AST transform otherwise aborts view compilation under Groovy 6. Remove once Spock ships a groovy-6.0 build. diff --git a/gradle/grails-extension-gradle-config.gradle b/gradle/grails-extension-gradle-config.gradle index d491c3f2e55..1b8a5b67aa0 100644 --- a/gradle/grails-extension-gradle-config.gradle +++ b/gradle/grails-extension-gradle-config.gradle @@ -35,7 +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 + // Published framework modules inherit the same default from 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/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-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) 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 987e5f6425b..48770292ed3 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 @@ -656,7 +656,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)