Skip to content

(2/3) feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor#15780

Open
borinquenkid wants to merge 29 commits into
feat/gorm-datastore-infrafrom
feat/gorm-registry-core-impl
Open

(2/3) feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor#15780
borinquenkid wants to merge 29 commits into
feat/gorm-datastore-infrafrom
feat/gorm-registry-core-impl

Conversation

@borinquenkid

@borinquenkid borinquenkid commented Jun 27, 2026

Copy link
Copy Markdown
Member

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

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

You are reading (2/3).


Summary

Introduces the GormRegistry singleton that replaces the O(M×N) static map allocation 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 on close()
  • GormApiFactory / DefaultGormApiFactory — pluggable factory per datastore type; adapters override this to supply typed API instances
  • GormApiResolver — routes static/instance/validation API lookups through the registry with fallback to the default datastore
  • ConnectionSourceNameResolver — extracts and normalises connection-source names from a datastore without leaking ConnectionSourcesSupport internals
  • GormEnhancer — delegates all registration and lookup to GormRegistry; allQualifiers() used only for datastore routing, not eager API allocation
  • GormStaticApi / GormInstanceApi / GormValidationApi — use DatastoreResolver instead of holding a direct Datastore reference; support qualifier-aware execution via executeQualified()
  • 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
  • DefaultTransactionTemplateFactory / TransactionTemplateFactory — pluggable transaction template creation per datastore type

Test plan

  • ./gradlew :grails-datamapping-core:test passes (tests are in the companion PR)
  • No regressions in GormEnhancerAllQualifiersSpec

Stack

🤖 Generated with Claude Code

borinquenkid and others added 7 commits June 27, 2026 13:25
…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>
@borinquenkid borinquenkid force-pushed the feat/gorm-registry-core-impl branch from 24dd795 to b49c19f Compare June 27, 2026 20:46
borinquenkid and others added 11 commits June 27, 2026 16:47
…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>
…passing (H5 core 6→3)

Root cause A of the GormRegistry core-impl regressions: dynamic finders such as
findByName failed on multi-tenant entities with MissingMethodException("call").

The structural rewrite added mc.static.propertyMissing in GormEnhancer. When a
finder is invoked through a channel that resolves the name as a property before
falling back to a method call (Spock's verifyMethodCondition / InvokerHelper),
that property channel reached HibernateGormStaticApi.propertyMissing, whose
override returned a connection-qualifier API for ANY name — including finder
names. Groovy then invoked .call(arg) on the qualifier API, yielding
methodMissing("call"). The base GormStaticApi.propertyMissing is finder-aware
(returns a finder closure first) but the adapter overrides bypassed it.

Fix the H5 and H7 HibernateGormStaticApi.propertyMissing overrides to delegate
to super.propertyMissing, which resolves finder property access to a finder
closure before falling back to connection-source qualifier lookup. Remove the
now-unused GormEnhancer (H5) and ConnectionSourcesProvider (H7) imports.

Also fix a second bug masked by the first: the new early
childDatastore.getCurrentSession() branch in Tenants.withId passed the GORM
session to two-argument closures typed against the native Hibernate session in
DISCRIMINATOR mode. Guard that branch with !isSharedConnection() so shared
connection modes (DISCRIMINATOR, SCHEMA) use the proven shared-connection
withSession path; DATABASE mode keeps the session-reuse optimization.

Add MultiTenantFinderDispatchSpec covering both behaviors via public APIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e retrieveAll (H5 core 3→0)

Root cause B of the GormRegistry core-impl regressions: getAll() did not implement
the documented GORM contract. HibernateSession.retrieveAll issued a single
`id IN (...)` query and returned rows in database order, so it could not preserve
the supplied id order, never produced a null slot for an unmatched id, and did not
convert String ids to the entity's identifier type.

Rework retrieveAll in both the Hibernate 5 and Hibernate 7 sessions to:
- convert each requested id to the entity identifier type via the mapping
  context's ConversionService (preserving order and duplicates),
- query only the distinct, non-null ids,
- index the results by identifier, then reassemble the result list in the
  requested order with a null slot for every id that resolved to no row.

Covered by the TCK GormEnhancerSpec getAll order/null-slot tests and
HibernateGetAllConvertibleIdSpec. Full grails-data-hibernate5-core suite is green.

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

GormRegistry fell back to DefaultGormApiFactory (a base GormStaticApi) for
MongoDatastore, breaking the `(MongoStaticApi) GormEnhancer.findStaticApi(...)`
cast in MongoEntity. Add MongoGormApiFactory (extends DefaultGormApiFactory,
overrides only createStaticApi -> MongoStaticApi) and register it in
MongoGormEnhancer.registerConstraints, calling super to keep the
ConstraintRegistrar. Clears all MongoStaticApi ClassCastExceptions
(:grails-data-mongodb-core:test 73 -> 7 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
borinquenkid and others added 9 commits June 28, 2026 22:29
GormStaticApi.findAll(D example, Map args) called query.list(args), but
Query.list() takes no Map argument (only criteria builders expose list(Map)),
raising MissingMethodException on adapters whose Query has no list(Map) (e.g.
MongoQuery). Apply args via DynamicFinder.populateArgumentsForCriteria, then
call query.list() — mirroring list(Map). Add FindByExampleSpec to the in-memory
CoreTestSuite TCK selection so the behaviour is covered for free.

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

The rewrite's createQuery + populateQueryByExample path did not apply restrictions on
Hibernate 7 (its session yields no usable persister / entity-access for a *transient*
example), so example queries returned all rows. Route find/findAll(example) through a
property map built from the example's own getters, delegated to findWhere/findAllWhere —
which every adapter already implements correctly (H5/H7 override; in-memory uses core),
and which guard an empty map to null. Restores the proven upstream approach and removes
the datastore-coupled populateQueryByExample helper. Covered by TCK FindByExampleSpec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
H7's HibernateGormStaticApi overrode first/last(Map) as list(m).first()/.last(), which
ignored sort direction (last returned the first element for an explicit sort). Remove the
overrides so H7 inherits core GormStaticApi.first/last(Map), which apply max:1 + order
asc/desc + a default identity sort. Covered by TCK FirstAndLastMethodSpec sort-parameter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GormApiAllocationSpec: read allocation via GormRegistry.*ApiRegistry.isAllocated (the
  GormEnhancer.STATIC_APIS/INSTANCE_APIS/VALIDATION_APIS maps were removed by the rewrite);
  for strict-mode (DATABASE/SCHEMA) entities verify per-tenant allocation via the tenant
  qualifier instead of a tenant-less DEFAULT lookup, which correctly throws
  TenantNotFoundException.
- GrailsIdentityGeneratorSpec: import the TCK TestEntity/ChildEntity it registers.
- FirstAndLastMethodSpec (TCK): extend the composite-key @PendingFeatureIf to the H7 suite,
  matching H5 (the feature was previously @ignore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HibernateQuery builds its JPA criteria from `detachedCriteria`, and overrides add/or/and/not
to write there — but inherited the core disjunction()/conjunction()/negation() factory
methods, which add to the unused base Query `criteria` field. A junction created via those
factories (e.g. CountByFinder's `q.disjunction()` for countByXOrY) was therefore silently
dropped, so count-over-OR ignored the disjunction and returned the total count (countByXAndY,
built via q.add(), was unaffected). Override the three factories to add to detachedCriteria.
Clears GormEnhancerSpec "count by query" and FindByMethodSpec OR-multiple; grails-data-
hibernate7-core is now fully green (21 -> 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eachTenant enumerates tenants, so it has no current tenant — but it fetched its api via
GormRegistry.findStaticApi, whose strict-mode (SCHEMA/DATABASE) path resolves the current
tenant and throws TenantNotFoundException when none is bound. Use the non-resolving
getStaticApi to obtain the base api, leaving the load-bearing strict-mode throw intact for
actual queries. Fixes Mongo SingleTenancySpec/MongoConnectionSourcesSpec tenancy tests whose
setup calls eachTenant with no tenant bound; H5/H7 tenancy specs remain green.

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

SchemaBasedMultiTenancySpec passed in isolation but failed in the full suite: a prior
DATABASE-mode tenancy spec left a stale CompanyB->datastore binding in the singleton
GormRegistry, so this SCHEMA-mode spec resolved the wrong datastore (CompanyB.DB.name
'test1Db' instead of 'test1'). Match the MongoDB adapter PR (#15783): make the datastore
@shared, create it once in setupSpec after GormRegistry.reset() to clear leaked state, and
keep per-feature tenant-property clearing in setup(). Applied to SchemaBasedMultiTenancySpec
and MultiTenancySpec. Mongo core 3 -> 1 (only the count-OR disjunction remains).

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

Regression from the GormRegistry rewrite (verified against the pre-rewrite base, where the
schema-per-tenant example passed): the @CurrentTenant AST transform located the TenantService via
apiResolver.findDatastore(domainClass). For a multi-tenant entity with a resolvable current tenant,
findDatastore correctly recurses into the current tenant's per-connection CHILD datastore (right for
tenant-scoped queries) — but that child cannot see schemas added at runtime via addTenantForSchema
(registered on the primary), so Tenants.withId -> withNewSession(tenantId) threw "DataSource not found".

The TenantService must be resolved on the tenant-MANAGER (primary) datastore, as it was pre-rewrite
(service's $targetDatastore). Add GormApiResolver.findServiceDatastore(entity) — a non-tenant-resolving
lookup of the entity's primary (DEFAULT) datastore — and use it in TenantTransform's service branch.
findDatastore is unchanged (tenant query routing preserved). The non-service path (findSingleDatastore,
null entity) already skips tenant resolution. Restores the schema-per-tenant example; H7 core stays green.

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

DisjunctionQuerySpec "Count all dogs or pets with the name Jack" (countByTypeOrName == 3 returns 1)
is a pre-existing MongoDB defect: count over an OR disjunction under-counts, while find-over-OR works
and the MongoQuery count path is unchanged from before the GormRegistry rewrite. Mark it @PendingFeature
(it already carried @issue('GPMONGODB-380')) so it does not block this PR, and track the independent fix
in #15789. grails-data-mongodb-core now reports 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
borinquenkid and others added 2 commits June 29, 2026 21:39
Fixes the functional/CI failures introduced by the O(M+N) GormRegistry rework,
verified locally across unit, TCK, container (Mongo) and functional suites.

Datastore routing (SimpleMapDatastore, GormRegistry, GormEnhancer,
MultipleConnectionSourceCapableDatastore):
- An entity mapped only to non-default connections routes its unqualified
  operations per datastore type. Real datastores (Hibernate, Mongo) manage a
  session per connection, so DEFAULT goes to the first mapped connection. The
  in-memory mock used by unit tests manages a single session on the parent and
  only fabricates isolated children for explicit connection access; routing its
  unqualified operations to such a child dropped writes the harness never
  flushes. A new MultipleConnectionSourceCapableDatastore
  .routesUnqualifiedToMappedConnection() (default true; the interface is now
  Java so the default method is visible to the Java datastore implementations)
  drives this, and GormRegistry's DEFAULT fallback consults it. This keeps
  CarSpec (datasource-mapped entity, single-store mock), MultipleDataSourceSpec
  (isolated datasources) and the Hibernate multi-datasource guards all correct.
- GormEnhancer.allQualifiers resolves the datastore's connection sources freshly
  instead of from a list cached at construction, so schema-/database-per-tenant
  tenants added at runtime via addTenantForSchema are mapped.
- SimpleMapDatastore.getDatastoreForConnection resolves a leaf/per-tenant child's
  own connection name to itself, keeping nested Tenants.withId resolution
  idempotent (mirrors ChildHibernateDatastore).

Hibernate dirty marking (HibernateGormApiFactory, H5 + H7):
- The factory took markDirty from the enhancer (default true) instead of the
  datastore (SETTING_MARK_DIRTY, default false). With it forced true, save()
  marked clean attached entities dirty, flushing a full UPDATE and firing
  spurious beforeUpdate/afterUpdate events. Now sourced from the datastore;
  GormApiAllocationSpec guards it.

Dynamic finders (ListOrderByFinder):
- Restore decapitalizeFirstChar (JavaBeans decapitalize left "ISize" unchanged),
  so listOrderBy works for Hungarian-notation properties such as "iSize".

Tests (graphql integration specs):
- The query-count assertions counted every stdout line; an emitted Hibernate 5
  legacy-criteria deprecation warning now lands in the capture window. They count
  only "Hibernate:" SQL lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove changes that rode along in the squashed scaling commit but are
unrelated to the GORM O(M+N) / GormRegistry work, restoring these files
to their base versions:

- .gitignore: drop personal AI-tooling ignores (.junie/, .cursorrules, etc.)
- build.gradle: drop repo-wide test logging-level overrides
- gradle/rat-root-config.gradle: drop ISSUES.md RAT exclusion (no such file)
- gradle/{hibernate5,hibernate7,mongodb}-test-config.gradle: drop blank-line churn

No source or behavior change; keeps the PR diff reviewable.

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

Copy link
Copy Markdown
Member Author

Note on gradle/test-config.gradle — GORM modules run with maxParallelForks = 1.

The dependsOnProject(...) helper forces single-fork test execution for any module that depends (directly or transitively) on grails-datamapping-core.

Rationale: GormRegistry is a process-wide singleton. With maxParallelForks > 1, sibling specs in the same module share a JVM and can leak registry state across each other (stale entity→datastore/api bindings), producing order-dependent flakiness — e.g. a schema-tenancy spec resolving a datastore bound by an unrelated prior spec. Serializing GORM-dependent test tasks removes that cross-spec pollution deterministically.

This is a known trade-off: it increases wall-clock time for those modules. It is intentional and scoped narrowly to GORM-dependent projects only — non-GORM modules keep the configured parallelism. A finer-grained alternative (per-spec registry reset in setup/cleanup) can supersede this later without changing the public API.

@testlens-app

testlens-app Bot commented Jun 30, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-scaffolding:integrationTest

Test Runs
UserControllerSpec > User list

🏷️ Commit: 312caa1
▶️ Tests: 40575 executed
⚪️ Checks: 41/41 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false))
geb.waiting.WaitTimeoutException: condition did not pass in 10 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.PotentiallyWaitingExecutor.execute(PotentiallyWaitingExecutor.groovy:31)
	at geb.Page.verifyThisPageAtOnly(Page.groovy:424)
	at geb.Page.getAtVerificationResult(Page.groovy:217)
	at geb.Page.verifyAt(Page.groovy:188)
	at geb.Browser.doAt(Browser.groovy:1208)
	at geb.Browser.at(Browser.groovy:410)
	at geb.Browser.to(Browser.groovy:566)
	at geb.Browser.to(Browser.groovy:543)
	at geb.Browser.to(Browser.groovy:532)
	at grails.plugin.geb.support.delegate.BrowserDelegate$Trait$Helper.to(BrowserDelegate.groovy:160)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:46)
Caused by: Assertion failed: 

title == pageTitle
|     |  |
|     |  'User List'
|     false
'Please sign in'

	at com.example.pages.UserListPage._clinit__closure1(UserListPage.groovy:28)
	at com.example.pages.UserListPage._clinit__closure1(UserListPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 11 more

Muted Tests

Select tests to mute in this pull request:

  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app.

@jamesfredley

Copy link
Copy Markdown
Contributor

@borinquenkid does #15771 fully replace this PR? And then does that remove the need for #15790

@jdaugherty

Copy link
Copy Markdown
Contributor

@borinquenkid I'm not sure I follow all of these PRs. There have been multiple opened and then closed. Can you help me understand what is still valid vs what needs closed and what order they're expected to be reviewed in? I don't see an index anywhere of expected review order.

@borinquenkid borinquenkid changed the title feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor (2/3) feat: GORM O(M+N) scaling — GormRegistry, GormEnhancer, and core API refactor Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants