# UNDF: UNDF-2026-000000295 # 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 getCacheOperations( Method method, @Nullable Class targetClass, boolean cacheNull) { if (ReflectionUtils.isObjectMethod(method)) { return null; } Object cacheKey = getCacheKey(method, targetClass); Collection cached = this.operationCache.get(cacheKey); // 1. read if (cached != null) { return (cached != NULL_CACHING_MARKER ? cached : null); // 2. hit } else { Collection 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 computeCacheOperations(Method method, @Nullable Class targetClass) { Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass); // proxy unwrap + class hierarchy Collection 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 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 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) 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`.