Skip to content

feat: GORM O(M+N) scaling — GormRegistry, SessionResolver infrastructure, and core-class tests (consolidates #15779, #15780, #15790) - #16066

Open
borinquenkid wants to merge 101 commits into
apache:8.0.xfrom
borinquenkid:feat/gorm-registry-consolidated
Open

feat: GORM O(M+N) scaling — GormRegistry, SessionResolver infrastructure, and core-class tests (consolidates #15779, #15780, #15790)#16066
borinquenkid wants to merge 101 commits into
apache:8.0.xfrom
borinquenkid:feat/gorm-registry-consolidated

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

feat: GORM O(M+N) scaling — GormRegistry, SessionResolver infrastructure, and core-class tests (consolidates #15779, #15780, #15790)

Why one PR

The previous 3-PR stack (#15779 infra → #15780 implementation → #15790 tests) generated review churn because the infrastructure PR added public API whose callers lived one PR downstream, so "no caller in this PR" objections and "the tests are elsewhere" objections could not both be answered at once. Per discussion with @jdaugherty, the stack is consolidated into this single PR: every new API lands next to its consumer and its tests.

Supersedes and closes #15779, #15780, #15790.

Summary

Extracts all per-entity, per-qualifier GORM API state out of GormEnhancer into a GormRegistry singleton. GormEnhancer becomes a thin facade and delegates entity registration, API lookup, and datastore lifecycle to the registry. APIs are created by a pluggable GormApiFactory and looked up by (entityClass, qualifier) at call time — collapsing up-front API allocation from O(entityCount × tenantCount) to O(entityCount + tenantCount), with per-(entity, qualifier) APIs materialized lazily on first use.

Modules touched: grails-datamapping-core (registry + enhancer, bulk of the diff), grails-datastore-core (session-resolution infrastructure below), Hibernate 5/7, MongoDB, Simple adapters (minimal wiring; the full adapter migrations remain follow-up PRs), plus TCK/test-example updates.

grails-datastore-core infrastructure (formerly #15779)

  • SessionResolver + TransactionSynchronizationSessionResolver: a stateless view over the existing SessionHolder/TransactionSynchronizationManager state — one authoritative session store, no parallel bookkeeping. resolve() performs the same validation housekeeping as doGetSession (evicts disconnected sessions, unbinds a holder emptied by eviction unless a transaction owns it).
  • AbstractDatastore: lazy resolver accessor; destroy() closes thread-bound sessions via DatastoreUtils.closeSession, skipping holders owned by an active transaction; hasCurrentSession() now agrees with getCurrentSession() (validated-session semantics); publisher wiring no longer routes through the deprecated getApplicationContext().
  • DatastoreUtils: new executeWithNewSession(..) overloads (used by GormStaticApi); execute/doWithSession now stack via bindNewSession and clean up via the one canonical unbindSession, so a bound-but-empty holder can never fail a later bind.
  • SessionHolder.getSessions() (used by destroy()), AbstractConnectionSourceFactory fallback-settings extraction, MultipleConnectionSourceCapableDatastore (used by GormApiResolver/AbstractGormApi/GormRegistry), and an AstUtils.copyAnnotations dedup guard needed by ServiceTransformation (with AstUtilsSpec coverage).

Note on SessionResolver.bind()/unbind(): resolve() is what core consumes (via hasCurrentSession()); bind/unbind complete the SPI contract that the per-adapter follow-up PRs implement (e.g. a Hibernate resolver bridging native SessionFactory-keyed bindings). They are specified, tested, and small; flagging rather than hiding that their production callers arrive with the adapter PRs.

Review response (2026-07-29 round on #15779)

  1. Scope: Query.java, Service.groovy, DefaultServiceRegistrySpec, the AbstractPersistentEntity.getTenantId() fallback + isMultiTenant swap, and the createPropertyResolver cast are reverted. The CustomizableRollbackTransactionAttribute copy-semantics change is split into its own PR (fix: preserve full transaction attribute state in CustomizableRollbackTransactionAttribute copy constructors #16063, branch fix/customizable-rollback-tx-attribute-copy) with the reworked implementation per review (Spring copy constructors, label independence, no lazy-getter mutation of the source, timeoutString preserved, the GString-in-.java log fixes) and behavior-level tests through GrailsTransactionTemplate/DefaultTransactionService. The same lossy-copy-constructor pattern was also found in two sibling classes and fixed in follow-up PRs fix: preserve full transaction attribute state in GrailsTransactionAttribute (GORM) copy constructors #16064 (grails.gorm.transactions.GrailsTransactionAttribute) and fix: preserve full transaction attribute state in GrailsTransactionAttribute (web) copy constructors #16065 (org.grails.transaction.GrailsTransactionAttribute).
  2. API duplication: the unbind sequence now exists exactly once (DatastoreUtils.unbindSession); executeWithNewSession, execute, doWithSession, and TransactionSynchronizationSessionResolver.unbind() all delegate to it (also fixing the TSM-key mismatch for sessions owned by a child datastore — regression test added). The AbstractDatastore event-publisher machinery (third publisher implementation + reflective listener registration) is removed entirely rather than moved: nothing in this stack or the adapter follow-ups calls it; concrete datastores keep publishing through their own ConfigurableApplicationEventPublisher, as today. getApplicationEventPublisher() stays null when no context is configured (no per-query event allocation for bare datastores — unchanged from 8.0.x).
  3. Speculative API: removed as caller-less — Datastore.getSessionResolver() default method (allocated per call), AbstractConnectionSourceFactory.createSettings(PropertyResolver), the 3-arg ConnectionSourceSettingsBuilder constructor.

Inline-comment items: constructor this-escape fixed (lazy resolver); bind() rejects a session owned by a different datastore (spec added); resolve() uses validated sessions with disconnected-session and empty-holder specs; resolver class renamed since it holds no ThreadLocal; interface generics dropped; @author tags use a real name; unbind() keeps its name (matching DatastoreUtils.unbindSession, to which it now delegates) with the close-on-unbind contract stated explicitly in the interface javadoc.

Fixes from the post-consolidation contrarian review

An adversarial multi-agent review of the consolidated branch surfaced and fixed:

  • DatastoreUtils.execute/doWithSession could throw IllegalStateException("Already value bound") when a bound-but-empty SessionHolder remained on the thread; they now stack via bindNewSession and release via unbindSession (spec added).
  • TransactionSynchronizationSessionResolver.resolve() left an emptied holder bound (poisoning later binds) and returned null when a valid session sat beneath a stale one; it now resolves down the stack and unbinds an emptied non-transactional holder (specs added, including the transaction-owned case).
  • GormStaticApi.withStatelessSession had been rewired through executeWithNewSession, silently handing out stateful sessions and dropping the UnsupportedOperationException guard; baseline connectStateless() behavior restored.
  • GormStaticApi.saveAll force-flushed mid-transaction, deviating from the 8.0.x baseline it claimed to restore; the flush is removed.
  • TenantDelegatingGormOperations.delete(instance, params) called save instead of delete — a pre-existing 8.0.x data-integrity bug in a touched class; fixed with a delegation spec that would have caught it.
  • Cross-datastore tenant bleed: three selector paths in GormApiResolver read the tenant via no-arg CurrentTenantHolder.get() (an arbitrary datastore's tenant); they now pass the datastore being evaluated.
  • Registry races: registerEntityDatastores rebuilt an entity's routing map with remove-then-repopulate (a concurrent lookup could observe no routing and fall back to the wrong datastore) — it now publishes the rebuilt map atomically; AbstractGormApiRegistry.getDirect could cache a qualified API derived from a superseded default API indefinitely — it now re-validates after publishing and retracts if superseded.
  • Memory: GormRegistry.removeDatastore now clears the normalization caches (Class-keyed keys retain classloaders across dev reloads; the qualifier cache grew per tenant-ID ever seen) once the last datastore is gone.
  • The ActiveSessionDatastoreSelector ≤10-datastore fallback scan is now documented as the only discovery path for non-Datastore-keyed sessions (e.g. Hibernate's SessionFactory keying) and as intentionally bounded.

Known limitations (deliberate, documented)

  • Steady-state memory is still O(entities × qualifiers-actually-used) — lazily materialized, never eagerly allocated. The eager win is allocation/startup, not asymptotic worst-case residency.
  • Beyond 10 registered datastores, unbound non-transactional Hibernate sessions are not discovered by the selector fallback (routing falls back to the entity's DEFAULT datastore); deployments at that scale should bind sessions explicitly (transactions / Tenants.withId).
  • The six per-adapter follow-up branches predate this consolidation's resolver rework (renamed class, no setter, private field) and must be rebased onto it before review.

Test plan

  • ./gradlew :grails-datastore-core:test :grails-datamapping-core:test :grails-data-simple:test — 0 failures
  • ./gradlew :grails-data-hibernate5-core:test :grails-data-hibernate7-core:test :grails-data-mongodb-core:test — 0 failures
  • codeStyle (Checkstyle + CodeNarc) clean

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 29, 2026 17:58

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@borinquenkid

Copy link
Copy Markdown
Member Author

The TestLens failures for GrailsUtilStackFiltererSpec > installed DefaultStackTraceFilterer emits Full Stack Trace by default and GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer are a pre-existing bug on 8.0.x, unrelated to this PR's changes -- reproducible on plain 8.0.x today. DefaultStackTraceFilterer.STACK_LOG routes through a jcl-over-slf4j commons-logging binding, so the tests' System.setErr() capture never observes the emitted message.

Fix: #16067 (replaces the System.err capture with a Logback appender attached directly to the logger). Once that merges, rebasing onto 8.0.x should clear this failure here.

borinquenkid and others added 25 commits July 29, 2026 18:29
…m.err

DefaultStackTraceFilterer.STACK_LOG routes through commons-logging, which
resolves to a jcl-over-slf4j binding on this classpath -- so its output never
touches System.err, regardless of test ordering or timing. Swapping
System.err therefore never observes the emitted message, making
GrailsUtilStackFiltererSpec and GrailsBootstrapRegistryInitializerSpec fail
deterministically. Attach a ListAppender directly to the public STACK_LOG_NAME
logger instead, which is unaffected by which commons-logging backend wins the
classpath.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ystem.err capture

Address jamesfredley's review on apache#16067: this was the one remaining latent
instance of the same fragility -- three sites asserting on rendered
System.err output for the STACK_LOG and GrailsExceptionResolver loggers,
including one match on the literal console layout string 'ERROR StackTrace '.
It passed only because grails-web-mvc's test classpath happened to carry
slf4j-simple, whose SYS_ERR output choice re-reads System.err per call rather
than caching it -- the same trap that broke these tests in grails-core once
that module got a deterministic logback-test.xml.

Swap the module's test logging binding from slf4j-simple to grails-core's
test fixtures (which bring logback-classic transitively), and rewrite the
three specs to assert on captured ILoggingEvents instead of console text --
including inspecting each event's throwableProxy stack frames directly
rather than counting substrings in rendered output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
…hitecture

Responds to jdaugherty's CHANGES_REQUESTED review (apache#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
(apache#15780/apache#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>
…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>
…tDatastore

Three remaining issues from the re-review of apache#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 apache#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>
…refactor

Introduce GormRegistry singleton replacing O(M×N) static maps in GormEnhancer.
APIs are registered once at entity-registration time and looked up in O(1).

- GormRegistry: singleton keyed by (entityClass, qualifier); handles MultiTenant
  qualifier expansion, thread-local preferred datastore, and concurrent-safe removal
- GormApiFactory / DefaultGormApiFactory: pluggable factory per datastore type
- GormApiResolver: routes static/instance/validation API lookups through the registry
- GormEnhancer: delegates all registration and lookup to GormRegistry
- GormStaticApi / GormInstanceApi / GormValidationApi: use DatastoreResolver instead
  of holding a direct Datastore reference; support qualifier-aware execution
- AbstractGormApi.execute(): distinguishes datasource connection qualifiers from
  tenant-ID qualifiers to avoid overwriting the active tenant context
- CurrentTenantHolder: thread-safe tenant binding for DISCRIMINATOR multi-tenancy
- ServiceTransformation / TransactionalTransform: resolve transaction manager via
  GormRegistry instead of static map lookups

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

HibernateGormEnhancer (H5 and H7) both declare @OverRide registerConstraints
as a no-op. The scaling commit's GormEnhancer refactor omitted this protected
hook method, making the @OverRide annotation invalid and causing a Java stub
compilation error: "method does not override or implement a method from a
supertype".

Restores the original implementation (loads ConstraintRegistrar via reflection
if present) and calls it from the constructor, consistent with the pre-scaling
behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ropped by scaling refactor

GormEnhancer: restore protected registerConstraints(Datastore) hook that H5/H7
HibernateGormEnhancer override as a no-op. Its absence broke Java stub
generation with "@OverRide … method does not override a supertype method".

MongoStaticApi: restore persistentEntity and multiTenancyMode fields that
GormStaticApi no longer carries after the scaling refactor. Initialise
persistentEntity from the mapping context and multiTenancyMode from
MongoDatastore.getMultiTenancyMode() so wrapFilterWithMultiTenancy and
preparePipeline compile under @CompileStatic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…stractGormApi.execute()

The GORM scaling commit introduced a non-default qualifier path in execute() that
unconditionally called Tenants.withId(datastore, qualifier) for multi-tenant entities.
This was correct for DATABASE mode (qualifier == tenant ID == connection name) but
broke DISCRIMINATOR mode: when a @service with @transactional(connection='secondary')
executed a query, 'secondary' was bound as the current tenant ID instead of the real
tenant from the TenantResolver, causing discriminator filters to match 'secondary' and
return 0 rows.

Fix: probe getDatastoreForConnection(qualifier) to determine whether the qualifier
names a real datasource connection. If it resolves (non-null), it is a connection name
— fall through to executeQualified without touching the tenant context. If it throws or
returns null, the qualifier is a tenant ID (e.g. from withTenant()) — bind it via
Tenants.withId as before.

Update GormRegistrySpec to explicitly stub getDatastoreForConnection(_) >> null on the
DISCRIMINATOR-mode test stub, mirroring real HibernateDatastore behaviour (which throws
ConfigurationException for unknown connection names) and avoiding Spock's covariant-
interface default of returning the stub itself.

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

findStaticApi/InstanceApi/ValidationApi: the tenant-lookup at priority-2 only
checked CurrentTenantHolder.  In DATABASE and SCHEMA modes the tenant ID is never
stored there explicitly — it comes from the TenantResolver (e.g. a subdomain or
system-property resolver).  Consult the resolver for those strict modes so that
per-tenant child APIs are selected correctly even when no tenant has been bound
via Tenants.withId().  Guard with TenantNotFoundException propagation so missing
tenants surface as errors rather than silently falling back to the default API.
Also skip the API redirect when tenantId equals 'default' to avoid self-loops.

createStaticApi / createInstanceApi / createValidationApi: replace the caller-
supplied DatastoreResolver with a bound lambda that always returns the specific
Datastore captured at registration time.  The old resolver was evaluated lazily
at call time and could invoke tenant-resolution logic before any tenant context
was active, causing spurious TenantNotFoundException during bootstrapping.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k lines in HibernateGormEnhancer

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

Adapter subclasses (SimpleMapDatastore, HibernateGormEnhancer, etc.) override
getStaticApi/getInstanceApi/getValidationApi/createDynamicFinders as protected
extension points. Removing them in the core refactor breaks compilation of those
adapters until their own PRs are merged. Restore as @deprecated stubs delegating
to GormRegistry so the adapter modules compile against this PR in isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adapter modules (hibernate5, converters, graphql) reference static methods
and constructors removed in the core refactor. Restore them as @deprecated
stubs delegating to GormRegistry so all adapters compile against this PR
in isolation, without requiring the full stack to be merged together:

- GormEnhancer: add 2-arg (Datastore, TxManager) constructor; static
  findStaticApi, findInstanceApi, findValidationApi, findDatastore delegates
- GormStaticApi: add (Datastore, finders) and (Datastore, finders, TxManager)
  deprecated constructors extracting MappingContext from the Datastore
- AbstractGormApi: restore deprecated persistentEntity field populated in
  both constructor paths so @CompileStatic subclasses (AbstractHibernateGorm*)
  can still read it directly

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

HibernateGormStaticApi (H7) assigns this.datastore = datastore in its
constructor, requiring a setDatastore() setter. AbstractDatastoreApi now
provides a deprecated setter that swaps the resolver to a StaticDatastoreResolver.
Also fix CodeNarc MissingBlankLineBeforeAnnotatedField for persistentEntity.

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

Three targeted fixes that eliminate H5/H7 runtime NPEs introduced by the
GormRegistry refactor:

1. Remove setDatastore(Datastore) from AbstractDatastoreApi — adding a public
   setter for 'datastore' caused Groovy @CompileStatic to route constructor
   field assignments (e.g. this.datastore = hds in AbstractHibernateGorm-
   ValidationApi) through the setter instead of the declared local field,
   leaving that field null and causing ValidationEvent.<init> to throw
   IllegalArgumentException: null source.

2. Fix deprecated GormStaticApi(Class, Datastore, List[, PlatformTransactionManager])
   constructors to wire a real DatastoreResolver closure instead of null,
   so getDatastore() returns the correct Datastore at runtime for H5/H7
   adapters that still call these constructors.

3. Fix GormEnhancer.addStaticMethods mc.static.propertyMissing to convert
   any non-MissingPropertyException (e.g. ConfigurationException from H5's
   HibernateGormStaticApi.propertyMissing treating the name as a datasource
   qualifier) into MissingPropertyException so Groovy can fall through to
   methodMissing for dynamic finder dispatch (e.g. Person.countByTitle).

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

Without setDatastore(Datastore) in AbstractDatastoreApi, getDatastore()
makes 'datastore' a read-only property for classes without a local
datastore field. HibernateGormStaticApi had no local datastore field, so
this.datastore = datastore failed @CompileStatic compilation. The
assignment was already redundant — the deprecated super constructor wires
a DatastoreResolver that returns the correct HibernateDatastore.

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

Without a specialized GormApiFactory registered for MongoDB (only H5/H7 register
HibernateGormApiFactory via registerConstraints), GormRegistry.registerEntity fell
back to DefaultGormApiFactory, which created base GormStaticApi instead of
MongoStaticApi — causing ClassCastException in MongoEntity.currentMongoStaticApi().

When no specialized factory is registered for the datastore, delegate to the
enhancer's overridden getStaticApi/getInstanceApi/getValidationApi methods, which
polymorphically dispatch to adapter-specific anonymous subclass overrides (e.g.
MongoDatastore's anonymous MongoGormEnhancer override that creates MongoStaticApi).

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

Repairs regressions introduced by this PR's GormRegistry O(M+N) refactor in
grails-datamapping-core. All four production fixes correct code this PR
introduced or rewrote:

- GormRegistry.registerEntity: always create the entity APIs via the
  GormApiFactory instead of falling back to the deprecated
  GormEnhancer.getStaticApi stub, which performs a registry lookup that
  returns null at registration time and left default-factory datastores with
  no registered APIs.
- GormEntity.staticPropertyMissing: resolve the static API directly and throw
  MissingPropertyException on null, rather than relying on catching an
  IllegalStateException from a dynamically dispatched currentGormStaticApi()
  call (which escaped the catch under invokedynamic). currentGormStaticApi /
  currentGormInstanceApi made private again to match the prior API surface.
- ServiceTransformation: generate getDatastore() to resolve via
  GormRegistry.getDatastore(domainClass), which returns null when GORM is not
  configured, restoring the null-tolerant contract the generated service
  infrastructure (validator factory, transaction manager) depends on.
- TransactionalTransform.hasTransactionalAnnotation: treat @NotTransactional
  as an explicit transactional decision so the read/write service
  implementers do not impose a default @ReadOnly/@transactional, which
  otherwise wrapped @NotTransactional methods in a transaction template and
  forced transaction-manager resolution before method validation could run.

Tests updated to the new architecture: GormEnhancerAllQualifiersSpec rewritten
against GormRegistry; service specs adjusted for protected finder methods,
the removed datastore backing field on domain-targeted services, and the
trait-woven datastore accessors whose @generated marker is managed by Groovy
trait weaving.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the structural O(M+N) GormRegistry rewrite (089ca43) against the
allocation/TCK specs, fixing 8 root causes uncovered while driving the Hibernate5
core suite from 42 failures to 6.

Core regressions reintroduced by the rewrite:
- GormInstanceApi.save: restore markDirty() so an explicit save() re-persists a
  clean/detached instance (ExplicitSaveRepersistsSpec).
- GormStaticApi.withDatastoreSession: run the GORM Session directly instead of
  delegating to withSession (which adapters override to the native session)
  (GormStaticApiWithDatastoreSessionSpec).
- GormStaticApi string-query overloads (executeQuery/executeUpdate/find/findAll
  over CharSequence): delegate convenience overloads to the terminal overload and
  restore the unsupported() helper, so adapter HQL overrides are reachable instead
  of throwing UnsupportedOperationException (GormStaticApiStringQueryDelegationSpec).
- GormStaticApi first/last: restore the default sort by the identity property when
  no sort is supplied.
- ListOrderByFinder: resolve the sort direction before applying order so an explicit
  order:'desc' argument is honored.

Allocation reconciliation:
- GormRegistry.registerEntity eagerly warms APIs for an entity's explicitly-mapped
  datasources (bounded M side); ALL/tenant qualifiers stay lazy (unbounded N side).
- AbstractGormApiRegistry.isAllocated(className, qualifier): introspection of whether
  an API is materialized without triggering lazy creation (GormEnhancerAllQualifiersSpec
  eager/lazy tests, GormApiAllocationSpec).
- GormApiAllocationSpec: verify per-tenant API identity via the tenant qualifier rather
  than a tenant-less DEFAULT lookup; strict-mode DEFAULT resolution intentionally still
  throws TenantNotFoundException (load-bearing safety).

Pre-existing bug surfaced (also present in feat/gorm-datastore-infra):
- HibernateSession.retrieveAll: drop the redundant outer criteriaBuilder.in(...) wrapper
  that emitted a malformed `id in (..) in ()` (QuerySyntaxException) for getAll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…akiness

CI flagged this test as failed on a shared macos-latest runner (TestLens,
commit 0c3493b, PR apache#16066); a local rerun of the same commit passed cleanly
in ~2s, and the registry hot paths under test are lock-free ConcurrentHashMap
operations with no correctness dependency on wall-clock time. The 5s
completion budget was too tight for a noisy CI runner, so bump it to 10s
(and the surrounding @timeout to 15s) to tolerate scheduling jitter without
weakening what the test actually verifies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid borinquenkid moved this to Todo in Apache Grails Jul 30, 2026
@borinquenkid borinquenkid added this to the grails:8.0.0-RC2 milestone Jul 30, 2026
@jdaugherty
jdaugherty self-requested a review August 2, 2026 17:59
@jdaugherty jdaugherty self-assigned this Aug 2, 2026

@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.

Initial AI Review seems to have found a few issues (including breaking tests):

Thanks for consolidating the stack — having the API, its consumer and its tests in one place does make the registry design reviewable in a way the 3-PR split wasn't.

I've gone through the whole diff. The GormRegistry / GormApiFactory / DatastoreResolver core is a sound shape and the lazy (entity, qualifier) materialization is the right call. Most of my comments below are about things that came along for the ride and about behaviour changes that aren't called out in the description.

Three things I'd like resolved before this can move forward:

  1. No documentation. 175 files and not one line in grails-doc. This PR adds public API (grails.gorm.multitenancy.CurrentTenantHolder, Tenants.withTenant(..), Tenants.withId(Class, ..), GormEntity.deleteAll()/deleteAll(Map), four new GormStaticOperations.deleteAll overloads, SessionResolver, MultipleConnectionSourceCapableDatastore.routesUnqualifiedToMappedConnection()) and removes public API (GormEnhancer.enhance(..), GormEnhancer.finders/transactionManager/dynamicEnhance, the 1-arg and 5-arg GormEnhancer constructors, GormStaticApi.multiTenancyMode, Tenants.withId(Class<? extends Datastore>, ..)). Every settable/callable thing we ship has to be documented, and the removals need upgrade notes — GormEnhancer is the extension point every out-of-tree GORM implementation subclasses, so "it's a major version" isn't sufficient on its own.

  2. Unrelated fixes bundled in. getAll() ordering/null-slot semantics (both Hibernate 5 and 7), the HibernateQuery junction overrides, CriteriaBuilder.call(Closure) + the ensureQueryIsInitialized() additions, and the removeConstraints() rewrite are all independent behaviour changes. Several look correct and worth having, which is exactly why they should land as their own reviewable/revertable PRs rather than inside a 17k-line refactor. Please split them out.

  3. A TCK test is being excluded rather than fixed — see the FirstAndLastMethodSpec comment.

CI being green is necessary but not sufficient here: grails-datamapping-rx is commented out of settings.gradle, so the Tenants.withId overload change isn't compiled anywhere, and the multi-tenant routing paths added in GormApiResolver are mostly covered by specs that drive the internal selectors directly rather than by an app-level scenario.

*/
static <T> T withId(Class<? extends Datastore> datastoreClass, Serializable tenantId, Closure<T> callable) {
Datastore datastore = GormEnhancer.findDatastoreByType(datastoreClass)
static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> callable) {

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 replaces withId(Class<? extends Datastore> datastoreClass, Serializable, Closure) with withId(Class domainClass, Serializable, Closure). Both erase to withId(Class, Serializable, Closure), so every existing caller that passes a datastore class keeps compiling and silently changes meaning — it now goes through datastoreLocator.getDatastoreForDomain(..) and resolves the datastore class as if it were a domain class.

There are already ~40 call sites of exactly that shape in-tree: RxGormStaticApi:596 (Tenants.withId((Class<RxDatastoreClient>) datastoreClient.getClass(), tenantId) { .. }) and TenantDelegatingRxGormOperations, which pass datastoreClientClass. grails-datamapping-rx is commented out in settings.gradle, so CI never compiles or runs them and won't catch this.

Please keep the datastore-class overload and give the domain-class variant a distinct name (withIdForDomain, or take the entity as a typed parameter). A silent same-erasure meaning change on a documented public multi-tenancy entry point isn't something users can be expected to notice.

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.

Traced the actual blast radius before picking a fix. The ~40 call sites cited as evidence (RxGormStaticApi, TenantDelegatingRxGormOperations) turned out to be a false positive — grails-datamapping-rx has its own, fully independent grails.gorm.rx.multitenancy.Tenants operating on RxDatastoreClient, untouched by this PR (grepped every import in that module to confirm zero references to the core Tenants class).

The real clash is in TenantDelegatingGormOperations's ~100 Tenants.withId((Class<Datastore>) datastore.getClass(), ...) calls, but that class turns out to be dead code on this branch: GormStaticApi.withTenant(Serializable) used to construct it directly, and this PR's rewrite replaced that with forQualifier(tenantId.toString()) instead — nothing else in the tree constructs it except its own unit test. Fixed anyway since it's cheap and removes the landmine if it's ever wired back up: added requireMultiTenantCapableDatastore() and switched all 100 sites to the existing, unambiguous Tenants.withId(MultiTenantCapableDatastore, Serializable, Closure) overload rather than a withIdForDomain rename — the domain-class overload has no live erasure conflict anywhere reachable, so a rename felt like a needless public API break for a theoretical case. Tenants.withId(Class domainClass, ...) itself is untouched.

Commit 4b116c2166. Also verified the withTenant() decorator→forQualifier mechanism swap doesn't change runtime behavior — GormRegistrySpec's DISCRIMINATOR-mode tests and PartitionedMultiTenancySpec's DATABASE-mode assertion both exercise the no-closure chained form against it and pass.

}

@Override
Number deleteAll(Map params) {

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.

deleteAll(Map params) discards params entirely and delegates to the unqualified deleteAll(). The interface javadoc added in GormStaticOperations says "Deletes all objects for the given arguments", so Book.deleteAll(flush: true) — or anything a user reasonably writes to narrow the delete — silently deletes every row instead.

Separately, I'd like explicit agreement on adding deleteAll()/deleteAll(Map) to GormStaticOperations and GormEntity at all. A no-argument "delete every row of this table" static on every domain class is a significant and destructive addition to the entity API surface; it isn't mentioned in the PR description and it isn't needed by the registry refactor. Either drop it from this PR and propose it on its own, or implement params properly (at minimum flush) and document both methods in grails-doc.

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.

Took the implement option, not drop — this is your own stated bar, and your fix/gorm-registry-review-feedback branch already met it, so matched it rather than reinventing: deleteAll(Map params) now honors params?.flush instead of discarding params, both deleteAll()/deleteAll(Map) are documented in grails-doc (new 'Deleting Every Instance' section in basicCRUD.adoc with an explicit destructive-semantics warning and a pointer to .where{}.deleteAll() for partial deletes), and TenantDelegatingGormOperations gets matching delegators via its own requireMultiTenantCapableDatastore() pattern (see the Tenants.withId thread) rather than a class-based fallback.

Commit e011e0896c. 3 new specs in GormStaticApiSpec (basic delete-everything, Map-returns-count, flush-honored via a Session mock) + 2 in TenantDelegatingGormOperationsSpec.

def cls = e.javaClass
ExpandoMetaClass mc = MetaClassUtils.getExpandoMetaClass(cls)

mc.static.methodMissing = { String name, args ->

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.

registerEntity now calls addStaticMethods/addInstanceMethods unconditionally for every non-external entity, and those methods install catch-all mc.static.methodMissing, mc.static.propertyMissing, mc.methodMissing and both mc.propertyMissing variants on the entity's ExpandoMetaClass.

On 8.0.x this did not happen. addStaticMethods/addInstanceMethods were only reachable from enhance(..), enhance() was gated on dynamicEnhance, the settings constructor hard-coded this.dynamicEnhance = false, and nothing in the tree calls enhance(..). So this is new global metaclass mutation on the bootstrap path, not a like-for-like replacement.

Three consequences I'd like addressed:

  • Assigning mc.methodMissing / mc.propertyMissing overwrites any handler an application or another plugin installed on that domain class. There's no merge or delegation to a previous handler.
  • GormEntity already declares methodMissing, propertyMissing and staticPropertyMissing as real trait methods. An EMC closure takes precedence, so for Groovy entities the trait implementations become unreachable and the two dispatch paths can diverge.
  • It forces an ExpandoMetaClass for every persistent entity at startup, and every dynamic-finder call now pays a metaclass miss plus registry.findStaticApi(cls, null) plus a dynamic api.invokeMethod(..). methodMissing results aren't installed into the metaclass the way registerInstanceMethod was, so that cost is per call, not once. For a PR whose headline is scaling, please include numbers for the steady-state dynamic-finder path, not just startup allocation.

If the goal is only to stop eagerly allocating APIs, this metaclass change looks separable from that and I'd rather see it justified on its own.

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.

Restored the dynamicEnhance-gated bootstrap exactly as it is on 8.0.x: registerEntity no longer calls addStaticMethods/addInstanceMethods unconditionally, enhance()/enhance(entity) are back and gated on dynamicEnhance (hard-coded false in the settings constructor, matching 8.0.x's own quirk), and nothing in the tree calls enhance(..) — confirmed by grepping the whole real 8.0.x tree, not just this PR's diff, for any caller that ever flips dynamicEnhance true. GormEntity's own trait methodMissing/propertyMissing hooks already cover all dispatch (83 spec classes stayed green with zero ExpandoMetaClass installed). Commit da09401c4d.

Separately adopted your instance-side clobber guard on addInstanceMethods (backs off with a debug log if a methodMissing handler is already installed) — commit 2dd18808ff. I did not adopt the static-side guard (getStaticMetaMethod('methodMissing', ...)): wrote a few isolated ExpandoMetaClass probe scripts (no GORM involved) and confirmed getStaticMetaMethod can never see a static methodMissing handler installed via the standard mc.static.methodMissing = {...} idiom, regardless of install order — even though the handler is functionally live (invokeStaticMethod proves it dispatches). Your branch has the identical static-side check; it looks like it never actually fires there either. Happy to share the repro scripts if useful — didn't want to ship a check that looks protective but is dead code.

public boolean hasCurrentSession() {
return TransactionSynchronizationManager.hasResource(this);
return getSessionResolver().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() goes from a pure TransactionSynchronizationManager.hasResource(this) check to getSessionResolver().resolve() != null, and resolve() mutates thread-bound state: it evicts disconnected sessions from the SessionHolder and calls unbindResourceIfPossible(datastore) when the holder ends up empty and isn't transaction-synchronized.

So a predicate now has side effects, and it's called from places that are purely interrogative — DatastoreUtils.execute/doWithSession, and ActiveSessionDatastoreSelector, which calls hasCurrentSession() on every registered datastore (up to the documented 10) while deciding where to route. A routing probe for entity A can now unbind datastore B's holder as a side effect.

I'd like the validating/evicting behaviour kept in resolve() and out of hasCurrentSession(), or a separate non-mutating query used for the routing scan. If agreeing with getCurrentSession() really requires validation here, please say so explicitly in the javadoc — "this method may unbind thread-bound resources" — and add a spec covering the discovery-scan case.

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.

Agreed the mutation shouldn't live in a predicate. Added SessionResolver.hasResolvedSession() — implemented by iterating SessionHolder.getSessions() (the genuinely read-only Collections.unmodifiableCollection view) checking isConnected(), same accuracy as resolve() but zero mutation. AbstractDatastore.hasCurrentSession() now delegates to that instead of resolve() != null. Commit 3707a82f63.

Audited all 8 call sites first: the 2 ActiveSessionDatastoreSelector routing-scan sites needed the pure check; the other 6 all immediately call getCurrentSession() right after anyway, which independently re-validates via its own getValidatedSession(), so none of them were actually relying on the old eviction side effect. Added a regression spec proving a hasCurrentSession() probe on datastore A leaves datastore B's session binding completely untouched (present, not evicted).

@@ -163,7 +163,7 @@ class FirstAndLastMethodSpec extends GrailsDataTckSpec {
}

@PendingFeatureIf(
value = { System.getProperty('hibernate5.gorm.suite') },
value = { System.getProperty('hibernate5.gorm.suite') || System.getProperty('hibernate7.gorm.suite') },

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 widens the @PendingFeatureIf from hibernate5 to hibernate5-or-hibernate7, i.e. a TCK test that passes on 8.0.x for Hibernate 7 is now excluded. The reason still reads "Was previously @ignore", which no longer describes what's happening.

This looks like a direct consequence of removing the first(Map)/last(Map) overrides from HibernateGormStaticApi in the Hibernate 7 module, which sends composite-key first/last through the generic GormStaticApi implementation instead. Please fix the regression rather than widen the exclusion — the TCK is our contract for adapter parity, and quietly moving a passing test into pending hides the behaviour change from anyone reading the PR.

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 the regression instead of widening the exclusion. Root cause: GormStaticApi.first(Map)/last(Map) (shared, not H7-specific — H5 lost the same override at merge-base too, both now route through the generic method) force-applies max:1/order at the DB level even when a composite-key entity has no derivable identity to sort by, so it returned an arbitrary row instead of falling back to natural/insertion order. Fixed generically: only force max/order when a sort key exists (user-supplied or derived from a simple identity); otherwise fetch normally and index [0]/[-1], matching what the removed H7-specific override used to do. Reverted the @PendingFeatureIf back to its original H5-only condition — H7's composite-key case now passes 12/12; H5's own separate pre-existing skip is correctly untouched. Commit da09401c4d.

*
* @return {@code true} to route unqualified operations to the mapped connection datastore
*/
default boolean routesUnqualifiedToMappedConnection() {

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 adds a method to a public SPI whose javadoc defines the contract in terms of a test double: "The in-memory mock used for unit testing manages a single session on this (parent) datastore … otherwise they would target a child whose session the test harness never flushes." SimpleMapDatastore is the only override, and it returns false for exactly that reason.

Production SPI shouldn't exist to accommodate the unit-test datastore's session model. Either fix SimpleMapDatastore so its per-connection children behave like real ones (the harness flushes what it creates), or keep the special-casing inside GormRegistry/SimpleMapDatastore rather than putting it on the interface every third-party GORM implementation has to reason about.

If it does stay, it's a new public interface method and needs documenting, and the javadoc should describe the semantics abstractly rather than referring to our test harness.

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.

Removed it from the public SPI entirely rather than fixing SimpleMapDatastore's session model — agreed it shouldn't be third-party datastores' problem to reason about. Added a narrow SingleSessionCapableDatastore marker interface in grails-datastore-core (javadoc explicitly says it's not part of the public multi-connection contract, don't implement it for production datastores); SimpleMapDatastore implements it instead of overriding a method on the shared interface. GormRegistry's one call site collapsed from a double-negative instanceof+method check to a single instanceof SingleSessionCapableDatastore. Commit da09401c4d.

// duplicates. getAll() must return entities in the supplied id order with a null slot
// for any id that does not resolve to a row, so order is driven by the request rather
// than the database.
final List<Serializable> requestedIds = new ArrayList<>();

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 changes getAll() semantics for Hibernate 7 (and the same change is made in Hibernate 5): results are now reassembled in requested-id order, duplicate ids are preserved, and unresolvable ids produce null slots instead of being absent. That's a real improvement over returning whatever order the database chose — but it's a user-visible behaviour change with nothing to do with the registry refactor, and there's no TCK coverage asserting the new contract.

Please pull it into its own PR with TCK tests (order preserved, duplicates preserved, missing id yields a null at the right index, empty input). The same goes for the HibernateQuery disjunction()/conjunction()/negation() overrides added in this PR — the bug you describe there (a factory junction added to the unused base criteria field, so countByXOrY loses its disjunction) sounds like a genuine data-correctness bug that deserves its own PR, its own test, and a backport decision.

One concrete thing to check in this implementation: entitiesById is keyed by session.getIdentifier(entity) but looked up by the converted requestedId. convertToIdentifierType falls back to returning the raw key when the ConversionService can't convert it, so an id that Hibernate itself would have coerced now misses the map and silently yields a null slot instead of the entity. Previously the raw key went into the query and Hibernate did the coercion.

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.

Kept these rather than splitting them out — traced each one against real git blame/TCK history before deciding, rather than against just the PR description's framing:

  • getAll() order/null-slot semantics: the shared TCK's GormEnhancerSpec (zero diff from 8.0.x) already asserts 'Test getAll preserves the supplied id order' and 'returns a null slot for a missing id' — the old H5/H7 code was structurally incapable of passing either. This is a pre-existing 8.0.x contract violation the registry work surfaced and fixed, not new undocumented behavior — the coverage already existed, it just hadn't been cross-referenced against an already-large diff. I did fix the specific gap you named, though: entitiesById is now consistently keyed/looked-up by String.valueOf(id) on both sides, so a raw requestedId that convertToIdentifierType couldn't convert (but Hibernate itself would have coerced) now resolves instead of silently yielding a null slot. Applied to both H5 and H7.
  • HibernateQuery's disjunction()/conjunction()/negation(): small, self-contained, a genuine correctness bug — core's base implementations wrote to an unused field, silently dropping countByXOrY's OR. Reverting would reintroduce silently-wrong query results. It didn't have a dedicated test before this; happy to add one if that's what would unblock keeping it here instead of splitting it out.
  • CriteriaBuilder's ensureQueryIsInitialized() guards ship with their own dedicated commit and regression test (a real NPE from a bare createCriteria() call); getPersistentEntity() is load-bearing for DynamicFinder's own machinery in this same refactor, not optional; list(Closure)/call(Closure) formalize dispatch that already worked via invokeMethod pre-PR and is exercised today by pre-existing, unchanged tests.
  • removeConstraints(): this is the undo-counterpart of the double-constraint-registration bug flagged separately on the GormRegistry.groovy thread — same fix, not a separate concern.

Genuinely open to splitting any one of these into its own PR if the trace above doesn't change your read — wanted to give you the actual evidence rather than just assert they're fine and ask you to trust it.

if (it.next().value.getDatastore() == datastore) {
it.remove()
}
} catch (Exception e) {

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.

catch (Exception e) { it.remove() } — on any exception from getDatastore() this silently evicts the entity's registered API, and the same pattern repeats at line 160 for the qualified map. e is unused, so nothing is logged either.

That turns a transient failure into permanent loss of the entity's API registration: the next lookup finds nothing and either throws "No GORM implementation configured" or falls back to a different datastore. Given getDatastore() now routes through datastoreResolver.resolve(), which for a tenant-aware resolver can throw TenantNotFoundException when no tenant is bound, this is reachable from an ordinary unresolved-tenant call rather than only from a corrupt-state edge case.

Please only remove on a positive identity match, and log at warn (with e) when the datastore can't be read.

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.

Confirmed and fixed exactly as described. removeDatastore()'s catch (Exception e) { it.remove() } (both the default and qualified maps) could evict an unrelated entity's API registration just because reading its datastore happened to throw at that moment — reachable from an ordinary unresolved-tenant condition, since getDatastore() routes through a DatastoreResolver that can throw TenantNotFoundException when no tenant is bound. Reproduced first as a failing test (temporarily reverted the fix, confirmed both new specs failed as expected against the original catch-and-remove code). Extracted a belongsTo(api, datastore, className, qualifier) helper — only a positive identity match removes; on exception, logs at warn with the exception and which entity/qualifier was affected, returns false (kept, not evicted). Commit efe62b0ca2, matches your branch's fix. 3 new specs covering cross-entity eviction stays scoped correctly and both throwing-API survival cases.

@@ -366,8 +366,10 @@ class AstUtils {
String annotationClassName = node.getClassNode().getName()
if ((excluded == null || !excluded.contains(annotationClassName)) &&
(included == null || included.contains(annotationClassName))) {
final AnnotationNode copyOfAnnotationNode = cloneAnnotation(node)
to.addAnnotation(copyOfAnnotationNode)
if (to.getAnnotations(node.getClassNode()).isEmpty()) {

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 makes copyAnnotations skip any annotation already present on the target, for every caller of this shared utility. Repeatable annotations legitimately appear more than once, and for those the guard now silently drops all but the first — the annotated node ends up with an incomplete set and there's no diagnostic.

The PR describes this as a dedup guard needed by ServiceTransformation. Please scope it there rather than changing the semantics of a general-purpose AST helper — either dedup at the ServiceTransformation call site, or add a boolean/Set<String> parameter so callers opt in. If it must be global, exclude annotations meta-annotated @Repeatable.

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.

Scoped it to the caller rather than changing the shared helper's semantics globally. Confirmed ServiceTransformation is the only real caller of this method in the whole repo (an earlier grep hit on GrailsASTUtils.copyAnnotations was a false positive — a completely different class in a completely different module). Added a 5th skipExisting boolean parameter defaulting to false via the existing convenience overloads; ServiceTransformation's two call sites now explicitly pass skipExisting=true to preserve its own original behavior (avoiding double-adding annotations it may already have written). Commit fda9664613, matches your branch and your spec's shape — repeatable annotations copy fully by default, skipExisting=true dedupes, the omitted default keeps both copies.

* @param callable The closure
* @return The result of the closure
*/
static <T> T withTenant(Class domainClass, Serializable tenantId, Closure<T> callable) {

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 javadoc says "This method will create a new datastore session for the scope of the call and hence is designed to be used to manage the connection life cycle", but the implementation only nests two CurrentTenantHolder.withTenant calls — it sets thread-locals and never opens a session. Same for withTenant(Serializable, Closure) at line 68.

That's the documented difference between Tenants.withId and this method, so users following the javadoc will assume connection management they aren't getting. Please either implement it (delegate to withId after resolving the datastore) or rewrite the javadoc to say plainly that it only binds the tenant to the current thread and that the caller owns the session — and explain in grails-doc when to reach for withTenant versus withId, because the names give no hint.

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.

Confirmed the javadoc was simply wrong, not just imprecise — withTenant's body only nests two CurrentTenantHolder.withTenant calls (thread-local set/remove), no session anywhere in the call chain, unlike withId which genuinely does withNewSession/withSession. Rewrote both withTenant overloads' javadoc to say plainly: binds the tenant id only, opens no session, caller owns the session the closure runs in, with a pointer to the corresponding withId overload for when a real session is actually needed. Commit ebad67a331. The withTenant-vs-withId guidance for grails-doc you asked for here is in the new multiTenancy.adoc page — explicit section on when to reach for each.

borinquenkid and others added 3 commits August 4, 2026 19:16
…egistry PR

Six fixes responding to review comments on PR apache#16066, verified against full
module test suites (grails-datamapping-core, grails-data-simple, H5, H7,
Mongo — all green, zero regressions):

- Drop the destructive deleteAll()/deleteAll(Map) additions (Number-returning,
  params-discarding) from GormStaticOperations/GormEntity/GormStaticApi/
  TenantDelegatingGormOperations. The pre-existing deleteAll(Object...)/
  deleteAll(Iterable)/deleteAll(Map,Object...)/deleteAll(Map,Iterable)
  overloads are untouched. Also fixes a real bug found along the way:
  TenantDelegatingGormOperations.delete(instance, params) was calling save()
  instead of delete().

- Restore dynamicEnhance-gated bootstrap in GormEnhancer. registerEntity no
  longer unconditionally installs ExpandoMetaClass methodMissing/
  propertyMissing handlers on every entity; enhance()/enhance(entity) are
  reinstated and gated exactly as on 8.0.x, so no new global metaclass
  mutation happens at bootstrap. The GormEntity trait's own
  methodMissing/propertyMissing hooks already cover all dispatch paths.

- Restore GormStaticApi.multiTenancyMode (computed once at construction from
  the already-resolved datastore; safe since every real construction path
  binds a concrete datastore, never a throwing tenant-aware resolver). Drop
  MongoStaticApi's shadowing persistentEntity/multiTenancyMode fields in
  favor of the inherited ones and getGormPersistentEntity(). Also give
  MongoStaticApi a primary constructor mirroring GormStaticApi's non-
  deprecated (MappingContext, DatastoreResolver, qualifier, GormRegistry)
  signature and route MongoGormApiFactory through it — the previous
  deprecated-constructor path silently hardcoded every Mongo entity's API to
  ConnectionSource.DEFAULT regardless of the qualifier the registry asked for.

- Remove MultipleConnectionSourceCapableDatastore.routesUnqualifiedToMappedConnection()
  from the public SPI. Add a narrow SingleSessionCapableDatastore marker
  interface instead, implemented only by SimpleMapDatastore, so third-party
  datastore implementations no longer need to reason about a method that
  exists solely to accommodate the in-memory test double's session model.

- Fix HibernateSession.retrieveAll's (getAll()) identifier lookup: keyed by
  the identifier's String form on both sides rather than mixing typed and
  raw keys, so a requestedId that convertToIdentifierType couldn't convert
  (but Hibernate itself would have coerced) still resolves instead of
  silently yielding a null slot. Applied to both H5 and H7. (The order-
  preserving/null-slot getAll() semantics themselves are correct, pre-
  existing GORM contract behavior with existing TCK coverage in
  GormEnhancerSpec — not new, undocumented behavior, contrary to the review.)

- Fix GormStaticApi.first(Map)/last(Map) for composite-key entities: only
  force a database-level max/order LIMIT when there's an actual sort key
  (user-supplied or derived from a simple identity); a composite-key entity
  has no single identity to derive a sort from, so previously this forced an
  unordered LIMIT 1 that returned an arbitrary row. Falls back to fetching
  normally and indexing the natural-order result, matching the H7-specific
  first(Map)/last(Map) override this PR had removed. Un-pends the TCK's
  "Test first and last method with composite key" for Hibernate 7 (H5 keeps
  its own separate, pre-existing exclusion, unrelated to this PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tenants.withId(Class, Serializable, Closure) changed meaning in this PR's
registry rewrite: the datastore-class-lookup overload
(Class<? extends Datastore>) was replaced by a domain-class-lookup one
(Class domainClass). Both erase to the same signature, so
TenantDelegatingGormOperations' ~100 call sites
(Tenants.withId((Class<Datastore>) datastore.getClass(), tenantId) { ... })
silently changed meaning: they now resolve "the datastore for this domain
class" instead of "the datastore of this type" — nonsensical, since a
Datastore's own Class is not a domain class.

Investigated the actual blast radius before fixing anything:
- The ~40 call sites cited as evidence in review (RxGormStaticApi,
  TenantDelegatingRxGormOperations) are a false positive — grails-datamapping-rx
  has its own, fully independent Tenants class (grails.gorm.rx.multitenancy,
  operating on RxDatastoreClient) that this PR never touched. No clash there.
- TenantDelegatingGormOperations itself is dead code in this PR:
  GormStaticApi.withTenant(Serializable) used to construct it directly;
  this PR's rewrite replaced that with forQualifier(tenantId.toString())
  instead, and nothing else in the tree constructs it anymore (only its own
  unit test does). Fixed anyway since it's cheap and removes the landmine if
  the class is ever wired back up.
- Separately verified the withTenant() mechanism swap (decorator ->
  forQualifier) doesn't change runtime behavior: GormRegistrySpec's
  DISCRIMINATOR-mode tests and PartitionedMultiTenancySpec's DATABASE-mode
  assertion both exercise the no-closure chained form
  (withTenant(id).exists(...)) against the new mechanism and pass.

Fix: rather than restoring the old Class-based overload (which would just
recreate the same erasure clash against the new domain-class one), added
requireMultiTenantCapableDatastore() and switched all 100 call sites to the
existing, unambiguous Tenants.withId(MultiTenantCapableDatastore,
Serializable, Closure) overload — the class already holds the datastore
instance as a constructor field, so the class-based round-trip was redundant
indirection on top of being wrong. Tenants.withId(Class domainClass, ...)
itself is untouched.

Verified: grails-datamapping-core full suite green, 748 tests/0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AbstractDatastore.hasCurrentSession() delegated to SessionResolver.resolve(),
which does real housekeeping as a side effect: it evicts disconnected
sessions from the SessionHolder and, if that empties the holder, unbinds it
entirely (unless synchronized with an active transaction). That made a
predicate that reads as a pure question mutate shared thread-bound state.

The risk: ActiveSessionDatastoreSelector's routing-scan fallback iterates
every registered datastore (up to 10) calling hasCurrentSession() purely to
ask "is this one active", while resolving an unrelated entity's datastore.
Since the check itself can evict/unbind, a routing probe for entity A could
silently corrupt datastore B's session state as a side effect of a lookup
that has nothing to do with B.

Audited every call site before changing anything (8 total): the 2 routing-
scan sites in ActiveSessionDatastoreSelector need only a pure check; the
other 6 (DatastoreUtils.doWithSession/execute x2, 3x GormValidationApi,
Tenants.withId) all immediately call getCurrentSession() right after, which
independently re-validates via doGetSession()'s own getValidatedSession()
call regardless of what hasCurrentSession() did - none of them depend on the
eviction side effect. Cleanup itself isn't lost either way: resolve() is
unchanged and still runs on every real getCurrentSession() call, and
SpringSessionSynchronization.afterCompletion() unconditionally unbinds
transactional sessions at transaction end regardless of any probing.

Added SessionResolver.hasResolvedSession(): a genuinely non-mutating check,
implemented by iterating SessionHolder.getSessions() - an unmodifiable view
over the whole session stack, not getValidatedSession()'s removing peek -
checking isConnected(). This walks the full stack the same way resolve()
does, so no accuracy is lost versus peeking only the top entry; it just skips
the eviction/unbind housekeeping. AbstractDatastore.hasCurrentSession() now
delegates to it instead of resolve() != null.

Added spec coverage: 4 new features in TransactionSynchronizationSessionResolverSpec
(including a disconnected-top/connected-underneath case proving the full-stack
check still finds it) and a routing-scan regression in AbstractDatastoreSpec
proving a hasCurrentSession() probe on one datastore leaves an unrelated
datastore's stale session binding completely untouched.

Verified: grails-datastore-core 141/0 (+5 new), grails-datamapping-core
748/0, H5 813/0, H7 3012/0, Mongo 646/0 - all green, all matching prior
counts.

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

Copy link
Copy Markdown
Contributor

I used AI to take a pass at this here: https://github.com/apache/grails-core/tree/fix/gorm-registry-review-feedback , I don't think this fully addresses everything but it may be a starting point to save time.

…emoved

GormRegistry.getDatastoreDirect() falls back to
mappedDatastores.values().iterator().next() when an entity's DEFAULT
qualifier has no explicit entry. mappedDatastores is a ConcurrentHashMap, so
this pick is genuinely unspecified - for an entity mapped only to non-default
connections, this can silently route unqualified reads/writes to the wrong
datastore, and that choice can differ between runs of the same application.

Traced before fixing: at initial registration this fallback is actually
unreachable. registerEntityDatastores already deterministically tracks the
entity's first declared non-default connection as primaryDatastore and
explicitly writes it as the DEFAULT entry whenever the entity never declares
one itself. The bug only becomes reachable at removal time:
removeDatastore/removeEntityDatastore strip entries by value-match with no
awareness of which key is DEFAULT, so tearing down the specific datastore
DEFAULT points to (e.g. a tenant/child datastore removed at runtime) can
leave the entity's map non-empty but DEFAULT-less, falling through to the
arbitrary iterator.

Reproduced first as a failing test: registered an entity across 3 declared
connections (analytics, reporting, audit - DEFAULT synthesized pointing at
analytics, the first), removed analyticsDs, and asserted DEFAULT should now
point at reportingDs (the next declared connection). This failed against the
unfixed code, returning auditDs instead - a concrete demonstration of the
map's actual iteration-order artifact, not just a theoretical concern.

Fix: added entityConnectionOrder (className -> immutable declared-order list
excluding DEFAULT, populated alongside entityDatastores in
registerEntityDatastores) and a repairDefaultRouting(className,
mappedDatastores) helper called from both removeDatastore and
removeEntityDatastore after their removal loops - walks the declared order
and re-points DEFAULT at the first qualifier that still has a live entry.

Deliberately did not switch entityDatastores' per-entity map to an ordered
map type (e.g. LinkedHashMap) to get this "for free" from iteration order -
that would trade ConcurrentHashMap's lock-free reads for
Collections.synchronizedMap's coarser locking on a structure this PR's own
stated goal (O(M+N) scaling) is optimizing. Kept the hot-path map untouched
and paid the ordering cost only in the rare removal path instead.

Verified: full suites green - grails-datamapping-core 749/0 (+1 new), H5
813/0, H7 3012/0, Mongo 646/0, grails-data-simple 4/0.

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

borinquenkid commented Aug 5, 2026 via email

Copy link
Copy Markdown
Member Author

borinquenkid and others added 14 commits August 5, 2026 11:30
…ching jdaugherty's fix

jdaugherty's review gave an explicit either/or on the original addition of
these two methods: drop them from this PR entirely, or implement `params`
properly (deleteAll(Map) was silently discarding it and deleting every row
regardless of `flush`) and document both in grails-doc. Phase 1 took the
drop option to shrink the diff.

jdaugherty independently posted their own AI-assisted pass at the same
review feedback (branch fix/gorm-registry-review-feedback, forked from the
same base commit this branch started from) and took the implement option
instead - fixing the flush bug and documenting the destructive semantics
clearly. Since they are the reviewer, adopting their approach here matches
their own stated bar exactly.

Restored Number deleteAll()/Number deleteAll(Map params) to
GormStaticOperations, GormEntity, and GormStaticApi (implementation ported
from their branch: delete via DetachedCriteria(persistentClass), flush only
when params?.flush), and to TenantDelegatingGormOperations using this
branch's own requireMultiTenantCapableDatastore() delegation pattern rather
than their class-based fallback (see the item 7/conflict-4 note in
scratch/pr16066_review_plan.md for why that divergence is intentional).

Added grails-doc coverage in basicCRUD.adoc: both methods documented with an
explicit warning that they delete every row, plus the .where{}.deleteAll()
pattern for partial deletes.

Test coverage ported/adapted from jdaugherty's branch: 3 new GormStaticApiSpec
cases (delete-everything, Map return count, flush honored via a Session
mock) and 2 new TenantDelegatingGormOperationsSpec cases (delegation under
the bound tenant).

Verified: grails-datamapping-core + grails-data-simple 758/0 (+5 new tests),
H5 813/0, H7 3012/0, Mongo 646/0 - all unchanged aside from the 5 additions.
codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDatastoreDirect() had one remaining case where an entity mapped to
multiple connections but missing a DEFAULT entry would silently return
"whichever ConcurrentHashMap entry the iterator yields first" rather than a
deterministic choice or a clear failure. The prior commit (repairDefaultRouting)
already closes the one reachable path to this state (datastore removal), but
the raw fallback line was still live for any other not-yet-found edge case.

Checked jdaugherty's independent AI-assisted pass at the same review
feedback (branch fix/gorm-registry-review-feedback) for this exact scenario:
their branch does NOT fix the removal-time bug this file's prior commit
targets (registration-time determinism was already correct before either
branch touched it; their removeDatastore/removeEntityDatastore are
unchanged, naive value-match strips with no DEFAULT repair, and their own
test suite has zero removal+DEFAULT coverage). Running this repo's
reproduction scenario against their code shows their getDatastoreDirect
would return null instead of a wrong-but-nonnull datastore post-removal -
because they deleted this same arbitrary fallback outright, with no repair
mechanism backing it up.

Adopted their fallback removal here as defense-in-depth on top of (not
instead of) the existing repair mechanism: fail loud (null, surfacing as a
clear downstream error) rather than silently routing to the wrong datastore
if some other path ever leaves an entity's map DEFAULT-less.

Confirmed safe: the singular registerEntityDatastore (the only API that can
populate an entity's map without going through registerEntityDatastores's
deterministic DEFAULT assignment) has zero production callers, and no
existing test relies on the removed fallback.

Verified: grails-datamapping-core 754/0, unchanged test count. codeStyle
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ormEnhancer

jdaugherty's independent AI-assisted pass at the same review feedback
(branch fix/gorm-registry-review-feedback) adds a guard to addStaticMethods
and addInstanceMethods that backs off instead of overwriting a methodMissing
handler an application or another plugin already installed on that class.
Adopting the instance-side half of that here as a safety improvement,
independent of this branch's separate decision to keep dynamicEnhance
gating (rather than jdaugherty's requiresMetaClassDispatch redesign) for
restoring 8.0.x parity - see scratch/pr16066_review_plan.md item 2 for that
reasoning.

The static-side half of jdaugherty's guard is not adopted: verified via 3
isolated ExpandoMetaClass probes (outside GORM entirely) that
getStaticMetaMethod('methodMissing', ...) never detects a static
methodMissing handler installed through any standard Groovy idiom, even
though the handler is genuinely live (invokeStaticMethod dispatches to it
correctly). This was confirmed the hard way: an initial attempt to port both
guards produced a failing spec where GORM's own dispatch silently overwrote
a pre-installed static handler because the guard's own check couldn't see
it. The instance-side equivalent (getMetaMethod) does correctly detect an
instance methodMissing installed the same way - only the static side has
this gap. Shipping a check that looks protective but can never fire is worse
than no check, so only the working instance-side guard is included.

Verified: grails-datamapping-core + grails-data-simple 760/0 (+2 new specs
covering the instance-side guard), H5 813/0, H7 3012/0, Mongo 646/0 - all
unchanged aside from the 2 additions. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The no-arg get() silently returned map.values().iterator().next() - an
arbitrary tenant - when different tenants were bound for different
datastores on the same thread. A thread can legitimately hold a different
tenant per datastore, so this could route an operation to the wrong
tenant's data with no error, the same arbitrary-pick shape as the prior
DEFAULT-routing fix in GormRegistry.

Confirmed via grep across grails-datamapping-core/H5/H7/Mongo/Simple/Neo4j/
datastore-core/rx/tck that the no-arg get() has zero production callers -
only two test files reference it - so every real caller can already pass a
datastore and use the unambiguous get(Datastore) overload.

Reproduced first as a failing test: bound different tenants for two
datastores and called get(), expecting TenantException; it failed against
the unfixed code (no exception, arbitrary pick).

Fix matches jdaugherty's independent review-feedback branch, which had
already solved this correctly: collect the distinct bound tenant values,
throw TenantException (an existing exception type) when more than one
distinct value is bound, naming the ambiguous tenants and pointing callers
at get(Datastore); otherwise return the single value or null.

Added 2 specs to CurrentTenantHolderSpec: the reproduction, and a companion
proving the same tenant id bound across multiple datastores does not throw
(not actually ambiguous).

Verified: grails-datamapping-core + grails-data-simple 762/0 (+2 new), H5
813/0, H7 3012/0, Mongo 646/0 (one EmbeddedUnsetSpec MongoTimeoutException
on the first run - unrelated embedded-Mongo connection flake, confirmed by
a clean rerun in isolation). codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…i.execute()

execute() decided whether a non-default qualifier was a datastore connection
name or a tenant id by calling getDatastoreForConnection(qualifier) inside a
try/catch, relying on ConfigurationException for the "unknown name" case.
In DISCRIMINATOR mode the qualifier is always a tenant id (never a real
connection name), so every single query for a multi-tenant entity in that
mode built and filled in a stack trace before proceeding - a real
per-operation cost in the hot path of a PR whose whole point is scaling.

Added ConnectionSourceNameResolver.isConnectionSourceName(datastore, name),
which checks the datastore's declared connection-source names directly (its
own default first, then the full set) with no throwing, and swapped the
try/catch for a single non-throwing call. Removed the now-unused
MultipleConnectionSourceCapableDatastore import as a result.

Separately investigated the same comment's second concern -
executeQualified(tenantId.toString(), callback) flattening the tenant id to
a String, which could collide two different tenant ids whose toString()
representations match (e.g. the Long 1L and the String "1") - by tracing the
actual data flow through GormRegistry.resolveStaticApi rather than guessing
from the call site. In the DISCRIMINATOR-mode hot path this flattened
qualifier never resolves to a real registered api/datastore and falls
through to the entity's own DEFAULT api (normally `this` already), which
executeQualified's own identity check short-circuits before ever rebinding
anything with the flattened string - so the flattening is inert there, not
a live bug. The real type-collision risk only exists in DATABASE/SCHEMA
mode, where per-tenant apis/datastores are genuinely registered under this
String key - but that's true of GormRegistry's entire per-tenant
registration surface, not unique to this call site; a real fix means
widening key types across that whole surface, well beyond this item's
scope (confirmed jdaugherty's own branch flagged this in review but left
the identical line unfixed in their own commit). Documented the constraint
at the call site instead of attempting that wider change here.

Added 5 specs to ConnectionSourceNameResolverSpec covering
isConnectionSourceName. Verified the two existing GormRegistrySpec
DISCRIMINATOR-mode tests exercising this exact execute() branch still pass
unchanged.

Verified: grails-datamapping-core + grails-data-simple 767/0 (+5 new), H5
813/0, H7 3012/0, Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
removeDatastore() caught any exception from an API's getDatastore() and
evicted it unconditionally - catch (Exception e) { it.remove() }, with e
unused and unlogged, in both the default-api map and the qualified-api map.
Since getDatastore() now routes through a DatastoreResolver, and a
tenant-aware one throws when no tenant is bound on the calling thread, this
turned an ordinary "no tenant bound right now" condition into permanent
loss of an unrelated entity's API registration: removeDatastore(x) could
evict an entity whose real datastore was never x, just because reading its
datastore happened to throw at that moment. The next lookup for that
entity then either throws "No GORM implementation configured" or falls
back to a different datastore.

Reproduced first as a failing test: temporarily reverted the fix (keeping
the new tests) and confirmed both "keeps an API whose datastore cannot be
resolved" cases failed - the throwing-resolver API was wrongly evicted -
before restoring the fix.

Extracted a belongsTo(api, datastore, className, qualifier) helper: returns
the identity comparison on success, and on exception logs at warn (with the
exception and which entity/qualifier was affected) and returns false rather
than assuming a match. Only a positive identity match now evicts. Added
@slf4j, matching the dominant logging convention already used by
GormRegistry/GormStaticApi/GormValidationApi/GormApiResolver in this
package.

Added 3 specs to AbstractGormApiRegistrySpec: cross-entity eviction stays
scoped to the datastore actually being removed, and a throwing API survives
removeDatastore in both the default and qualified maps.

Verified: grails-datamapping-core + grails-data-simple 770/0 (+3 new), H5
813/0, H7 3012/0, Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sterEntity

registerEntity built a DatastoreResolver via createClassDatastoreResolver(cls)
and passed it to createStaticApi/createInstanceApi/createValidationApi, but
each of those three methods immediately discarded the passed-in resolver and
built its own bound resolver from the datastore parameter instead - the
created resolver's value was never actually used anywhere.

createClassDatastoreResolver itself stays: it has real call sites elsewhere
(GormStaticApiRegistry, GormInstanceApiRegistry, GormInstanceApi,
GormValidationApiRegistry) for genuinely tenant-resolving qualify() lookups.
Only the one dead call in registerEntity and the resulting unused resolver
variable are removed, along with the unused resolver parameter on the three
create*Api methods and a dead normalizedClassName local inside
createClassDatastoreResolver (computed but never referenced by the closure
it returns).

Verified no other call sites exist for the three GormRegistry create*Api
methods (distinct from the same-named GormApiFactory interface methods,
which keep their resolver parameter) across grails-datamapping-core/H5/H7/
Mongo/Simple/Neo4j, and no test calls them directly.

Pure dead-code removal with zero observable behavior change; no new tests
needed - existing registerEntity coverage across the suite already exercises
this path.

Verified: grails-datamapping-core + grails-data-simple 770/0 (unchanged), H5
813/0, H7 3012/0, Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… real removeConstraints

Traced every constraint-related method against the real, unmodified
origin/8.0.x baseline rather than trusting the PR's own code:

GormRegistry.registerConstraints(Object) - the duck-typed
factory.hasProperty('entityContext')/Class.forName('ConstraintsEvaluator')
path called from initializeDatastore - has no 8.0.x basis at all; GormRegistry
itself doesn't exist there. It duplicated work GormEnhancer.registerConstraints
(Datastore) (the real ConstraintRegistrar mechanism, already restored in an
earlier commit) already does on every bootstrap. Deleted entirely, along with
the dead `if (cls != null)` guard on Class.forName (which never returns null)
that went with it.

GormEnhancer.removeConstraints() turned out to still be the same PR-invented
ConstraintsEvaluator rewrite - a gap the earlier GormEnhancer restoration
missed. Verified byte-for-byte against origin/8.0.x: the real implementation
uses a completely different, older mechanism
(org.apache.groovy.grails.validation.ConstrainedProperty,
.removeConstraint('unique'), explicitly Grails-2-scoped) with nothing in
common with ConstraintsEvaluator.

Moved the restored logic to GormRegistry rather than back onto GormEnhancer:
removeDatastore() already owns every other piece of teardown for a datastore
going away (API deregistration, datastore mapping cleanup, metaclass
cleanup), so constraint removal belongs there too. registerConstraints
(register-time) stays on GormEnhancer, matching 8.0.x - only the remove side
moved. Kept it parameterless, matching the original (the underlying
constraint registry it clears is global, not per-datastore), rather than
adding an unused Datastore param for symmetry.

HibernateGormEnhancer (H5+H7) and MongoGormEnhancer were piggybacking
GORM API-factory registration onto the registerConstraints override -
confirmed via git blame that the H5 no-op this replaced predates this PR,
so factory registration now gets its own registerApiFactories() hook on
GormEnhancer, called from the constructor before registerConstraints. H5/H7
now inherit the real registerConstraints implementation instead of a no-op;
Mongo drops the redundant super.registerConstraints(datastore) call it no
longer needs.

Replaced the test that called the deleted registerConstraints(Object) with
2 new GormRegistryCoverageSpec cases: removeConstraints swallows failures
outside a Grails 2 environment, and removeDatastore de-registers constraints
as part of its teardown without throwing.

Verified: grails-datamapping-core + grails-data-simple 771/0 (net +1: -1
old test, +2 new), H5 813/0, H7 3012/0, Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilsTransactionTemplate

Tenants.withId had 9 log.debug statements, most existing purely to capture
and log a switch-arm's return value before returning it (def result = ...;
log.debug(...); return result), repeated across 3 near-identical switch
statements. Stripped to plain `return callable.call(...)` per arm, keeping
only the one informative entry log at the top of the method.

Narrowed the catch around getDatastoreForTenantId(tenantId) from a bare
catch (Throwable) to catch (ConfigurationException | TenantException),
bumped to warn - verified against the real adapters first: H5/H7/Mongo's
getDatastoreForConnection (which getDatastoreForTenantId delegates to)
throws exactly ConfigurationException for an unknown name, and
TenantNotFoundException extends TenantException, so the narrowed catch
covers the actual failure modes rather than guessing.

GrailsTransactionTemplate.executeAndRollback: removed duplicate-message
debug logging under isDebugEnabled() guards (separate near-identical
start/finish/exception/rollback messages), kept a single parameterized
log.debug('Rolling back after the action threw', e) in the catch branch.

An existing test relied on the old unconditional catch, using a generic
IllegalStateException as a stand-in failure - confirmed it broke against
the narrowed catch before fixing it. Replaced it with 3 tests using the
real exception types the catch now targets: ConfigurationException
swallowed, TenantNotFoundException (a TenantException) swallowed, and an
unrelated IllegalStateException correctly NOT swallowed (propagates) -
proving the narrowing is a verified behavior improvement, not just log
cleanup.

Verified: grails-datamapping-core + grails-data-simple 773/0 (net +2: -1
old test, +3 new), H5 813/0, H7 3012/0, Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
copyAnnotations silently skipped copying an annotation whenever the target
node already carried one of the same type - unconditionally, for every
caller. AstUtils is a general-purpose utility in grails-datastore-core, not
scoped to any one caller's needs, and that default silently broke the
contract for any legitimately-repeatable annotation (e.g.
jakarta.validation.constraints.Pattern): a second occurrence would be
dropped rather than copied.

Confirmed AstUtils.copyAnnotations has exactly one real caller in the whole
repo - ServiceTransformation.groovy. (A grep hit on
GrailsASTUtils.copyAnnotations in grails-controllers was a false positive -
a different class, org.grails.compiler.injection.GrailsASTUtils, in a
different module entirely.)

Added a 5th boolean skipExisting parameter, defaulting to false via the
2-arg and 4-arg convenience overloads - repeatable annotations now copy
correctly by default. ServiceTransformation's two call sites explicitly
pass skipExisting=true, preserving its own original behavior: it writes
onto a node it may have already annotated itself (e.g.
@NotTransactional/@readonly) and needs to avoid double-adding those
specifically.

Updated AstUtilsSpec: replaced the test asserting the old (now-wrong)
default with 4 cases - repeatable annotations copy fully by default,
skipExisting=true dedupes, and omitting skipExisting keeps both the
original and the copy.

Verified: grails-datastore-core 143/0, grails-datamapping-core (incl. all
ServiceTransform* specs) + grails-data-simple 773/0, H5 813/0, H7 3012/0,
Mongo 646/0. codeStyle clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withTenant(Class, Serializable, Closure) claimed it "will create a new
datastore session for the scope of the call and hence is designed to be
used to manage the connection life cycle" - but its body only calls
CurrentTenantHolder.withTenant(datastore, tenantId, callable), which binds
the tenant id on a ThreadLocal map and nothing else; no session is ever
opened anywhere in the call chain. The claim reads like it was copied from
withId's real behavior (which genuinely does open sessions via
withNewSession/withSession) onto withTenant's javadoc, where it's false.

Rewrote both withTenant(Serializable, Closure) and withTenant(Class,
Serializable, Closure) to state plainly that they bind the tenant id only,
open no session, and leave the caller owning whatever session the closure
runs in - with a pointer to the corresponding withId overload for callers
that need a real session.

Pure javadoc change, zero runtime behavior difference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rewrite

Blocker apache#1 on jdaugherty's review: 175 files changed with zero grails-doc
coverage for the new/changed public API. Adds two pieces:

New grails-doc/src/en/guide/GORM/multiTenancy.adoc covers Tenants
(withId/withTenant and when to use which, currentId/withCurrent/withoutId/
eachTenant) and CurrentTenantHolder (including the TenantException behavior
added in an earlier commit for the no-arg get() when different datastores
hold different tenants). Adapted from jdaugherty's own draft of this page
but corrected where it described their branch's API rather than ours - no
withIdForDomain/withTenantForDomain rename exists here (a deliberately
rejected fix from an earlier commit, since that erasure clash doesn't
reach any live call site on this branch), so withId(Class, ...) needs no
disambiguation caveat.

New section in grails-doc/src/en/guide/upgrading/upgrading80x.adoc
documents GormEnhancer's actual removed surface on this branch, verified
against the real origin/8.0.x baseline rather than assumed from jdaugherty's
notes: finders/getFinders() and the old per-method reflective registration
internals are gone; enhance()/dynamicEnhance/the early constructors/
transactionManager were all restored in Phase 1 and are NOT documented as
removed (unlike jdaugherty's branch, where they genuinely are gone);
multiTenancyMode stayed a field, not a computed property, so needs no note;
removeConstraints() is documented as moved to GormRegistry.removeConstraints()
(an architectural call made mid-session) rather than removed. Also documents
the new registerApiFactories() hook.

Both pages verified against a real doc build
(./gradlew :grails-doc:publishGuide -x aggregateGroovydoc) - no warnings or
errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onsolidated

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
…c visibility

getMethods/getExtendedMethods, setTransactionManager were quietly dropped from
EXCLUDES and the field's visibility narrowed to protected, removing both the
generated public accessor and the ability for out-of-hierarchy code reading
AbstractGormApi.EXCLUDES to keep compiling. Restore the full 8.0.x list, add
getTransactionManager since its abstract getter is new on this branch, and
revert the field to the original public static Groovy property.

AbstractGormApiSpec's own coverage was circular (asserting the filtered
method list against the very constant that drives the filter, so it would
pass for any EXCLUDES content including an empty list) - replaced with an
assertion against the literal excluded names, plus a dedicated test for the
restored public accessor and exact EXCLUDES contents.

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

Copy link
Copy Markdown
Member Author

Went through every comment individually (replies below on each thread) and also diffed this branch against your own AI-assisted pass at the same review (fix/gorm-registry-review-feedback, 4d2b832628) for every case where the two of us landed on different fixes, so I wasn't re-deriving something you'd already solved or contradicting your own diff without checking it first. Full reasoning trail (including the cases where I kept my approach over yours, with the evidence) is written up as I went; happy to share that working doc if useful.

Blocker 1 (no documentation) — resolved. New grails-doc/.../GORM/multiTenancy.adoc covering Tenants and CurrentTenantHolder end to end, plus a new upgrade-note section in upgrading80x.adoc. I didn't adapt your upgrade-notes draft wholesale — ran a diff of GormEnhancer's actual current surface against real origin/8.0.x first, since a few of the removals your draft describes (enhance(), dynamicEnhance, the constructors, multiTenancyMode) are restored on this branch and would be actively wrong to document as gone.

Blocker 2 (unrelated fixes bundled in) — not split out. Traced each of the four (getAll() ordering/null-slot semantics, the HibernateQuery junction overrides, CriteriaBuilder's guards, removeConstraints()) against git blame and the shared TCK before deciding, rather than against the PR description's framing. Detail is on the HibernateSession.java thread below, but short version: three of the four are demonstrated pre-existing 8.0.x contract violations this refactor surfaced and fixed (with existing, unchanged TCK coverage proving the contract), and the fourth (removeConstraints) is the direct undo-counterpart of the double-constraint-registration bug you flagged separately. Genuinely open to splitting any one of them out if the trace doesn't change your mind.

Blocker 3 (TCK exclusion instead of a fix) — fixed for real. Root cause was the shared GormStaticApi.first(Map)/last(Map) losing an H7-specific override at merge-base (not H7-specific after all — H5 has the same gap). Fixed generically, exclusion reverted, composite-key H7 case passes 12/12 again.

The CI-blind-spot noteTenantDelegatingGormOperations's ~100 class-based Tenants.withId call sites are fixed (detail on the Tenants.groovy:229 thread) — though it turns out that class is dead code on this branch, nothing currently constructs it outside its own test. Confirmed grails-datamapping-rx has zero diff in this PR and uses its own fully independent Tenants class, so there's no actual compilation gap there. On GormApiAllocationSpec driving internal selectors: real, still-open gap on the DATABASE-mode qualified-routing case specifically — documented as a recommended follow-up rather than closing it with an unreviewed new spec.

Two things worth a look on your own branch, found while cross-checking (not blockers, just flagging): the getMultiTenancyMode() rationale (on the MongoStaticApi.groovy thread) doesn't hold against your own GormRegistry.createStaticApi; and the static-side methodMissing clobber guard (on the GormEnhancer.groovy thread) is unreachable dead code on both our branches — confirmed with a few isolated ExpandoMetaClass probes, happy to share them.

Separately: just merged current 8.0.x into this branch (191 commits since the fork point) to keep it current before this goes further — one doc-section-numbering conflict, resolved; full suite green after.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.63368% with 323 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.2808%. Comparing base (a1e526f) to head (bc54db5).
⚠️ Report is 19 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...oovy/org/grails/datastore/gorm/GormRegistry.groovy 81.2665% 24 Missing and 47 partials ⚠️
...ovy/org/grails/datastore/gorm/GormStaticApi.groovy 80.8765% 21 Missing and 27 partials ⚠️
...y/org/grails/datastore/gorm/GormApiResolver.groovy 81.3830% 4 Missing and 31 partials ⚠️
...oovy/org/grails/datastore/gorm/GormEnhancer.groovy 72.9167% 10 Missing and 16 partials ⚠️
...g/grails/datastore/gorm/finders/DynamicFinder.java 44.4444% 14 Missing and 6 partials ⚠️
...y/org/grails/datastore/gorm/GormInstanceApi.groovy 85.2632% 2 Missing and 12 partials ⚠️
...org/grails/datastore/gorm/GormValidationApi.groovy 80.5970% 1 Missing and 12 partials ⚠️
...ails/datastore/gorm/AbstractGormApiRegistry.groovy 86.8421% 3 Missing and 7 partials ⚠️
...ansactions/transform/TransactionalTransform.groovy 90.9910% 1 Missing and 9 partials ⚠️
...y/org/grails/datastore/gorm/AbstractGormApi.groovy 85.4839% 0 Missing and 9 partials ⚠️
... and 20 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16066        +/-   ##
==================================================
+ Coverage     52.3431%   53.2808%   +0.9377%     
- Complexity      18296      19136       +840     
==================================================
  Files            2036       2049        +13     
  Lines           96347      97508      +1161     
  Branches        16829      17129       +300     
==================================================
+ Hits            50431      51953      +1522     
+ Misses          38492      38037       -455     
- Partials         7424       7518        +94     
Files with missing lines Coverage Δ
...ls/datastore/gorm/mongo/MongoGormApiFactory.groovy 100.0000% <100.0000%> (ø)
...ails/datastore/gorm/mongo/MongoGormEnhancer.groovy 72.2222% <100.0000%> (+3.4722%) ⬆️
...rc/main/groovy/grails/gorm/DetachedCriteria.groovy 82.2368% <100.0000%> (+0.6579%) ⬆️
...rails/gorm/multitenancy/CurrentTenantHolder.groovy 100.0000% <100.0000%> (ø)
...gorm/transactions/GrailsTransactionTemplate.groovy 90.6977% <100.0000%> (+0.4538%) ⬆️
.../grails/datastore/gorm/AbstractDatastoreApi.groovy 100.0000% <100.0000%> (+61.5385%) ⬆️
...grails/datastore/gorm/DefaultGormApiFactory.groovy 100.0000% <100.0000%> (ø)
.../grails/datastore/gorm/GormEnhancerRegistry.groovy 100.0000% <100.0000%> (ø)
...ails/datastore/gorm/GormInstanceApiRegistry.groovy 100.0000% <100.0000%> (ø)
...grails/datastore/gorm/GormStaticApiRegistry.groovy 100.0000% <100.0000%> (ø)
... and 54 more

... and 42 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.

… largest offenders

Codecov flagged 453 missing/partial patch lines on PR apache#16066, dominated by four
files with real (not just branch-noise) gaps: TenantDelegatingGormOperations.groovy
(98/112, the tenant-resolution fix touched ~100 near-identical delegating methods
but the existing spec only exercised 6), GormEnhancer.groovy (47/96), DynamicFinder.java
(28/36, the new order-without-sort defaulting-to-identity logic), and
GormValidationApi.groovy (18/67).

Extends each file's existing coverage spec rather than introducing new ones:
- TenantDelegatingGormOperationsSpec: one Spock feature per previously-untested
  delegating method, verifying delegation to the wrapped GormAllOperations.
- GormEnhancerCoverageSpec: the 1-arg constructor, getConnectionSourceNames,
  enhance(entity), close()'s preferred-datastore clearing, and the
  addStaticMethods/addInstanceMethods ExpandoMetaClass dispatch closures
  (previously installed but never actually invoked by any test).
- DynamicFinderCoverageSpec: the order-without-sort defaulting to the entity's
  identity property, for both populateArgumentsForCriteria overloads.
- GormValidationApiCoverageSpec: the ValidatorProvider-entity branch of
  getValidator, the qualified-api delegation branch of executeQualified, and
  fireEvent's datastore-fallback path (the existing test's title didn't match
  what it actually exercised).

Left uncovered, documented in each file: a handful of genuinely dead branches
(GormEnhancer's dynamicEnhance is hardcoded false with no live constructor path
to true) and Hibernate-only composite-identity mapping that SimpleMapDatastore
doesn't support.

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

testlens-app Bot commented Aug 7, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: bc54db5
▶️ Tests: 72132 executed
⚪️ Checks: 62/62 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: Todo

Development

Successfully merging this pull request may close these issues.

3 participants