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.
5.6 KiB
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 | 1128–1158 (processAnnotation), 1235–1272 (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:
- Process
@Transactional(context = {MyAnnotation}) → process@InterceptorBinding[visit 1] - 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-processorannotation processing infrastructure AbstractAnnotationMetadataBuilder.processAnnotationlines 1128–1158AbstractAnnotationMetadataBuilder.extractStereotypeslines 1235–1272ProcessingContext.isProcessedlines 1817–1819