jdaugherty commented on code in PR #16094:
URL: https://github.com/apache/grails-core/pull/16094#discussion_r3792091499
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
}
}
+ /**
+ * Wires up the cache the JDK can write for an application, so the next
start reads what a
+ * training run worked out rather than working it out again.
+ *
+ * <p>Three steps, because the cache is only usable against the layout it
was trained on: the
+ * archive is extracted, the extracted application is run and asked for
its pages, and what the
+ * run recorded is left beside it. An application asks for this with
+ * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+ */
+ protected void configureAotCache(Project project) {
+ AotCacheExtension extension = ((ExtensionAware)
project.extensions.getByName('grails'))
+ .extensions.create('aotCache', AotCacheExtension)
+ extension.enabled.convention(false)
+ extension.paths.convention([])
+ extension.jvmArguments.convention(['-Dspring.aot.enabled=true',
'-Dgrails.env=production'])
+ extension.port.convention(TRAINING_PORT)
+
extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+ project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+ TaskProvider<?> bootJar = project.tasks.named('bootJar')
+ Provider<Directory> application =
project.layout.buildDirectory.dir('aot-cache/application')
+ Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+ TaskProvider<ExtractApplicationTask> extract =
project.tasks.register(
+ 'extractAotCacheApplication', ExtractApplicationTask) {
ExtractApplicationTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Extracts the application, which is the
form the cache is read against'
+ task.onlyIf { extension.enabled.get() }
+ task.archiveFile.set(archiveFileOf(bootJar))
+ // Mapped rather than read, so the JDK the toolchain resolves
to is not provisioned
+ // by every build that merely reads this project.
+ task.javaExecutable.set(launcher.map { JavaLauncher java ->
java.executablePath.asFile.absolutePath })
+ task.destination.set(application)
+ }
+
+ project.tasks.register('trainAotCache', TrainAotCacheTask) {
TrainAotCacheTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Runs the application once so the JDK can
write down what starting it needs'
+ task.onlyIf { extension.enabled.get() }
+ task.dependsOn(extract)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set(archiveFileOf(bootJar).map {
RegularFile file -> file.asFile.name })
+ // Beside the extracted application rather than inside it.
Inside, the cache and its
+ // metadata land in the directory this task declares as its
input, so writing them
+ // changes that input and the task can never be up to date --
every build would run
+ // the application again to record what the last one already
recorded.
+ Provider<Directory> beside =
project.layout.buildDirectory.dir('aot-cache')
+ task.cacheFile.set(beside.map { Directory dir ->
dir.file("${project.name}.aot") })
Review Comment:
`project` is captured in a provider that is evaluated after configuration.
`beside.map { Directory dir -> dir.file("${project.name}.aot") }` reads
`project.name` when the provider is queried — at property finalization or
execution — so the closure carries the `Project` into the task's serialized
state. That is the same class of problem as the `doFirst` block from the last
round, just deferred through a `map` instead.
`org.gradle.configuration-cache=false` in this repo's `gradle.properties`,
so CI will not catch it; a consumer building with the cache on will. Hoisting
`String archiveName = project.name` outside the closure fixes it.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
}
}
+ /**
+ * Wires up the cache the JDK can write for an application, so the next
start reads what a
+ * training run worked out rather than working it out again.
+ *
+ * <p>Three steps, because the cache is only usable against the layout it
was trained on: the
+ * archive is extracted, the extracted application is run and asked for
its pages, and what the
+ * run recorded is left beside it. An application asks for this with
+ * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+ */
+ protected void configureAotCache(Project project) {
+ AotCacheExtension extension = ((ExtensionAware)
project.extensions.getByName('grails'))
+ .extensions.create('aotCache', AotCacheExtension)
+ extension.enabled.convention(false)
+ extension.paths.convention([])
+ extension.jvmArguments.convention(['-Dspring.aot.enabled=true',
'-Dgrails.env=production'])
+ extension.port.convention(TRAINING_PORT)
+
extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+ project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+ TaskProvider<?> bootJar = project.tasks.named('bootJar')
+ Provider<Directory> application =
project.layout.buildDirectory.dir('aot-cache/application')
+ Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+ TaskProvider<ExtractApplicationTask> extract =
project.tasks.register(
+ 'extractAotCacheApplication', ExtractApplicationTask) {
ExtractApplicationTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Extracts the application, which is the
form the cache is read against'
+ task.onlyIf { extension.enabled.get() }
+ task.archiveFile.set(archiveFileOf(bootJar))
+ // Mapped rather than read, so the JDK the toolchain resolves
to is not provisioned
+ // by every build that merely reads this project.
+ task.javaExecutable.set(launcher.map { JavaLauncher java ->
java.executablePath.asFile.absolutePath })
+ task.destination.set(application)
+ }
+
+ project.tasks.register('trainAotCache', TrainAotCacheTask) {
TrainAotCacheTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Runs the application once so the JDK can
write down what starting it needs'
+ task.onlyIf { extension.enabled.get() }
+ task.dependsOn(extract)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set(archiveFileOf(bootJar).map {
RegularFile file -> file.asFile.name })
+ // Beside the extracted application rather than inside it.
Inside, the cache and its
+ // metadata land in the directory this task declares as its
input, so writing them
+ // changes that input and the task can never be up to date --
every build would run
+ // the application again to record what the last one already
recorded.
+ Provider<Directory> beside =
project.layout.buildDirectory.dir('aot-cache')
+ task.cacheFile.set(beside.map { Directory dir ->
dir.file("${project.name}.aot") })
+ task.metadataFile.set(beside.map { Directory dir ->
dir.file('aot-cache.properties') })
+ task.javaExecutable.set(launcher.map { JavaLauncher java ->
java.executablePath.asFile.absolutePath })
+ // Recorded from the JDK that will run the training, not the
one running the build.
+ // A cache is read only by the JDK build that wrote it, and
this is what a deployment
+ // checks that against -- so naming the wrong one is worse
than naming none.
+ task.javaVersion.set(launcher.map { JavaLauncher java ->
java.metadata.jvmVersion })
+ task.javaVendor.set(launcher.map { JavaLauncher java ->
java.metadata.vendor })
+ task.jvmArguments.set(extension.jvmArguments)
+ task.paths.set(extension.paths)
+ task.port.set(extension.port)
+ task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+ }
+ }
+ }
+
+ /**
+ * The archive a task should consume, as something to be resolved when it
runs.
+ *
+ * <p>A provider rather than a file: asking the task for its archive
resolves the task, and a
+ * task resolved while the build is configured is one every build pays for
whether or not it
+ * asked for an archive.</p>
+ */
+ private static Provider<RegularFile> archiveFileOf(TaskProvider<?>
bootJar) {
+ bootJar.flatMap { Task task -> (Provider<RegularFile>)
task.property('archiveFile') }
+ }
+
+ /**
+ * The JDK the cache will be trained on, which is the project's toolchain
where it declares one.
+ *
+ * <p>A cache is read only by the JDK build that wrote it, so training has
to happen on the JDK
+ * the application is compiled for rather than whichever one happens to be
running Gradle. A
+ * project on the Java 21 baseline with a Java 25 toolchain compiles with
25 and would otherwise
+ * have trained with 21, where the cache options do not exist -- and the
failure it reported was
+ * that the training run ended before it started serving.</p>
+ *
+ * <p>Where no toolchain is declared this resolves to the JDK running the
build, which is also
+ * the one that compiled the application.</p>
+ */
+ private static Provider<JavaLauncher> trainingLauncher(Project project) {
+ JavaToolchainService toolchains =
project.extensions.getByType(JavaToolchainService)
+ JavaPluginExtension java =
project.extensions.getByType(JavaPluginExtension)
+ toolchains.launcherFor(java.toolchain)
+ }
+
+ /**
+ * Records the application's own classes and pages so a native image keeps
them usable. The build
+ * output already names both, so an application does not have to be traced
to be buildable.
+ */
+ protected void configureNativeMetadata(Project project) {
+ // Only where an image is actually being built. The metadata is read
by nothing else, and
+ // generating it means reading the compiled classes and the pages
compiled into every
+ // dependency -- so wiring it into processResources unconditionally
made a build that only
+ // wanted to write a resource compile its sources and resolve its
whole runtime classpath
+ // first, which a project that had declared no repositories could not
do.
+ project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+ configureNativeMetadataTask(project)
+ }
+ }
+
+ private void configureNativeMetadataTask(Project project) {
+ SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+ TaskProvider<GenerateNativeMetadataTask> metadataTask =
project.tasks.register(
+ 'generateNativeMetadata', GenerateNativeMetadataTask) {
GenerateNativeMetadataTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Records the application classes and pages a
native image must keep'
+ // The classes directory is written by more than one task, so the
dependency has to be
+ // stated. It is stated against the compilation rather than the
classes task, because
+ // the classes task also runs processResources, which consumes
this task's output.
+ task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+ ['compileGroovy', 'copyAstClasses'].each { String name ->
+ if (project.tasks.findByName(name)) {
+ task.dependsOn(project.tasks.named(name))
+ }
+ }
+ task.classesDirs.from(sourceSet.output.classesDirs)
+
task.pageClassesDirs.from(project.layout.buildDirectory.dir('gsp-classes/main'))
Review Comment:
**Blocker.** `generateNativeMetadata` can never see the pages.
`pageClassesDirs` points at `build/gsp-classes/main`, which is written by
`compileGroovyPages`. That task declares `dependsOn(tasks.named('classes'))`
(`GroovyPagePlugin.groovy:100-105`), `classes` depends on `processResources`,
and `processResources.from(metadataTask)` two lines below puts
`generateNativeMetadata` ahead of it. So the ordering is
```
generateNativeMetadata -> processResources -> classes -> compileGroovyPages
```
and the directory this reads is always empty on a clean build, stale
otherwise. Recording the compiled pages is one of the two things the task
exists for, and it silently records none of them: an image built from this
metadata is missing every page, which shows up as a page not found at runtime
rather than as anything at build time.
It is also an undeclared implicit dependency on another task's output,
declared `@InputFiles` on a `@CacheableTask` — Gradle 9 reports that as a
validation problem once the producer is reachable.
No ordering satisfies both directions, so this needs rewiring rather than a
`dependsOn`: the pages have to be recorded by something that runs after
`compileGroovyPages` and feeds `bootJar`/`processAot` directly, not through
`processResources`.
Worth noting nothing exercises this. `configureNativeMetadata` is gated on
`org.graalvm.buildtools.native`, only `native-defaults-on` applies that plugin,
and `GrailsNativeImageDefaultsSpec` only runs `inspectDefaults` — so the task
is registered in a test and never executed in one.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -1115,6 +1265,146 @@ ${importStatements}
}
}
+ /**
+ * Wires up the cache the JDK can write for an application, so the next
start reads what a
+ * training run worked out rather than working it out again.
+ *
+ * <p>Three steps, because the cache is only usable against the layout it
was trained on: the
+ * archive is extracted, the extracted application is run and asked for
its pages, and what the
+ * run recorded is left beside it. An application asks for this with
+ * {@code grails.aotCache.enabled}, and says which of its pages matter.</p>
+ */
+ protected void configureAotCache(Project project) {
+ AotCacheExtension extension = ((ExtensionAware)
project.extensions.getByName('grails'))
+ .extensions.create('aotCache', AotCacheExtension)
+ extension.enabled.convention(false)
+ extension.paths.convention([])
+ extension.jvmArguments.convention(['-Dspring.aot.enabled=true',
'-Dgrails.env=production'])
+ extension.port.convention(TRAINING_PORT)
+
extension.startTimeoutSeconds.convention(TRAINING_START_TIMEOUT_SECONDS)
+
+ project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+ TaskProvider<?> bootJar = project.tasks.named('bootJar')
+ Provider<Directory> application =
project.layout.buildDirectory.dir('aot-cache/application')
+ Provider<JavaLauncher> launcher = trainingLauncher(project)
+
+ TaskProvider<ExtractApplicationTask> extract =
project.tasks.register(
+ 'extractAotCacheApplication', ExtractApplicationTask) {
ExtractApplicationTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Extracts the application, which is the
form the cache is read against'
+ task.onlyIf { extension.enabled.get() }
+ task.archiveFile.set(archiveFileOf(bootJar))
+ // Mapped rather than read, so the JDK the toolchain resolves
to is not provisioned
+ // by every build that merely reads this project.
+ task.javaExecutable.set(launcher.map { JavaLauncher java ->
java.executablePath.asFile.absolutePath })
+ task.destination.set(application)
+ }
+
+ project.tasks.register('trainAotCache', TrainAotCacheTask) {
TrainAotCacheTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Runs the application once so the JDK can
write down what starting it needs'
+ task.onlyIf { extension.enabled.get() }
+ task.dependsOn(extract)
+ task.applicationDirectory.set(application)
+ task.archiveFileName.set(archiveFileOf(bootJar).map {
RegularFile file -> file.asFile.name })
+ // Beside the extracted application rather than inside it.
Inside, the cache and its
+ // metadata land in the directory this task declares as its
input, so writing them
+ // changes that input and the task can never be up to date --
every build would run
+ // the application again to record what the last one already
recorded.
+ Provider<Directory> beside =
project.layout.buildDirectory.dir('aot-cache')
+ task.cacheFile.set(beside.map { Directory dir ->
dir.file("${project.name}.aot") })
+ task.metadataFile.set(beside.map { Directory dir ->
dir.file('aot-cache.properties') })
+ task.javaExecutable.set(launcher.map { JavaLauncher java ->
java.executablePath.asFile.absolutePath })
+ // Recorded from the JDK that will run the training, not the
one running the build.
+ // A cache is read only by the JDK build that wrote it, and
this is what a deployment
+ // checks that against -- so naming the wrong one is worse
than naming none.
+ task.javaVersion.set(launcher.map { JavaLauncher java ->
java.metadata.jvmVersion })
+ task.javaVendor.set(launcher.map { JavaLauncher java ->
java.metadata.vendor })
+ task.jvmArguments.set(extension.jvmArguments)
+ task.paths.set(extension.paths)
+ task.port.set(extension.port)
+ task.startTimeoutSeconds.set(extension.startTimeoutSeconds)
+ }
+ }
+ }
+
+ /**
+ * The archive a task should consume, as something to be resolved when it
runs.
+ *
+ * <p>A provider rather than a file: asking the task for its archive
resolves the task, and a
+ * task resolved while the build is configured is one every build pays for
whether or not it
+ * asked for an archive.</p>
+ */
+ private static Provider<RegularFile> archiveFileOf(TaskProvider<?>
bootJar) {
+ bootJar.flatMap { Task task -> (Provider<RegularFile>)
task.property('archiveFile') }
+ }
+
+ /**
+ * The JDK the cache will be trained on, which is the project's toolchain
where it declares one.
+ *
+ * <p>A cache is read only by the JDK build that wrote it, so training has
to happen on the JDK
+ * the application is compiled for rather than whichever one happens to be
running Gradle. A
+ * project on the Java 21 baseline with a Java 25 toolchain compiles with
25 and would otherwise
+ * have trained with 21, where the cache options do not exist -- and the
failure it reported was
+ * that the training run ended before it started serving.</p>
+ *
+ * <p>Where no toolchain is declared this resolves to the JDK running the
build, which is also
+ * the one that compiled the application.</p>
+ */
+ private static Provider<JavaLauncher> trainingLauncher(Project project) {
+ JavaToolchainService toolchains =
project.extensions.getByType(JavaToolchainService)
+ JavaPluginExtension java =
project.extensions.getByType(JavaPluginExtension)
+ toolchains.launcherFor(java.toolchain)
+ }
+
+ /**
+ * Records the application's own classes and pages so a native image keeps
them usable. The build
+ * output already names both, so an application does not have to be traced
to be buildable.
+ */
+ protected void configureNativeMetadata(Project project) {
+ // Only where an image is actually being built. The metadata is read
by nothing else, and
+ // generating it means reading the compiled classes and the pages
compiled into every
+ // dependency -- so wiring it into processResources unconditionally
made a build that only
+ // wanted to write a resource compile its sources and resolve its
whole runtime classpath
+ // first, which a project that had declared no repositories could not
do.
+ project.pluginManager.withPlugin(NATIVE_IMAGE_PLUGIN) {
+ configureNativeMetadataTask(project)
+ }
+ }
+
+ private void configureNativeMetadataTask(Project project) {
+ SourceSet sourceSet = SourceSets.findMainSourceSet(project)
+
+ TaskProvider<GenerateNativeMetadataTask> metadataTask =
project.tasks.register(
+ 'generateNativeMetadata', GenerateNativeMetadataTask) {
GenerateNativeMetadataTask task ->
+ task.group = BasePlugin.BUILD_GROUP
+ task.description = 'Records the application classes and pages a
native image must keep'
+ // The classes directory is written by more than one task, so the
dependency has to be
+ // stated. It is stated against the compilation rather than the
classes task, because
+ // the classes task also runs processResources, which consumes
this task's output.
+ task.dependsOn(project.tasks.named(sourceSet.compileJavaTaskName))
+ ['compileGroovy', 'copyAstClasses'].each { String name ->
+ if (project.tasks.findByName(name)) {
Review Comment:
`findByName` realizes the task, which is what `named`/`matching` exist to
avoid — and it is the pattern the `assetCompile` thread moved away from earlier
in this same PR.
`project.tasks.names.contains(name)` answers the same question without
creating anything.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/ExtractApplicationTask.groovy:
##########
@@ -0,0 +1,88 @@
+/*
+ * 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.grails.gradle.plugin.aot
+
+import javax.inject.Inject
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.process.ExecOperations
+
+/**
+ * Unpacks an executable jar into the form an AOT cache is read against.
+ *
+ * <p>An executable jar loads its dependencies through a nested-jar class
loader; the extracted form
+ * loads them as ordinary jars on the classpath. A cache records the class
path it saw, so only the
+ * second is a layout a later run can reuse one against.</p>
+ *
+ * <p>The work is done through injected services rather than through the
project, so that nothing is
+ * reached at execution time that the configuration cache forbids.</p>
+ *
+ * @since 8.0
+ */
+@CacheableTask
+@CompileStatic
+abstract class ExtractApplicationTask extends DefaultTask {
+
+ /** The archive to unpack, which has to be the one the cache will be
trained against. */
+ @InputFile
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract RegularFileProperty getArchiveFile()
+
+ /**
+ * The java to unpack with. Its {@code jarmode} does the extracting, so it
is the JDK the
+ * application was built for rather than whichever one is running Gradle.
+ */
+ @Input
+ abstract Property<String> getJavaExecutable()
Review Comment:
An absolute JDK path as `@Input` on a `@CacheableTask` puts the machine into
the cache key, so this task can never hit the cache on CI or on another
developer's machine — which is most of what the annotation is for.
`GroovyPageForkCompileTask` in this same PR solves it correctly with
`@Nested Property<JavaLauncher>` (see its javadoc); the launcher's relevant
metadata is then what is fingerprinted rather than the path. Same approach here.
##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends
AbstractDatastoreInitializer {
callable.delegate = delegate
callable.call()
- ApplicationEventPublisher eventPublisher
- if (beanDefinitionRegistry instanceof
ConfigurableApplicationContext) {
- eventPublisher = new
ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext)
beanDefinitionRegistry)
- } else {
- eventPublisher = new DefaultApplicationEventPublisher()
- }
+ // Registered rather than built here, so what the datastore holds
is a reference the
+ // container can build. Holding the publisher itself puts a live
object in the
+ // definition, and generating code for a definition means writing
out what it holds --
+ // which a publisher bound to a running context is not.
+ //
+ // The choice is made here rather than through
AbstractDatastoreInitializer, because this
+ // build resolves that class from a released
grails-datamapping-core rather than from
+ // the source beside it, and a method added there is not one this
can call.
+ grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof
ConfigurableApplicationContext
Review Comment:
`grailsDatastoreEventPublisher` is now registered under the same name by
four initializers — Hibernate 5, Hibernate 7, Mongo and this one — so in an
application with more than one datastore, whichever drains last defines it for
all of them.
For the other three that is harmless: they resolve the class through
`AbstractDatastoreInitializer.findEventPublisherClass`, which falls back to
`resourcePatternResolver.resourceLoader` and so agrees. This one tests only
`beanDefinitionRegistry instanceof ConfigurableApplicationContext`, so it can
pick `DefaultApplicationEventPublisher` where the shared method would have
picked the context-backed one. A Hibernate + Neo4j application can therefore
end up with Hibernate publishing through a no-op: GORM events stop firing and
auto-timestamping stops working, with nothing logged.
Either give the bean a per-datastore name, or make the choice here agree
with `findEventPublisherClass`.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -83,6 +93,22 @@ import javax.inject.Inject
@CompileStatic
class GrailsGradlePlugin implements Plugin<Project> {
+ private static final String NATIVE_IMAGE_PLUGIN =
'org.graalvm.buildtools.native'
+
+ private static final String SPRING_BOOT_PLUGIN = 'org.springframework.boot'
+
+ private static final String ASSET_COMPILE_TASK = 'assetCompile'
+
+ /** Where an executable jar reads its classpath from, and so where assets
have to be to be found. */
+ private static final String CLASSPATH_ASSETS_PATH =
'BOOT-INF/classes/assets'
+
+ private static final int TRAINING_PORT = 18080
Review Comment:
Fixed ports with no bind check.
`serving()` (`TrainAotCacheTask.groovy:319-329`, and the same in
`TraceNativeMetadataTask.groovy:458-468`) only opens a TCP connection to
`localhost:<port>`. Anything already listening on 18080 or 18081 — a second
build on the same CI agent, a developer's own server, a leftover process from a
cancelled build — makes `awaitStarted` return immediately, and the task then
trains a cache or records a trace against an unrelated application. It reports
success.
Either bind the port first and fail if it is taken, or start with
`--server.port=0` and read the chosen port out of the application's output.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/ConfigurableApplicationContextEventPublisher.groovy:
##########
@@ -32,14 +34,26 @@ import
org.springframework.context.ConfigurableApplicationContext
* @since 6.0
*/
@CompileStatic
-class ConfigurableApplicationContextEventPublisher implements
ConfigurableApplicationEventPublisher {
+class ConfigurableApplicationContextEventPublisher implements
ConfigurableApplicationEventPublisher, ApplicationContextAware {
- final ConfigurableApplicationContext applicationContext
+ ConfigurableApplicationContext applicationContext
+
+ /**
+ * Takes the context from the container. A bean definition built this way
holds no
+ * already-constructed object, which is what allows it to be processed
ahead of time.
+ */
+ ConfigurableApplicationContextEventPublisher() {
+ }
ConfigurableApplicationContextEventPublisher(ConfigurableApplicationContext
applicationContext) {
this.applicationContext = applicationContext
}
+ @Override
+ void setApplicationContext(ApplicationContext applicationContext) {
Review Comment:
Two things to tighten here.
The cast is unchecked: any `ApplicationContext` that is not
`ConfigurableApplicationContext` throws `ClassCastException` out of a container
callback. Narrow it with an `instanceof` and leave the field null otherwise, or
implement the interface only where the cast is guaranteed.
The bigger one is that nothing guards against the field staying null.
`AbstractDatastoreInitializer.findEventPublisherClass` selects this class when
*either* the registry or `resourcePatternResolver.resourceLoader` is a
`ConfigurableApplicationContext`, but the bean is built by whoever owns the
registry. Where the registry is a bare `DefaultListableBeanFactory` and only
the resource loader is a context, `ApplicationContextAware` is never applied
and the first `publishEvent` NPEs. The old `findEventPublisher` covered that
case by passing the resolved context explicitly.
Also worth noting the field went from `final` to a mutable property, so its
publication is no longer safe.
##########
grails-data-neo4j/grails-plugin/src/main/groovy/grails/neo4j/bootstrap/Neo4jDataStoreSpringInitializer.groovy:
##########
@@ -75,17 +74,27 @@ class Neo4jDataStoreSpringInitializer extends
AbstractDatastoreInitializer {
callable.delegate = delegate
callable.call()
- ApplicationEventPublisher eventPublisher
- if (beanDefinitionRegistry instanceof
ConfigurableApplicationContext) {
- eventPublisher = new
ConfigurableApplicationContextEventPublisher((ConfigurableApplicationContext)
beanDefinitionRegistry)
- } else {
- eventPublisher = new DefaultApplicationEventPublisher()
- }
+ // Registered rather than built here, so what the datastore holds
is a reference the
+ // container can build. Holding the publisher itself puts a live
object in the
+ // definition, and generating code for a definition means writing
out what it holds --
+ // which a publisher bound to a running context is not.
+ //
+ // The choice is made here rather than through
AbstractDatastoreInitializer, because this
+ // build resolves that class from a released
grails-datamapping-core rather than from
+ // the source beside it, and a method added there is not one this
can call.
+ grailsDatastoreEventPublisher(beanDefinitionRegistry instanceof
ConfigurableApplicationContext
+ ? ConfigurableApplicationContextEventPublisher
+ : DefaultApplicationEventPublisher)
final boolean isRecentGrailsVersion =
GrailsVersion.isAtLeastMajorMinor(3, 3)
neo4jConnectionSourceFactory(Neo4jConnectionSourceFactory) { bean
->
bean.autowire = true
}
- neo4jDatastore(Neo4jDatastore, configuration,
ref("neo4jConnectionSourceFactory"), eventPublisher,
collectMappedClasses(DATASTORE_TYPE))
+ // The configuration is held rather than named. Naming the
environment instead is what
+ // lets a definition be generated without writing the build
machine's own settings into
+ // it, but that is asked through a method added to
AbstractDatastoreInitializer, and this
+ // build resolves that class from a released
grails-datamapping-core rather than from the
+ // source beside it -- so the call compiles here and goes missing
at run time.
+ neo4jDatastore(Neo4jDatastore, configuration,
ref("neo4jConnectionSourceFactory"), ref('grailsDatastoreEventPublisher'),
collectMappedClasses(DATASTORE_TYPE))
Review Comment:
This is the leak `configurationReference()` was written to prevent, still
open for Neo4j.
The comment explains why the call cannot be made here, and that reasoning is
sound — but the consequence is that a Neo4j application running `processAot`
writes the build machine's `PropertyResolver`, environment variables and all,
into its generated source. Credentials among them. That is a worse outcome than
not being AOT-processable, and nothing tells the user it happened.
Since the fix is not available in this build, please make it fail rather
than generate: `if (AheadOfTimeProcessing.generatingCode) throw ...` naming
Neo4j as not yet AOT-processable. An unsupported combination that says so is
fine; one that quietly ships the builder's environment is not.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy:
##########
@@ -836,6 +869,36 @@ ${importStatements}
it.destinationDirectory =
project.layout.buildDirectory.dir('assetCompile/assets')
}
}
+ configureAssetsOnTheClasspath(project)
+ }
+
+ /**
+ * Packages the compiled assets where an executable jar can read them.
+ *
+ * <p>The asset pipeline plugin puts them at the root of whatever archive
is built, which is
+ * where a war serves its web content from and is therefore right for a
war. An executable jar
+ * has no web content: it serves assets by reading them off the classpath,
and its classpath is
+ * {@code BOOT-INF/classes} -- so the same assets, at the same place, in a
jar rather than a war,
+ * are packaged but unreachable, and every asset a page asks for is a 404
while the page itself
+ * renders. Adding them under the classpath directory is what makes them
found.</p>
+ *
+ * <p>Only for {@code bootJar}. A war already serves them from the root,
and putting them on its
+ * classpath as well would ship the same bytes twice.</p>
+ */
+ private void configureAssetsOnTheClasspath(Project project) {
+ project.pluginManager.withPlugin(SPRING_BOOT_PLUGIN) {
+ // Matched by the task the pipeline registers rather than by the
plugin that registers
+ // it: the asset pipeline's plugin id has changed once already,
and the task name has
+ // not. Matched rather than looked up, so nothing depends on which
plugin was applied
+ // first and no task is resolved to find out.
+ FileCollection compiledAssets = project.files(
+ project.tasks.matching { Task task -> task.name ==
ASSET_COMPILE_TASK })
Review Comment:
This holds a live `TaskCollection` inside a `ConfigurableFileCollection`
that becomes part of `bootJar`'s `@InputFiles` — so `TaskContainer`, and
through it `Project`, is reachable from `bootJar`'s state, and resolving the
collection force-realizes every task in the container.
I take the point from the earlier thread that `matching {}.configureEach {}`
fires too late; this is about what the collection *holds*, not about when it
fires. `project.provider { }` over `tasks.names`, or `withType`/`named` guarded
by `pluginManager.withPlugin` on the asset-pipeline plugin id, gets the same
laziness without capturing the container.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TraceNativeMetadataTask.groovy:
##########
@@ -0,0 +1,484 @@
+/*
+ * 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.grails.gradle.plugin.aot
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.time.Duration
+import java.util.concurrent.TimeUnit
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application under GraalVM's tracing agent and writes down the
reflection it did.
+ *
+ * <p>{@code GenerateNativeMetadataTask} records an application's own
artefacts by reading the build
+ * output, which needs no run and misses nothing of the application's. What it
cannot know is the
+ * framework's own reflection along a request path -- a controller method
reached through Groovy's
+ * dispatch, a conversion asked for while binding a form -- because none of
that is in the
+ * application's classes. An image built without it starts, serves its home
page, and fails on the
+ * request that first takes such a path.</p>
+ *
+ * <p>The agent records exactly that, and records only what ran. So the paths
are declared rather
+ * than discovered, and what is not listed is not covered:</p>
+ *
+ * <pre>
+ * grails {
+ * nativeMetadata {
+ * paths = ['/', '/login', '/book', '/book/create']
+ * forms = ['/book/create']
+ * }
+ * }
+ * </pre>
+ *
+ * <p>A form is asked for, read, and submitted with the fields it declares,
because posting fewer
+ * than it declares records less than the application does. A checkbox is a
string on the wire and a
+ * boolean on the domain class, so a form submitted without one never asks the
conversion service
+ * anything -- and an image built from that trace fails the first time someone
ticks a box.</p>
+ *
+ * <p>Forms are submitted before paths are asked for, and the session one
establishes is carried
+ * through the rest of the trace. A page behind a login is reached by listing
the login form, which
+ * makes the pages named after it reachable:</p>
+ *
+ * <pre>
+ * grails {
+ * nativeMetadata {
+ * forms = ['/login?username=admin&password=secret',
'/book/create']
+ * paths = ['/', '/book']
+ * }
+ * }
+ * </pre>
+ *
+ * <p>Where a page carries more than one form -- a layout's search or sign-out
beside the page's own
+ * -- the one declaring the most fields is submitted, and which it was is
reported. A page whose
+ * wanted form is not the fullest names it: {@code
'/book/create#bookForm'}.</p>
+ *
+ * <p>Written to the application's sources rather than to the build directory:
what an image was
+ * built from should be reviewable, and a trace is only as good as the paths
someone thought of.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Records what a run of the application did,
which is not an output of its inputs')
+abstract class TraceNativeMetadataTask extends DefaultTask {
+
+ /** How the agent is asked for, and the only way its output can be merged
with what is there. */
+ private static final String AGENT = 'native-image-agent'
+
+ /** The names the agent library goes by, one of which is beside a
GraalVM's java. */
+ private static final List<String> AGENT_LIBRARIES = [
+ 'libnative-image-agent.dylib', 'libnative-image-agent.so',
'native-image-agent.dll'
+ ]
+
+ private static final Pattern FORM =
Pattern.compile(/(?is)<form\b[^>]*>.*?<\/form>/)
+ private static final Pattern ACTION =
Pattern.compile(/(?i)\baction\s*=\s*"([^"]*)"/)
+ private static final Pattern ID =
Pattern.compile(/(?i)\bid\s*=\s*"([^"]+)"/)
+ private static final Pattern FIELD =
Pattern.compile(/(?is)<(?:input|select|textarea)\b[^>]*>/)
+ private static final Pattern NAME =
Pattern.compile(/(?i)\bname\s*=\s*"([^"]+)"/)
+ private static final Pattern VALUE =
Pattern.compile(/(?i)\bvalue\s*=\s*"([^"]*)"/)
+ private static final Pattern TYPE =
Pattern.compile(/(?i)\btype\s*=\s*"([^"]+)"/)
+
+ /**
+ * Carries the session from one request to the next, which is what makes a
form that
+ * authenticates worth submitting: the pages that follow it are only
reachable once it has been.
+ *
+ * <p>Its own cookie store rather than the JVM's. The store belongs to the
client, so nothing is
+ * left behind in a daemon that outlives the build and two traces at once
cannot share one.</p>
+ */
+ private HttpClient client
+
+ /** The archive to run, which has to be the one the image will be built
from. */
+ @InputFile
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract RegularFileProperty getArchiveFile()
+
+ /**
+ * A java from a GraalVM. The agent ships with GraalVM rather than with a
JDK, and an application
+ * built for an image is compiled for GraalVM's Java -- so the JDK that
runs the build is usually
+ * neither, and running the trace on it fails at load or refuses the class
file.
+ */
+ @Input
+ abstract Property<String> getJavaExecutable()
+
+ @Input
+ abstract ListProperty<String> getJvmArguments()
+
+ /** Paths to ask for. */
+ @Input
+ abstract ListProperty<String> getPaths()
+
+ /** Pages whose form is to be filled in and submitted. */
+ @Input
+ abstract ListProperty<String> getForms()
+
+ @Input
+ abstract Property<Integer> getPort()
+
+ @Input
+ abstract Property<Integer> getStartTimeoutSeconds()
+
+ /**
+ * Where the agent merges what it recorded. Not declared as an output: it
is in the application's
+ * sources, and a directory Gradle believes it owns is a directory Gradle
will delete.
+ */
+ @Internal
+ abstract DirectoryProperty getOutputDirectory()
+
+ @TaskAction
+ void trace() {
+ File java = new File(javaExecutable.get())
+ File metadata = outputDirectory.get().asFile
+ metadata.mkdirs()
+ refuseWithoutAgent(java)
+
+ List<String> command = []
+ command << java.absolutePath
+ command <<
"-agentlib:${AGENT}=config-merge-dir=${metadata.absolutePath}".toString()
+ command.addAll(jvmArguments.get())
+ command << '-jar' << archiveFile.get().asFile.absolutePath
+ command << "--server.port=${port.get()}".toString()
+
+ File output = new File(temporaryDir, 'trace.log')
+ Process process = new ProcessBuilder(command)
+ .directory(archiveFile.get().asFile.parentFile)
+ .redirectErrorStream(true)
+ .redirectOutput(output)
+ .start()
+
+ client = HttpClient.newBuilder()
Review Comment:
The `HttpClient` is stored in a task field and never closed. It is
`AutoCloseable` on 21+, and it owns a selector thread and an executor — so both
outlive the build inside the daemon, one set per task instance.
The javadoc at :113-119 says nothing is left behind in a daemon that
outlives the build, which is the thing that happens. Closing it in a `finally`
around the tracing run, or building it per run in a try-with-resources, settles
both.
##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/aot/TrainAotCacheTask.groovy:
##########
@@ -0,0 +1,376 @@
+/*
+ * 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.grails.gradle.plugin.aot
+
+import java.security.MessageDigest
+import java.time.Duration
+
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+/**
+ * Runs the application once so the JDK can write down what starting it needs.
+ *
+ * <p>The run is the point. A cache records the classes loaded and linked, and
the profiles of the
+ * methods that ran, so the next start reads them rather than working them out
again -- which means
+ * what the next start is <em>fast at</em> is whatever this run did. A run
that only refreshes the
+ * context leaves every request path to be worked out on the day.</p>
+ *
+ * <p>So the application is started, asked for the pages an application is
asked for, and then asked
+ * to stop. It has to stop of its own accord: the cache is written as the JVM
exits, and a run that is
+ * killed writes nothing.</p>
+ *
+ * @since 8.0
+ */
+@CompileStatic
+@DisableCachingByDefault(because = 'Runs the application and records what it
did, which is not reproducible')
+abstract class TrainAotCacheTask extends DefaultTask {
+
+ /**
+ * The extracted application: the cache is only usable against the layout
it was trained on.
+ *
+ * <p>Compared by what is in it and where each file sits within it, not by
where the directory
+ * itself is. Without saying so, Gradle takes the absolute path to be part
of the input and
+ * refuses to validate the task at all -- and a build that did run would
key its result to a
+ * path, so the same application checked out somewhere else, or built on
CI, would agree about
+ * everything and still share nothing.</p>
+ */
+ @InputDirectory
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract DirectoryProperty getApplicationDirectory()
+
+ @Input
+ abstract Property<String> getArchiveFileName()
+
+ @OutputFile
+ abstract RegularFileProperty getCacheFile()
+
+ @Input
+ abstract Property<String> getJavaExecutable()
+
+ /** The version of the JDK above, which is the JDK the cache will only
ever be readable by. */
+ @Input
+ abstract Property<String> getJavaVersion()
+
+ @Input
+ abstract Property<String> getJavaVendor()
+
+ /** Given to the training run, and to be given to every run that reads the
cache. */
+ @Input
+ abstract ListProperty<String> getJvmArguments()
+
+ /** The paths to ask for, so their methods are profiled rather than met
for the first time later. */
+ @Input
+ abstract ListProperty<String> getPaths()
+
+ @Input
+ abstract Property<Integer> getPort()
+
+ @Input
+ abstract Property<Integer> getStartTimeoutSeconds()
+
+ /** Written beside the cache, so what the cache was made from can be
checked before it is used. */
+ @OutputFile
+ abstract RegularFileProperty getMetadataFile()
+
+ /** The first JDK that can write one. Before it, {@code
-XX:AOTCacheOutput} is not an option. */
+ private static final int MINIMUM_JAVA_VERSION = 25
+
+ @TaskAction
+ void train() {
+ refuseWhereTheRunCannotBeAskedToStop()
+ refuseWhereTheJdkCannotWriteACache()
+ File directory = applicationDirectory.get().asFile
+ File cache = cacheFile.get().asFile
+ cache.delete()
+
+ List<String> command = []
+ command << javaExecutable.get()
+ command << "-XX:AOTCacheOutput=${cache.absolutePath}".toString()
+ command.addAll(jvmArguments.get())
+ command << '-jar' << archiveFileName.get()
+ command << "--server.port=${port.get()}".toString()
+
+ File output = new File(temporaryDir, 'training.log')
+ ProcessBuilder builder = new ProcessBuilder(command)
+ .directory(directory)
+ .redirectErrorStream(true)
+ .redirectOutput(output)
+ withoutEmptyVariables(builder.environment())
+ Process process = builder.start()
+ try {
+ awaitStarted(process, output)
+ exercise()
+ }
+ finally {
+ stop(process)
+ }
+ if (!cache.isFile()) {
+ throw new GradleException("The training run wrote no cache. What
it printed is in ${output}")
+ }
+ describe(cache, new File(directory, archiveFileName.get()))
+ logger.lifecycle('Trained {} ({} MB) over {} paths',
+ cache.name, (cache.length() / (1024 * 1024)) as long,
paths.get().size())
+ }
+
+ /**
+ * Drops the variables that are present but empty from what the run will
inherit.
+ *
+ * <p>The run inherits the daemon's environment, and the daemon's is not
the one the build was
+ * started from. A daemon that once ran a build with a variable set keeps
the name afterwards
+ * and empties the value, so a later build started from a shell that never
mentioned it hands
+ * the run {@code SOME_VARIABLE=""} all the same.</p>
+ *
+ * <p>Spring Boot binds an environment variable over the application's own
configuration, and
+ * relaxed binding means {@code GRAILS_MONGODB_URL} is {@code
grails.mongodb.url}. So an empty
+ * leftover replaced a configured value with nothing, and the training run
failed on a property
+ * the application had set correctly -- reporting it against a name nobody
had typed, in a
+ * build that had run cleanly minutes earlier from a different shell.</p>
+ *
+ * <p>An empty variable says nothing that an absent one does not, so it is
not passed on. What
+ * the build was actually given, empty or not, still arrives: this drops
only what the daemon
+ * kept after the build that set it had finished.</p>
+ */
+ private static void withoutEmptyVariables(Map<String, String> environment)
{
+ environment.entrySet().removeIf { Map.Entry<String, String> variable ->
+ !variable.value
+ }
+ }
+
+ /**
+ * Refuses to start a run that could not be ended properly, before it is
started.
+ *
+ * <p>The cache is written as the training JVM exits normally, so the run
has to be asked to
+ * stop rather than killed. {@link Process#destroy()} asks on POSIX and
kills on Windows, where
+ * it is {@code TerminateProcess} and no shutdown runs -- so on Windows
the run would be
+ * exercised in full, killed, and leave no cache, and the build would fail
at the end saying the
+ * cache was not written rather than saying why it could not be.</p>
+ */
+ private static void refuseWhereTheRunCannotBeAskedToStop() {
+ String os = System.getProperty('os.name', '')
+ if (os.toLowerCase(Locale.ROOT).contains('win')) {
+ throw new GradleException('Training an AOT cache needs the
training run to be asked to ' +
+ 'stop, and on Windows a child process can only be killed
-- which writes no ' +
+ 'cache. Train on Linux or macOS, or set
grails.aotCache.enabled to false.')
+ }
+ }
+
+ /**
+ * Refuses on a JDK that has no cache to write, before the run is started.
+ *
+ * <p>{@code -XX:AOTCacheOutput} arrived in JDK 25. An earlier JDK does
not recognise it and
+ * stops immediately, so the run would be started, fail at once, and be
reported as having
+ * ended before it started serving -- which reads as a broken application
rather than as the
+ * wrong JDK, and sends whoever is reading the build into a log of the
application's own
+ * start-up that never happened.</p>
+ *
+ * <p>Asked of the JDK the training will run on, which is the project's
toolchain rather than
+ * whichever one is running Gradle.</p>
+ */
+ private void refuseWhereTheJdkCannotWriteACache() {
+ String version = javaVersion.get()
+ Integer major = majorVersionOf(version)
+ if (major != null && major < MINIMUM_JAVA_VERSION) {
+ throw new GradleException("Training an AOT cache needs Java
${MINIMUM_JAVA_VERSION} or " +
+ "later, and the toolchain this would train on is
${version}. Point the project " +
+ 'toolchain at a newer JDK, or set grails.aotCache.enabled
to false.')
+ }
+ }
+
+ /**
+ * The feature version of a runtime version string, or {@code null} where
it does not begin with
+ * one. Unreadable rather than old: a version this cannot parse is left to
the run to answer for,
+ * since refusing over it would fail a build on a JDK that may be
perfectly capable.
+ */
+ private static Integer majorVersionOf(String version) {
+ int digits = 0
+ while (digits < version.length() &&
Character.isDigit(version.charAt(digits))) {
+ digits++
+ }
+ digits > 0 ? Integer.valueOf(version.substring(0, digits)) : null
+ }
+
+ /**
+ * Writes down what the cache was made from.
+ *
+ * <p>A cache is read only by the JDK build that wrote it, against the
archive it was trained on,
+ * with the arguments it was trained with. A JVM given one made from
anything else declines it and
+ * starts as it would have anyway -- so what is lost is the speed,
silently, and this is what
+ * tells a deployment which of those it has.</p>
+ */
+ private void describe(File cache, File archive) {
+ Properties properties = new Properties()
+ properties.setProperty('cache.file', cache.name)
+ properties.setProperty('cache.bytes', String.valueOf(cache.length()))
+ properties.setProperty('application.archive', archive.name)
+ properties.setProperty('application.sha256', sha256(archive))
+ properties.setProperty('training.arguments', jvmArguments.get().join('
'))
+ properties.setProperty('training.paths', paths.get().join(' '))
+ properties.setProperty('java.vendor', javaVendor.get())
+ properties.setProperty('java.runtime.version', javaVersion.get())
+ properties.setProperty('os.name', System.getProperty('os.name', ''))
+ properties.setProperty('os.arch', System.getProperty('os.arch', ''))
+ metadataFile.get().asFile.withOutputStream { OutputStream out ->
+ properties.store(out, 'What this AOT cache was trained from')
+ }
+ }
+
+ private static String sha256(File file) {
+ MessageDigest digest = MessageDigest.getInstance('SHA-256')
+ file.withInputStream { InputStream input ->
+ byte[] buffer = new byte[8192]
+ int read
+ while ((read = input.read(buffer)) != -1) {
+ digest.update(buffer, 0, read)
+ }
+ }
+ digest.digest().encodeHex().toString()
+ }
+
+ /**
+ * Waits for the application to start serving, and stops waiting if the
run ends first --
+ * otherwise a run that fails immediately is waited on for the whole
timeout.
+ *
+ * <p>Either the run says it started or its port answers. The message is
Spring Boot's and is
+ * the earlier and clearer of the two, but it is an INFO log an
application is free to turn off
+ * or reword; a port that accepts a connection is the application's own
doing and cannot be
+ * configured away while it is still an application worth training.</p>
+ */
+ private void awaitStarted(Process process, File output) {
+ long deadline = System.currentTimeMillis() +
Duration.ofSeconds(startTimeoutSeconds.get()).toMillis()
+ while (System.currentTimeMillis() < deadline) {
+ if (!process.isAlive()) {
+ throw new GradleException('The training run ended before it
started serving.' +
+ whyItEnded(output) + " What it printed is in
${output}")
+ }
+ if (output.isFile() && output.text.contains('Started ')) {
Review Comment:
`contains('Started ')` matches well before the application is ready.
Jetty logs `Started ServerConnector@...` and `Started Server@...` during
start-up, and there are several other `Started ` lines depending on the
container. Training or tracing that begins there records a partially started
application, which is silently a worse cache and a thinner trace.
Spring Boot's own line is `Started <AppName> in <n> seconds`; anchoring on
`Started ` plus ` in ` and ` seconds` is enough, and an actuator health probe
is better still.
Minor, in the same method: the whole log is re-read every 250 ms with the
platform default charset.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]