Skip to content

(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779

Open
borinquenkid wants to merge 9 commits into
8.0.xfrom
feat/gorm-datastore-infra
Open

(1/3) feat: add SessionResolver infrastructure and extend core datastore APIs#15779
borinquenkid wants to merge 9 commits into
8.0.xfrom
feat/gorm-datastore-infra

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Jun 27, 2026

Copy link
Copy Markdown
Member

📖 Reading order: 1 of 3 — this GormRegistry O(M+N) scaling change is split across three PRs; please review them in order:

  1. (1/3) (1/3) feat: add SessionResolver infrastructure and extend core datastore APIs #15779 — SessionResolver infrastructure & core datastore API extensions
  2. (2/3) (2/3) feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor #15780 — GormRegistry implementation (core production code)
  3. (3/3) (3/3) test: add core-class unit tests for the GormRegistry O(M+N) rewrite #15790 — core-class unit tests

You are reading (1/3).


Summary

Prerequisite for the GormRegistry O(M+N) scaling stack. Introduces low-level infrastructure in grails-datastore-core that the GormRegistry layer depends on:

  • Add SessionResolver and ThreadLocalSessionResolver for thread-safe session lookup decoupled from a specific Datastore reference
  • Extend AbstractDatastore, Datastore, and DatastoreUtils with hooks for registry lifecycle events (register/deregister on open/close)
  • Add MappingContext and AbstractMappingContext extensions needed by GormRegistry for entity lookup
  • Minor additions to AbstractPersistentEntity, ClassUtils, DirtyCheckingSupport, ConnectionSourceSettingsBuilder, CustomizableRollbackTransactionAttribute, AstUtils

This PR carries no GORM API changes and has no dependency on any GormRegistry class — it can be reviewed and merged independently.


Relationship to the upstream fix

This stack builds on top of fix/gorm-api-registration-scaling, which solves the same O(M×N) problem through a different strategy. The two approaches are deliberately complementary:

Upstream fix strategy — incremental optimization (4 files, ~815 lines)

The partner's fix adds apiQualifiers() to GormEnhancer to split tenant qualifiers into a canonical eager set and a lazy-on-demand set, then introduces an internal GormEnhancerRegistry that allocates per-qualifier API providers via computeIfAbsent. This collapses startup allocation from O(entityCount × tenantCount) to O(entityCount + tenantCount) while keeping GormEnhancer as the sole stateholder. Two follow-up commits fix TSM consistency bugs that surfaced from the lazy-allocation: a stale ConnectionHolder in GrailsHibernateTemplate.executeWithNewSession (DATABASE mode) and stale cached APIs after addTenantForSchema re-creates a child datastore (SCHEMA mode).

Footprint: all changes contained within GormEnhancer.groovy and GrailsHibernateTemplate.java (H5 + H7). No new public types.

This stack strategy — registry-first architecture (251 files across 9 PRs)

This stack extracts all per-entity, per-qualifier API state from GormEnhancer into a dedicated GormRegistry singleton. GormEnhancer becomes stateless — it delegates entity registration, API lookup, and datastore lifecycle to the registry. APIs are created at entity-registration time by a pluggable GormApiFactory (one implementation per datastore type), then looked up O(1) by (entityClass, qualifier) at call time.

Key differences from the upstream fix:

Dimension Upstream fix This stack
Complexity O(M+N) via lazy computeIfAbsent O(M+N) via eager factory allocation at registration time
New public types None GormRegistry, GormApiFactory, SessionResolver
Stale-entry eviction Explicit eviction heuristic in registerEntity Not needed — registry owns the authoritative copy
Lifecycle No change Datastore open/close fires register/deregister events on the registry
Pluggability Single allocation path Each datastore registers its own GormApiFactory
Scope GormEnhancer + H5/H7 template All datastore adapter modules

Conflict resolution: our GormRegistry supersedes the upstream GormEnhancerRegistry. When rebasing this stack onto the upstream fix, conflicts in GormEnhancer.groovy are always resolved in favour of our version — the registry approach provides a strict superset of the optimization.


Review response

See this comment for the full writeup responding to @jdaugherty's review. Short version: SessionResolver no longer maintains independent state (it's now a stateless view over the same SessionHolder/TSM store DatastoreUtils already uses), the public API surface is narrower (Datastore.getSessionResolver() is a default method, MappingContext.initialize() is back to protected), several unrelated behavioral changes were reverted out of this PR, and every touched class has new/updated test coverage for the gaps called out as untested.


