# UNDF: (pending)
# spring-MOADX-0001: AbstractFallbackCacheOperationSource — cache stampede O(N) concurrent annotation scans on cold start

## MOAD-0006 Candidate — The Thundering Herd

| Field | Value |
|-------|-------|
| ID | spring-MOADX-0001 |
| Severity | MEDIUM |
| Ecosystem | spring-framework |
| File 1 | `spring-context/src/main/java/org/springframework/cache/interceptor/AbstractFallbackCacheOperationSource.java` |
| File 2 | `spring-context-support/src/main/java/org/springframework/cache/jcache/interceptor/AbstractFallbackJCacheOperationSource.java` |
| Lines | 95–121 (standard), 67–91 (JCache) |
| Pattern | ConcurrentHashMap.get → null check → reflection scan → ConcurrentHashMap.put |
| Trigger | N concurrent requests to same `@Cacheable`-annotated method before cache warms → N concurrent annotation reflection scans |
| Hot path | Every `@Cacheable` / `@CacheResult` method invocation before the operation metadata is cached |
| Compute cost | Java reflection: `AopUtils.getMostSpecificMethod`, method/class annotation traversal, proxy unwrapping |

## Defect

`AbstractFallbackCacheOperationSource` is the core metadata resolver for Spring's `@Cacheable`, `@CachePut`, and `@CacheEvict` annotations. It caches the resolved `CacheOperation` collection in a `ConcurrentHashMap`, but uses a non-atomic get-null-compute-put pattern:

```java
// AbstractFallbackCacheOperationSource.java:95–121
private @Nullable Collection<CacheOperation> getCacheOperations(
        Method method, @Nullable Class<?> targetClass, boolean cacheNull) {

    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }

    Object cacheKey = getCacheKey(method, targetClass);
    Collection<CacheOperation> cached = this.operationCache.get(cacheKey);   // 1. read

    if (cached != null) {
        return (cached != NULL_CACHING_MARKER ? cached : null);              // 2. hit
    }
    else {
        Collection<CacheOperation> cacheOps = computeCacheOperations(method, targetClass); // 3. compute
        if (cacheOps != null) {
            this.operationCache.put(cacheKey, cacheOps);                     // 4. write
        }
        else if (cacheNull) {
            this.operationCache.put(cacheKey, NULL_CACHING_MARKER);
        }
        return cacheOps;
    }
}
```

`computeCacheOperations` resolves the most-specific method (proxy unwrapping), then walks the method and declaring class for cache annotations — up to 4 reflection calls:

```java
private @Nullable Collection<CacheOperation> computeCacheOperations(Method method, @Nullable Class<?> targetClass) {
    Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);  // proxy unwrap + class hierarchy
    Collection<CacheOperation> opDef = findCacheOperations(specificMethod);       // method annotation scan
    if (opDef != null) return opDef;
    opDef = findCacheOperations(specificMethod.getDeclaringClass());              // class annotation scan
    if (opDef != null && ClassUtils.isUserLevelMethod(method)) return opDef;
    if (specificMethod != method) {
        opDef = findCacheOperations(method);                                      // original method scan
        if (opDef != null) return opDef;
        opDef = findCacheOperations(method.getDeclaringClass());                  // original class scan
        if (opDef != null && ClassUtils.isUserLevelMethod(method)) return opDef;
    }
    return null;
}
```

The identical pattern exists in `AbstractFallbackJCacheOperationSource.getCacheOperation` for `@CacheResult`/`@CachePut`/`@CacheRemove` annotations (lines 67–91).

## Stampede Scenario

1. Application receives `N` concurrent requests to the same `@Cacheable`-annotated method (e.g., a REST endpoint called on startup health check or during load test ramp-up).
2. The `operationCache` is empty (cold start, or first invocation after context refresh).
3. All N threads call `getCacheOperations` simultaneously.
4. All N threads get a cache miss (step 1).
5. All N threads independently call `computeCacheOperations`: proxy unwrapping, annotation scanning.
6. All N threads race to `put` the same result — identical values, redundant work.

In a large Spring application with many `@Cacheable` beans, concurrent startup traffic forces redundant annotation scans across hundreds of method-class pairs simultaneously. This delays initial response times and causes contention in the reflection infrastructure.

## Fix

Replace get-null-compute-put with `computeIfAbsent`. The `ConcurrentHashMap` already supports this atomically:

### Standard cache annotation source

```java
// AbstractFallbackCacheOperationSource.java
private @Nullable Collection<CacheOperation> getCacheOperations(
        Method method, @Nullable Class<?> targetClass, boolean cacheNull) {

    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }

    Object cacheKey = getCacheKey(method, targetClass);
    // computeIfAbsent is atomic: only one thread computes, others wait for the result
    Object cached = this.operationCache.computeIfAbsent(cacheKey, k -> {
        Collection<CacheOperation> ops = computeCacheOperations(method, targetClass);
        return (ops != null) ? ops : (cacheNull ? NULL_CACHING_MARKER : null);
    });

    if (cached == null) {
        return null;  // cacheNull was false and no operation found
    }
    return (cached != NULL_CACHING_MARKER ? (Collection<CacheOperation>) cached : null);
}
```

Note: `ConcurrentHashMap.computeIfAbsent` does not accept null return values; the `NULL_CACHING_MARKER` sentinel handles the "no annotation" case correctly.

### JCache annotation source

Same fix applies to `AbstractFallbackJCacheOperationSource.getCacheOperation`:

```java
private @Nullable JCacheOperation<?> getCacheOperation(Method method, @Nullable Class<?> targetClass, boolean cacheNull) {
    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }
    MethodClassKey cacheKey = new MethodClassKey(method, targetClass);
    Object cached = this.operationCache.computeIfAbsent(cacheKey, k -> {
        JCacheOperation<?> op = computeCacheOperation(method, targetClass);
        return (op != null) ? op : (cacheNull ? NULL_CACHING_MARKER : null);
    });
    if (cached == null) return null;
    return (cached != NULL_CACHING_MARKER ? (JCacheOperation<?>) cached : null);
}
```

## Severity Rationale

- **MEDIUM**: The compute cost (annotation reflection) is lower than I/O or SQL plan building, but reflection is synchronized on the JVM class object and can serialize under high parallelism.
- Triggered on every cold start or after Spring context refresh — affects all `@Cacheable`-heavy applications.
- At N=200 concurrent startup requests across 50 annotated methods, 10,000 redundant annotation scans execute before the cache warms. Each scan acquires internal JVM locks.
- The fix is a one-line change per method — `computeIfAbsent` is already present on the `ConcurrentHashMap` backing the `operationCache`.
