Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support) - #16094
Draft
codeconsole wants to merge 67 commits into
Draft
Make Grails applications processable by Spring AOT (Leyden AOT Cache + GraalVM Native Image Support)#16094codeconsole wants to merge 67 commits into
codeconsole wants to merge 67 commits into
Conversation
filteringCodecsByContentTypeSettings took the live GrailsApplication as a
constructor argument, and groovyPagesServlet took a new GroovyPagesServlet().
Spring AOT's ValueCodeGenerator cannot emit code for an arbitrary object, so
processAot aborted the entire run:
UnsupportedTypeValueCodeGenerationException:
Code generation does not support grails.core.DefaultGrailsApplication
Both now use forms the generator understands: a reference by bean name, as
errorsViewStackTracePrinter directly above already does, and an inner bean
definition. This was the first processAot failure for a stock web application
on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike. Further blockers remain behind it,
so this does not by itself make an application AOT-processable.
Constructing the servlet through the registry rather than with new means it now
passes through bean post-processing. Its pluginManager is unaffected -
initFrameworkServlet already autowires the servlet's properties by type, which
covers that setter - but a post-processor whose pointcut matched the servlet
would hand ServletRegistrationBean a proxy in its place. No pointcut in the
framework does.
CoreGrailsPlugin registers a second ConfigurationClassPostProcessor so that @configuration beans contributed by plugins through doWithSpring - which arrive after Spring's own processor has finished - still get parsed. An AOT-optimized context has no ConfigurationClassPostProcessor at all: the configuration classes were parsed at build time and their bean definitions are in the generated initializer. Registering one there parses them a second time, and the re-parse collides with what AOT already emitted: BeanDefinitionStoreException: Invalid bean definition with name 'propertySourcesPlaceholderConfigurer' defined in org.grails.plugins.CoreAutoConfiguration: Bean name derived from @bean method 'propertySourcesPlaceholderConfigurer' clashes with bean name for containing configuration class For a static @bean method AOT emits the bean definition with the configuration class as its bean class, so the re-parse rediscovers CoreAutoConfiguration under the bean name propertySourcesPlaceholderConfigurer and then collides with itself. The processor is skipped when AotDetector.useGeneratedArtifacts() reports generated artifacts are in use; behaviour without AOT is unchanged. This was reachable only after the GSP live-instance beans were fixed, since processAot did not previously get far enough to produce an initializer.
An abstract bean definition is a template: it carries property values for
children to inherit and is never instantiated. Spring's bean-definition code
generator has no representation for one - BeanDefinitionPropertiesCodeGenerator
emits lazyInit, primary, scope, role and synthetic, but not the abstract flag,
and nothing filters abstract definitions out beforehand. The definition is
regenerated as a concrete bean of type Object still carrying the template's
properties, and the context fails applying them:
BeanCreationException: Error creating bean with name
'abstractGrailsResourceLocator': Invalid property 'searchLocations' of bean
class [java.lang.Object]
CoreGrailsPlugin contributes exactly such a template through
AbstractResourceLocatorPostProcessor, and it is public surface - third-party
plugins inherit from it with bean.parent, asset-pipeline's assetResourceLocator
among them - so it cannot simply be removed.
A BeanRegistrationExcludeFilter registered in META-INF/spring/aot.factories
keeps every abstract definition out of generation. Children are unaffected,
because AOT generates them from their merged definition with inherited values
already folded in; a definition contributed dynamically still finds its parent,
since the post-processor that registers the template runs during refresh in an
AOT context as it does in any other.
With this an AOT-processed application starts. Its bean definitions differ from
a normal boot only by the annotation-processing infrastructure AOT replaces:
the configuration, autowired and common-annotation processors, Boot's shared
metadata reader factory, and grailsConfigurationClassPostProcessor.
SiteMeshViewResolver now implements ServletContextAware, so the bean-definition
wrap no longer passes the servlet context as a constructor argument. That
reference was to a bean the container only registers once the web server has
started, which has no bean definition for AOT to resolve, and it failed
processAot for every application with GSP on the classpath:
AotBeanProcessingException: Error processing bean with name 'jspViewResolver'
Caused by: NoSuchBeanDefinitionException: No bean named 'servletContext' available
GrailsSiteMeshViewResolver gains the matching three-argument constructor and
reads the context through the inherited accessor rather than keeping its own
copy. The four-argument constructor stays for callers that build the resolver
directly, which is how the instance-level post-processor still creates it.
This was the last blocker: a stock Grails web application now completes
processAot and starts with spring.aot.enabled=true. Its bean definitions differ
from a normal boot only by the annotation-processing infrastructure AOT
replaces - the configuration, autowired and common-annotation processors,
Boot's shared metadata reader factory, and grailsConfigurationClassPostProcessor.
The sitemesh version is moved to 3.3.0-SNAPSHOT because the change it depends on
is not in 3.3.0-M3. It must be pinned to a released version before this merges.
AOT support is opt-in and needs configuration that is not guessable - the Spring Boot AOT plugin, and generation in production mode, without which the url mappings holder takes its reload-mode proxy shape and generation fails naming a bean unrelated to anything in the application. The deployment guide now covers enabling it, what changes at runtime, and the limitations: registrar-contributed beans are not generated, definitions holding live objects cannot be generated, abstract definitions are excluded, and AOT alone does not make an application native-image ready. Two layers of coverage, because the failures differ in kind. CoreGrailsPluginAotSpec runs the real generator over the core plugin's bean definitions, so a definition holding a live instance fails the build. It also covers the configuration class post-processor being registered normally and withheld when generated artifacts are in use, which no existing test reached. A fourth case registers a definition holding a live instance and asserts generation rejects it, so the check above cannot pass vacuously. Generation succeeding does not mean the application starts, and the post-processor condition is a runtime-only behaviour. The grails-test-examples aot application therefore runs processAot and then starts the packaged jar with spring.aot.enabled=true, asserting the beans an AOT context must still contain. Its check task fails the build if either half regresses.
codeconsole
marked this pull request as draft
August 5, 2026 04:21
Spring AOT generates the bean definitions for a context at build time, and it can only do so for beans the container knows how to build. A bean registered as an already-constructed object has nothing to generate from, so the MongoDB datastore could not be processed ahead of time. The MongoDB initializer built the event publisher itself and passed the instance to the datastore constructor. It now registers the publisher as a definition and refers to it by name, so the container builds both. ConfigurableApplicationContextEventPublisher could only be built by passing a context to its constructor, which is the thing a definition cannot do. It now also takes the context from the container through ApplicationContextAware, leaving the existing constructor in place for callers outside a container. Which publisher gets registered still depends on where GORM is being bootstrapped: outside an application context the no-op publisher is used, as before, because the context-aware one would never be given a context there and would fail on the first event it published.
The welcome page shows the Spring Security version only when the dependency is
present, and reached it with Class.getMethod('getVersion').invoke(null). Calling
Method.invoke from a GSP expression goes through Groovy's dynamic dispatch, which
resolves to the private caller-sensitive overload the JDK added for core reflection.
A native image cannot supply the caller argument that overload requires and aborts
the process rather than raising an exception.
Spring's ReflectionUtils performs the invocation from Java, so Groovy never
dispatches on Method.invoke itself. The presence check is unchanged, which keeps the
page compiling for generated applications that did not select Spring Security.
Both copies of the page are updated: the one the profiles CLI writes into a new
application and the one grails-forge serves.
The file was added without one, which fails the release audit.
A scaffolded controller had no views of its own: the resolver expanded a template into GSP source and compiled the result the first time each view was asked for. That costs the first request, and a native image cannot do it at all, because defining a class at run time is what an ahead-of-time image gives up. The templates are now expanded during the build and compiled with the rest of the views, so at run time they are found rather than produced. Only naming is substituted, and the templates defer everything about the domain class to the field tag libraries at render time, so this needs no GORM, no application context and no loading of application classes: the controllers are read with ASM and the domain class name is enough. The generated pages are staged with the application's own before a single compilation. Compiling them separately would produce a second gsp/views.properties, and the archive tasks discard duplicates, so one of the two manifests would be lost and the views it listed would never be found. A view the application declares is not generated over, which keeps a hand-written page ahead of a scaffolded one as it is at run time.
Handling a request reaches the controller, URL mapping, data binding and content negotiation APIs through Groovy's dynamic dispatch, which reads a type's declared methods to choose an overload. An ahead-of-time image keeps only the members something asks for, so these were stripped and dispatch failed at the point of use. Leaving this to the tracing agent does not work, because the agent records only the paths that were exercised. Each of these failed on a path an ordinary check misses: content negotiation runs only for a request that states what it accepts, the method check only for POST, PUT and DELETE, binding only for a request carrying data, and the parameter accessor only once a mapping carries parameters. All four passed a page walk and failed for a real visitor. Each registrar sits in the module owning the API and names its types as strings, registering only those present, so an application that does not use a given plugin is unaffected. None of this varies between applications, which is why it belongs with the framework rather than in every application's metadata.
Calling a closure goes through doCall, and Groovy reads its parameter types reflectively to choose an overload. In an ahead-of-time image those are stripped unless something asks for them, and the call then fails where the closure is used rather than at start-up. The framework ships thousands across its plugins, so naming them would be neither complete nor stable, and leaving it to each application means every one of them rediscovers the same list. They are found while the hints are written instead. That happens during the build, on an ordinary JVM with the whole classpath, so scanning is available; it is only the image that cannot do it. Two kinds of closure are deliberately left out, both of which otherwise fail the build rather than degrading at run time. One is a closure whose declaring class does not resolve: the GSP compiler's task extends an Ant type absent at run time, and its closures reach it through invokedynamic, so nothing in their own bytecode reveals the dependency. The other is a closure naming an absent class in a method body: the JSP closures link happily against a runtime with no JSP API, because loading a class resolves its signatures and not the bodies the image analysis goes on to parse.
Grails reaches an application's artefacts reflectively, and a precompiled page is looked up by the name recorded for its view, so a native image that keeps only the members something asked for leaves both present but unusable. Until now an application had to run under the tracing agent to discover them, which records only the paths that were exercised: a page nobody visited during the trace is a page missing from the image. The build already knows the answer. The compiled classes are on disk and the pages are named in the manifest the GSP compiler writes, so both are read directly. Pages a plugin contributes are read from the manifest inside its artifact as well, because an application renders those as readily as its own and they are not in its own build output. The closure registration is widened at the same time. A plugin declares its descriptor in whatever package it chooses, and the bean definitions in that descriptor are closures the container calls while the context is built, so scanning only the framework's own packages missed them.
A rendered page and a persisted entity are both reached through Groovy, so an image that keeps only the members something asked for serves part of an application and fails on the rest. What fails depends on what was exercised: a page renders until it reaches a tag that was stripped, and a datastore reads until something writes. Three registrars cover the runtimes an application does not own: Tag libraries and the page runtime, found by name wherever a plugin declares them. A flash message only exists after a redirect that set one, and a field is only rendered by a form, so a walk of an application's pages exercises neither and both fail for the first person who edits a record. The scaffolded resource controller, including the protected methods it defines for the write operations, which serving its pages never reaches. The persistence runtime, registered by package so that a datastore's own persisters and query support are covered without the shared module naming any of them. Fields are registered as well as methods: a persister hands work to anonymous inner classes that read the state they captured as properties, which is how removing an entity reaches its session. Each skips a type that does not resolve. The JSP integration and the datastores are compiled against APIs an application need not have, and registering a type the image analysis then cannot parse fails the build rather than degrading at run time. The two build tasks state their dependency on the compilation rather than inferring it from the classes directory, which more than one task writes to.
Matching only the library by name left the classes it declares inside it stripped, so a page rendered until a tag reached one. The fields plugin keeps the bean stack that a nested tag reads its subject from in such a class, which is why a list or a form failed while the pages around them were fine. Found by driving the pages in a browser. An automated check that asks for a URL and looks at the status never nests a tag deeply enough to reach it.
Whether the pages compiled at build time are used is decided by whether a development environment is available, which comes down to whether the application looks like a project on disk. That is the right question for a running JVM, where skipping them lets an edit take effect without a restart. It is the wrong question for an ahead-of-time image. Such an image is a single executable that may be run from anywhere, including the directory it was built in, where the sources are still present and the answer is yes. It then read those sources and tried to compile them, which is the one thing it cannot do, so every page failed. Running the executable from somewhere else worked, which made this look like a property of the working directory rather than of the check. Both the plugin that loads the compiled page registry and the locator that reads it now also accept an image, which cannot compile a page whatever its surroundings suggest. Development is unchanged. The registry is loaded from a plugin descriptor, and a descriptor is Groovy, so the call that asks whether this is an image is itself dispatched dynamically and the class it is made on has to survive into the image.
Each registrar decided for itself whether a type it found could be kept, and the two checks that decision needs were rediscovered a piece at a time, wrongly, three times over. Registering a type that cannot be loaded does not degrade at run time: it fails the image build, because the analysis parses what it is asked to keep. Either check alone lets one case through, which is what made this easy to get wrong. Whether the type and the class declaring it load covers a closure whose enclosing class extends something absent, which the closure never names itself because it reaches it through invokedynamic. Whether the types its bytecode names load covers the opposite, a class that loads cleanly while a method body names an absent one, because loading resolves signatures and not bodies. Both now live with the reason each exists, where a registrar for a new part of the framework will find them.
The task had no tests. Two of these cover what went wrong while it was being written: the pages a plugin contributes, whose absence stopped an application starting because it renders those as readily as its own, and the rule that the manifest rather than the directory listing decides which pages exist, so a class left behind by an earlier build is not recorded.
The registrars were written without the style checks having been run, and they indented array initialisers one level too far and put their imports in the wrong group. Behaviour is unchanged.
The expansion staged the views into a directory and pointed the compilation there, for every project. A project with no scaffolded controller then copied its views on every build and compiled them from somewhere other than where they are, for nothing. Whether anything is scaffolded is read from the controller sources, because the decision is needed before anything has been compiled. A project that does not scaffold is left exactly as it was.
Generating a context ahead of time normally makes the injection annotations unnecessary: the generator reads them and writes the field and method access into the code it emits. Two kinds of injection did not survive that, and neither failed at start-up -- the first request that reached the bean did. A bean's autowire mode was not written out at all. Grails registers much of what it contributes as autowired by name, so a tag library arrived holding null where it expected a message source. The mode is now carried into the generated definition, for whatever set it. An annotated member is only generated for when the generator can see the class declaring it. A bean contributed as an interface built by a supplier hides that class -- the link generator is declared as LinkGenerator and built by a closure -- so its annotated field was injected by nobody. The two processors that read those annotations are registered when running on generated artifacts. Only those two: registering the whole set brings back the processor that reads configuration classes, and reading them again in a context whose configuration is already generated makes a second definition for beans the generated code has contributed, which fails the context outright. (cherry picked from commit dbe0b2f9ff1f205266e0f1b2c9202baf708ad616)
The GSP plugin registered a bean for every tag library from doWithSpring(), which meant registering them again on every start, over whatever was already there. On a running JVM that only made noise. On a context generated ahead of time it replaced definitions that carried the injection the generator had worked out, leaving a tag library holding null where it expected a collaborator, and by-name autowiring did not stand in for it because those collaborators are fields. The definitions are contributed by a post-processor instead, which leaves an existing definition alone. The artefacts are read from the application rather than named, so a tag library still belongs to whoever declared it: the application, another plugin, or one supplied through providedArtefacts. Where a definition is already there but has lost its autowire mode, the mode is raised back to by-name and never lowered, so a definition asking for something else keeps it. (cherry picked from commit 03c353953382fbf50a7924dcb5151f9618bb2591)
A bean declared through the plugin DSL passes its arguments positionally, and a
constructor ending in a variable-argument parameter is called the way the language
allows: one value where the parameter is an array, or a collection where it is an
array of that element type. Building the bean, Spring adapts the argument to the
parameter. Reading the definition to generate code for it, Spring does not -- it
looks the argument up by the parameter's type, and a lone String does not answer
to String[].
The argument is then missed and resolved as a dependency instead, and an array of
a type nobody publishes as a bean resolves to an empty array rather than failing.
So the bean is built, and built wrong: a datastore that maps no classes, or a
servlet registration with no URL mapping, which then falls back to mapping
everything and swallows every request. Nothing is logged, and the bean that goes
wrong is rarely the one that reports it.
Gathering the argument ahead of time means the generator writes out
new String[] {"*.gsp"}, which the lookup does find. An argument already usable as
the array is left alone, and one whose elements would need converting is left to
the resolution that exists today rather than guessed at here.
The tests read the code that is actually generated, because the failure this
guards against is one where every bean is still registered and still built.
(cherry picked from commit b91d09ee1c3b8dcd1ef97e9b025c642705f13366)
A datastore was given the configuration object itself to hold, and a definition holding a property resolver is a definition holding everything that resolver can reach. Generating code for it wrote those values out: the environment of whatever machine ran the build, 223 entries of it in the case at hand, credentials among them, committed into generated source. Worse, the application then read its settings from there rather than from where it runs, so a value that is meant to differ between build and run -- a host, a port, a password -- did not. While code is being generated, and only then, the context's environment is named instead, so the lookup is made where the application runs. Every other time the resolver is held as it always was, which matters for a datastore brought up on its own: its configuration is whatever the caller passed, and there is no environment holding it. The classes a datastore maps are also collected as the array its constructor takes, rather than as a collection that only becomes one because Spring adapts it. Verified on an ahead-of-time application: nothing of the build machine appears in the generated sources, and the datastore resolves its address at startup. (cherry picked from commit f437fdec01ccc40da7b6aa0ef787c336039bbd63)
Running on generated artifacts, the plugins whose doWithSpring() produced these definitions have already run: they ran while the artifacts were being generated, and what they registered was written out as code and registered again from that code, ahead of this phase. Registering over it discarded the instance supplier the generator wrote -- which resolves the constructor, the fields and the injection methods up front -- and replaced it with a definition that has to find all of that by reflection, which is what a generated image does not carry. So the generated definition stands and the one contributed here is dropped. Anything the generator did not produce a definition for is registered as usual, so a bean a plugin contributes conditionally is unaffected, and on a normal start nothing is skipped: a plugin overrides what came before it exactly as it did. This is one guard where the definitions land rather than one at each call site, so it holds for the early registration phase and the post-processor alike. An ahead-of-time application went from 27 overridden definitions to none. The one remaining message is Spring replacing its own import-aware post-processor with an equivalent definition, which comes from two configuration class post-processors each contributing the same registration. grails-spring had no test task wired, having had no tests; it now applies the shared test configuration. (cherry picked from commit f21062e981349f7eb4962fd95b4214f0add5bc47)
The core plugin registers a ConfigurationClassPostProcessor so that @configuration beans contributed by plugins get parsed. A context that annotation configuration has already been set up on has one of its own, and the plugin definitions are registered ahead of it, so it sees them: the second processor parses the same registry over again. While code is being generated the two of them each contribute a registration of the import-aware post-processor, so the generated initializer registers it twice under one name and the second replaces the first on every start -- the last overriding definition message an ahead-of-time application reported. It is now registered only where there is none: a context assembled without that step, such as a test slice registering this plugin's beans on a bare registry, which is what it was for. An ahead-of-time application now starts with no overridden bean definitions at all. (cherry picked from commit fa92bd33942f3ebdf5d76ae200e044cd3ab95374)
Both decorate the same generated block of definition properties, one after the other, and no bean in the application under test happens to draw on both, so nothing was proving they compose. A bean that is autowired by name and takes a variable-argument constructor argument now shows both contributions surviving. (cherry picked from commit 65ce6fbe9e52b7bc197c896118891aec08085296)
A plugin declares them in a closure, and Groovy resolves each call in a closure where the call is written rather than when the closure is compiled, so every call on the registry it is handed is made reflectively. An image keeps a method for that only when something has asked it to, and nothing did: the registry belongs to Spring, an application never names it, and the closures that call it are registered as closures rather than for what they call. So the image refused the first call and the context did not start, reporting a method of an interface that appears nowhere in the application. It stood only as long as an application's own traced metadata happened to cover the framework's API, which is not the application's to know. Verified by a native image, which now starts. (cherry picked from commit c4cfdaf7e8997fe71b56720c61497ce2b0374228)
…ings Generating the native metadata was wired into processResources, so a build that only wanted to write a resource compiled its sources and resolved its whole runtime classpath first -- which a project that had declared no repositories could not do. Four of the Gradle plugin's own tests failed on it. The metadata is read by nothing but a native image, so it is generated only where one is being built. A tag library the application had already declared was kept but still altered: any existing definition with no autowiring had by-name autowiring put back on it, which cannot tell a generated definition from one an application deliberately declared that way. What a generated definition needs is carried into it while it is generated, so nothing has to be put back afterwards, and an existing definition is now left exactly as it was found. A banner colour given as a number was passed on whatever it was, so -1 or 999 wrote an escape the terminal does not understand and then showed it in the banner. Numbers are held to the 256 a terminal has, and anything else falls back. grails.banner.art.color is documented beside the rest of the banner configuration and declared in the configuration metadata, so an IDE offers it and says what it takes. The Hibernate datastore held its event publisher rather than referencing one, and its connection-source definitions declared what they produce in a way that reads back as a mismatch: a supplier typed for the DataSource that builds the factory bean which produces it. Neither could be expressed as generated code, so a Hibernate application could not be built ahead of time at all -- the first fails generation, the second fails to compile what generation wrote. (cherry picked from commit 4f8c2c99cee33bbf32f0c586349fe31da1a32909)
The core plugin registers a proxy handler for an application that has none, and these registrars run after the doWithSpring drain -- so it was registered over the one a GORM implementation had already declared, which is the one that knows how to unwrap that datastore's proxies. A Hibernate application was left unwrapping Hibernate proxies with the general case, and said so on every start by reporting proxyHandler as an overridden definition. It is registered only where nothing has declared one, the way the resource locator already backs off to the one GSP declares. (cherry picked from commit 2476b1b5b012964f817e34c30dca672c0984aea2)
A plugin descriptor is Groovy written largely without static compilation, so reading a setting, asking the application about its artefacts, or asking the plugin manager about a plugin are all resolved where the call is written rather than when the descriptor is compiled -- and are therefore made reflectively. An image keeps a method for that only when asked, and an application never names these itself: they are the framework's, called from the framework's own descriptors. So the image refused the call and reported a method of an interface appearing nowhere in the application. The one that stopped a context from starting was ConfigMap.getProperty, which is how nearly every plugin reads its settings. Only the interfaces are named. What implements them is reached through them, and registering the implementations would be registering most of the framework. (cherry picked from commit 3d18e3417f543c99aa6d8a056c5fc678d5e5ad7d)
A closure loads without the class it was written inside -- it extends Closure and
nothing else -- so asking only whether the closure loads lets one through whose
surroundings are absent. Registering it makes the image analyse it, and analysing
a closure means reading the method it was written in.
The Hibernate plugin ships a Spock specification for applications to write their
tests against, and ships it in its main jar, so it is on the runtime classpath of
every application that uses Hibernate whether or not that application tests with
Spock. One that does not has no spock.lang.Specification for the image to read,
and the build failed on a class the application never asked for:
Error encountered while parsing grails.test.hibernate.HibernateSpec$_setupSpec_closure5
Caused by: NoClassDefFoundError: spock/lang/Specification
The class the closure was written inside now has to load as well.
The test asks the guard directly. A class loader that hides Spock does not
reproduce this, because loading the enclosing class delegates to a parent that can
still see it -- which is worth knowing, since such a test passes while proving
nothing.
(cherry picked from commit 7d63e65b7aeb390e58aeb60b9fafd2305e6bb5fc)
A plugin descriptor is given the environment, the context and the resource loader, and calls them the same dynamic way it calls the framework's own interfaces -- so each call is made reflectively, and an application names none of them either. Reading a setting from the environment stopped a Hibernate application from starting in exactly the way reading one from the configuration stopped every application: PropertyResolver.getProperty, from the datastore initializer. (cherry picked from commit 6a30b0c96aac2dedde0f739d5573a780ebc1cf00)
The aligned colons were a style violation, and nothing reads the file in a way the alignment helped. (cherry picked from commit 28c32101e9ac0dd61e91bd0483972b4db3422e65)
Spring's AOT processing removes the work of deciding what the beans are,
which turns out to be the smaller half. What remains is loading and linking
the classes and interpreting the methods until they compile, and JDK 25 can
record a run of an application and read that record back on the next start
instead of doing it again.
An application asks for it and says which of its pages matter:
grails {
aotCache {
enabled = true
paths = ['/', '/login', '/book/index']
}
}
That extracts the executable jar -- a cache is only usable against the layout
it was trained on, and a nested-jar classloader is not one -- runs what it
extracted, asks for each path, and stops it. Startup falls from 2.5s to 1.0s
on an application with GORM, security and asset pipeline; asking for paths
does not move startup further, but halves the first hit of a cold path.
The cache is written as the training JVM exits, so a run that never started
leaves nothing behind. That fails the build rather than shipping an
application whose cache is silently absent: a JVM given a cache it cannot use
declines it and starts as it would have anyway, so the only symptom of a
missing or stale cache is the speed it was built for not being there.
Beside the cache goes aot-cache.properties, recording the JDK build, the
archive digest and the arguments it was trained with, so a deployment can
tell whether the cache it has still applies before it finds out by being slow.
(cherry picked from commit 743206bd2b07bfd40af58cc8462f8658a334edd3)
A page under Deployment covering what the cache is, how an application asks for one, what the training run should be asked to do, and why a cache stops applying -- with the measured figures, because the interesting part is which of the two AOT steps actually moves the number. Also warns about the training run being a real run of the application: it connects to whatever the configuration points at, in the environment the cache is for, which is production by default. (cherry picked from commit ea966fdb61706be18ea46383cff52ec77bb0279b)
The asset pipeline puts them at the root of whatever archive is built. That is where a war serves its web content from, so a war is right. An executable jar has no web content: it serves assets by reading them off the classpath, and its classpath is BOOT-INF/classes -- so the same bytes at the same place, in a jar rather than a war, are packaged but unreachable. The page renders and every asset on it is a 404, including the manifest, so the pages do not even ask for the digest names the pipeline produced. Adding them under the classpath directory is what makes them found. Only for bootJar: a war already serves them from the root, and putting them on its classpath as well would ship the same bytes twice. Keyed on the assetCompile task rather than the plugin that registers it -- the asset pipeline's plugin id has changed once already and the task name has not. (cherry picked from commit c027ce77a80973ed9051b3aaa81019995976e97b)
Re-measured on a quiet machine, against the jar that now carries its assets on the classpath, and with both caches trained from the same archive so the two rows are comparable. Two claims were wrong. Naming paths does move startup, by about a tenth, rather than not at all; and it takes about a third off the first requests rather than half. The first figures were taken with the refresh-only cache trained from an earlier build, and some of them while the machine was busy. (cherry picked from commit f9706aa3989cf3b32cba335b4d1a716651366af0)
Say that training does not work on Windows, before the run rather than after it. The cache is written as the training JVM exits normally, so the run has to be asked to stop -- and Process.destroy() asks on POSIX but kills on Windows, where it is TerminateProcess and no shutdown runs. The run was being exercised in full, killed, and the build then failed saying the cache was not written, which is true and useless. Skip a path that is not a URI rather than failing the build on one. A path written without its leading slash makes a URI with no valid authority, and URI.create throws IllegalArgumentException, which the IOException catch did not cover -- so a typo in a list whose purpose is to make the next start quicker failed the build with a stack trace, against this method's stated contract that nothing in it fails a build. Declare the task's caching intent, which this build requires of every custom task type, and say what the extract task reads and writes so a second run skips it rather than unpacking the same archive again. The spec now drives a training run that really starts, is asked for its paths and stops -- so the start detection, the exercise loop, the graceful stop, and the metadata that is written all have a test that fails if they break. It runs through a stand-in for the JVM rather than java -jar, because a real one would have to understand -XX:AOTCacheOutput and that would test which JDK the build runs on. Replaces a test that asserted a Gradle ListProperty returns what was set on it, which could not have failed. (cherry picked from commit c0ec7d18d390021053727c5cf36a992aad009f59)
The definitions declare InstanceFactoryBean<T> rather than T, so that the bean a definition stands for is known from the definition alone -- which is what generating bean definitions as code requires, and what asking the factory would have prevented, since asking it means creating it. The spec still asserted the older shape and had been failing since the change. (cherry picked from commit a6cf3dd2329346e9a0e4c698ae7c22fa7cca6780)
This decides whether a scanned type can be registered for reflection, and gets it wrong in one direction only: a type it wrongly accepts fails the image build, which is what it exists to prevent. It read supertypes, method parameters, and the owners of the calls and field accesses in a method body. So a class naming an absent type as a field's type, a return type, something it throws or catches, an argument of a call, or a class literal was accepted -- and an array of an absent type was discarded outright, since the name of an array was skipped rather than reduced to the element type that has to exist. Now also read fields, return types, exceptions, the descriptors of calls and field accesses, constants, and invokedynamic -- which for Groovy is not an edge case but how a call site names what it dispatches on. The spec writes a class naming an absent type in one place and nowhere else, once per place, and the same class naming a type that loads: so a rejection is the absent type rather than the shape it was named in. (cherry picked from commit 0a67cd8e679bb9da32a2d44694d3b967bd645c45)
Three things, all about which JDK and which directory. Training ran on whichever JDK was running Gradle. A project on the Java 21 baseline with a Java 25 toolchain compiles with 25 and would have trained with 21, where the cache options do not exist -- and what it reported was that the training run ended before it started serving. It now runs on the toolchain where one is declared, and on the JDK running the build where one is not, which is then also the JDK that compiled the application. The metadata recorded that same JDK, so it described the build's JVM rather than the one that wrote the cache. That file exists to tell a deployment whether the cache it has still applies, and a cache is read only by the JDK build that wrote it -- so naming the wrong one is worse than naming none. It is now read from the launcher that will run the training. The cache and its metadata were written into the extracted application, which is the directory the training task declares as its input. Writing them changed that input, so the task could never be up to date and every build ran the application again to record what the last run had already recorded. They now go beside the extracted application rather than inside it. Readiness no longer rests only on Spring Boot's startup message, which is an INFO log an application may reword or switch off; a port that accepts a connection now counts too. (cherry picked from commit b32df5c6c343f250a44924e2035a87de245de4ae)
An image, a JVM handed a cache to read, and one running bean definitions that were generated all start differently from an ordinary one, and until now all looked the same. The banner now says which, centred under the art and above the versions, strongest first: NATIVE, AOT CACHE, AOT. An ordinary start says nothing, so the line appears only where there is something to say. In its own colour, brighter than the art, so that what an application was started as does not read as the last line of the drawing above it. Written plainly rather than drawn over time. A banner is printed on the thread that is starting the application, which cannot get on until this returns, so anything animated here is time the application is not starting -- and an image that starts in two thirds of a second should not spend a third of it announcing itself. The cache is detected from the arguments the JVM was started with rather than from the cache itself: a JVM handed one it cannot use declines it and starts as it would have anyway, so the mark says what was asked for, not what was obtained.
Turning on grails.banner.versions.include stopped a native image from starting:
Could not find matching constructor for: ...OptionalVersionOption(String, Integer)
A Groovy enum builds its constants through a synthetic $INIT(Object[]) whose body
is this(*para) -- a spread constructor call that cannot be resolved at compile
time, so it goes through the metaclass and enumerates constructors reflectively.
@CompileStatic does not reach it: the call site is already static (GROOVY-10845),
the generated body is not. An image keeps a constructor only when asked, and the
optional enum is asked for only when an application includes a version -- so an
image traced with none carries no record of it, and turning a version on turns
the application off. The three enums are registered here rather than left to a
trace, so what an application configures does not decide whether it starts.
The banner reads a library's version by loading the class that carries it. It
was loading it the initialising way, so the library got to do whatever it does
on the way -- and Spring Security logs a line of its own from its static
initialiser. That line arrived in the middle of the banner, between the mark
and the very versions it was being read for:
> https://grails.apache.org <
N A T I V E
... INFO ... : You are running with Spring Security Core 7.1.0
nativedemo2: 0.1 | JVM: GraalVM Community 25.0.4
A version is read about a library rather than from it. The manifest is attached
to the package when the class is loaded, and loading is all this needs, so the
class is now loaded without being initialised. The version still reads; the
library is left to start when something actually wants it.
Note for anyone tempted by the other direction: reading the version through the
library's own accessor -- SpringSecurityCoreVersion.getVersion() -- does not
avoid this. Calling a static method initialises the class, so it logs too.
Spring Security was something an application had to ask for, because a version
that could not be found was shown as unknown and nobody wants a banner that
says it does not know. Now a version that cannot be determined is left out
altogether, which is what lets it be on by default: an application with Spring
Security shows it without configuring anything, and one without it says nothing
rather than saying unknown. Turn it off with
grails.banner.versions.exclude: [spring-security]
Tomcat is read from the resource it ships its version in rather than from the
manifest. The manifest route only works while a jar is a plain entry on the
classpath: repackaged into an executable jar its attributes are no longer
attached to the package, and an image has no jars at all -- so the container
version read as unknown in exactly the two places it is most worth having. It
now reads 11.0.22.0 in a native image, where it used to read nothing.
findVersion returns null rather than the string unknown, so there is one way to
say "no version" and the caller decides what to do about it.
Every application runs on a servlet container, so the banner shows it without being asked to. One key, container, covers whichever one it is: an application does not have to know what it is on to be told about it, and moving between them needs no configuration change. An application serves on one container -- choosing another means excluding the starter for this one, so two are not on the classpath together. They are therefore tried in the order they are commonly used, and the first one found is the answer: an application on Tomcat never goes looking for Jetty. The specific names stay available to include, for an application that wants to be told about a particular container whether or not it is the one serving. A container that records no version, or an application on none of these, leaves the line out rather than saying it does not know.
The pinned JDK moves in four places at once. A release is built to be reproducible, which means the JDK is part of what is being pinned: the version in .sdkmanrc, the one the release workflows install, and the one the verification container is built from all have to be the same, or a rebuild of a release does not produce the release. grails-test-examples/gsp-spring-boot keeps its own, older toolchain and is not part of this set.
Two things a Grails application needs that neither Spring nor GraalVM can work out for it, and that every application building an image or a cache had to write for itself. An image includes what it can prove is reached. Resources are reached by name at run time -- a compiled asset by request path, a message bundle by locale -- so nothing proves they are needed and the image is built without them. The application starts and then serves every page with a missing stylesheet and an untranslated string. Generating bean definitions reads the ones the application declares, and an application declares different ones in different environments: development declares reloadable beans, which cannot be written out as code. Left at the default, what is generated is development's, and what is built is not the application that was asked for. Both are reactions to a plugin the application applied. Applying GraalVM's plugin is already how an application says it wants an image, and Spring Boot's AOT plugin is how it asks for generated definitions -- so neither needs a setting of its own, and an application that applies neither reaches none of this and sees no change at all. Invokedynamic is a convention rather than a decision: an image cannot define the class a classic call site defines as it runs, so it is on by default where an image is being built, and an application that has said otherwise keeps what it said.
The mark was upper cased on its way out, which the three it works out for itself did not need -- they are written that way -- and which took something away from the one an application supplies: a name whose case is part of it, CRaC, came out as CRAC, and an application asking for Leyden was answered LEYDEN.
An image includes what it can prove is reached. Resources are reached by name at run time -- a compiled asset by request path, a message bundle by locale -- so nothing proves they are needed and an image is built without them: it starts and then serves every page with a missing stylesheet and an untranslated string. They were named with -H:IncludeResources, which GraalVM now warns about twice on every build: the option is experimental and will have to be unlocked. Hints are the supported way, and are what Spring writes an image's resource configuration from -- so this moves out of the Gradle plugin, which was passing build arguments, and into the framework beside the hints already registered there.
Compiling pages is forked, and what comes out depends on which Groovy and which
Java did it. Neither is described by this task's inputs, so Gradle handed one
build's pages to another that wanted different ones.
An application building a native image resolves Groovy 6 and builds for JDK 25;
one training an AOT cache resolves Groovy 5 and builds for a later JDK. Cached,
the first build's pages were restored into the second and failed at the moment a
page was first rendered:
BUG! your call tried to do a property set, which is not supported
at gsp_appindex_gsp.init(gsp_appindex_gsp.groovy)
and, before that, an UnsupportedClassVersionError -- both long after the build
said it had succeeded, and both surviving a clean, which is what made them hard
to place.
Declaring the launcher an input was not enough: it says which Java runs the
fork, not which Groovy the fork compiles against, and the evidence is that it
did not reach the key either. Compiling the pages again costs seconds.
Gradle refuses to validate a task whose directory input does not say how its path is to be treated, and validatePlugins fails on this branch for that alone -- an @InputDirectory with no normalization strategy. The extracted application is compared by what is in it and where each file sits within it, not by where the directory itself is. Left unsaid, the absolute path counts as part of the input, so the same application checked out under another name, or built on CI, would agree about everything and still share nothing.
A training run that cannot start fails the build with "The training run ended before it started serving" and the path to a log. The log is eighty lines of banner, plugin list and class names, and the reason is four of them somewhere in the middle. The reason is what someone reading a failed build needs. One of these turned out to be an environment variable that Spring binds over the application's own configuration -- GRAILS_MONGODB_URL, empty, reaching the run because the training JVM inherits the Gradle daemon's environment and the daemon's environment is not the one the build was started from. Nothing in the build output said so; it said a run had ended. Spring Boot already prints why it could not start. This puts those lines in the failure that reports it, without the box they were printed in, and reads the log defensively: a log that cannot be read is no reason to lose the failure already being reported.
🚨 TestLens detected 1 failed test 🚨Here is what you can do:
Test SummaryCI / Build Grails-Core (windows-latest, 25) > :grails-core:test
🏷️ Commit: 1848212 Test FailuresGrailsBannerNativeMarkSpec > nothing is drawn over, wherever it is read (:grails-core:test in CI / Build Grails-Core (windows-latest, 25))Muted TestsSelect tests to mute in this pull request:
Reuse successful test results:
Click the checkbox to trigger a rerun:
Learn more about TestLens at testlens.app. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on
wondrify/asset-pipeline#465
Makes a Grails application processable by Spring AOT. A stock web application now completes
processAotand starts withspring.aot.enabled=true:Apply the Spring Boot AOT plugin to opt in:
AOT is also the gate on GraalVM native images: Boot refuses to start a native image without a build-time generated initializer, so nothing native was reachable before this.
What was blocking it
Four independent mechanisms, each only visible once the previous was cleared.
Live object instances in bean definitions.
GroovyPagesGrailsPluginpassed the liveGrailsApplicationas a constructor argument and anew GroovyPagesServlet()instance. A bean definition is a recipe;ValueCodeGeneratorcannot emit source that reconstructs an arbitrary object:Both now use forms the generator understands — a reference by bean name and an inner bean definition. This was the first
processAotfailure on 7.2.1, 8.0.0-M4 and 8.0.0-SNAPSHOT alike.A second
ConfigurationClassPostProcessor.CoreGrailsPluginregisters one so that@Configurationbeans contributed by plugins throughdoWithSpring— which arrive after Spring's own processor has finished — still get parsed. An AOT context has none of its own by design, and adding one back parses the configuration classes a second time, colliding with what AOT already emitted:For a static
@Beanmethod AOT emits the definition with the configuration class as its bean class, so the re-parse rediscoversCoreAutoConfigurationunder the bean's own name and collides with itself.Abstract bean definitions.
BeanDefinitionPropertiesCodeGeneratoremitslazyInit,primary,scope,roleandsyntheticbut not the abstract flag, and nothing filters abstract definitions out beforehand — a template is silently regenerated as a concreteObjectbean still carrying the template's properties.CoreGrailsPlugincontributes exactly such a template (abstractGrailsResourceLocator), and it is public surface: third-party plugins inherit from it withbean.parent, asset-pipeline'sassetResourceLocatoramong them. ABeanRegistrationExcludeFilterkeeps them out of generation; children are unaffected because AOT generates them from their merged definition.A servlet context wired as a bean reference. SiteMesh's bean-definition wrap referenced the
servletContextbean, which the container registers only once the web server starts and which has no bean definition at all.Requires a SiteMesh release
The last item is fixed upstream in
spring-webmvc-sitemesh—SiteMeshViewResolvernow implementsServletContextAwareinstead of taking the context as a constructor argument.dependencies.gradletherefore moves to3.3.0-SNAPSHOT, and must be pinned to a released version before this merges.Limitations
processAotmust run in production mode. In developmentreloadEnabledis true and the URL mappings holder becomes aProxyFactoryBeanwhose lazy object type cannot be predicted without instantiating it. Set it on the task:beanRegistrar()-contributed beans are still excluded from generation by Spring's ownaotProcessingIgnoreRegistrationflag and re-registered at runtime whenGrailsApplicationPostProcessorre-runs. Correct, but it repeats the plugin scan on every boot — the work AOT exists to precompute.GroovyPagesServletis now created by the container rather than withnew, so it passes through bean post-processing. ItspluginManageris unaffected (initFrameworkServletalready autowires by type), but a post-processor whose pointcut matched it would handServletRegistrationBeana proxy. No pointcut in the framework does.Verification
An AOT-processed application's bean definitions differ from a normal boot only by the annotation-processing infrastructure AOT replaces — 410 beans against 405, the difference being the configuration, autowired and common-annotation processors, Boot's shared metadata reader factory, and
grailsConfigurationClassPostProcessor. No application bean is missing and none is added.