This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch 
fix/flaky-where-query-closure-capture-cache
in repository https://gitbox.apache.org/repos/asf/grails-core.git


The following commit(s) were added to 
refs/heads/fix/flaky-where-query-closure-capture-cache by this push:
     new 0d195f1493 Address review: skip caching interned nodes, compute 
outside lock, fix isDomainClass javadoc claim
0d195f1493 is described below

commit 0d195f1493d136ab3f69e61aecf36b7ccaf5eac0
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Mon Aug 17 08:59:47 2026 -0500

    Address review: skip caching interned nodes, compute outside lock, fix 
isDomainClass javadoc claim
    
    Responds to jdaugherty's latest review round on AstPropertyResolveUtils:
    
    - The per-ClassNode property cache does not make isDomainClass 
collision-free:
      AstUtils#isDomainClass(ClassNode) is @Memoized by Groovy's default,
      equality-keyed memoize, and ClassNode#equals()/hashCode() reduce to the 
class
      name. Corrected the javadoc's "no collision is possible" claim to scope 
it to
      the property map and call out this separate, pre-existing exposure 
instead of
      overclaiming immunity to it. Restored the "must stay unique" wording in
      WhereQueryClosureCaptureSpec/WhereQueryEmbeddedBlockTransformSpec 
accordingly.
    
    - getPropertiesFromCache now skips caching for non-primary ClassNodes
      (ClassNode#isPrimaryClassNode() == false), which is what interned,
      JVM-wide-shared singletons like ClassHelper.OBJECT_TYPE/STRING_TYPE are.
      QueryStringTransformer can feed such a node in as a cache holder for a
      JDK-typed property; this class no longer writes its metadata key into a 
node
      it doesn't own. These nodes are cheap to recompute, so skipping the cache 
for
      them has no material cost.
    
    - computeProperties can force a domain class's static initializer and invoke
      user-written static getters via ClassPropertyFetcher. Moved that call 
outside
      the synchronized(cacheHolder) block so the per-node monitor is only held 
to
      check for and publish the result, not while running that code.
    
    - IDENTITY/VERSION property types now use
      ClassHelper.make(Long.class).getPlainNodeReference(), matching the 
existing
      hasMany/belongsTo/hasOne convention, instead of a fresh, uninterned
      new ClassNode(Long.class) per computation.
    
    - Fixed a no-op assertion in AstPropertyResolveUtilsSpec: a bare boolean
      expression in an "and:" block continuing "given:" is never implicitly
      asserted by Spock. Added an explicit assert.
    
    - Added cleanup: blocks to the concurrency tests (executor.shutdownNow(),
      timed barrier.await()) and closed the GroovyClassLoader in the reflection
      test, so a hung/failed thread can no longer leak a non-daemon thread pool
      into the rest of the test JVM (forkEvery = 50).
    
    Verified: :grails-datamapping-core:test full module suite passes, including
    all three touched specs. codeStyle (Checkstyle + CodeNarc) reports no
    violations across the whole repo.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 .../gorm/transform/AstPropertyResolveUtils.java    | 53 ++++++++++++++++++++--
 .../transform/WhereQueryClosureCaptureSpec.groovy  | 18 ++++----
 .../WhereQueryEmbeddedBlockTransformSpec.groovy    | 16 ++++---
 .../transform/AstPropertyResolveUtilsSpec.groovy   | 21 ++++++---
 4 files changed, 82 insertions(+), 26 deletions(-)

diff --git 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
index 3ea291deb6..4778217fae 100644
--- 
a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
+++ 
b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java
@@ -49,7 +49,8 @@ import org.grails.datastore.mapping.reflect.NameUtils;
 public class AstPropertyResolveUtils {
 
     /**
-     * Key under which the resolved property map is stashed via {@link 
ClassNode#getNodeMetaData(Object, java.util.function.Function)}.
+     * Key under which the resolved property map is stashed as {@code 
ClassNode} metadata via
+     * {@link #getPropertiesFromCache}.
      * <p>
      * Earlier versions of this class cached resolved properties in a single 
static, process-wide
      * {@code Map} keyed by class name (later by {@code ClassNode} identity). 
Both designs share a
@@ -65,12 +66,21 @@ public class AstPropertyResolveUtils {
      *     <li>No collision is possible between distinct {@code ClassNode} 
instances that happen to
      *     share a name (e.g. classes compiled without a package, or the same 
source compiled twice
      *     in separate {@code GroovyClassLoader}s) - each instance owns its 
own metadata storage, so
-     *     there is no shared key space to collide on in the first place.</li>
+     *     there is no shared key space to collide on in the first place. This 
holds for the resolved
+     *     <em>property map</em> built here.</li>
      *     <li>No leak is possible - the cached data is only reachable through 
the {@code ClassNode}
      *     it describes, so it becomes eligible for garbage collection at the 
same time as the node
      *     (and the compilation/classloader it belongs to) rather than being 
pinned forever by a
      *     static field.</li>
      * </ul>
+     * This does <strong>not</strong> extend to whether a given {@code 
ClassNode} is treated as a
+     * domain class in the first place: {@link 
AstUtils#isDomainClass(ClassNode)} is
+     * {@code @Memoized}, and Groovy's default memoize is keyed by argument 
<em>equality</em>
+     * ({@code ClassNode#equals}/{@code hashCode} both reduce to the class 
name), not identity. Two
+     * distinct, same-named {@code ClassNode}s can therefore still receive the 
same domain-class
+     * verdict from each other via that memo cache, independent of the 
property cache here. That is
+     * a narrower, pre-existing exposure in {@code isDomainClass} itself - 
this class neither causes
+     * nor fixes it, and it is tracked separately rather than folded into this 
cache's guarantees.
      * Per {@link ClassNode#getModule()}'s own convention, the cache is stored 
on
      * {@link ClassNode#redirect()} - the node a 
placeholder/generics-parameterized reference
      * ultimately stands in for - so that looking a class up through different 
reference nodes still
@@ -116,6 +126,24 @@ public class AstPropertyResolveUtils {
      * metadata key to the same shared node concurrently without also 
synchronizing on that node -
      * this class has no way to compel that. That residual risk belongs to 
{@code ClassNode}'s
      * metadata storage in general, not to anything specific to the cache here.
+     * <p>
+     * Those interned singleton nodes ({@link ClassNode#isPrimaryClassNode()} 
returns {@code false}
+     * for them, since {@link ClassHelper#make(Class)} builds them via the 
{@code ClassNode(Class)}
+     * constructor) are never written to by this class at all: {@link 
#getPropertiesFromCache}
+     * recomputes for them on every call instead of caching. Caching there 
would mean writing this
+     * class's own metadata key into a node no single compilation owns, 
growing the residual risk
+     * above from "someone else's key might race with a read" to "this class's 
own writes pollute a
+     * JVM-wide-shared node forever" - not a real cost, since the properties 
of a JDK type are cheap
+     * to recompute and there are only a handful of these nodes in practice.
+     * <p>
+     * {@link #computeProperties} can run arbitrary user code for a resolved 
domain class - it
+     * forces the class's static initializer and invokes user-written static 
getters via
+     * {@link ClassPropertyFetcher}. Running that while holding the per-node 
monitor would risk
+     * deadlocking against unrelated code the caller has no visibility into, so
+     * {@link #getPropertiesFromCache} computes outside the lock and only 
takes the monitor to check
+     * for and, if needed, publish the result. Two threads can therefore both 
compute for the same
+     * as-yet-uncached node; the second to acquire the monitor discards its 
own (equivalent) result
+     * and returns whatever the first published, so only one map is ever 
visible to callers.
      */
     private static final String PROPERTIES_CACHE_KEY = 
AstPropertyResolveUtils.class.getName() + ".properties";
 
@@ -165,8 +193,23 @@ public class AstPropertyResolveUtils {
 
     private static Map<String, ClassNode> getPropertiesFromCache(ClassNode 
classNode) {
         ClassNode cacheHolder = classNode.redirect();
+        if (!cacheHolder.isPrimaryClassNode()) {
+            return computeProperties(cacheHolder);
+        }
         synchronized (cacheHolder) {
-            return cacheHolder.getNodeMetaData(PROPERTIES_CACHE_KEY, key -> 
computeProperties(cacheHolder));
+            Map<String, ClassNode> cached = 
cacheHolder.getNodeMetaData(PROPERTIES_CACHE_KEY);
+            if (cached != null) {
+                return cached;
+            }
+        }
+        Map<String, ClassNode> computed = computeProperties(cacheHolder);
+        synchronized (cacheHolder) {
+            Map<String, ClassNode> cached = 
cacheHolder.getNodeMetaData(PROPERTIES_CACHE_KEY);
+            if (cached != null) {
+                return cached;
+            }
+            cacheHolder.putNodeMetaData(PROPERTIES_CACHE_KEY, computed);
+            return computed;
         }
     }
 
@@ -174,8 +217,8 @@ public class AstPropertyResolveUtils {
         Map<String, ClassNode> newProperties = new HashMap<>();
         boolean isDomainClass = AstUtils.isDomainClass(classNode);
         if (isDomainClass) {
-            newProperties.put(GormProperties.IDENTITY, new 
ClassNode(Long.class));
-            newProperties.put(GormProperties.VERSION, new 
ClassNode(Long.class));
+            newProperties.put(GormProperties.IDENTITY, 
ClassHelper.make(Long.class).getPlainNodeReference());
+            newProperties.put(GormProperties.VERSION, 
ClassHelper.make(Long.class).getPlainNodeReference());
         }
         ClassNode currentNode = classNode;
         while (currentNode != null && 
!currentNode.equals(ClassHelper.OBJECT_TYPE)) {
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryClosureCaptureSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryClosureCaptureSpec.groovy
index 31ee016851..240c372779 100644
--- 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryClosureCaptureSpec.groovy
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryClosureCaptureSpec.groovy
@@ -31,14 +31,16 @@ import spock.lang.Specification
  */
 class WhereQueryClosureCaptureSpec extends Specification {
 
-    // Historical note: these domain class names were made unique across the 
test JVM
-    // (ClosureCaptureBook/ClosureCaptureAuthor rather than the more generic 
Book/Author)
-    // because AstPropertyResolveUtils used to cache resolved properties in a 
single
-    // static map keyed by class name, so a same-named fixture in another spec 
could
-    // collide with this one. AstPropertyResolveUtils now caches per-ClassNode 
instance
-    // (see its javadoc), so that collision can no longer happen regardless of 
naming -
-    // the unique names are kept only because they make the fixture's purpose 
clearer,
-    // not because uniqueness is required for correctness.
+    // These domain class names (ClosureCaptureBook/ClosureCaptureAuthor 
rather than the more
+    // generic Book/Author) must stay unique across the test JVM. 
AstPropertyResolveUtils caches
+    // resolved properties per-ClassNode instance (see its javadoc), which is 
enough to stop two
+    // same-named-but-distinct ClassNodes from corrupting each other's 
property maps. It does not
+    // cover AstUtils#isDomainClass(ClassNode): that check is @Memoized by 
Groovy's default,
+    // equality-keyed memoize, and ClassNode#equals()/hashCode() reduce to the 
class name - so a
+    // same-named, differently-shaped fixture compiled in another spec sharing 
this JVM fork
+    // (forkEvery = 50) can still hand this class a stale domain-class verdict 
from that memo
+    // cache, regardless of the property-map fix. Renaming these back to 
Book/Author would risk
+    // exactly that order-dependent flake again.
     private static final String SERVICE_SOURCE = '''
 import grails.gorm.DetachedCriteria
 import grails.gorm.annotation.Entity
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedBlockTransformSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedBlockTransformSpec.groovy
index 8b2660ad9d..0bed29a7e7 100644
--- 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedBlockTransformSpec.groovy
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/transform/WhereQueryEmbeddedBlockTransformSpec.groovy
@@ -33,13 +33,15 @@ import spock.lang.Specification
  */
 class WhereQueryEmbeddedBlockTransformSpec extends Specification {
 
-    // Historical note: these domain class names were made unique across the 
test JVM
-    // because AstPropertyResolveUtils used to cache resolved properties in a 
single
-    // static map keyed by class name, so a same-named fixture in another spec 
could
-    // collide with this one. AstPropertyResolveUtils now caches per-ClassNode 
instance
-    // (see its javadoc), so that collision can no longer happen regardless of 
naming -
-    // the distinctive names are kept only because they make the fixture's 
purpose
-    // clearer, not because uniqueness is required for correctness.
+    // These domain class names must stay unique across the test JVM. 
AstPropertyResolveUtils
+    // caches resolved properties per-ClassNode instance (see its javadoc), 
which is enough to
+    // stop two same-named-but-distinct ClassNodes from corrupting each 
other's property maps. It
+    // does not cover AstUtils#isDomainClass(ClassNode): that check is 
@Memoized by Groovy's
+    // default, equality-keyed memoize, and ClassNode#equals()/hashCode() 
reduce to the class name
+    // - so a same-named, differently-shaped fixture compiled in another spec 
sharing this JVM
+    // fork (forkEvery = 50) can still hand this class a stale domain-class 
verdict from that memo
+    // cache, regardless of the property-map fix. Reusing a generic name would 
risk exactly that
+    // order-dependent flake again.
     private static final String SERVICE_SOURCE = '''
 import grails.gorm.DetachedCriteria
 import grails.gorm.annotation.Entity
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
index 4ddc7a6204..d1b6dd9cd3 100644
--- 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy
@@ -95,8 +95,10 @@ class AstPropertyResolveUtilsSpec extends Specification {
         // these are two unrelated ClassNode instances with different declared 
properties. A cache
         // keyed by name or by equals()/hashCode() would treat them as the 
same entry; only
         // reference identity (!first.is(second)) tells them apart, which is 
exactly what the
-        // cache must key on.
-        !first.is(second)
+        // cache must key on. This is an "and:" continuing "given:", so Spock 
does not apply an
+        // implicit condition here - the explicit assert is required for this 
to actually fail
+        // the test if it were ever untrue.
+        assert !first.is(second)
 
         when: 'both class nodes are resolved'
         List<String> firstProperties = 
AstPropertyResolveUtils.getPropertyNames(first)
@@ -202,6 +204,9 @@ class AstPropertyResolveUtilsSpec extends Specification {
 
         then: 'the reflected association properties are present alongside the 
injected identity/version properties'
         propertyNames.containsAll([GormProperties.IDENTITY, 
GormProperties.VERSION, 'books', 'author', 'publisher'])
+
+        cleanup:
+        gcl.close()
     }
 
     void "concurrent resolution of distinct, identically-named ClassNode 
instances never corrupts each other's cached properties"() {
@@ -213,7 +218,7 @@ class AstPropertyResolveUtilsSpec extends Specification {
         when: 'all threads race to populate the cache for their own instance 
at the same time'
         List<Future<Boolean>> futures = (0..<threadCount).collect { int i ->
             executor.submit({ ->
-                barrier.await()
+                barrier.await(30, TimeUnit.SECONDS)
                 ClassNode node = new ClassNode('ConcurrentWidget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
                 String propertyName = "prop${i}".toString()
                 node.addProperty(propertyName, Modifier.PUBLIC, 
ClassHelper.STRING_TYPE, null, null, null)
@@ -223,10 +228,12 @@ class AstPropertyResolveUtilsSpec extends Specification {
             } as Callable<Boolean>)
         }
         List<Boolean> outcomes = futures.collect { Future<Boolean> future -> 
future.get(30, TimeUnit.SECONDS) }
-        executor.shutdown()
 
         then: 'every thread resolved its own property set, uncontaminated by 
any of the other concurrently-resolved same-named instances'
         outcomes.every { it }
+
+        cleanup:
+        executor.shutdownNow()
     }
 
     void "concurrent resolution of the exact same shared ClassNode instance 
from many threads is safe"() {
@@ -261,15 +268,17 @@ class AstPropertyResolveUtilsSpec extends Specification {
         when: 'all threads race to resolve properties for the same instance at 
once'
         List<Future<List<String>>> futures = (0..<threadCount).collect {
             executor.submit({ ->
-                barrier.await()
+                barrier.await(30, TimeUnit.SECONDS)
                 AstPropertyResolveUtils.getPropertyNames(sharedNode)
             } as Callable<List<String>>)
         }
         List<List<String>> results = futures.collect { Future<List<String>> 
future -> future.get(30, TimeUnit.SECONDS) }
-        executor.shutdown()
 
         then: 'every thread observes the same, fully and correctly populated 
result - none sees a partial or corrupted map'
         results.every { it == ['label'] }
+
+        cleanup:
+        executor.shutdownNow()
     }
 
     private static Expression mapExpressionOf(String key, ClassNode valueType) 
{

Reply via email to