Skip to content

GROOVY-11966: AnnotationNode.isTargetAllowed introduces concurrent wr…#2492

Merged
paulk-asert merged 1 commit intoapache:masterfrom
paulk-asert:groovy11966
May 1, 2026
Merged

GROOVY-11966: AnnotationNode.isTargetAllowed introduces concurrent wr…#2492
paulk-asert merged 1 commit intoapache:masterfrom
paulk-asert:groovy11966

Conversation

@paulk-asert
Copy link
Copy Markdown
Contributor

@paulk-asert paulk-asert commented Apr 26, 2026

…ite to shared ListHashMap

https://issues.apache.org/jira/browse/GROOVY-11966

Background

NodeMetaDataHandler stores per-ASTNode metadata in a ListHashMap — a small-N optimised map that promotes its internal storage from arrays to a HashMap once it grows past three entries. This map is not thread-safe, and the array→HashMap promotion inside ListHashMap.put is the specific operation that races under concurrent access.

Built-in annotation ClassNodes such as @CompileStatic, @Inject, and @Target are cached process-wide via ClassHelper.makeCached. AnnotationNode.isTargetAllowed then memoises an allowed-targets bitmask on each cached node via classNode.redirect().getNodeMetaData(Target.class, factory). That call looks like a read but is actually a write into the shared ListHashMap.

Any toolchain that compiles Groovy sources in parallel within a single JVM hits this:

  • Grails GSP compilation — see grails-core#15558. The GSP compiler used Executors.newFixedThreadPool(availableProcessors() * 2) and parallel compilation of pages annotated with @CompileStatic, @Inject, etc. reliably produced ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3 ... at org.codehaus.groovy.util.ListHashMap.toMap. Grails worked around it by defaulting GSP compiler parallelism to 1 on Groovy 6 (configurable via -Dgrails.gsp.compiler.parallelism=N) pending an upstream fix.
  • Gradle parallel compilation, Maven multi-module builds, and any other multi-threaded consumer of groovy-ast are affected similarly.

The new reproducer under subprojects/stress exercises this directly on a shared cached ClassNode from 16 threads.

Fix

Switch the default newMetaDataMap() to return Collections.synchronizedMap(new ListHashMap()). Per-entry operations on the wrapper (get, put, remove, computeIfAbsent) become atomic on a private mutex inside the wrapper, eliminating the race on the array→HashMap promotion. Lazy first-creation of the map field uses a double-checked-locking idiom over a volatile metaDataMap field on ASTNode and CompileUnit. Iteration over the result of getMetaDataMap() is documented in Javadoc as a caller responsibility — Collections#synchronizedMap requires synchronized (map) { … } around iteration.

ConcurrentHashMap was the main alternative considered.

Trade-off comparison

Groovy prior to any fix Collections.synchronizedMap(new ListHashMap()) (this PR) ConcurrentHashMap
Per-node memory overhead (when metadata is present) 0 (raw ListHashMap) +24–32 bytes wrapper much heavier than the synchronized wrapper; most metadata maps hold 0–3 entries where CHM is the wrong shape
Hot-path read cost unsynchronised (unsafe) volatile field read + monitor enter on wrapper's private mutex volatile / CAS, no monitor
computeIfAbsent atomicity not atomic atomic (Collections#synchronizedMap overrides since JDK 8u) atomic
getNodeMetaData() no-arg result live unmodifiableMap over an unprotected ListHashMap (unsafe under contention) Map.copyOf snapshot taken under the wrapper's mutex Map.copyOf snapshot (lock-free traversal)
Iteration semantics on result of getMetaDataMap() undefined under contention caller must wrap iteration in synchronized (map) { … } per the Collections#synchronizedMap contract weakly consistent — iterators reflect state at some point at or after creation, no ConcurrentModificationException
ArrayIndexOutOfBoundsException / NullPointerException from parallel access to ListHashMap (GROOVY-11966) yes eliminated eliminated (different storage)
API caller multi-threading contract implicit single-threaded; undocumented; widely violated by parallel-compile tooling per-entry mutators are thread-safe; iteration of getMetaDataMap() requires caller-side synchronisation per-entry mutators are thread-safe; iteration is weakly consistent

Extensibility hook

NodeMetaDataHandler.newMetaDataMap() (added in 5.0.0) remains the extension point for downstream consumers. AST-aware IDE/editor tooling that reuses AST nodes across long-lived sessions has historically overridden it to inject a thread-safe map. With this change, the default is already thread-safe so such overrides are no longer required for correctness; the hook now serves as a performance / customisation knob — for example, downstream consumers may still choose to override with ConcurrentHashMap for workloads that favour lock-free iteration, or with a differently-tuned ListHashMap for memory-constrained scenarios.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 26, 2026

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 67.1372%. Comparing base (7c36803) to head (7a7a5b7).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...a/org/codehaus/groovy/ast/NodeMetaDataHandler.java 95.0000% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #2492        +/-   ##
==================================================
- Coverage     67.1394%   67.1372%   -0.0022%     
+ Complexity      31627      31625         -2     
==================================================
  Files            1451       1451                
  Lines          122560     122561         +1     
  Branches        22007      22007                
==================================================
- Hits            82286      82284         -2     
- Misses          33194      33195         +1     
- Partials         7080       7082         +2     
Files with missing lines Coverage Δ
src/main/java/org/codehaus/groovy/ast/ASTNode.java 93.5484% <ø> (ø)
...main/java/org/codehaus/groovy/ast/CompileUnit.java 74.2424% <ø> (ø)
...a/org/codehaus/groovy/ast/NodeMetaDataHandler.java 88.3721% <95.0000%> (-2.1041%) ⬇️

... and 7 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.

This comment was marked as outdated.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

@paulk-asert paulk-asert merged commit 8dde1c8 into apache:master May 1, 2026
36 of 37 checks passed
@paulk-asert paulk-asert deleted the groovy11966 branch May 1, 2026 18:58
jamesfredley pushed a commit to apache/grails-core that referenced this pull request May 2, 2026
Re-audited every Groovy 6 workaround in this canary against the latest
Apache Groovy master. Three fixes have merged and are present in the
6.0.0-SNAPSHOT artifact (latest publication: 2026-05-02 11:47:43 UTC,
build #546), so the corresponding workarounds can be removed.

GROOVY-11968 (apache/groovy#2495), merged 2026-05-01 03:40 UTC,
SHA 84f2f37c4f93d6ea44ad8bc76570704c84499c6b
  - grails-geb/.../ContainerSupport.groovy: revert @CompileDynamic to
    @CompileStatic now that the trait-static-field VerifyError under
    indy=false no longer triggers.

GROOVY-11967 (apache/groovy#2493), merged 2026-05-01 09:37 UTC,
SHA 406feaf5082f1741c318f924b520c4c27bfa0754
  - DefaultConstraintFactory.groovy: collapse the two explicit
    constructors back to a single constructor with a default-valued
    List parameter; the @CompileStatic VerifyError on the synthesised
    bridge constructor no longer reproduces.
  - MappingContextAwareConstraintFactory.groovy: same collapse.

GROOVY-11966 (apache/groovy#2492), merged 2026-05-01 18:58 UTC,
SHA 8dde1c84134ef6fdeecf26b5cbb5183d5aab4dac
  - GroovyPageCompiler.groovy: drop the parallelism guard and the
    grails.gsp.compiler.parallelism system property; restore the
    original Executors.newFixedThreadPool(availableProcessors() * 2)
    sizing now that AnnotationNode.isTargetAllowed -> ListHashMap is
    thread-safe again.
  - AbstractGroovyTemplateCompiler.groovy: same restoration; drop the
    grails.views.compiler.parallelism system property.

Verified locally on Java 21 / Groovy 6.0.0-SNAPSHOT build #546:
  ./gradlew :grails-datamapping-validation:compileGroovy
  ./gradlew :grails-datamapping-core:compileGroovy
  ./gradlew :grails-gsp-core:compileGroovy
  ./gradlew :grails-views-core:compileGroovy
  ./gradlew :grails-geb:compileTestFixturesGroovy
  -> all BUILD SUCCESSFUL

The remaining workarounds (TraitReceiverTransformer static-method
override loss, MetaClassImpl genericGetMethod hijack on GORM entities,
@CompileStatic named-argument render(Map) silent no-op, smart-cast
in 'if (cond && !(x instanceof Y))', VariableScopeVisitor NPE, and
ConfigObject [] mutation) have no upstream fix yet and stay in place.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants