6.9 KiB
UNDF: UNDF-2026-000000827
UNDF: (pending)
hibernate-orm-MOADX-0001: QueryInterpretationCacheStandardImpl — cache stampede O(N) concurrent plan builds on cold start
MOAD-0006 Candidate — The Thundering Herd
| Field | Value |
|---|---|
| ID | hibernate-orm-MOADX-0001 |
| Severity | HIGH |
| Ecosystem | hibernate-orm |
| File | hibernate-core/src/main/java/org/hibernate/query/internal/QueryInterpretationCacheStandardImpl.java |
| Lines | 81–103 (resolveSelectQueryPlan), 115–149 (resolveHqlInterpretation) |
| Pattern | get → null check → compute → put (no lock, no computeIfAbsent) |
| Trigger | N concurrent requests with same uncached query → N concurrent expensive plan builds |
| Hot path | Per-query execution path: every session.createQuery() / session.createMutationQuery() call |
| Compute cost | HQL → SQM AST translation + SQL query plan building (type resolution, join analysis, SQL generation strategy) |
Defect
QueryInterpretationCacheStandardImpl is Hibernate ORM's query plan cache, used on every HQL/JPQL query execution. Two methods exhibit the classic check-then-act stampede pattern on the underlying InternalCache (BoundedConcurrentHashMap):
resolveSelectQueryPlan (lines 81–103)
@Override
public <K extends Key, R> SelectQueryPlan<R> resolveSelectQueryPlan(
K key,
Function<K, SelectQueryPlan<R>> creator) {
@SuppressWarnings("unchecked")
final var cached = (SelectQueryPlan<R>) queryPlanCache.get( key ); // 1. read
if ( cached != null ) {
return cached; // 2. hit
}
final var plan = creator.apply( key ); // 3. expensive: SQL plan build
queryPlanCache.put( key.prepareForStore(), plan ); // 4. write
return plan;
}
creator.apply(key) calls buildSelectQueryPlan() → QuerySplitter.split() + buildConcreteQueryPlan() → new ConcreteSqmSelectQueryPlan(...). This involves SQM polymorphism expansion, entity graph resolution, fetch strategy analysis, and SQL generation strategy computation.
resolveHqlInterpretation (lines 115–149)
@Override
public <R> HqlInterpretation<R> resolveHqlInterpretation(
String queryString,
Class<R> expectedResultType,
HqlTranslator translator) {
...
final var existing = hqlInterpretationCache.get( cacheKey ); // 1. read
if ( existing != null ) {
return (HqlInterpretation<R>) existing; // 2. hit
}
// optional second lookup for query-only key
final var hqlInterpretation =
createHqlInterpretation( queryString, expectedResultType, translator, statistics ); // 3. expensive
hqlInterpretationCache.put( cacheKey, hqlInterpretation ); // 4. write
return hqlInterpretation;
}
createHqlInterpretation() calls translator.translate(queryString, expectedResultType) — full HQL lexing, parsing, AST construction, semantic analysis (type binding, relationship traversal, parameter extraction).
Inconsistency
resolveNativeQueryParameters (line 192) in the same class correctly uses computeIfAbsent:
return nativeQueryParamCache.computeIfAbsent( queryString, creator );
This confirms the fix was known but not applied consistently.
Stampede Scenario
- Application starts and first
Nconcurrent HTTP requests all execute the same JPQL query (e.g.SELECT u FROM User u WHERE u.id = :id). - All N threads call
resolveHqlInterpretationsimultaneously. - All N threads see a cache miss (step 1) — the cache is empty.
- All N threads enter
createHqlInterpretationsimultaneously. - All N threads independently: lex and parse the HQL string, build an SQM AST, resolve entity types, extract parameters.
- All N writes race to
put— result is the same but work was done N times. - Under moderate load (N=50 threads, ~20 query types), 1000 duplicate plan builds execute before the cache warms.
Same cascade applies after Hibernate's queryPlanCacheMaxSize evicts a plan (default 2048) — a popular query that gets evicted causes a burst of recomputes.
Fix
Replace the get-null-compute-put triple with a single computeIfAbsent call, consistent with what resolveNativeQueryParameters already does.
resolveSelectQueryPlan
@Override
public <K extends Key, R> SelectQueryPlan<R> resolveSelectQueryPlan(
K key,
Function<K, SelectQueryPlan<R>> creator) {
LOG.tracef( "Resolving cached query plan for [%s]", key );
final var statistics = getStatistics();
final boolean statisticsEnabled = statistics.isStatisticsEnabled();
@SuppressWarnings("unchecked")
final var plan = (SelectQueryPlan<R>) queryPlanCache.computeIfAbsent(
key,
k -> {
if ( statisticsEnabled ) {
statistics.queryPlanCacheMiss( k.getQueryString() );
}
return creator.apply( (K) k );
});
if ( statisticsEnabled && plan != null ) {
// hit was recorded if computeIfAbsent returned without calling the function
// (we cannot distinguish hit from miss here without a flag, but misses are counted inside)
}
return plan;
}
Or simpler — use the existing computeIfAbsent interface on InternalCache:
final var plan = (SelectQueryPlan<R>) queryPlanCache.computeIfAbsent(
key,
k -> creator.apply( (K) k ) );
resolveHqlInterpretation
@Override
public <R> HqlInterpretation<R> resolveHqlInterpretation(
String queryString,
Class<R> expectedResultType,
HqlTranslator translator) {
final Object cacheKey = expectedResultType != null
? new HqlInterpretationCacheKey( queryString, expectedResultType )
: queryString;
@SuppressWarnings("unchecked")
final var result = (HqlInterpretation<R>) hqlInterpretationCache.computeIfAbsent(
cacheKey,
k -> createHqlInterpretation( queryString, expectedResultType, translator, getStatistics() ) );
return result;
}
Note: InternalCache.computeIfAbsent is already defined in the interface (line in InternalCache.java) and implemented in LegacyInternalCacheImplementation via BoundedConcurrentHashMap.computeIfAbsent. The infrastructure is in place — only the call sites need updating.
Severity Rationale
- HIGH: Called on every query execution, not just startup. Cache misses recur after eviction.
- Compute cost: HQL parsing + type resolution is CPU-intensive and involves synchronization inside the HQL translator.
- At N=100 concurrent requests on a cold start (k8s pod restart, cache eviction), 100 redundant plan builds stall the thread pool simultaneously — classic thundering herd causing cascading latency.
- The fix requires changing two call sites to use the already-present
computeIfAbsentAPI.