java-topology/defects/micronaut/patch/micronaut-0004-processannotation-diamond-recursion.md
russell@unturf.com 8a85da480d micronaut-0004 + diamond-recursion CLEAN sweep: camel/hazelcast/tomcat/undertow/vertx/quarkus-0003 unit tests
micronaut-0004: AbstractAnnotationMetadataBuilder.processAnnotation O(2^D) diamond recursion
in meta-annotation stereotype traversal. isProcessed() guard tracks only current-path ancestors,
not globally visited nodes — diamond meta-annotation hierarchies cause exponential re-visits
of shared base annotations (e.g. @Transactional + @Retryable both extend @InterceptorBinding).
13x at D=8, 41x at D=10. 10/10 unit tests PASS.

quarkus-0003 unit tests: added to QuarkusTest.java for the existing quarkus-0003
BeanDeployment.recursiveBuild diamond defect. 9/9 PASS.

CLEAN markers: camel, hazelcast, tomcat, undertow, vertx — no diamond recursion pattern found.
Hazelcast uses proper Tarjan algorithm. Tomcat uses iterative constraint propagation.
2026-03-29 17:21:51 -04:00

5.6 KiB
Raw Blame History

micronaut-0004: AbstractAnnotationMetadataBuilder.processAnnotation — O(2^D) diamond recursion in meta-annotation traversal

CWE-407 — Algorithmic Complexity: Exponential Recursion on Diamond Annotation Graphs

Field Value
ID micronaut-0004
Severity MEDIUM
Ecosystem micronaut
Package core-processor
File core-processor/src/main/java/io/micronaut/inject/annotation/AbstractAnnotationMetadataBuilder.java
Lines 11281158 (processAnnotation), 12351272 (extractStereotypes)
Complexity O(2^D) where D = depth of diamond-shaped meta-annotation hierarchy
Hot path Called for every annotated element during Micronaut annotation processing (compile time)

Description

AbstractAnnotationMetadataBuilder.processAnnotation() traverses the meta-annotation stereotype hierarchy recursively. The cycle guard context.isProcessed(annotationValue) only checks whether the annotation appears as an ancestor in the current recursion path — it does NOT prevent re-visiting an already-processed node via a diamond-shaped meta-annotation graph.

private Stream<ProcessedAnnotation> processAnnotation(ProcessingContext context,
                                                      ProcessedAnnotation processedAnnotation) {
    AnnotationValue<?> annotationValue = processedAnnotation.getAnnotationValue();
    // isProcessed checks only parentAnnotations (current path ancestors), NOT a global visited set
    if (AnnotationUtil.INTERNAL_ANNOTATION_NAMES.contains(annotationValue.getAnnotationName())
        || context.isProcessed(annotationValue)) {
        return Stream.empty();
    }
    // ...
    processedAnnotation = addStereotypes(context, processedAnnotation, stereotypesProvided);
    // addStereotypes → extractStereotypes → processAnnotation (recursive call)
private List<ProcessedAnnotation> extractStereotypes(ProcessingContext context,
                                                     ProcessedAnnotation processedAnnotation) {
    ProcessingContext newContext = context.withParent(processedAnnotation.annotationValue);
    // ...
    return ... .flatMap(stereotype -> processAnnotation(newContext, stereotype)).toList();
}

ProcessingContext.isProcessed is defined as:

boolean isProcessed(AnnotationValue<?> annotationValue) {
    return parentAnnotations.contains(annotationValue.getAnnotationName());
}

parentAnnotations tracks only the current traversal path (ancestors), not globally visited nodes. With a diamond meta-annotation graph:

@MyAnnotation  →  @Transactional  ─┐
                                   ├─→  @InterceptorBinding  (visited twice)
               →  @Retryable      ─┘

When processing @MyAnnotation:

  1. Process @Transactional (context = {MyAnnotation}) → process @InterceptorBinding [visit 1]
  2. Process @Retryable (context = {MyAnnotation}) → process @InterceptorBinding [visit 2]

@InterceptorBinding is NOT in {MyAnnotation} during step 2, so the guard misses it.

At diamond depth D=5: @InterceptorBinding is visited 2^4 = 16 times. At depth D=10: 512 times.

Each visit calls getAnnotationsForType() (a compiler API call to read the annotation type's own annotations) and potentially invokes annotation mappers/transformers — all redundant work.

Real-world trigger

Micronaut applications commonly use composite annotations that meta-annotate with multiple interceptor bindings (e.g., @Cacheable, @Transactional, @Validated, @Retryable). When two custom composite annotations share a common base meta-annotation, the diamond is formed. Large enterprise Micronaut apps with 5-10 custom composite annotations can reach diamond depth 4-6, causing 16-64x redundant annotation processing per element.

Fix

Replace the path-only isProcessed guard with a globally shared visited set passed through the recursion:

// In ProcessingContext or as a separate memoization map:
// Key: annotationName; Value: already-computed result
private final Set<String> globalVisited = new HashSet<>();

private Stream<ProcessedAnnotation> processAnnotation(ProcessingContext context,
                                                      ProcessedAnnotation processedAnnotation) {
    String annotationName = processedAnnotation.getAnnotationValue().getAnnotationName();
    if (AnnotationUtil.INTERNAL_ANNOTATION_NAMES.contains(annotationName)
        || context.isProcessed(processedAnnotation.getAnnotationValue())
        || !globalVisited.add(annotationName)) {  // ADD: global visited guard
        return Stream.empty();
    }
    // ... rest of method unchanged

Alternatively, the top-level caller can pass a Set<String> globalVisited down through ProcessingContext and check it early in processAnnotation.

Speedup

Diamond depth (D) Before (calls to getAnnotationsForType) After Speedup
4 16 4 4×
6 64 6 11×
8 256 8 32×
10 1,024 10 102×

References

  • CWE-407: Inefficient Algorithmic Complexity
  • Micronaut core-processor annotation processing infrastructure
  • AbstractAnnotationMetadataBuilder.processAnnotation lines 11281158
  • AbstractAnnotationMetadataBuilder.extractStereotypes lines 12351272
  • ProcessingContext.isProcessed lines 18171819