Test plan

  • ./gradlew :grails-datastore-core:test passes
  • New/updated specs: AbstractDatastoreSpec, SessionResolverIntegrationSpec, ThreadLocalSessionResolverSpec, DatastoreUtilsSpec, AbstractConnectionSourceFactorySpec, CustomizableRollbackTransactionAttributeSpec
  • Full sweep: grails-datastore-core, grails-datamapping-core, grails-data-mongodb-core, grails-data-simple, grails-data-hibernate5-core, grails-data-hibernate7-coreBUILD SUCCESSFUL, 0 failures
  • codeStyle (Checkstyle/CodeNarc) clean on grails-datastore-core and grails-data-mongodb-core

Stack

This is the prereq for:

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces foundational datastore-layer infrastructure needed for the upcoming GormRegistry O(M+N) scaling work by adding session-resolution abstractions, expanding mapping-context lifecycle hooks, and tightening several low-level behaviors across datastore + Hibernate adapters.

Changes:

  • Add SessionResolver + default ThreadLocalSessionResolver, and wire session lookup into Datastore/AbstractDatastore and DatastoreUtils.
  • Extend MappingContext/AbstractMappingContext initialization and multi-tenancy configurability; adjust key mapping-context implementations accordingly.
  • Refine supporting utilities and behaviors (query pre-event source, dirty-checking semantics, transaction attribute copying, Hibernate template TSM handling) and add/adjust targeted tests.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/services/DefaultServiceRegistrySpec.groovy Adjust test service stub to include a datastore property.
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolverSpec.groovy New unit spec covering basic bind/resolve/unbind for ThreadLocalSessionResolver.
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/SessionResolverIntegrationSpec.groovy New spec exercising resolver availability through an AbstractDatastore subclass.
grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/core/AbstractDatastoreSpec.groovy New spec validating event publisher behavior and session creation event publication.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java Improve copying semantics (incl. qualifier/rollbackRules/connection) and fix trace logging strings.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/services/Service.groovy Minor formatting-only adjustment.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/ClassUtils.java Add getIntegerFromMap helper.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/reflect/AstUtils.groovy Avoid duplicating annotations when copying to a target node.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/query/Query.java Update PreQueryEvent creation to use (source, query) constructor.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/MappingContext.java Add initialize(ConnectionSourceSettings) and setMultiTenancyMode(...) hooks.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractPersistentEntity.java Lazily resolve tenantId property and fix multi-tenant check call site.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AbstractMappingContext.java Make initialize public and add setter for multi-tenancy mode.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/keyvalue/mapping/config/KeyValueMappingContext.java Switch to GormMappingConfigurationStrategy and route through updated initialization.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContext.java Align initialize visibility with new MappingContext contract.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/dirty/checking/DirtyCheckingSupport.groovy Expand dirty-checking to detect changes within initialized collections and DirtyCheckableCollection.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/ThreadLocalSessionResolver.groovy New default session resolver implementation.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/SessionResolver.groovy New session-resolver SPI.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/DatastoreUtils.java Prefer resolver-based session lookup; add execute-with-new-session helpers; adjust session binding behavior and a generic array cast.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/Datastore.java Add getSessionResolver() to the datastore contract.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettingsBuilder.groovy Add constructor supporting fallback configuration.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/AbstractConnectionSourceFactory.java Add createSettings(PropertyResolver) convenience.
grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/AbstractDatastore.java Add default event publisher + listener wiring, session resolver field, destroy-time unbind, and updated “current session” logic.
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerAllQualifiersSpec.groovy Expand tests around qualifier allocation, lazy API creation, and close semantics.
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy Add enhancer registry, eager-vs-lazy API qualifier separation, lazy API initialization, and safer close semantics.
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java Align initialize visibility with new mapping-context contract.
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GrailsHibernateTemplateSpec.groovy Add regression coverage for TSM restoration when DS is bound without SF.
grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy New spec verifying lazy API allocation behavior across multi-tenancy modes.
grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTemplate.java Unbind/rebind DataSource holders independently of SessionFactory holders in executeWithNewSession.
grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/GrailsHibernateTemplateSpec.groovy New regression test for TSM restoration in Hibernate 5 template.
grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/GormApiAllocationSpec.groovy New spec verifying lazy API allocation behavior across multi-tenancy modes (Hibernate 5).
grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/GrailsHibernateTemplate.java Same TSM unbind/rebind fix as Hibernate 7 template.
grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateDatastore.java Trivial whitespace removal.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.1080%. Comparing base (ef09bcd) to head (ce346f0).
⚠️ Report is 122 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ails/datastore/mapping/core/AbstractDatastore.java 88.6364% 1 Missing and 4 partials ⚠️
...ions/CustomizableRollbackTransactionAttribute.java 79.1667% 3 Missing and 2 partials ⚠️
...ore/mapping/core/ThreadLocalSessionResolver.groovy 88.8889% 0 Missing and 2 partials ⚠️
.../grails/datastore/mapping/core/DatastoreUtils.java 92.3077% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15779        +/-   ##
==================================================
+ Coverage     49.4833%   51.1080%   +1.6247%     
- Complexity      16883      17459       +576     
==================================================
  Files            1998       2014        +16     
  Lines           93672      94310       +638     
  Branches        16400      16482        +82     
==================================================
+ Hits            46352      48200      +1848     
+ Misses          40169      38899      -1270     
- Partials         7151       7211        +60     
Files with missing lines Coverage Δ
...y/org/grails/datastore/mapping/core/Datastore.java 100.0000% <100.0000%> (ø)
...e/connections/AbstractConnectionSourceFactory.java 95.6522% <100.0000%> (+27.2311%) ⬆️
...connections/ConnectionSourceSettingsBuilder.groovy 100.0000% <100.0000%> (ø)
...astore/mapping/model/AbstractPersistentEntity.java 76.5027% <100.0000%> (+0.9346%) ⬆️
...oovy/org/grails/datastore/mapping/query/Query.java 83.2184% <100.0000%> (+2.0690%) ⬆️
.../datastore/mapping/transactions/SessionHolder.java 82.3529% <100.0000%> (+9.6257%) ⬆️
.../grails/datastore/mapping/core/DatastoreUtils.java 65.5319% <92.3077%> (+3.2001%) ⬆️
...ore/mapping/core/ThreadLocalSessionResolver.groovy 88.8889% <88.8889%> (ø)
...ails/datastore/mapping/core/AbstractDatastore.java 78.9474% <88.6364%> (+6.3446%) ⬆️
...ions/CustomizableRollbackTransactionAttribute.java 81.3559% <79.1667%> (+25.3559%) ⬆️

... and 111 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

borinquenkid added a commit that referenced this pull request Jul 2, 2026
- ThreadLocalSessionResolver: qualifier-bound sessions were stored in a
  single shared ConcurrentHashMap, leaking across threads; move to a
  ThreadLocal<Map> and clear it on unbind().
- AbstractDatastore.DefaultApplicationEventPublisher: dispatched every
  event to every listener regardless of declared generic type, risking
  ClassCastException for typed listeners; filter via Spring's own
  GenericApplicationListenerAdapter/ResolvableType before invoking, and
  make the listener list a CopyOnWriteArrayList.
- DatastoreUtils.bindSession: silently skipped binding when a
  SessionHolder was already present for the datastore, which could
  leave a freshly created session unbound (e.g. DataTestSetupInterceptor
  creates a new session per test method). Add the session to the
  existing holder instead, matching bindNewSession's push/pop semantics.
- Align new spec file license headers with the standard ASF header used
  elsewhere in the module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
