139 lines
6.9 KiB
Markdown
139 lines
6.9 KiB
Markdown
# 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<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`.
|