java-topology/defects/spring/patch/spring-MOADX-0001-fallback-cache-operation-source-stampede.md
russell@unturf.com 9325c98470 moad-sweep: 9 initial findings — MOAD-0005 (Hungry Regex) x1, MOAD-0006 (Thundering Herd) x8
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)
2026-03-29 21:09:07 -04:00

138 lines
6.9 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: (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 | 95121 (standard), 6791 (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:95121
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 6791).
## 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`.