borinquenkid and others added 4 commits July 4, 2026 12:17
Introduce SessionResolver and ThreadLocalSessionResolver for thread-safe
session lookup without coupling callers to a specific Datastore instance.
Extend AbstractDatastore, Datastore, DatastoreUtils, and MappingContext
with the hooks GormRegistry needs for O(M+N) API registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pilation

Making getDatastore()/setDatastore() abstract in the Service trait breaks
@CompileStatic classes that implement the trait (e.g. DefaultTenantService),
because Groovy's static compiler does not properly satisfy trait abstract
method contracts when the implementing class declares the same method in its
own body. Restore the original backing-field implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MappingContext interface declares initialize(ConnectionSourceSettings) as
public; MongoMappingContext.initialize was protected, which Java rejects
as assigning weaker access privileges to an interface method implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ThreadLocalSessionResolver: qualifier-bound sessions were stored in a
  single shared ConcurrentHashMap, leaking across threads; move to a
  ThreadLocal<Map> and clear it on unbind().
- AbstractDatastore.DefaultApplicationEventPublisher: dispatched every
  event to every listener regardless of declared generic type, risking
  ClassCastException for typed listeners; filter via Spring's own
  GenericApplicationListenerAdapter/ResolvableType before invoking, and
  make the listener list a CopyOnWriteArrayList.
- DatastoreUtils.bindSession: silently skipped binding when a
  SessionHolder was already present for the datastore, which could
  leave a freshly created session unbound (e.g. DataTestSetupInterceptor
  creates a new session per test method). Add the session to the
  existing holder instead, matching bindNewSession's push/pop semantics.
- Align new spec file license headers with the standard ASF header used
  elsewhere in the module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this. I do not think the PR is ready to merge in its current form.

The primary concern is architectural: SessionResolver introduces a second independently mutable source of truth alongside TransactionSynchronizationManager and SessionHolder, then gives it precedence during lookup. This can cause work to execute on one session while transaction commit or rollback operates on another, and it breaks nested and executeWithNewSession semantics. I recommend keeping SessionHolder as the authoritative session stack and composing the resolver as an adapter over it.

The event-publisher changes have a similar parallel-state problem. Concrete datastores publish through their own publisher fields, while the new listener API registers against the superclass publisher. Using a datastore-owned Spring multicaster with an optional outbound publisher delegate would provide one consistent path without custom dispatch or reflection.

The PR also widens public interfaces with mutable lifecycle methods and includes several unrelated behavioral changes, including key-value JPA mapping, tenant metadata, dirty checking, AST annotation copying, and transaction-attribute copying. These should be removed or split into focused PRs with dedicated tests.

The module tests pass, but they do not cover the transaction precedence, nested-session restoration, concrete datastore publisher, or cleanup scenarios raised in the inline comments. My recommendation is to address the session and event architecture first, narrow the public API changes, and separate the unrelated behavior changes before proceeding.

