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.
This commit is contained in:
russell@unturf.com 2026-03-29 17:21:51 -04:00
parent 29308bfe00
commit 8a85da480d
8 changed files with 496 additions and 0 deletions

View file

@ -0,0 +1,121 @@
# 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