MOAD-0005 candidates (ReDoS): - bleach-MOADX-0001: sanitize_css O(2^N) — 71-char input causes 12s hang HIGH MOAD-0006 candidates (Thundering Herd / cache stampede): - hibernate-orm-MOADX-0001: QueryInterpretationCacheStandardImpl HQL plan cache HIGH - elasticsearch-MOADX-0001: EnrichCache "intentionally non-locking" enrich search HIGH - hadoop-MOADX-0001: FederationJCache/FederationCaffeineCache YARN default config HIGH - celery-MOADX-0001: BaseBackend.get_task_meta chord fan-in Redis stampede HIGH - traefik-MOADX-0001: CNAMEFlatten data race + N*30s DNS stampede HIGH - kafka-MOADX-0001: CachedConnectors.lookup classloader scan MEDIUM - spring-MOADX-0001: AbstractFallbackCacheOperationSource @Cacheable cold start MEDIUM - django-MOADX-0001: cached.Loader.get_template template compile MEDIUM CLEAN: Consul (singleflight), Kubernetes (RWMutex), Prometheus (per-scraper), Pulsar (AsyncLoadingCache), Cassandra (LoadingCache), Flink (CAS loop), ZooKeeper (synchronized), Airflow (hand-rolled singleflight)
6.9 KiB
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:
// 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:
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
- Application receives
Nconcurrent requests to the same@Cacheable-annotated method (e.g., a REST endpoint called on startup health check or during load test ramp-up). - The
operationCacheis empty (cold start, or first invocation after context refresh). - All N threads call
getCacheOperationssimultaneously. - All N threads get a cache miss (step 1).
- All N threads independently call
computeCacheOperations: proxy unwrapping, annotation scanning. - All N threads race to
putthe 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
// 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:
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 —
computeIfAbsentis already present on theConcurrentHashMapbacking theoperationCache.