@Override
void bind(S session) {
currentSession.set(session)
// Note: In a production scenario, we'd need to link the session's datastore qualifier here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This placeholder comment ("In a production scenario, we’d need to...") signals the design is incomplete and should not ship as the default core implementation. A single value also cannot support nested scopes: bind(A); bind(B); unbind() leaves no current session instead of restoring A, while the existing SessionHolder already has push/pop semantics. Qualified bindings have the same replacement problem, and plain unbind() unexpectedly clears every qualified binding as well. Prefer composing the default resolver over TSM/SessionHolder; if an independent context is genuinely required, it needs scoped stack semantics and automatic cleanup tied to session close.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The independent ThreadLocal source has been replaced with the shared SessionHolder, but nested resolver cleanup is still incomplete. bind() pushes onto the holder while unbind() removes the entire holder, so bind(A); bind(B); unbind() loses A instead of restoring it. I am leaving this thread open and adding a current-line comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied on the current-line thread you split this into — see the comment on ThreadLocalSessionResolver.groovy re: bind()/unbind(). Fixed in 48c9c49.

…hitecture

Responds to jdaugherty's CHANGES_REQUESTED review (#15779). Fixes the session/event
architecture concerns, narrows the public API surface, and splits out the unrelated
behavioral changes he flagged, per that review's blast-radius check against PR2/PR3
(#15780/#15790) - neither depends on anything reworked here beyond hasCurrentSession(),
which is now strictly more correct.

Session/event architecture (the core concern):
- SessionResolver/ThreadLocalSessionResolver no longer maintain independent ThreadLocal
  state. They're now a thin, stateless view over the same SessionHolder/TSM store
  DatastoreUtils already uses, so resolve() can never disagree with the transactional
  session. Nested scopes fall out for free from SessionHolder's existing stack.
- DatastoreUtils.doGetSession() no longer short-circuits through the resolver before
  transaction-synchronization registration and session validation - that shortcut
  silently bypassed both, plus the allowCreate contract.
- AbstractDatastore.hasCurrentSession() collapses to a single check now that resolver
  and TSM read the same state instead of being OR'd together.
- Dropped the unused, asymmetric resolve(String)/bind(String, S) qualifier surface from
  SessionResolver (zero callers anywhere in the codebase; the concrete class's own bind()
  admitted the feature was never finished).
- Replaced the hand-rolled event publisher with one composing SimpleApplicationEventMulticaster.
  addApplicationListener() now routes through getApplicationEventPublisher() (virtual) instead
  of the raw field, so it reaches whatever publisher a subclass (Mongo/Hibernate/Neo4j) actually
  publishes through, without touching those modules.
- Fixed the applicationEventPublisher triple-assignment and the bug where
  setApplicationContext(null) discarded a caller-installed custom publisher.
- @PreDestroy now closes every session held by the current thread's SessionHolder instead of
  just dropping the reference.

API surface:
- Datastore.getSessionResolver() is now a default method (was abstract - broke every external
  implementer); the default is now safe to construct per-call since the resolver holds no
  private state of its own.
- MappingContext.initialize(ConnectionSourceSettings) is back to protected on AbstractMappingContext,
  not promoted onto the public interface - nothing needed the promotion.

Restored/fixed semantics:
- DatastoreUtils.bindSession()/bindSession(creator) fail fast again (IllegalStateException) on a
  double-bind, instead of silently stacking - bindNewSession() already provides stacking for
  callers that need it (used internally by executeWithNewSession).
- CustomizableRollbackTransactionAttribute's copy constructors now deep-copy the rollback-rule
  list instead of aliasing the source's mutable list, and also copy transaction labels.
- AbstractConnectionSourceFactory.createSettings() now composes the same fallback-settings path
  create(name, configuration) uses, so it also applies the injected TenantResolver/customTypes.
- Deduplicated DatastoreUtils.executeWithNewSession's void-overload to delegate instead of
  copy-pasting the whole method body.

Split out (unrelated to SessionResolver infrastructure, reverted from this PR):
- KeyValueMappingContext's JpaMappingConfigurationStrategy -> GormMappingConfigurationStrategy
  swap - untested, no registry-related justification found.
- DirtyCheckingSupport's O(elements)/transitive dirty-checking change - algorithmic and semantic
  change, zero tests.
- AstUtils's annotation-copy dedup change - unrelated AST behavior change, no coverage.
- Dropped MappingContext.setMultiTenancyMode and ClassUtils.getIntegerFromMap - zero callers
  anywhere in the codebase.

Every touched class has new or updated Spock coverage, including the specific gaps the review
called out as untested: transaction precedence (resolver reads the same store as TSM), nested-session
restoration, concrete-datastore publisher wiring, and @PreDestroy cleanup. Full test sweep across
grails-datastore-core, grails-datamapping-core, grails-data-mongodb-core, grails-data-simple,
grails-data-hibernate5-core, and grails-data-hibernate7-core: BUILD SUCCESSFUL, 0 failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid

Copy link
Copy Markdown
Member Author

@jdaugherty Thanks for the thorough review — pushed a commit (23f6b48) addressing all of it.

Session/event architecture (your primary concern): SessionResolver/ThreadLocalSessionResolver no longer maintain independent ThreadLocal state. They're now a thin, stateless view over the same SessionHolder/TransactionSynchronizationManager store DatastoreUtils already uses, so resolve() can't disagree with the transactional session. doGetSession() no longer short-circuits through the resolver before transaction-sync registration and session validation — that shortcut was bypassing both, plus the allowCreate contract, as you flagged. hasCurrentSession() collapses to a single check now that both sides read the same state. Nested scopes fall out of SessionHolder's existing stack for free, so I dropped the unfinished, unused qualifier resolve(String)/bind(String, S) surface rather than redesigning a feature with zero callers anywhere in the codebase.

Event publisher: replaced the hand-rolled dispatch with one composing SimpleApplicationEventMulticaster. addApplicationListener() now routes through getApplicationEventPublisher() (virtual) instead of the raw field, so it reaches whatever publisher a subclass (Mongo/Hibernate/Neo4j) actually publishes through — fixes the disconnect you called out without touching those modules. Also fixed the triple-assignment and the setApplicationContext(null)-discards-a-custom-publisher bug.

API surface: Datastore.getSessionResolver() is now a default method — safe now that the resolver holds no private state to lose. MappingContext.initialize() is back to protected, not promoted onto the public interface; nothing needed the promotion (checked #15780/#15790, zero callers).

Split out of this PR (reverted, all untested and unrelated to SessionResolver): the KeyValueMappingContext JPA-strategy swap, the DirtyCheckingSupport O(elements)/transitive-dirty change, and the AstUtils annotation-dedup change. Also dropped setMultiTenancyMode/getIntegerFromMap — zero callers.

Other fixes: bindSession()/bindSession(creator) fail fast again on a double-bind (bindNewSession already provides the stacking behavior for executeWithNewSession, so nothing lost); CustomizableRollbackTransactionAttribute deep-copies rollback rules instead of aliasing the source's list, and now copies labels too; AbstractConnectionSourceFactory.createSettings() composes the same fallback-settings path create() uses, so it picks up the injected TenantResolver/custom types; @PreDestroy now closes every session it finds instead of dropping the reference; deduplicated the executeWithNewSession void-overload.

Every touched class has new/updated Spock coverage for exactly the gaps you called out as untested — transaction precedence, nested-session restoration, concrete-datastore publisher wiring, @PreDestroy cleanup. Full sweep across grails-datastore-core, grails-datamapping-core, grails-data-mongodb-core, grails-data-simple, grails-data-hibernate5-core, and grails-data-hibernate7-core: BUILD SUCCESSFUL, 0 failures.

I checked the blast radius against #15780/#15790 before touching the resolver: neither depends on anything here beyond Datastore#hasCurrentSession() (used in GormApiResolver.select()'s fallback), and that's now strictly more correct than before, not just differently shaped.

…coverage gaps

getTenantId()'s lazy fallback ignored the DISCRIMINATOR-mode check that
initialize() uses and NPE'd when persistentProperties was null under
deferred entity initialization. Also adds unit coverage for the
still-live gaps Codecov flagged after review: Datastore's default
getSessionResolver(), AbstractDatastore's reflective listener fallback,
its Object-payload event wrapping and destroy() error handling, the
bare-TransactionDefinition copy constructor, and the 3-arg
ConnectionSourceSettingsBuilder constructor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
borinquenkid added a commit that referenced this pull request Jul 10, 2026
PR1 (#15779) correctly reverted this AstUtils.copyAnnotations change as
unrelated scope creep on the SessionResolver PR, per jdaugherty's review.
But merging that revert into this branch broke ServiceTransformSpec and
MethodValidationTransformSpec: ServiceTransformation.groovy pre-annotates
generated method impls with @NotTransactional/@readonly before calling
copyAnnotations(method, methodImpl), and without the dedup guard, Groovy
rejects the resulting duplicate annotation outright.

This is where the fix actually belongs - it has real, previously-missing
test coverage now (AstUtilsSpec) plus the existing integration coverage
in ServiceTransformSpec/MethodValidationTransformSpec that demonstrably
fails without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@Override
void unbind() {
TransactionSynchronizationManager.unbindResourceIfPossible(datastore)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bind() pushes onto the existing SessionHolder, but unbind() removes the entire holder. This still breaks nested scope restoration: after bind(A); bind(B); unbind(), resolve() returns null instead of A, and both sessions are detached without either being closed. The new nested test currently asserts this destructive behavior. Please remove/close only the top session and leave the outer binding intact.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48c9c49. unbind() no longer calls unbindResourceIfPossible() unconditionally — it now pops and closes only the top session:

void unbind() {
    SessionHolder holder = (SessionHolder) TransactionSynchronizationManager.getResource(datastore)
    if (holder == null) {
        return
    }
    Session session = holder.getSession()
    if (session != null) {
        holder.removeSession(session)
    }
    if (holder.isEmpty()) {
        TransactionSynchronizationManager.unbindResourceIfPossible(datastore)
    }
    if (session != null) {
        DatastoreUtils.closeSessionOrRegisterDeferredClose(session, datastore)
    }
}

Same removeSession()/isEmpty()/closeSessionOrRegisterDeferredClose() sequence DatastoreUtils.executeWithNewSession already used, so it's consistent with the rest of the session-binding code rather than a new pattern.

The nested test now asserts restoration instead of the destructive behavior:

when:
resolver.bind(first)
resolver.bind(second)

then: "resolve() returns the most recently bound (top-of-stack) session"
resolver.resolve() == second

when:
resolver.unbind()

then: "unbind() closes and pops only the top session, restoring the outer binding"
1 * second.disconnect()
0 * first.disconnect()
resolver.resolve() == first

when:
resolver.unbind()

then: "unbinding the last remaining session closes it and clears the binding entirely"
1 * first.disconnect()
resolver.resolve() == null

bind(A); bind(B); unbind() now leaves resolve() == A, with only B closed — verified this actually passes locally, not just trusting the commit message.


public boolean hasCurrentSession() {
return TransactionSynchronizationManager.hasResource(this);
return sessionResolver.resolve() != null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasCurrentSession() uses the replaceable sessionResolver, but getCurrentSession() still delegates to DatastoreUtils.doGetSession(), which reads only TSM/SessionHolder. A custom resolver can therefore make this return true while getCurrentSession() throws or returns a different session. This also breaks DatastoreUtils.execute(), which branches here and then calls getCurrentSession(). Either remove the alternate resolver contract/setter or make both methods use the same authoritative resolution path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48c9c49. sessionResolver is now final, set once in the constructor to new ThreadLocalSessionResolver<>(this) — the alternate-resolver contract/setter you flagged (setSessionResolver()) is gone entirely; confirmed zero remaining references to it anywhere in the repo.

ThreadLocalSessionResolver no longer maintains independent state — its resolve() reads the exact same TransactionSynchronizationManager.getResource(datastore)/SessionHolder state that DatastoreUtils.doGetSession() (which getCurrentSession() delegates to) also reads. So hasCurrentSession() and getCurrentSession() are now guaranteed to agree — there's no longer a second source of truth for either of them, or for DatastoreUtils.execute()'s branch, to disagree over.

else if (publisher instanceof MulticasterApplicationEventPublisher) {
((MulticasterApplicationEventPublisher) publisher).addApplicationListener(listener);
}
else if (publisher != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still silently loses listeners for any valid ApplicationEventPublisher that does not expose the unrelated addApplicationListener method; logging does not satisfy this API contract. The new test explicitly accepts the drop with only noExceptionThrown(). Please retain listeners in a datastore-owned multicaster and use the configured publisher as an outbound delegate, or narrow the accepted publisher type so registration cannot silently succeed without delivery.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48c9c49, along the second option you suggested (narrow what can silently succeed, rather than retain-and-delegate): the reflective fallback now throws IllegalStateException instead of logging and swallowing when the publisher exposes no addApplicationListener(ApplicationListener) method, so a caller can no longer register a listener that will never fire without finding out immediately.

else if (publisher != null) {
    try {
        Method method = publisher.getClass().getMethod("addApplicationListener", ApplicationListener.class);
        method.invoke(publisher, listener);
    }
    catch (Exception e) {
        throw new IllegalStateException("Could not register application listener [" + listener + "] with publisher [" +
                publisher + "]: it does not expose an addApplicationListener(ApplicationListener) method", e);
    }
}

The test was rewritten to match — it no longer accepts the drop with noExceptionThrown(); it now asserts the throw:

void "addApplicationListener fails loudly rather than silently dropping the listener when the publisher exposes no addApplicationListener method"() {
    ...
    when:
    datastore.addApplicationListener(listener)

    then: "the caller finds out immediately that the listener will never receive events, instead of it being silently dropped"
    thrown(IllegalStateException)
}

borinquenkid and others added 2 commits July 10, 2026 13:48
…tDatastore

Three remaining issues from the re-review of #15779:

- ThreadLocalSessionResolver.unbind() called unbindResourceIfPossible(),
  discarding the whole SessionHolder instead of popping just the top
  session. bind(A); bind(B); unbind() lost A entirely instead of
  restoring it, and neither session was closed. unbind() now pops and
  closes only the top session via the same
  removeSession()/isEmpty()/closeSessionOrRegisterDeferredClose() path
  DatastoreUtils.executeWithNewSession already uses, leaving the outer
  binding intact. The nested-scope test previously asserted the
  destructive behavior as correct; it now asserts restoration.

- AbstractDatastore.hasCurrentSession() read the swappable sessionResolver
  field, while getCurrentSession() read TSM/SessionHolder directly via
  DatastoreUtils.doGetSession() - a caller-installed custom resolver could
  make these two methods disagree. setSessionResolver() had zero callers
  anywhere in the codebase (confirmed via search), so removed it and made
  sessionResolver final: both methods are now guaranteed to read the same
  authoritative state.

- addApplicationListener()'s reflective fallback silently logged and
  swallowed registration failures for a plain ApplicationEventPublisher
  with no addApplicationListener method, so a caller had no way to know
  the listener would never fire. It now throws IllegalStateException
  instead of silently succeeding from the caller's perspective.

Every fix has updated Spock coverage. Full grails-datastore-core,
grails-datamapping-core, and grails-data-simple suites pass; codeStyle/
CodeNarc clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…enantId()'s lazy fallback

PR #15779's getTenantId() lazy DISCRIMINATOR-mode fallback (lines 101-108, added to fix
jdaugherty's review comment about the eager-only lookup) had 25% Codecov patch coverage - 5
missing lines and 1 partial branch - despite AbstractPersistentEntityGetTenantIdSpec already
existing. Root cause: that spec's existing tests all configure DISCRIMINATOR mode *before*
adding the entity, so initialize()'s eager loop (line 165-169) already assigns tenantId by the
time getTenantId() runs, and the new lazy block's `this.tenantId == null` guard is never true.

Added 4 tests that switch the context into DISCRIMINATOR mode *after* the entity is already
initialized (an entity added while still in NONE mode never runs the eager assignment, so
tenantId stays null even once DISCRIMINATOR mode is applied later) - the exact scenario the
lazy fallback exists for:
- successful lazy match (drives the loop's find-and-break path)
- no tenantId property present (drives the loop's exhaust-without-match path; this needed NONE
  mode at initialize() time since DISCRIMINATOR mode at that point makes initialize() itself
  throw ConfigurationException for a multi-tenant class with no tenant identifier property)
- a plain non-multi-tenant entity (drives the isMultiTenant()==false short-circuit branch of
  the compound guard, the one remaining uncovered branch outcome after the above)

Verified via local JaCoCo: lines 101-110 (the PR's new code) now have 0 missed instructions and
0 missed branches, up from 5 missing lines/1 partial branch. Full grails-datastore-core suite
and codeStyle both pass with no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@testlens-app

testlens-app Bot commented Jul 12, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: ce346f0
▶️ Tests: 53244 executed
⚪️ Checks: 59/59 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants