Skip to content

Commit ca62119

Browse files
committed
Evict context from ContextCache before loading a new context
Since Spring Framework 4.2, DefaultContextCache supported an LRU (least recently used) eviction policy via a custom LruCache which extended LinkedHashMap. The LruCache reacted to LinkedHashMap's removeEldestEntry() callback to remove the LRU context if the maxSize of the cache was exceeded. Due to the nature of the implementation in LinkedHashMap, the removeEldestEntry() callback is invoked after a new entry has been stored to the map. Consequently, a Spring ApplicationContext (C1) was evicted from the cache after a new context (C2) was loaded and added to the cache, leading to failure scenarios such as the following. - C1 and C2 share an external resource -- for example, a database. - C2 initializes the external resource with test data when C2 is loaded. - C1 cleans up the external resource when C1 is closed. - C1 is loaded and added to the cache. - C2 is loaded and added to the cache before C1 is evicted. - C1 is evicted and closed. - C2 tests fail, because C1 removed test data required for C2. To address such scenarios, this commit replaces the custom LruCache with custom LRU eviction logic in DefaultContextCache and revises the put(MergedContextConfiguration, ApplicationContext) method to delegate to a new evictLruContextIfNecessary() method. This commit also introduces a new put(MergedContextConfiguration, LoadFunction) method in the ContextCache API which is overridden by DefaultContextCache to ensure that an evicted context is removed and closed before a new context is loaded to take its place in the cache. In addition, DefaultCacheAwareContextLoaderDelegate has been revised to make use of the new put(MergedContextConfiguration, LoadFunction) API. Closes gh-21007
1 parent 8eca3a3 commit ca62119

File tree

4 files changed

+506
-121
lines changed

4 files changed

+506
-121
lines changed

spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.springframework.context.ApplicationContext;
2222
import org.springframework.test.annotation.DirtiesContext.HierarchyMode;
2323
import org.springframework.test.context.MergedContextConfiguration;
24+
import org.springframework.util.Assert;
2425

2526
/**
2627
* {@code ContextCache} defines the SPI for caching Spring
@@ -102,6 +103,7 @@ public interface ContextCache {
102103
* @param key the context key; never {@code null}
103104
* @return the corresponding {@code ApplicationContext} instance, or {@code null}
104105
* if not found in the cache
106+
* @see #put(MergedContextConfiguration, LoadFunction)
105107
* @see #unregisterContextUsage(MergedContextConfiguration, Class)
106108
* @see #remove(MergedContextConfiguration, HierarchyMode)
107109
*/
@@ -113,9 +115,37 @@ public interface ContextCache {
113115
* @param key the context key; never {@code null}
114116
* @param context the {@code ApplicationContext}; never {@code null}
115117
* @see #get(MergedContextConfiguration)
118+
* @see #put(MergedContextConfiguration, LoadFunction)
116119
*/
117120
void put(MergedContextConfiguration key, ApplicationContext context);
118121

122+
/**
123+
* Explicitly add an {@link ApplicationContext} to the cache under the given
124+
* key, potentially honoring a custom eviction policy.
125+
* <p>The supplied {@link LoadFunction} will be invoked to load the
126+
* {@code ApplicationContext}.
127+
* <p>Concrete implementations which honor a custom eviction policy must
128+
* override this method to ensure that an evicted context is removed from the
129+
* cache and closed before a new context is loaded via the supplied
130+
* {@code LoadFunction}.
131+
* @param key the context key; never {@code null}
132+
* @param loadFunction a function which loads the context for the supplied key;
133+
* never {@code null}
134+
* @return the {@code ApplicationContext}; never {@code null}
135+
* @since 7.0
136+
* @see #get(MergedContextConfiguration)
137+
* @see #put(MergedContextConfiguration, ApplicationContext)
138+
*/
139+
default ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) {
140+
Assert.notNull(key, "Key must not be null");
141+
Assert.notNull(loadFunction, "LoadFunction must not be null");
142+
143+
ApplicationContext applicationContext = loadFunction.loadContext(key);
144+
Assert.state(applicationContext != null, "LoadFunction must return a non-null ApplicationContext");
145+
put(key, applicationContext);
146+
return applicationContext;
147+
}
148+
119149
/**
120150
* Remove the context with the given key from the cache and explicitly
121151
* {@linkplain org.springframework.context.ConfigurableApplicationContext#close() close}
@@ -281,4 +311,26 @@ default int getContextUsageCount() {
281311
*/
282312
void logStatistics();
283313

