java-topology/defects/micronaut/patch/micronaut-0004-processannotation-diamond-recursion.md

122 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# UNDF: UNDF-2026-000000484
# 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.
```java
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)
```
```java
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:
```java
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:
```java
// 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