Fix flaky WhereQueryClosureCaptureSpec: key AstPropertyResolveUtils cache by ClassNode identity - #16034
Fix flaky WhereQueryClosureCaptureSpec: key AstPropertyResolveUtils cache by ClassNode identity#16034borinquenkid wants to merge 3 commits into
Conversation
…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>
There was a problem hiding this comment.
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
cachedClassPropertiesto a synchronizedIdentityHashMapkeyed byClassNodeidentity (==) 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
ClassNodecache 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.
| 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) |
There was a problem hiding this comment.
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.
|
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 grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
🚀 New features to boost your workflow:
|
|
Thanks for the review! The scenario you're describing — two distinct 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 The test this comment is attached to intentionally covers a different case: two |
jdaugherty
left a comment
There was a problem hiding this comment.
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:
- Keying a
staticmap byClassNodeturns a bounded cache into a classloader leak. A primaryClassNodetransitively pins itsCompileUnitand therefore itsGroovyClassLoader, and nothing ever evicts. Detail inline — the per-nodegetNodeMetaDataalternative 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. - 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 isclassNode.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.
| /** | ||
| * 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<>()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ClassNode currentNode = classNode; | ||
| while (currentNode != null && !currentNode.equals(ClassHelper.OBJECT_TYPE)) { | ||
| populatePropertiesForClassNode(currentNode, cachedProperties, isDomainClass, !isDomainClass); | ||
| populatePropertiesForClassNode(currentNode, newProperties, isDomainClass, !isDomainClass); | ||
| currentNode = currentNode.getSuperClass(); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| first == second | ||
| first.hashCode() == second.hashCode() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
✅ All tests passed ✅🏷️ Commit: c3a4d92 Learn more about TestLens at testlens.app. |
Summary
WhereQueryClosureCaptureSpechas 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.cachedClassPropertiesis a static, non-thread-safeHashMapused by the
where-transform's variable-scope recompute path(
DetachedCriteriaTransformer). It was already keyed byClassNode.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 compilesidentical source twice into two separate
GroovyClassLoaders, producing twodistinct
ClassNodeinstances that areequals()-identical and therefore collide asthe 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
cachedClassPropertiesto a synchronizedIdentityHashMap<ClassNode, ...>— keys compared by
==, so two distinctClassNodes can never collide regardless ofshared naming.
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
AstPropertyResolveUtilsSpec.groovy(public-API only): includes a test thatconstructs two distinct
ClassNodes sharing the exact same name and asserts eachresolves 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.
Related: #16030