jdaugherty commented on code in PR #16034:
URL: https://github.com/apache/grails-core/pull/16034#discussion_r3652199198
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -94,22 +117,25 @@ public static List<String> getPropertyNames(ClassNode
classNode) {
}
private static Map<String, ClassNode> getPropertiesFromCache(ClassNode
classNode) {
- String className = classNode.getName();
- Map<String, ClassNode> cachedProperties =
cachedClassProperties.get(className);
+ 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);
Review 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.
##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name-
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one
class with those of
+ * an unrelated class that happens to share its name. This spec proves the
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
+
+ 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)
+
+ when: 'the first class node is resolved, populating its cache entry'
+ List<String> firstProperties =
AstPropertyResolveUtils.getPropertyNames(first)
+
+ then: 'only its own property is resolved'
+ firstProperties.contains('color')
+ !firstProperties.contains('weight')
+
+ when: 'the second, differently-packaged, same-simple-name class node
is resolved'
+ List<String> secondProperties =
AstPropertyResolveUtils.getPropertyNames(second)
+
+ then: 'its own property is resolved, not leaked from the first class
node'
+ secondProperties.contains('weight')
+ !secondProperties.contains('color')
+
+ and: 'the first class node cache entry remains unaffected by resolving
the second'
+ List<String> firstPropertiesAfter =
AstPropertyResolveUtils.getPropertyNames(first)
+ firstPropertiesAfter.contains('color')
+ !firstPropertiesAfter.contains('weight')
+ }
+
+ 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()
Review 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.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
* @since 6.1
*/
public class AstPropertyResolveUtils {
- protected static Map<String, Map<String, ClassNode>> cachedClassProperties
= new HashMap<>();
+
+ /**
+ * 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<>());
Review 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 `ClassNode`s 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:
```java
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.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
* @since 6.1
*/
public class AstPropertyResolveUtils {
- protected static Map<String, Map<String, ClassNode>> cachedClassProperties
= new HashMap<>();
+
+ /**
+ * 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.
Review 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.
##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name-
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one
class with those of
+ * an unrelated class that happens to share its name. This spec proves the
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
+
+ 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)
+
+ when: 'the first class node is resolved, populating its cache entry'
+ List<String> firstProperties =
AstPropertyResolveUtils.getPropertyNames(first)
+
+ then: 'only its own property is resolved'
+ firstProperties.contains('color')
+ !firstProperties.contains('weight')
+
+ when: 'the second, differently-packaged, same-simple-name class node
is resolved'
+ List<String> secondProperties =
AstPropertyResolveUtils.getPropertyNames(second)
+
+ then: 'its own property is resolved, not leaked from the first class
node'
+ secondProperties.contains('weight')
+ !secondProperties.contains('color')
+
+ and: 'the first class node cache entry remains unaffected by resolving
the second'
+ List<String> firstPropertiesAfter =
AstPropertyResolveUtils.getPropertyNames(first)
+ firstPropertiesAfter.contains('color')
+ !firstPropertiesAfter.contains('weight')
+ }
+
+ 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)
+
+ when: 'both class nodes are resolved'
+ List<String> firstProperties =
AstPropertyResolveUtils.getPropertyNames(first)
+ List<String> secondProperties =
AstPropertyResolveUtils.getPropertyNames(second)
+
+ then: 'each keeps its own, independently-resolved properties despite
comparing equal'
+ firstProperties.contains('color')
+ !firstProperties.contains('weight')
+ secondProperties.contains('weight')
+ !secondProperties.contains('color')
+ }
+
+ 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
Review 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.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
* @since 6.1
*/
public class AstPropertyResolveUtils {
- protected static Map<String, Map<String, ClassNode>> cachedClassProperties
= new HashMap<>();
+
+ /**
+ * 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 =
Review 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.
##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name-
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one
class with those of
+ * an unrelated class that happens to share its name. This spec proves the
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
Review Comment:
Good to see this class get its first coverage. As written, though, it only
exercises hand-built non-domain `ClassNode`s, 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.
##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -94,22 +117,25 @@ public static List<String> getPropertyNames(ClassNode
classNode) {
}
private static Map<String, ClassNode> getPropertiesFromCache(ClassNode
classNode) {
- String className = classNode.getName();
- Map<String, ClassNode> cachedProperties =
cachedClassProperties.get(className);
+ 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();
}
Review 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 `ClassNode`s 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.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]