Keep GORM entities on the DevTools restart classloader - #16299
Keep GORM entities on the DevTools restart classloader#16299jamesfredley wants to merge 5 commits into
Conversation
Spring Boot DevTools loads application domain classes in RestartClassLoader while GORM and Hibernate stay on the base loader. Hibernate's JPA metamodel keys entities by Class identity, so GORM Criteria calls such as count() and save() fail with "Not an entity". Resolve Hibernate's CLASSLOADERS setting to the restart thread context class loader so domain Class identity matches the metamodel. Fixes #16287
There was a problem hiding this comment.
🟡 Changes recommended
The added DevTools documentation overstates behavior when DevTools is merely present on the classpath, and should be narrowed to cases where the restart classloader is actually active.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes a Grails 8 + Spring Boot DevTools compatibility issue where domain classes can be loaded in DevTools’ RestartClassLoader while Hibernate/GORM stay on the base loader, causing class-identity mismatches (e.g. IllegalArgumentException: Not an entity) for criteria-style operations.
Changes:
- Introduces a shared
DevToolsClassLoadersutility to consistently prefer the DevTools restart thread context class loader when present. - Updates Hibernate 5 and Hibernate 7
HibernateMappingContextConfigurationto use the resolved class loader forAvailableSettings.CLASSLOADERS. - Adds targeted specs in datastore + Hibernate modules and documents the behavior in the guide.
File summaries
| File | Description |
|---|---|
| grails-doc/src/en/guide/gettingStarted/developmentReloading.adoc | Documents how DevTools restart class loading interacts with Hibernate/GORM entity identity. |
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/DevToolsClassLoaders.java | Adds a reusable classloader-resolution helper for DevTools restart scenarios. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/reflect/DevToolsClassLoadersSpec.groovy | Adds unit tests for restart-loader detection and resolution behavior. |
| grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java | Uses DevToolsClassLoaders.resolve(...) when setting/reading Hibernate CLASSLOADERS. |
| grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfigurationSpec.groovy | Adds a regression spec ensuring restart TCCL is preferred over the app context loader. |
| grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfiguration.java | Aligns Hibernate 5 config with the same restart-aware classloader resolution. |
| grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextConfigurationSpec.groovy | Adds coverage for Hibernate 5 behavior with and without restart TCCL. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
The user's observation is accurate. The documentation should be updated to clarify that the |
Copilot noted that DevTools on the classpath does not always mean RestartClassLoader is in use, for example when restart is disabled. The Hibernate bootstrap only prefers that loader when it is active.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16299 +/- ##
==================================================
+ Coverage 54.7242% 54.7325% +0.0083%
- Complexity 20531 20543 +12
==================================================
Files 2104 2105 +1
Lines 101149 101172 +23
Branches 17966 17973 +7
==================================================
+ Hits 55353 55374 +21
- Misses 37775 37776 +1
- Partials 8021 8022 +1
🚀 New features to boost your workflow:
|
jdaugherty
left a comment
There was a problem hiding this comment.
AI did the below review and seems to think the hibernate 5 is still broken. Did you test to confirm these changes fix it? See below for it's review.
Clean consolidation of the duplicated RestartClassLoader sniffing, and the new helper is well covered. But tracing it against the reported reproduction, I don't think it changes behavior on the path that actually fails.
The change appears to be a no-op on the reporter's path
The issue is Grails 8.0.0-M5 from start.grails.org with Hibernate 5.6.15. Forge's default is GormImpl.DEFAULT_OPTION = HIBERNATE5 (GormImpl.java:31), so the failing path is hibernate5:
-
grails-data-hibernate5/core/.../connections/HibernateConnectionSourceFactory.java:142branches onapplicationContext.containsBean(dataSourceConnectionSource.getName()).ConnectionSource.DEFAULTis"default"(ConnectionSource.java:34) and there is no bean nameddefault— the DataSource bean isdataSource. So this always takes theelsebranch,setDataSourceConnectionSource(...). (hibernate7 computes the real bean name"dataSource"and therefore takes thesetApplicationContextbranch — the two modules genuinely diverge here.) -
setDataSourceConnectionSourcehas preferred a restart TCCL since56df99d587("Support DevTools RestartClassLoader. Fixes #11159"). That is exactly the code this PR replaces with an equivalentDevToolsClassLoaders.resolve(...)call. -
create()(HibernateConnectionSourceFactory.java:107-109) callsbuildConfiguration(...)and thenconfiguration.buildSessionFactory()back to back on the same thread. So the newresolve(storedClassLoader)inbuildSessionFactoryreads the sameThread.currentThread().getContextClassLoader()the setter just read, and can only return the same loader.
So on the hibernate5 default path the resolved loader is identical before and after this PR. Either the failure has a root cause other than AvailableSettings.CLASSLOADERS, or the TCCL was not a RestartClassLoader at connection-source creation — and in that second case this PR doesn't help either, for the same-thread reason above.
Earlier investigation of this issue confirmed via identityHashCode/loader diagnostics that the JPA metamodel held AppClassLoader copies while GORM held restart-loader copies. Since the restart-loader preference was already active on that path, something else is re-resolving those classes by name.
The one genuine behavior change here is hibernate7's setApplicationContext, which now prefers a restart TCCL over applicationContext.getClassLoader(). Under devtools those normally agree, since AbstractApplicationContext extends DefaultResourceLoader and captures the TCCL at construction on restartedMain.
Ask: how was the fix verified? Ideally a generated app with gorm-hibernate5 + spring-security-core + devtools that failed before and boots after. The checklist also has "verified that all existing tests pass" unchecked. If verification was against hibernate7, the "Fixes #16287" claim should probably be re-scoped, since the reporter's stack is hibernate5.
CI is green apart from the known-flaky UserControllerSpec > User list (#16030).
Hibernate 5 looked up a Spring bean named "default" instead of "dataSource", so setApplicationContext never ran on the reporter's path. Named sources now set dataSourceName before that lookup. Hibernate re-resolves entity Class objects via TCCL during SessionFactory construction, so both Hibernate 5 and 7 wrap super.buildSessionFactory with the preferred restart loader. Rename DevToolsClassLoaders.resolve to preferRestartClassLoader, match RestartClassLoader by FQCN, and keep a descendant fallback.
|
Independent check of the review feedback (not taking the review text as given): The Hibernate 5 factory was looking up a bean named Hibernate also re-resolves entity Updates in c8945fd:
Targeted tests passed in I did not run a generated Forge app with |
|
@jamesfredley it looks like the tests aren't running on this PR b/c of the infrastructure unapproving the actions. |
|
I opened #16324 to address all of my feedback on this review. |
🚨 TestLens detected 2 failed tests 🚨Here is what you can do:
Test SummaryCI / Functional Tests (Java 25, indy=false, shard 1) > :grails-test-examples-app1:integrationTest
Groovy Snapshot Canary Build / Build Grails (shard 2) > :grails-test-examples-scaffolding:integrationTest
🏷️ Commit: a137709 Test FailuresUserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in Groovy Snapshot Canary Build / Build Grails (shard 2))RedirectWithAndWithoutParamsFunctionalSpec > Params are not added to the url after a redirect even if they are passed to the redirect (:grails-test-examples-app1:integrationTest in CI / Functional Tests (Java 25, indy=false, shard 1))Rerun ControlsSelect 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/docs. |
Description
Fixes #16287.
This is a real compatibility bug, not user error. With Spring Boot DevTools restart active, Grails 8 loads application domain classes in DevTools'
RestartClassLoaderwhile GORM and Hibernate remain on the base loader. Hibernate's JPA metamodel keys entities byClassidentity, so GORM Criteria calls such ascount()/list()andsave()fail withIllegalArgumentException: Not an entitywhileget()and HQL still work.What was actually wrong on Hibernate 5
The Hibernate 5 connection-source factory looked up a Spring bean named
default(ConnectionSource.DEFAULT). The DataSource bean isdataSource, sosetApplicationContextnever ran andCLASSLOADERSstayed onconnectionSource.getClass().getClassLoader()(the base loader). Hibernate 7 already computeddataSource/dataSource_<name>.Hibernate then re-resolves entity
Classobjects by name during SessionFactory construction (ReflectHelper.classForNameuses the thread context class loader). SettingAvailableSettings.CLASSLOADERSis not enough if TCCL is still the base loader at bootstrap.What this PR does
dataSource/dataSource_<name>and setsdataSourceNamebeforesetApplicationContext, so the application-context class loader is used. Named sources injectdataSource_<name>, not the default DataSource.DevToolsClassLoaders.preferRestartClassLoader(...)at SessionFactory build time and temporarily set TCCL aroundsuper.buildSessionFactory.CLASSLOADERSis left unset when the application context loader is null and DevTools is not active, preserving the old Hibernate 7 fallback.RestartClassLoaderby FQCN first (simple-name fallback for tests/shaded copies), keeps a descendant-loader guard, and deprecatesresolve()in favor ofpreferRestartClassLoader().MappingContextacross loaders.Contributor Checklist
Please review the following checklist before submitting your pull request. Pull requests that do not meet these requirements may be closed without review.
Issue and Scope
7.0.x): Bug fixes only. No new features or API changes.7.1.x): New features are welcome, but breaking existing APIs must be avoided.8.0.x): Reserved for major changes. Breaking API changes are permitted.Code Quality
./gradlew build --rerun-tasks../gradlew codeStyleand resolved any violations. See Code Style for details.Licensing and Attribution
Documentation
Generative AI attribution
Generative AI tooling (Cursor Grok 4.6 with GPT review) was used to draft the implementation, tests, and documentation. The change was reviewed, tested in the affected modules (
grails-datastore-core,grails-data-hibernate5-core,grails-data-hibernate7-core), and edited before submission.