Add opt-in legacy Holder shim for early plugin registration - #16101
Add opt-in legacy Holder shim for early plugin registration#16101jamesfredley wants to merge 7 commits into
Conversation
Publish the promoted GrailsApplication fallback before plugin doWithSpring callbacks run so Grails 7-era plugins can continue resolving it through Holders. Exchange the fallback without invoking discovery strategies and restore the exact prior value on every failure path, including sneaky checked exceptions from Groovy closures. Add lifecycle regressions for audit-logging-style Holder access, runtime and checked failures, discovery-strategy isolation, and process-wide state cleanup. Assisted-by: opencode:gpt-5.6-sol
Use atomic fallback publication and compare-and-restore semantics so a failed application context cannot overwrite a newer concurrent publisher. Cover both lifecycle replacement and a real two-thread race. Assisted-by: opencode:gpt-5.6-sol
There was a problem hiding this comment.
Pull request overview
Restores Grails 7-era lifecycle behavior during Grails 8 early plugin registration so legacy plugin doWithSpring callbacks can access the promoted GrailsApplication via Holders, while also making rollback of that global state safe in failure/concurrent-initialization scenarios.
Changes:
- Publish the promoted
DefaultGrailsApplicationintoHoldersbeforepluginManager.doRuntimeConfiguration(...)executes (and restore prior state on failure). - Make
Holders’ fallbackGrailsApplicationstorage atomic and add ownership-aware replace/restore operations to prevent failed contexts from clobbering newer publications. - Expand ordering/rollback/concurrency regression tests for early plugin registration, including checked-exception wrapping and discovery-strategy isolation.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java | Publishes GrailsApplication to Holders before doWithSpring/runtime config, and restores global state safely on failure. |
| grails-core/src/main/groovy/grails/util/Holders.java | Switches the fallback GrailsApplication to an AtomicReference and adds replace/conditional-restore helpers. |
| grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy | Adds regression coverage for legacy Holders access, failure rollback, discovery-strategy non-invocation, checked exception wrapping, and concurrent publisher safety. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16101 +/- ##
==================================================
+ Coverage 52.3566% 52.3589% +0.0023%
- Complexity 18300 18302 +2
==================================================
Files 2036 2036
Lines 96347 96360 +13
Branches 16829 16832 +3
==================================================
+ Hits 50444 50453 +9
Misses 38481 38481
- Partials 7422 7426 +4
🚀 New features to boost your workflow:
|
jdaugherty
left a comment
There was a problem hiding this comment.
A Grails application isn't mean to be available - beans have been processed. We are now wiring beans when spring wires them - which by definition is before the application can be made available. Allowing the application to be found is likely just going to mask other problems with the lifecycle not being ready. In this case it only would fix the audit logging plugin because it's trying to access the environment, which is ready at this point.
I'm a -1 on this change as it is - it's the default for all applications and the application should legitimately not be ready at this point. I'm okay adding a shim to add this behavior for legacy applications, but this shouldn't be the default for new applications as it creates a misunderstanding with the spring life cycle.
✅ All tests passed ✅🏷️ Commit: 1640000 Learn more about TestLens at testlens.app. |
Track failed fallback publications by weak identity so concurrent rollback cannot resurrect a failed GrailsApplication. Clear the failure history on Holder reset and drain collected tombstones during fallback reads. Assisted-by: opencode:gpt-5.6-sol
Keep the Grails 8 lifecycle unchanged by default and expose the promoted application during doWithSpring only when the compatibility setting is enabled. Preserve newer global publishers across both early and downstream promotion. Assisted-by: opencode:gpt-5.6-sol
Expose the default-off setting through configuration metadata and explain its limited migration purpose, preexisting Holder caveat, and recommended plugin migration path in the Grails 8 upgrade guide. Assisted-by: opencode:gpt-5.6-sol
|
Updated this to follow the default-off compatibility-bridge pattern from #16011 rather than enabling the old behavior globally. The Grails 8 lifecycle remains the default: the application being initialized is not published to grails:
legacy:
holdersDuringDoWithSpring: trueWith that property enabled, only the promoted application is exposed during legacy The upgrade guide and configuration metadata now describe the shim as transitional support for plugins such as audit-logging 6.0.0, with migration toward configuration/injection or |
Summary
This PR adds a narrow, default-off compatibility bridge for Grails 7-era plugins that read
Holders.grailsApplicationfromdoWithSpring.What broke backward compatibility
The behavior changed in PR #15934, specifically commit
a853dda.PR #15934 intentionally moved plugin bean registration earlier so plugin-provided beans exist before Spring Boot evaluates auto-configuration such as
@ConditionalOnMissingBean. This allows Boot to back off cleanly instead of creating a bean that a plugin must override later.Before #15934,
GrailsApplicationPostProcessor.postProcessBeanDefinitionRegistryperformed this sequence:Holders.setGrailsApplication(application).pluginManager.doRuntimeConfiguration(springConfig), which executes plugindoWithSpringclosures.After #15934,
GrailsEarlyPluginRegistrationPostProcessorperforms the plugin phase before Boot auto-configuration:DefaultGrailsApplicationandDefaultGrailsPluginManager.pluginManager.doRuntimeConfiguration(springConfig)and run pluginbeanRegistrar()implementations.Holderslater in the lifecycle.That new ordering solves the Boot conditional-bean problem, but it is backward-incompatible for older plugins whose
doWithSpringclosures readHolders.grailsApplication. Their code now executes before the application being initialized is published globally. In a normal single-application JVM with no prior Holder value,Holders.grailsApplicationthrowsIllegalArgumentException: GrailsApplication not found. If another context previously published an application, the plugin can instead observe that unrelated application.The concrete compatibility case is audit-logging 6.0.0, which accesses
Holders.grailsApplicationfromdoWithSpringto obtain the environment. The environment is available during this phase, but the application is not generally lifecycle-ready.Why the bridge is default-off
Publishing the application early for every Grails 8 application would imply that the application is ready while Spring is still wiring bean definitions. That can mask other lifecycle assumptions and create difficult-to-debug behavior.
Following the default-off compatibility pattern used for
legacyCommandSupportin PR #16011, this PR keeps the #15934 lifecycle as the default and provides an explicit migration bridge:This setting is transitional. Plugins should migrate to the configuration/environment available to the registration phase, injected dependencies, or
beanRegistrar()rather than relying on process-global Holder state.Behavior
doWithSpringfalsetrueThe bridge only makes the promoted application discoverable through the Holder fallback during legacy registration. It does not make the application generally ready or restore every Grails 7 lifecycle assumption.
Implementation details
Settings.LEGACY_HOLDERS_DURING_DO_WITH_SPRINGdefinesgrails.legacy.holdersDuringDoWithSpring, with runtime and metadata defaults offalse.GrailsEarlyPluginRegistrationPostProcessorreads the setting from the application environment. When enabled, it publishes the exact promoted application before plugin runtime configuration and emits a migration warning.GrailsApplicationPostProcessorrecognizes the early-registration completion marker and preserves the early processor's ownership decision instead of publishing unconditionally during the downstream handoff.Holdersstores the fallback application in anAtomicReferenceand provides identity-based replacement and conditional restoration operations.clear()andreset()clear failure history, while normal fallback reads drain collected weak tombstones so prior applications are not retained indefinitely.Errorinstances retain their identity after cleanup. Sneaky checked exceptions from Groovy closures are wrapped only after Holder andEnvironment.initializingstate is restored.The ownership hardening is split into commits
0c83760and787eb7d.Documentation and configuration
spring-configuration-metadata.jsonexposes the property as a Boolean with defaultfalseunder thegrails.legacygroup.ConfigReportCommandSpecverifies that the metadata is visible through configuration-report tooling.Verification
./gradlew :grails-core:test --tests "grails.util.HoldersSpec" -x :grails-core:testCli./gradlew :grails-core:test --tests "grails.boot.config.EarlyPluginRegistrationOrderingSpec" -x :grails-core:testCli./gradlew :grails-core:testCli --tests "org.apache.grails.core.cli.ConfigReportCommandSpec"./gradlew :grails-core:checkstyleMain./gradlew :grails-doc:publishGuide -x aggregateGroovydocFocused coverage includes:
false, and explicittrueconfiguration.Errorfailure paths.clear()/reset()isolation.