Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,6 +115,12 @@ class CompilePlugin implements Plugin<Project> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ class GroovydocEnhancerPlugin implements Plugin<Project> {
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
}
}
}

Expand Down Expand Up @@ -110,9 +118,10 @@ class GroovydocEnhancerPlugin implements Plugin<Project> {

// 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<File> 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 }
}
}
20 changes: 20 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion gradle/grails-extension-gradle-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,15 +377,20 @@ class GormStaticApi<D> extends AbstractGormApi<D> implements GormAllOperations<D

@Override
Integer count() {
log.debug('GormStaticApi.count() called for {}', persistentClass.name)
// Capture the @Slf4j logger before entering the SessionCallback. With
// invokedynamic off (Grails 8 default), log.debug(...) inside that
// closure is dispatched through methodMissing as a dynamic finder on
// the persistent class (MissingMethodException: debug).
def logger = log
logger.debug('GormStaticApi.count() called for {}', persistentClass.name)
Integer result = execute({ Session session ->
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<Integer>)
log.debug('count() result is {}', result)
logger.debug('count() result is {}', result)
return result
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading