Skip to content

Fix flaky WhereQueryClosureCaptureSpec: key AstPropertyResolveUtils cache by ClassNode identity - #16034

Open
borinquenkid wants to merge 3 commits into
8.0.xfrom
fix/flaky-where-query-closure-capture-cache
Open

Fix flaky WhereQueryClosureCaptureSpec: key AstPropertyResolveUtils cache by ClassNode identity#16034
borinquenkid wants to merge 3 commits into
8.0.xfrom
fix/flaky-where-query-closure-capture-cache

Conversation

@borinquenkid

Copy link
Copy Markdown
Member

Summary

WhereQueryClosureCaptureSpec has two flaky feature methods (~2% flaky per
#16030, 0 hard failures) verifying that closures generated by the
where {} query transform only capture the variables they actually reference.

Root cause

AstPropertyResolveUtils.cachedClassProperties is a static, non-thread-safe HashMap
used by the where-transform's variable-scope recompute path
(DetachedCriteriaTransformer). It was already keyed by ClassNode.getName()
(qualified name), not a bare simple name as first suspected — but ClassNode.equals()/
hashCode() also compare by name, and this spec's own test deliberately compiles
identical source twice into two separate GroovyClassLoaders, producing two
distinct ClassNode instances that are equals()-identical and therefore collide as
the same cache entry. This is reachable by any two classes sharing a name across
separate compilations, not just this test — a prior partial fix (08bd0bf) only
renamed this spec's fixtures to reduce collisions with sibling specs, without fixing
the cache itself.

Fix

  • Switched cachedClassProperties to a synchronized IdentityHashMap<ClassNode, ...>
    — keys compared by ==, so two distinct ClassNodes can never collide regardless of
    shared naming.
  • Fixed a related concurrency bug in the same method: a new cache entry was previously
    published into the shared map before its population loop finished, letting a
    concurrent reader observe a partially-populated entry. Population now completes into
    a local map first, then publishes atomically.

Testing

  • New AstPropertyResolveUtilsSpec.groovy (public-API only): includes a test that
    constructs two distinct ClassNodes sharing the exact same name and asserts each
    resolves independently — a direct, deterministic reproduction of the collision
    mechanism, not a rerun-and-hope test.
  • :grails-datamapping-core:test (full module): BUILD SUCCESSFUL, no failures,
    including both previously-flaky feature methods.
  • CodeNarc/Checkstyle: clean.

Related: #16030

…ache by ClassNode identity

WhereQueryClosureCaptureSpec ("generated closure constructors are identical
across compilations" and "association criteria closures capture only the
variables they reference") was ~2% flaky in CI with no hard failures on the
same commit - a classic sign of shared, racy static state rather than a test
bug.

Root cause: AstPropertyResolveUtils.cachedClassProperties is a static,
process-wide java.util.HashMap that the where{} query transform
(DetachedCriteriaTransformer) consults to resolve a domain class's property
names/types. Two problems compounded:

1. Not thread-safe. Gradle runs many spec classes concurrently within one
   JVM/fork, so concurrent put()/resize on a plain HashMap can corrupt its
   internal structure - a well known source of nondeterministic, rare
   failures that reproduce inconsistently between reruns.

2. Keyed by name (ClassNode#getName()), and ClassNode#equals()/hashCode()
   themselves compare by name too. Two distinct ClassNode instances that
   share a name - e.g. the same source compiled twice into separate
   GroovyClassLoaders, as WhereQueryClosureCaptureSpec's "identical across
   compilations" test does on purpose, or any two test fixtures compiled
   without a package - collide on the same cache entry. Whichever
   compilation populates the entry first "wins", so the second compilation's
   property resolution (and therefore the generated closure's captured
   variables) can silently depend on stale data from an unrelated
   ClassNode/classloader.

Fix: back the cache with an IdentityHashMap (keys compared by `==`, not
`equals()`), wrapped in Collections.synchronizedMap for thread safety. This
eliminates both the corruption hazard and the name-collision hazard
outright, since two different ClassNode instances can never share an entry
regardless of what they're named. Also stopped publishing the new entry into
the shared map until it is fully populated, so a concurrent reader can never
observe a partially-built entry.

Note: the cache already keyed by ClassNode#getName() (the fully-qualified
name), not a bare simple name as initially suspected - but for classes
compiled without a package (common in tests and generated sources), the
qualified name *is* the simple name, so name-based keying could not have
fixed the collision this spec's own "compile the same source twice" test
depends on. Identity-based keying removes the ambiguity entirely instead of
narrowing it.

Added AstPropertyResolveUtilsSpec, a new unit test that builds two distinct
ClassNode instances with the same unqualified name and different property
sets, and proves each resolves and caches its own properties independent of
the other - directly reproducing and proving the fix for the collision,
without relying on reruns of the flaky spec to catch it.

Verified: :grails-datamapping-core:test passes in full, including
WhereQueryClosureCaptureSpec and the new AstPropertyResolveUtilsSpec.
codeStyle (Checkstyle + CodeNarc) reports no violations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 21, 2026 21:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses flakiness in WhereQueryClosureCaptureSpec by fixing AstPropertyResolveUtils’s static property-cache to avoid key collisions between distinct ClassNode instances that compare equal by name, and by ensuring cache entries are only published once fully populated.

Changes:

  • Switch cachedClassProperties to a synchronized IdentityHashMap keyed by ClassNode identity (==) instead of name/equals().
  • Avoid publishing partially-populated cache entries by building into a local map first, then publishing to the shared cache.
  • Add a new Spock spec to deterministically reproduce and guard against same-name ClassNode cache collisions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java Reworks the static cache to use identity-based keys and fixes non-atomic publication of newly created cache entries.
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy Adds tests that exercise same-name ClassNode scenarios to prevent future cache-collision regressions.

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

Comment on lines +40 to +46
void "property lookups for two same-named ClassNodes in different packages do not corrupt each other"() {
given: 'two distinct ClassNodes with the same simple name declared in different packages'
ClassNode first = new ClassNode('org.example.one.Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, null, null, null)

ClassNode second = new ClassNode('org.example.two.Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
second.addProperty('weight', Modifier.PUBLIC, ClassHelper.Integer_TYPE, null, null, null)

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.

Good catch on the mechanics — you're right that this test's two ClassNodes use different fully-qualified names (org.example.one.Widget vs org.example.two.Widget), so it wouldn't have collided under the old name-/equals()-keyed cache either, and doesn't by itself guard the regression.

That guard is the next test below, "property lookups for two distinct ClassNode instances with the exact same unqualified name do not corrupt each other" — both ClassNodes there are named plain Widget with no package, so they do compare equal (first == second, matching hashCode()) exactly as the old cache's key would, and the test asserts they still resolve independently under the new identity-keyed cache. That's the one that fails under the old implementation and passes under this fix.

This first test is intentionally a different, narrower check — that two distinct, differently-named classes never get confused with each other, which is a correctness property worth keeping on its own regardless of the collision bug. Leaving both as-is: this one for general non-contamination across genuinely different classes, the next one for the actual same-name collision regression.

@bito-code-review

Copy link
Copy Markdown

The test case "property lookups for two same-named ClassNodes in different packages do not corrupt each other" currently uses different fully-qualified names ('org.example.one.Widget' and 'org.example.two.Widget'). To better exercise the regression mechanism and ensure the fix works for same-named classes, you should update this test to use the same fully-qualified name for both ClassNode instances. This will confirm that the IdentityHashMap correctly distinguishes between distinct instances that share the same name.

grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy

given: 'two distinct ClassNodes with the same simple name declared in different packages'
        ClassNode first = new ClassNode('org.example.Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
        first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, null, null, null)

        ClassNode second = new ClassNode('org.example.Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
        second.addProperty('weight', Modifier.PUBLIC, ClassHelper.Integer_TYPE, null, null, null)

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.85714% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 51.8557%. Comparing base (6d1acad) to head (c3a4d92).
⚠️ Report is 139 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...astore/gorm/transform/AstPropertyResolveUtils.java 92.8571% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@               Coverage Diff                @@
##             8.0.x     #16034         +/-   ##
================================================
+ Coverage         0   51.8557%   +51.8557%     
- Complexity       0      18112      +18112     
================================================
  Files            0       2046       +2046     
  Lines            0      96273      +96273     
  Branches         0      16726      +16726     
================================================
+ Hits             0      49923      +49923     
- Misses           0      38981      +38981     
- Partials         0       7369       +7369     
Files with missing lines Coverage Δ
...astore/gorm/transform/AstPropertyResolveUtils.java 80.7229% <92.8571%> (ø)

... and 2045 files with indirect coverage changes

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

@borinquenkid

Copy link
Copy Markdown
Member Author

Thanks for the review! The scenario you're describing — two distinct ClassNode instances sharing the exact same fully-qualified name — is already covered by the test right below this one:

void "property lookups for two distinct ClassNode instances with the exact same unqualified name do not corrupt each other"() {
    given: 'two distinct ClassNode instances - as produced by two separate compilations - sharing an identical unqualified name'
    ClassNode first = new ClassNode('Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
    first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, null, null, null)

    ClassNode second = new ClassNode('Widget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
    second.addProperty('weight', Modifier.PUBLIC, ClassHelper.Integer_TYPE, null, null, null)

    expect: 'the two ClassNode instances compare equal by name - the exact condition that would collide in a name-keyed or equals()-keyed cache'
    first == second
    first.hashCode() == second.hashCode()
    !first.is(second)
    ...

It goes a step further than the suggested edit by asserting first == second and matching hashCode(), which explicitly proves the collision condition a name-/equals()-keyed cache would hit — exactly the regression this fix guards against.

The test this comment is attached to intentionally covers a different case: two ClassNodes that share a simple name but differ by package (so they're not equal), which is a distinct scenario worth keeping separate. Given the exact-FQN-collision case is already exercised, I'll leave both tests as-is rather than introduce a near-duplicate.

@jamesfredley jamesfredley moved this to Todo in Apache Grails Jul 24, 2026
@borinquenkid borinquenkid added this to the grails:8.0.0-RC1 milestone Jul 25, 2026
@borinquenkid borinquenkid removed the status in Apache Grails Jul 25, 2026
@borinquenkid borinquenkid moved this to Todo in Apache Grails Jul 25, 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.

I had AI review this before taking a look, here's it's comments:

Reviewed the identity-keyed cache change. The direction is right — ClassNode.equals/hashCode really do compare by getText() (confirmed in Groovy 5.0.7: equals falls through to ((ClassNode) that).getText().equals(getText())), so a name- or equals()-keyed static cache genuinely conflates distinct compilations, and reordering so an entry is published only once fully populated is a real fix.

Two things I'd want resolved before this lands:

  1. Keying a static map by ClassNode turns a bounded cache into a classloader leak. A primary ClassNode transitively pins its CompileUnit and therefore its GroovyClassLoader, and nothing ever evicts. Detail inline — the per-node getNodeMetaData alternative removes the collision and the leak, and drops the static state entirely. This is the part I'm most concerned about, since the whole point of the change is to make a process-wide static cache safe.
  2. The stated root cause doesn't explain a flaky failure. The same source compiled twice yields two ClassNodes with identical property sets, so a name collision between just those two can't diverge. The genuinely order-dependent input is classNode.isResolved() at first-lookup time, which this PR doesn't change. Inline.

Smaller items, all inline: the protected field's type change plus new final is a breaking change worth converting into private + an explicit clear hook; the javadoc's concurrency rationale cites parallel test forks, which are separate JVMs and so cannot race on a static field; and the new spec doesn't cover the domain-class branches or the concurrency behaviour it claims to fix.

One follow-up outside the diff: WhereQueryClosureCaptureSpec:35 and WhereQueryEmbeddedBlockTransformSpec:37 both still carry the comment "The domain class names must be unique across the test JVM because AstPropertyResolveUtils caches resolved properties statically by class name". That is no longer true after this change, and the fixture renaming in 08bd0bf that it justifies is now unnecessary. Please update both comments (and note whether the renaming can be reverted) so those specs aren't left documenting the old behaviour.

Comment on lines +53 to +73
/**
* Cache of resolved properties per {@link ClassNode}.
* <p>
* Keyed by {@code ClassNode} identity rather than name. {@link ClassNode#equals(Object)} and
* {@link ClassNode#hashCode()} compare by {@link ClassNode#getText()} (essentially the class
* name), so a {@code Map} keyed by name - or even by {@code ClassNode} itself as the map key -
* treats any two distinct {@code ClassNode} instances that happen to share a name as the same
* cache entry. That collision is a real hazard for classes compiled without a package (common
* in tests and dynamically generated sources), and for the same source compiled more than once
* in separate {@code GroovyClassLoader}s: each compilation produces its own {@code ClassNode}
* instance that must never share cached property data with another compilation's instance of a
* same-named class. An {@link IdentityHashMap} avoids that collision entirely by comparing keys
* with {@code ==} instead of {@code equals()}.
* <p>
* Wrapped in {@link Collections#synchronizedMap(Map)} because AST transforms that populate and
* read this cache can run concurrently on multiple threads (e.g. parallel test execution within
* one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for concurrent structural
* modification and can corrupt its internal state under concurrent {@code put()} calls.
*/
protected static final Map<ClassNode, Map<String, ClassNode>> cachedClassProperties =
Collections.synchronizedMap(new IdentityHashMap<>());

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.

Keying this static map by ClassNode identity does fix the collision, but it converts a bounded cache into a classloader leak.

Under the old String key the map held at most one entry per distinct class name. With ClassNode keys it holds one entry per instance, and a primary ClassNode transitively pins its entire compilation:

ClassNode.getModule()  ->  ModuleNode.getUnit()  ->  CompileUnit.loader  (GroovyClassLoader)

plus ClassNode.clazz directly for resolved nodes. Since nothing ever removes an entry and the field is now static final, every compilation that runs a GORM AST transform inside a long-lived JVM permanently retains that compilation's classloader and every class it loaded. That is not hypothetical: the Gradle Groovy compiler daemon is reused across compileGroovy tasks and across builds, and dev-mode / GroovyClassLoader-driven recompiles land in the same place. The map values are ClassNodes too, so they pin loaders as well — that part pre-existed, but it was O(distinct class names) and is now unbounded in the number of compilations.

Groovy already stores per-node state exactly this way (ClassNode.getModule() above is itself getNodeMetaData(ModuleNode.class)), so the cleanest fix is to drop the static map and hang the cache off the node:

private static final String PROPERTIES_CACHE_KEY = AstPropertyResolveUtils.class.getName() + ".properties";

private static Map<String, ClassNode> getPropertiesFromCache(ClassNode classNode) {
    return classNode.getNodeMetaData(PROPERTIES_CACHE_KEY, cn -> computeProperties(classNode));
}

getNodeMetaData(Object, Function) is available in Groovy 5, is identity-scoped by construction (so the collision this PR fixes cannot arise at all), needs no global lock, and is collected together with the node. Worth deciding explicitly whether the holder should be classNode or classNode.redirect()getModule() uses redirect(), and redirected nodes are the one case where the two differ.

If a process-wide map has to stay for some reason, it needs weak identity keys and/or an explicit eviction point, plus a note documenting the expected lifecycle.

@borinquenkid borinquenkid Jul 31, 2026

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, and went with your suggested direction: the static map is gone entirely, and the cache is now stored as ClassNode metadata (classNode.redirect().getNodeMetaData(key, fn)), so a cache entry is only reachable through the node it describes and is collected with it. See the updated javadoc on cachedClassProperties's replacement (PROPERTIES_CACHE_KEY) for the full writeup, including why keying on redirect() is safe here (setRedirect() throws for a primary node - i.e. every real caller of this utility, since they're all mid-compilation - so redirect() is just this for the node's whole life in practice) and why access ended up needing explicit per-node synchronization (some real callers can resolve to interned singletons like ClassHelper.OBJECT_TYPE for a plain Object/def-typed property, and those are shared across every compilation in the JVM, not scoped to one thread the way a normal node is). Landed in 078ac9d, tightened further in c3a4d92 after an adversarial self-review turned up the synchronization gap.

* one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for concurrent structural
* modification and can corrupt its internal state under concurrent {@code put()} calls.
*/
protected static final Map<ClassNode, Map<String, ClassNode>> cachedClassProperties =

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.

Two breaking changes to a protected member of a public class on one line: the key type changed (String -> ClassNode) and the field became final. Anything downstream that reassigned it — today the only way to clear this cache, which plugin AST transforms and test harnesses plausibly do — now fails to compile rather than merely behaving differently.

If it's being broken anyway, take it the rest of the way: make it private static final and expose an explicit, documented clearCache(). That shrinks the exposed surface and gives the retention problem above an escape hatch, instead of leaving protected visibility on a field nobody can usefully touch any more.

@borinquenkid borinquenkid Jul 31, 2026

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.

Went further than private + clearCache() - the static map is gone entirely (078ac9d), so there's no field left to expose or protect. That's still technically a breaking removal for anyone who referenced the old protected field directly, just a cleaner one than a silent type change. Given it's an internal implementation-detail field with no getter/documented extension use, I'm inclined to leave it out of the upgrade guide, but flagging it in case there's a reason to cover it there.

Comment on lines +67 to +70
* Wrapped in {@link Collections#synchronizedMap(Map)} because AST transforms that populate and
* read this cache can run concurrently on multiple threads (e.g. parallel test execution within
* one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for concurrent structural
* modification and can corrupt its internal state under concurrent {@code put()} calls.

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 rationale is inaccurate in a way that will mislead the next reader. maxParallelForks = configuredTestParallel (gradle/test-config.gradle:86) forks separate JVMs, and each JVM gets its own copy of a static field — parallel test forks can therefore never race on this map. JUnit's in-JVM parallel execution isn't enabled anywhere in the build either.

The concurrency exposure that does exist is the compiler itself: multiple compileGroovy tasks running concurrently in a shared Gradle worker, and any embedded compilation driven from more than one thread. Worth rewording to that, otherwise the comment justifies the synchronization with a scenario that can't happen.

@borinquenkid borinquenkid Jul 31, 2026

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.

You're right, fixed (078ac9d). The rationale no longer claims parallel test forks race on this - confirmed against gradle/test-config.gradle that maxParallelForks really does fork separate JVMs and this build never enables JUnit's in-JVM parallel execution, so that was never a real race. Rewrote the javadoc around the actual exposure instead: interned ClassHelper singletons (OBJECT_TYPE, STRING_TYPE, etc.) that any concurrently-running compilation in the same JVM could resolve to and touch through this cache - which is also why access is now synchronized per-node (c3a4d92) rather than resting on "only one thread ever touches a given node," which turned out not to be universally true.

Comment on lines +120 to +136
Map<String, ClassNode> cachedProperties = cachedClassProperties.get(classNode);
if (cachedProperties == null) {
cachedProperties = new HashMap<>();
Map<String, ClassNode> newProperties = new HashMap<>();
boolean isDomainClass = AstUtils.isDomainClass(classNode);
if (isDomainClass) {
cachedProperties.put(GormProperties.IDENTITY, new ClassNode(Long.class));
cachedProperties.put(GormProperties.VERSION, new ClassNode(Long.class));
newProperties.put(GormProperties.IDENTITY, new ClassNode(Long.class));
newProperties.put(GormProperties.VERSION, new ClassNode(Long.class));
}
cachedClassProperties.put(className, cachedProperties);
ClassNode currentNode = classNode;
while (currentNode != null && !currentNode.equals(ClassHelper.OBJECT_TYPE)) {
populatePropertiesForClassNode(currentNode, cachedProperties, isDomainClass, !isDomainClass);
populatePropertiesForClassNode(currentNode, newProperties, isDomainClass, !isDomainClass);
currentNode = currentNode.getSuperClass();
}
} return cachedProperties;
// Publish only once fully populated so a concurrent reader can never observe a
// partially-populated entry for this ClassNode.
cachedProperties = newProperties;
cachedClassProperties.put(classNode, cachedProperties);

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 reorder-and-publish-last fix is correct, and synchronizedMap does supply the safe publication it depends on: get and put synchronize on the same mutex, so a reader that observes the entry also observes a fully-populated HashMap. Worth stating that explicitly in the inline comment, because "publish only once fully populated" is only sufficient given that happens-before edge — with a bare HashMap the reordering alone wouldn't have been enough.

One remaining wrinkle: the check-then-act across the get on line 120 and the put on line 136 is not atomic, so two concurrent callers can each build a complete map and the later put wins. Benign here (the maps are equivalent and never mutated after publication), but cachedClassProperties.computeIfAbsent(classNode, ...) on the synchronized wrapper would be atomic and shorter — the tradeoff being that it holds the mutex for the whole superclass walk.

@borinquenkid borinquenkid Jul 31, 2026

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.

getNodeMetaData(key, fn) uses computeIfAbsent internally (078ac9d), and I additionally wrapped the call in synchronized (cacheHolder) (c3a4d92) after an adversarial self-review flagged that the backing ListHashMap is explicitly documented as not thread-safe, and that some real callers can land on interned singleton nodes (ClassHelper.OBJECT_TYPE etc.) that more than one concurrent compilation could reach - the "only one thread per node" assumption doesn't hold for those. See the javadoc for the precise scope of what the synchronization does and doesn't cover.

Comment on lines 128 to 132
ClassNode currentNode = classNode;
while (currentNode != null && !currentNode.equals(ClassHelper.OBJECT_TYPE)) {
populatePropertiesForClassNode(currentNode, cachedProperties, isDomainClass, !isDomainClass);
populatePropertiesForClassNode(currentNode, newProperties, isDomainClass, !isDomainClass);
currentNode = currentNode.getSuperClass();
}

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.

Identity keying makes the cache immune to name collisions, but I don't think it removes the nondeterminism that a flake like #16030 needs. populatePropertiesForClassNode consults ClassPropertyFetcher only when classNode.isResolved() (line 172), so an entry is a snapshot of whatever resolution state the node happened to be in at the first lookup, and it is never refreshed afterwards. If the first lookup for a node lands at a different compilation phase between runs, the cached property set still differs between runs — identity keys or not.

That also weakens the root-cause story in the description: compiling the same source twice produces two ClassNodes with identical property sets, so a name-keyed collision between just those two is harmless and can't produce divergent bytecode on its own. The collisions that would actually diverge are with a differently-shaped same-named class, or with a same-named node whose entry was cached at a different resolution state.

Can you pin down which one you observed — e.g. the two colliding class names, or the flake reproducing in a loop pre-fix and not post-fix? The change is an improvement regardless, but if the resolution-state snapshot is the real driver then #16030 comes back and this gets recorded as already fixed.

@borinquenkid borinquenkid Jul 31, 2026

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.

Wanted to actually test this rather than argue about it. I reverted to the pre-fix cache and ran the exact "same source compiled twice" scenario WhereQueryClosureCaptureSpec's second test exercises, 40 times sequentially in one JVM (simulating many test classes sharing one fork's static state) - it never diverged, even with the old buggy cache. So I can't point to a repro that pins the ~1% CI flake specifically to the name/identity-collision mechanism, and you may well be right that it isn't the actual trigger. I'm keeping the identity-safety fix regardless, since it's an independently real, provable bug on its own terms (the spec's second test shows two distinct, differently-shaped ClassNodes that happen to share a name getting their properties conflated under the old cache) - but I want to be upfront that I haven't confirmed it's the flake's cause, only that it's a real bug. I'll be watching the flaky-test dashboard (#16030) after this merges to see whether WhereQueryClosureCaptureSpec actually clears.

On the isResolved() snapshot mechanism specifically, I checked the Groovy 5.0.7 source directly: ClassNode.clazz has no setter anywhere outside the ClassNode(Class) constructor, and setRedirect() throws a GroovyBugError if called on a primary node - which is what every real caller of this utility passes in, since they're all resolving a class mid-compilation. So for the nodes this cache actually serves, redirect() is simply the node itself for its entire life, and isResolved() can't flip after the node is first cached. I don't think the staleness you're describing can occur for this code path as it's actually used, though I agree the "cache once, forever" design would be fragile if it could - documented the reasoning (and the primary-node guarantee it leans on) in the class javadoc.

* {@code ClassNode} identity, so same-named-but-distinct class nodes never contaminate each
* other's cached property data.
*/
class AstPropertyResolveUtilsSpec extends Specification {

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.

Good to see this class get its first coverage. As written, though, it only exercises hand-built non-domain ClassNodes, so the branches that actually matter to the transform never execute: AstUtils.isDomainClass is false in all three features, which means the injected id/version entries, the hasMany/belongsTo/hasOne handling in populatePropertiesForInitialExpression, the isResolved() / ClassPropertyFetcher path, and the superclass walk are all untested. This PR rewrites the population loop feeding every one of those, so per the repo rule that a touched class gets its behaviour verified they should be covered here — an @Entity-annotated ClassNode with a hasMany initial expression and a domain superclass would reach most of it.

Also missing: a test for the concurrency fix the PR claims. Several threads calling getPropertyNames concurrently on the same node and on distinct nodes, asserting every returned list is complete, would exercise both the synchronized map and the publish-after-populate ordering. As it stands, reverting either half of the concurrency change leaves this spec green.

@borinquenkid borinquenkid Jul 31, 2026

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.

Added both. Domain-class coverage now includes identity/version injection and hasMany/belongsTo/hasOne resolved two ways: via AST initial expressions (a hand-built ClassNode with a crafted MapExpression, matching what static hasMany = [...] compiles to) and via reflection on an already-resolved class (compiled through GroovyClassLoader.parseClass then re-wrapped with ClassHelper.make(), since that's the only way to get isResolved() == true for that branch).

Also added two concurrency tests: many threads each resolving their own distinct, identically-named ClassNode (proves the identity-collision-freedom property holds under concurrent load, not just sequentially), and - after an adversarial self-review pointed out the first test can't exercise any real race, since nothing is shared between the threads - a second test where 32 threads resolve the exact same shared ClassNode concurrently, which is what the synchronized fix mentioned above actually protects. Worth being honest about that second test's limits: I checked whether it fails without the synchronized guard, and it didn't, in 8 runs - the cached computation is deterministic and idempotent, so a black-box return-value test can't reliably force the underlying race into an observably wrong result. Said that directly in the test's comment rather than overclaiming what it proves; the synchronization is justified by ListHashMap's own "not thread-safe" documentation, not by this test having caught a live bug.

Comment on lines +92 to +99
void "getPropertyType resolves and caches the type of a declared property"() {
given: 'a class node with a declared property'
ClassNode classNode = new ClassNode('org.example.PropertyTypeWidget', Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
classNode.addProperty('label', Modifier.PUBLIC, ClassHelper.STRING_TYPE, null, null, null)

expect: 'the resolved property type matches the declared type, both on first (cache-populating) and second (cache-hit) lookup'
AstPropertyResolveUtils.getPropertyType(classNode, 'label') == ClassHelper.STRING_TYPE
AstPropertyResolveUtils.getPropertyType(classNode, 'label') == ClassHelper.STRING_TYPE

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 name says "and caches", but neither assertion can distinguish a cache hit from a miss — on a miss getPropertyType falls through to classNode.getProperty(propertyName) and returns the same STRING_TYPE, so both lines pass with caching entirely disabled.

To actually pin the caching behaviour: resolve once, then add a second property to the ClassNode and assert getPropertyNames still returns the stale view. That snapshot-at-first-lookup semantics is what this class really guarantees and what the transform relies on, and it's currently unverified.

@borinquenkid borinquenkid Jul 31, 2026

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.

Replaced it. The new test adds a second property to the ClassNode after the first getPropertyNames lookup and asserts the second lookup doesn't see it - getPropertyNames has no live-fallback path (unlike getPropertyType, which falls through to a direct classNode.getProperty() lookup when the cache doesn't contain the key), so this actually proves the result was cached rather than recomputed. It also asserts, via a direct classNode.getProperty('extra') != null check, that the property really was added to the underlying node - so the test demonstrates staleness specifically, not just a lookup that happens to return nothing.

Comment on lines +77 to +78
first == second
first.hashCode() == second.hashCode()

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.

These two assert Groovy's own ClassNode equality contract rather than anything about AstPropertyResolveUtils. They hold today — 5.0.7's ClassNode.equals compares getText() and hashCode() delegates to getText().hashCode() — but if Groovy ever moves ClassNode to identity equality this spec fails while the production behaviour it guards is still perfectly correct.

!first.is(second) on line 79 is the precondition the test actually needs. Consider keeping that and demoting the other two to a comment explaining why two same-named nodes used to collide.

@borinquenkid borinquenkid Jul 31, 2026

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.

Done - trimmed to just !first.is(second), with a comment explaining why the equals()/hashCode() equality matters (it's the exact collision condition a name- or equals()-keyed cache would hit) rather than asserting on Groovy's own ClassNode equality contract.

borinquenkid and others added 2 commits July 30, 2026 21:03
…st gaps

Responds to review feedback on the AstPropertyResolveUtils identity-cache fix:

- Cache resolved properties as ClassNode metadata (classNode.redirect()
  .getNodeMetaData(key, fn)) instead of a static IdentityHashMap. This removes
  the classloader leak a static, never-evicted map would cause in long-lived
  JVMs (Gradle daemon, dev-mode recompiles) - cached data now becomes eligible
  for GC together with the ClassNode/compilation it describes. It also removes
  the now-unnecessary protected static field entirely (no more breaking-change
  surface on it), and eliminates the identity-collision hazard by construction
  rather than by choice of key type, since each ClassNode owns its own cache
  slot with no shared keyspace to collide on.

- Corrected the javadoc's concurrency rationale: parallel Gradle test forks are
  separate JVMs (maxParallelForks) and this build does not enable JUnit's
  in-JVM parallel execution, so they were never a real race. Documented the
  actual, narrower exposure and why per-ClassNode storage sidesteps it (a
  given ClassNode is only ever populated by the single thread compiling it).

- Verified classNode.isResolved() cannot flip from false to true for a given
  ClassNode instance post-construction (ClassNode.clazz has no setter outside
  the ClassNode(Class) constructor - checked against Groovy 5.0.7 sources), so
  the resolve-once-cache-forever design does not have the stale-snapshot
  hazard it would otherwise risk for classes resolved at varying compile
  phases.

- Updated the stale "must be unique" comments in WhereQueryClosureCaptureSpec
  and WhereQueryEmbeddedBlockTransformSpec: per-instance caching means no
  same-named fixture can collide regardless of naming; the distinctive names
  are kept for readability, not correctness.

- Extended AstPropertyResolveUtilsSpec: domain-class identity/version
  injection and hasMany/belongsTo/hasOne resolution via both AST initial
  expressions and (for an already-compiled class) reflection; a concurrency
  test resolving many distinct, identically-named ClassNodes across threads
  simultaneously; a test proving getPropertyNames returns the cached snapshot
  rather than recomputing after the ClassNode is mutated post-cache. Trimmed
  the equals()/hashCode() assertions in the collision test to the actual
  precondition the cache depends on (!first.is(second)), with a comment
  explaining why the equals()/hashCode() equality is the interesting/dangerous
  part rather than something to assert on.

Verified: :grails-datamapping-core:test passes in full (including 3 repeated
runs of AstPropertyResolveUtilsSpec to rule out flakiness in the new
concurrency test). codeStyle (Checkstyle + CodeNarc) reports no violations
across the whole repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Before pushing, ran an adversarial review of the prior commit's per-ClassNode
cache redesign. It found the new concurrency test didn't test concurrency at
all (all threads operated on distinct ClassNode instances, so nothing was
actually shared), and, digging into why that mattered, surfaced a genuine gap:
getNodeMetaData(key, fn) is backed by ListHashMap, which is explicitly
documented as not thread-safe, and some real callers (any property typed
Object/def) can resolve to ClassHelper.OBJECT_TYPE/STRING_TYPE - small,
JVM-wide-shared singleton nodes touched by every compilation in the process,
not scoped to one thread the way the previous javadoc assumed.

- Synchronize per-ClassNode (synchronized (cacheHolder) in
  getPropertiesFromCache) so concurrent calls into this class are safe with
  respect to each other, including on a shared singleton node. Documented
  precisely what this does and does not cover (it can't force unrelated
  compiler code writing a different metadata key to the same node to
  synchronize on the same monitor - that residual risk belongs to ClassNode's
  metadata storage in general).

- Replaced the placebo concurrency test (distinct nodes only) with one that
  actually shares a single ClassNode across 32 threads, plus kept the
  distinct-node test since it still legitimately covers the identity-collision
  property. Verified the shared-node test's value honestly: with the
  synchronized guard removed, it still passed 8/8 runs, because the cached
  computation is deterministic/idempotent, so a black-box return-value test
  can't reliably force ListHashMap's undocumented internals into an observably
  wrong state. Said so directly in the test's comment rather than overclaiming
  what it proves - the synchronization is justified by ListHashMap's own
  "not thread-safe" documentation, not by this test catching a live bug.

- Tightened the isResolved()-invariance javadoc: the previous version's
  argument ("clazz has no setter, so isResolved() can't flip") was incomplete,
  since isResolved() also delegates through redirect(), which can in principle
  be reassigned after construction. Verified via an actual GroovyBugError at
  test runtime that ClassNode.setRedirect() refuses to run on a primary node -
  and every ClassNode this utility's real callers pass in during an AST
  transform is primary - so redirect() is simply the node itself for the
  entire object's life in every real usage. Tried to write a unit test proving
  the redirect/self-healing behavior for the one remaining theoretical case
  (a non-primary reference node) and confirmed such a node can't be
  constructed from outside Groovy's own ast package without reflection, which
  would violate this repo's public-API-only testing rule - dropped that test
  and rely on the (now precise) javadoc instead.

Verified: :grails-datamapping-core:test passes in full from a clean checkout
of the correct worktree/branch (caught and corrected a shell cwd mixup that
had briefly pointed a test run at a different local branch entirely).
codeStyle (Checkstyle + CodeNarc) reports no violations across the whole repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid
borinquenkid requested a review from jdaugherty July 31, 2026 18:00
@testlens-app

testlens-app Bot commented Jul 31, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: c3a4d92
▶️ Tests: 57639 executed
⚪️ Checks: 60/60 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.

4 participants