314+
315+
/**
316+
* Represents a function that loads an {@link ApplicationContext}.
317+
*
318+
* @since 7.0
319+
*/
320+
@FunctionalInterface
321+
interface LoadFunction {
322+
323+
/**
324+
* Load a new {@link ApplicationContext} based on the supplied
325+
* {@link MergedContextConfiguration} and return the context in a fully
326+
* <em>refreshed</em> state.
327+
* @param mergedConfig the merged context configuration to use to load the
328+
* application context
329+
* @return a new application context; never {@code null}
330+
* @see org.springframework.test.context.SmartContextLoader#loadContext(MergedContextConfiguration)
331+
*/
332+
ApplicationContext loadContext(MergedContextConfiguration mergedConfig);
333+
334+
}
335+
284336
}

spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -139,34 +139,44 @@ public boolean isContextLoaded(MergedContextConfiguration mergedConfig) {
139139
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) {
140140
mergedConfig = replaceIfNecessary(mergedConfig);
141141
synchronized (this.contextCache) {
142-
ApplicationContext context = this.contextCache.get(mergedConfig);
143142
try {
144-
if (context == null) {
145-
int failureCount = this.contextCache.getFailureCount(mergedConfig);
146-
if (failureCount >= this.failureThreshold) {
147-
throw new IllegalStateException("""
148-
ApplicationContext failure threshold (%d) exceeded: \
149-
skipping repeated attempt to load context for %s"""
150-
.formatted(this.failureThreshold, mergedConfig));
143+
ApplicationContext context = this.contextCache.get(mergedConfig);
144+
if (context != null) {
145+
if (logger.isTraceEnabled()) {
146+
logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted(
147+
System.identityHashCode(context), mergedConfig));
151148
}
149+
return context;
150+
}
151+
152+
int failureCount = this.contextCache.getFailureCount(mergedConfig);
153+
if (failureCount >= this.failureThreshold) {
154+
throw new IllegalStateException("""
155+
ApplicationContext failure threshold (%d) exceeded: \
156+
skipping repeated attempt to load context for %s"""
157+
.formatted(this.failureThreshold, mergedConfig));
158+
}
159+
160+
return this.contextCache.put(mergedConfig, key -> {
152161
try {
153-
if (mergedConfig instanceof AotMergedContextConfiguration aotMergedConfig) {
154-
context = loadContextInAotMode(aotMergedConfig);
162+
ApplicationContext newContext;
163+
if (key instanceof AotMergedContextConfiguration aotMergedConfig) {
164+
newContext = loadContextInAotMode(aotMergedConfig);
155165
}
156166
else {
157-
context = loadContextInternal(mergedConfig);
167+
newContext = loadContextInternal(key);
158168
}
159169
if (logger.isTraceEnabled()) {
160170
logger.trace("Storing ApplicationContext [%s] in cache under key %s".formatted(
161-
System.identityHashCode(context), mergedConfig));
171+
System.identityHashCode(newContext), key));
162172
}
163-
this.contextCache.put(mergedConfig, context);
173+
return newContext;
164174
}
165175
catch (Exception ex) {
166176
if (logger.isTraceEnabled()) {
167-
logger.trace("Incrementing ApplicationContext failure count for " + mergedConfig);
177+
logger.trace("Incrementing ApplicationContext failure count for " + key);
168178
}
169-
this.contextCache.incrementFailureCount(mergedConfig);
179+
this.contextCache.incrementFailureCount(key);
170180
Throwable cause = ex;
171181
if (ex instanceof ContextLoadException cle) {
172182
cause = cle.getCause();
@@ -182,22 +192,13 @@ ApplicationContext failure threshold (%d) exceeded: \
182192
}
183193
}
184194
}
185-
throw new IllegalStateException(
186-
"Failed to load ApplicationContext for " + mergedConfig, cause);
195+
throw new IllegalStateException("Failed to load ApplicationContext for " + key, cause);
187196
}
188-
}
189-
else {
190-
if (logger.isTraceEnabled()) {
191-
logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted(
192-
System.identityHashCode(context), mergedConfig));
193-
}
194-
}
197+
});
195198
}
196199
finally {
197200
this.contextCache.logStatistics();
198201
}
199-
200-
return context;
201202
}
202203
}
203204

spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.ArrayList;
2020
import java.util.Collections;
2121
import java.util.HashSet;
22+
import java.util.Iterator;
2223
import java.util.LinkedHashMap;
2324
import java.util.List;
2425
import java.util.Map;
@@ -62,7 +63,7 @@ public class DefaultContextCache implements ContextCache {
6263
* Map of context keys to Spring {@code ApplicationContext} instances.
6364
*/
6465
private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
65-
Collections.synchronizedMap(new LruCache(32, 0.75f));
66+
Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true));
6667

6768
/**
6869
* Map of parent keys to sets of children keys, representing a top-down <em>tree</em>
@@ -157,7 +158,41 @@ public void put(MergedContextConfiguration key, ApplicationContext context) {
157158
Assert.notNull(key, "Key must not be null");
158159
Assert.notNull(context, "ApplicationContext must not be null");
159160

161+
evictLruContextIfNecessary();
162+
putInternal(key, context);
163+
}
164+
165+
@Override
166+
public ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) {
167+
Assert.notNull(key, "Key must not be null");
168+
Assert.notNull(loadFunction, "LoadFunction must not be null");
169+
170+
evictLruContextIfNecessary();
171+
ApplicationContext context = loadFunction.loadContext(key);
172+
Assert.state(context != null, "LoadFunction must return a non-null ApplicationContext");
173+
putInternal(key, context);
174+
return context;
175+
}
176+
177+
/**
178+
* Evict the least recently used (LRU) context if necessary.
179+
* @since 7.0
180+
*/
181+
private void evictLruContextIfNecessary() {
182+
if (this.contextMap.size() >= this.maxSize) {
183+
Iterator<MergedContextConfiguration> iterator = this.contextMap.keySet().iterator();
184+
Assert.state(iterator.hasNext(), "Failed to retrieve LRU context");
185+
// The least recently used (LRU) key is the first/head in a LinkedHashMap
186+
// configured for access-order iteration order.
187+
MergedContextConfiguration lruKey = iterator.next();
188+
remove(lruKey, HierarchyMode.CURRENT_LEVEL);
189+
}
190+
}
191+
192+
private void putInternal(MergedContextConfiguration key, ApplicationContext context) {
160193
this.contextMap.put(key, context);
194+
195+
// Update context hierarchy map.
161196
MergedContextConfiguration child = key;
162197
MergedContextConfiguration parent = child.getParent();
163198
while (parent != null) {
@@ -357,37 +392,4 @@ public String toString() {
357392
.toString();
358393
}
359394

360-
361-
/**
362-
* Simple cache implementation based on {@link LinkedHashMap} with a maximum
363-
* size and a <em>least recently used</em> (LRU) eviction policy that
364-
* properly closes application contexts.
365-
* @since 4.3
366-
*/
367-
@SuppressWarnings("serial")
368-
private class LruCache extends LinkedHashMap<MergedContextConfiguration, ApplicationContext> {
369-
370-
/**
371-
* Create a new {@code LruCache} with the supplied initial capacity
372-
* and load factor.
373-
* @param initialCapacity the initial capacity
374-
* @param loadFactor the load factor
375-
*/
376-
LruCache(int initialCapacity, float loadFactor) {
377-
super(initialCapacity, loadFactor, true);
378-
}
379-
380-
@Override
381-
protected boolean removeEldestEntry(Map.Entry<MergedContextConfiguration, ApplicationContext> eldest) {
382-
if (this.size() > DefaultContextCache.this.getMaxSize()) {
383-
// Do NOT delete "DefaultContextCache.this."; otherwise, we accidentally
384-
// invoke java.util.Map.remove(Object, Object).
385-
DefaultContextCache.this.remove(eldest.getKey(), HierarchyMode.CURRENT_LEVEL);
386-
}
387-
388-
// Return false since we invoke a custom eviction algorithm.
389-
return false;
390-
}
391-
}
392-
393395
}

0 commit comments

Comments
 (0)