001/*
002 * Copyright 2002-2019 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.test.context.cache;
018
019import java.util.ArrayList;
020import java.util.Collections;
021import java.util.HashSet;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026import java.util.concurrent.ConcurrentHashMap;
027import java.util.concurrent.atomic.AtomicInteger;
028
029import org.apache.commons.logging.Log;
030import org.apache.commons.logging.LogFactory;
031
032import org.springframework.context.ApplicationContext;
033import org.springframework.context.ConfigurableApplicationContext;
034import org.springframework.core.style.ToStringCreator;
035import org.springframework.lang.Nullable;
036import org.springframework.test.annotation.DirtiesContext.HierarchyMode;
037import org.springframework.test.context.MergedContextConfiguration;
038import org.springframework.util.Assert;
039
040/**
041 * Default implementation of the {@link ContextCache} API.
042 *
043 * <p>Uses a synchronized {@link Map} configured with a maximum size
044 * and a <em>least recently used</em> (LRU) eviction policy to cache
045 * {@link ApplicationContext} instances.
046 *
047 * <p>The maximum size may be supplied as a {@linkplain #DefaultContextCache(int)
048 * constructor argument} or set via a system property or Spring property named
049 * {@code spring.test.context.cache.maxSize}.
050 *
051 * @author Sam Brannen
052 * @author Juergen Hoeller
053 * @since 2.5
054 * @see ContextCacheUtils#retrieveMaxCacheSize()
055 */
056public class DefaultContextCache implements ContextCache {
057
058        private static final Log statsLogger = LogFactory.getLog(CONTEXT_CACHE_LOGGING_CATEGORY);
059
060        /**
061         * Map of context keys to Spring {@code ApplicationContext} instances.
062         */
063        private final Map<MergedContextConfiguration, ApplicationContext> contextMap =
064                        Collections.synchronizedMap(new LruCache(32, 0.75f));
065
066        /**
067         * Map of parent keys to sets of children keys, representing a top-down <em>tree</em>
068         * of context hierarchies. This information is used for determining which subtrees
069         * need to be recursively removed and closed when removing a context that is a parent
070         * of other contexts.
071         */
072        private final Map<MergedContextConfiguration, Set<MergedContextConfiguration>> hierarchyMap =
073                        new ConcurrentHashMap<>(32);
074
075        private final int maxSize;
076
077        private final AtomicInteger hitCount = new AtomicInteger();
078
079        private final AtomicInteger missCount = new AtomicInteger();
080
081
082        /**
083         * Create a new {@code DefaultContextCache} using the maximum cache size
084         * obtained via {@link ContextCacheUtils#retrieveMaxCacheSize()}.
085         * @since 4.3
086         * @see #DefaultContextCache(int)
087         * @see ContextCacheUtils#retrieveMaxCacheSize()
088         */
089        public DefaultContextCache() {
090                this(ContextCacheUtils.retrieveMaxCacheSize());
091        }
092
093        /**
094         * Create a new {@code DefaultContextCache} using the supplied maximum
095         * cache size.
096         * @param maxSize the maximum cache size
097         * @throws IllegalArgumentException if the supplied {@code maxSize} value
098         * is not positive
099         * @since 4.3
100         * @see #DefaultContextCache()
101         */
102        public DefaultContextCache(int maxSize) {
103                Assert.isTrue(maxSize > 0, "'maxSize' must be positive");
104                this.maxSize = maxSize;
105        }
106
107
108        /**
109         * {@inheritDoc}
110         */
111        @Override
112        public boolean contains(MergedContextConfiguration key) {
113                Assert.notNull(key, "Key must not be null");
114                return this.contextMap.containsKey(key);
115        }
116
117        /**
118         * {@inheritDoc}
119         */
120        @Override
121        @Nullable
122        public ApplicationContext get(MergedContextConfiguration key) {
123                Assert.notNull(key, "Key must not be null");
124                ApplicationContext context = this.contextMap.get(key);
125                if (context == null) {
126                        this.missCount.incrementAndGet();
127                }
128                else {
129                        this.hitCount.incrementAndGet();
130                }
131                return context;
132        }
133
134        /**
135         * {@inheritDoc}
136         */
137        @Override
138        public void put(MergedContextConfiguration key, ApplicationContext context) {
139                Assert.notNull(key, "Key must not be null");
140                Assert.notNull(context, "ApplicationContext must not be null");
141
142                this.contextMap.put(key, context);
143                MergedContextConfiguration child = key;
144                MergedContextConfiguration parent = child.getParent();
145                while (parent != null) {
146                        Set<MergedContextConfiguration> list = this.hierarchyMap.computeIfAbsent(parent, k -> new HashSet<>());
147                        list.add(child);
148                        child = parent;
149                        parent = child.getParent();
150                }
151        }
152
153        /**
154         * {@inheritDoc}
155         */
156        @Override
157        public void remove(MergedContextConfiguration key, @Nullable HierarchyMode hierarchyMode) {
158                Assert.notNull(key, "Key must not be null");
159
160                // startKey is the level at which to begin clearing the cache,
161                // depending on the configured hierarchy mode.s
162                MergedContextConfiguration startKey = key;
163                if (hierarchyMode == HierarchyMode.EXHAUSTIVE) {
164                        MergedContextConfiguration parent = startKey.getParent();
165                        while (parent != null) {
166                                startKey = parent;
167                                parent = startKey.getParent();
168                        }
169                }
170
171                List<MergedContextConfiguration> removedContexts = new ArrayList<>();
172                remove(removedContexts, startKey);
173
174                // Remove all remaining references to any removed contexts from the
175                // hierarchy map.
176                for (MergedContextConfiguration currentKey : removedContexts) {
177                        for (Set<MergedContextConfiguration> children : this.hierarchyMap.values()) {
178                                children.remove(currentKey);
179                        }
180                }
181
182                // Remove empty entries from the hierarchy map.
183                for (Map.Entry<MergedContextConfiguration, Set<MergedContextConfiguration>> entry : this.hierarchyMap.entrySet()) {
184                        if (entry.getValue().isEmpty()) {
185                                this.hierarchyMap.remove(entry.getKey());
186                        }
187                }
188        }
189
190        private void remove(List<MergedContextConfiguration> removedContexts, MergedContextConfiguration key) {
191                Assert.notNull(key, "Key must not be null");
192
193                Set<MergedContextConfiguration> children = this.hierarchyMap.get(key);
194                if (children != null) {
195                        for (MergedContextConfiguration child : children) {
196                                // Recurse through lower levels
197                                remove(removedContexts, child);
198                        }
199                        // Remove the set of children for the current context from the hierarchy map.
200                        this.hierarchyMap.remove(key);
201                }
202
203                // Physically remove and close leaf nodes first (i.e., on the way back up the
204                // stack as opposed to prior to the recursive call).
205                ApplicationContext context = this.contextMap.remove(key);
206                if (context instanceof ConfigurableApplicationContext) {
207                        ((ConfigurableApplicationContext) context).close();
208                }
209                removedContexts.add(key);
210        }
211
212        /**
213         * {@inheritDoc}
214         */
215        @Override
216        public int size() {
217                return this.contextMap.size();
218        }
219
220        /**
221         * Get the maximum size of this cache.
222         */
223        public int getMaxSize() {
224                return this.maxSize;
225        }
226
227        /**
228         * {@inheritDoc}
229         */
230        @Override
231        public int getParentContextCount() {
232                return this.hierarchyMap.size();
233        }
234
235        /**
236         * {@inheritDoc}
237         */
238        @Override
239        public int getHitCount() {
240                return this.hitCount.get();
241        }
242
243        /**
244         * {@inheritDoc}
245         */
246        @Override
247        public int getMissCount() {
248                return this.missCount.get();
249        }
250
251        /**
252         * {@inheritDoc}
253         */
254        @Override
255        public void reset() {
256                synchronized (this.contextMap) {
257                        clear();
258                        clearStatistics();
259                }
260        }
261
262        /**
263         * {@inheritDoc}
264         */
265        @Override
266        public void clear() {
267                synchronized (this.contextMap) {
268                        this.contextMap.clear();
269                        this.hierarchyMap.clear();
270                }
271        }
272
273        /**
274         * {@inheritDoc}
275         */
276        @Override
277        public void clearStatistics() {
278                synchronized (this.contextMap) {
279                        this.hitCount.set(0);
280                        this.missCount.set(0);
281                }
282        }
283
284        /**
285         * {@inheritDoc}
286         */
287        @Override
288        public void logStatistics() {
289                if (statsLogger.isDebugEnabled()) {
290                        statsLogger.debug("Spring test ApplicationContext cache statistics: " + this);
291                }
292        }
293
294        /**
295         * Generate a text string containing the implementation type of this
296         * cache and its statistics.
297         * <p>The string returned by this method contains all information
298         * required for compliance with the contract for {@link #logStatistics()}.
299         * @return a string representation of this cache, including statistics
300         */
301        @Override
302        public String toString() {
303                return new ToStringCreator(this)
304                                .append("size", size())
305                                .append("maxSize", getMaxSize())
306                                .append("parentContextCount", getParentContextCount())
307                                .append("hitCount", getHitCount())
308                                .append("missCount", getMissCount())
309                                .toString();
310        }
311
312
313        /**
314         * Simple cache implementation based on {@link LinkedHashMap} with a maximum
315         * size and a <em>least recently used</em> (LRU) eviction policy that
316         * properly closes application contexts.
317         * @since 4.3
318         */
319        @SuppressWarnings("serial")
320        private class LruCache extends LinkedHashMap<MergedContextConfiguration, ApplicationContext> {
321
322                /**
323                 * Create a new {@code LruCache} with the supplied initial capacity
324                 * and load factor.
325                 * @param initialCapacity the initial capacity
326                 * @param loadFactor the load factor
327                 */
328                LruCache(int initialCapacity, float loadFactor) {
329                        super(initialCapacity, loadFactor, true);
330                }
331
332                @Override
333                protected boolean removeEldestEntry(Map.Entry<MergedContextConfiguration, ApplicationContext> eldest) {
334                        if (this.size() > DefaultContextCache.this.getMaxSize()) {
335                                // Do NOT delete "DefaultContextCache.this."; otherwise, we accidentally
336                                // invoke java.util.Map.remove(Object, Object).
337                                DefaultContextCache.this.remove(eldest.getKey(), HierarchyMode.CURRENT_LEVEL);
338                        }
339
340                        // Return false since we invoke a custom eviction algorithm.
341                        return false;
342                }
343        }
344
345}