Skip to content

Commit 51f405d

Browse files
authored
XCOMMONS-3258: Introduce a cache for ServletEnvironment#getResource (#1234)
* Introduce a dynamically initialized cache to avoid issues with recursive component initialization calls. * Add test cases to confirm that the cache works as expected and that cache initialization can do recursive calls for getting resources.
1 parent 6a00796 commit 51f405d

File tree

3 files changed

+226
-13
lines changed

3 files changed

+226
-13
lines changed

xwiki-commons-core/xwiki-commons-environment/xwiki-commons-environment-servlet/pom.xml

+5
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
<artifactId>xwiki-commons-jakartabridge-servlet</artifactId>
4646
<version>${project.version}</version>
4747
</dependency>
48+
<dependency>
49+
<groupId>org.xwiki.commons</groupId>
50+
<artifactId>xwiki-commons-cache-api</artifactId>
51+
<version>${project.version}</version>
52+
</dependency>
4853
<dependency>
4954
<groupId>jakarta.servlet</groupId>
5055
<artifactId>jakarta.servlet-api</artifactId>

xwiki-commons-core/xwiki-commons-environment/xwiki-commons-environment-servlet/src/main/java/org/xwiki/environment/internal/ServletEnvironment.java

+64
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,21 @@
2525
import java.net.MalformedURLException;
2626
import java.net.URISyntaxException;
2727
import java.net.URL;
28+
import java.util.Optional;
29+
import java.util.concurrent.atomic.AtomicBoolean;
2830

31+
import javax.inject.Inject;
2932
import javax.inject.Singleton;
3033

3134
import jakarta.servlet.ServletContext;
3235

3336
import org.apache.commons.lang3.exception.ExceptionUtils;
37+
import org.xwiki.cache.Cache;
38+
import org.xwiki.cache.CacheControl;
39+
import org.xwiki.cache.CacheManager;
40+
import org.xwiki.cache.config.LRUCacheConfiguration;
3441
import org.xwiki.component.annotation.Component;
42+
import org.xwiki.component.manager.ComponentManager;
3543
import org.xwiki.jakartabridge.servlet.JakartaServletBridge;
3644

3745
/**
@@ -54,6 +62,41 @@ public class ServletEnvironment extends AbstractEnvironment
5462
*/
5563
private javax.servlet.ServletContext javaxServletContext;
5664

65+
@Inject
66+
private ComponentManager componentManager;
67+
68+
@Inject
69+
private CacheControl cacheControl;
70+
71+
private Cache<Optional<URL>> resourceURLCache;
72+
73+
private final AtomicBoolean cacheInitializing = new AtomicBoolean();
74+
75+
private Cache<Optional<URL>> getResourceURLCache()
76+
{
77+
// Don't block on cache initialization to avoid loops.
78+
if (this.resourceURLCache == null && this.cacheInitializing.compareAndSet(false, true)) {
79+
try {
80+
// Compare again inside the "lock" to avoid race conditions. Only after seeing the false value we can
81+
// be sure that we also see the resourceURLCache variable write from the thread that wrote it -
82+
// writing atomics with "set" guarantees sequential consistency.
83+
if (this.resourceURLCache == null) {
84+
// The cache manager can't be injected directly as it depends on this component, so it is
85+
// important to only request it once this component has been initialized.
86+
CacheManager cacheManager = this.componentManager.getInstance(CacheManager.class);
87+
this.resourceURLCache = cacheManager.createNewCache(
88+
new LRUCacheConfiguration("environment.servlet.resourceURLCache", 10000));
89+
}
90+
} catch (Exception e) {
91+
this.logger.error("Failed to initialize resource URL cache.", e);
92+
} finally {
93+
this.cacheInitializing.set(false);
94+
}
95+
}
96+
97+
return this.resourceURLCache;
98+
}
99+
57100
/**
58101
* @param servletContext see {@link #getServletContext()}
59102
*/
@@ -107,6 +150,27 @@ public InputStream getResourceAsStream(String resourceName)
107150

108151
@Override
109152
public URL getResource(String resourceName)
153+
{
154+
Cache<Optional<URL>> cache = getResourceURLCache();
155+
156+
if (cache != null && this.cacheControl.isCacheReadAllowed()) {
157+
Optional<URL> cachedURL = cache.get(resourceName);
158+
159+
if (cachedURL != null) {
160+
return cachedURL.orElse(null);
161+
}
162+
}
163+
164+
URL url = getResourceInternal(resourceName);
165+
166+
if (cache != null) {
167+
cache.set(resourceName, Optional.ofNullable(url));
168+
}
169+
170+
return url;
171+
}
172+
173+
private URL getResourceInternal(String resourceName)
110174
{
111175
URL url;
112176
try {

xwiki-commons-core/xwiki-commons-environment/xwiki-commons-environment-servlet/src/test/java/org/xwiki/environment/internal/ServletEnvironmentTest.java

+157-13
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,71 @@
2222
import java.io.File;
2323
import java.net.MalformedURLException;
2424
import java.net.URL;
25+
import java.util.Optional;
26+
import java.util.concurrent.CompletableFuture;
27+
import java.util.concurrent.ExecutorService;
28+
import java.util.concurrent.Executors;
29+
import java.util.concurrent.TimeUnit;
2530

2631
import jakarta.servlet.ServletContext;
2732

2833
import org.apache.commons.io.FileUtils;
34+
import org.apache.commons.lang3.mutable.Mutable;
35+
import org.apache.commons.lang3.mutable.MutableObject;
2936
import org.junit.jupiter.api.AfterEach;
3037
import org.junit.jupiter.api.BeforeEach;
3138
import org.junit.jupiter.api.Test;
3239
import org.junit.jupiter.api.extension.RegisterExtension;
40+
import org.junit.jupiter.params.ParameterizedTest;
41+
import org.junit.jupiter.params.provider.ValueSource;
42+
import org.mockito.Mock;
3343
import org.slf4j.Logger;
34-
import org.xwiki.component.embed.EmbeddableComponentManager;
44+
import org.xwiki.cache.Cache;
45+
import org.xwiki.cache.CacheControl;
46+
import org.xwiki.cache.CacheManager;
3547
import org.xwiki.component.util.ReflectionUtils;
36-
import org.xwiki.environment.Environment;
3748
import org.xwiki.test.junit5.LogCaptureExtension;
49+
import org.xwiki.test.junit5.mockito.ComponentTest;
50+
import org.xwiki.test.junit5.mockito.InjectMockComponents;
51+
import org.xwiki.test.junit5.mockito.MockComponent;
3852

3953
import static org.junit.jupiter.api.Assertions.assertEquals;
4054
import static org.junit.jupiter.api.Assertions.assertNull;
4155
import static org.junit.jupiter.api.Assertions.assertThrows;
42-
import static org.mockito.Mockito.*;
56+
import static org.mockito.ArgumentMatchers.any;
57+
import static org.mockito.Mockito.doAnswer;
58+
import static org.mockito.Mockito.doReturn;
59+
import static org.mockito.Mockito.mock;
60+
import static org.mockito.Mockito.never;
61+
import static org.mockito.Mockito.times;
62+
import static org.mockito.Mockito.verify;
63+
import static org.mockito.Mockito.verifyNoInteractions;
64+
import static org.mockito.Mockito.when;
4365

4466
/**
4567
* Unit tests for {@link ServletEnvironment}.
4668
*
4769
* @version $Id$
4870
* @since 3.5M1
4971
*/
72+
@SuppressWarnings({ "checkstyle:ClassFanOutComplexity", "checkstyle:MultipleStringLiterals" })
73+
@ComponentTest
5074
class ServletEnvironmentTest
5175
{
5276
private File servletTmpDir;
5377

5478
private File systemTmpDir;
5579

80+
@MockComponent
81+
private CacheManager cacheManager;
82+
83+
@Mock
84+
private Cache<Optional<URL>> cache;
85+
86+
@MockComponent
87+
private CacheControl cacheControl;
88+
89+
@InjectMockComponents
5690
private ServletEnvironment environment;
5791

5892
@RegisterExtension
@@ -64,9 +98,7 @@ public void setUp() throws Exception
6498
this.servletTmpDir = new File(System.getProperty("java.io.tmpdir"), "ServletEnvironmentTest-tmpDir");
6599
this.systemTmpDir = new File(System.getProperty("java.io.tmpdir"), "xwiki-temp");
66100

67-
EmbeddableComponentManager ecm = new EmbeddableComponentManager();
68-
ecm.initialize(getClass().getClassLoader());
69-
this.environment = ecm.getInstance(Environment.class);
101+
doReturn(this.cache).when(this.cacheManager).createNewCache(any());
70102
}
71103

72104
@AfterEach
@@ -90,10 +122,15 @@ void getResourceOk() throws Exception
90122
{
91123
ServletContext servletContext = mock(ServletContext.class);
92124
this.environment.setServletContext(servletContext);
93-
when(servletContext.getResource("/test")).thenReturn(new URL("file:/path/../test"));
94-
95-
assertEquals(new URL("file:/test"), this.environment.getResource("/test"));
96-
verify(servletContext).getResource("/test");
125+
String resourceName = "/test";
126+
when(servletContext.getResource(resourceName)).thenReturn(new URL("file:/path/../test"));
127+
128+
URL expectedURL = new URL("file:/test");
129+
assertEquals(expectedURL, this.environment.getResource(resourceName));
130+
verify(servletContext).getResource(resourceName);
131+
verify(this.cache).set(resourceName, Optional.of(expectedURL));
132+
// As cache control returns false, the cache shouldn't be read.
133+
verify(this.cache, never()).get(any());
97134
}
98135

99136
@Test
@@ -104,25 +141,132 @@ void getResourceAsStreamOk() throws Exception
104141
this.environment.getResourceAsStream("/test");
105142

106143
verify(servletContext).getResourceAsStream("/test");
144+
// No cache for streams.
145+
verifyNoInteractions(this.cache);
107146
}
108147

109148
@Test
110149
void getResourceNotExisting() throws Exception
111150
{
112151
ServletContext servletContext = mock(ServletContext.class);
113152
this.environment.setServletContext(servletContext);
114-
assertNull(this.environment.getResource("unknown resource"));
153+
String resourceName = "unknown resource";
154+
assertNull(this.environment.getResource(resourceName));
155+
verify(this.cache).set(resourceName, Optional.empty());
156+
// As cache control returns false, the cache shouldn't be read.
157+
verify(this.cache, never()).get(any());
115158
}
116159

117160
@Test
118161
void getResourceWhenMalformedURLException() throws Exception
119162
{
120163
ServletContext servletContext = mock(ServletContext.class);
121-
when(servletContext.getResource("bad resource")).thenThrow(new MalformedURLException("invalid url"));
164+
String resourceName = "bad resource";
165+
when(servletContext.getResource(resourceName)).thenThrow(new MalformedURLException("invalid url"));
122166
this.environment.setServletContext(servletContext);
123-
assertNull(this.environment.getResource("bad resource"));
167+
assertNull(this.environment.getResource(resourceName));
124168
assertEquals("Error getting resource [bad resource] because of invalid path format. Reason: [invalid url]",
125169
logCapture.getMessage(0));
170+
verify(this.cache).set(resourceName, Optional.empty());
171+
// As cache control returns false, the cache shouldn't be read.
172+
verify(this.cache, never()).get(any());
173+
}
174+
175+
@Test
176+
void getResourceNotExistingFromCache()
177+
{
178+
String resourceName = "unknown resource";
179+
when(this.cacheControl.isCacheReadAllowed()).thenReturn(true);
180+
when(this.cache.get(resourceName)).thenReturn(Optional.empty());
181+
assertNull(this.environment.getResource(resourceName));
182+
verify(this.cache).get(resourceName);
183+
verify(this.cache, never()).set(any(), any());
184+
}
185+
186+
@Test
187+
void getResourceExistingFromCache()
188+
{
189+
String resourceName = "known resource";
190+
URL expectedURL = mock(URL.class);
191+
when(this.cacheControl.isCacheReadAllowed()).thenReturn(true);
192+
when(this.cache.get(resourceName)).thenReturn(Optional.of(expectedURL));
193+
assertEquals(expectedURL, this.environment.getResource(resourceName));
194+
verify(this.cache).get(resourceName);
195+
verify(this.cache, never()).set(any(), any());
196+
}
197+
198+
@ParameterizedTest
199+
@ValueSource(booleans = { true, false })
200+
void getResourceWithRecursiveCallInCacheInitialization(boolean cached) throws Exception
201+
{
202+
ServletContext servletContext = mock(ServletContext.class);
203+
String resourceName = "/resource";
204+
when(servletContext.getResource(resourceName)).thenReturn(new URL("file:/path/../resource"));
205+
this.environment.setServletContext(servletContext);
206+
207+
when(this.cacheControl.isCacheReadAllowed()).thenReturn(true);
208+
209+
URL cachedURL = mock();
210+
if (cached) {
211+
when(this.cache.get(resourceName)).thenReturn(Optional.of(cachedURL));
212+
}
213+
214+
// Two futures to synchronize the background thread that creates the cache.
215+
CompletableFuture<Void> arrivedInCreateCacheFuture = new CompletableFuture<>();
216+
CompletableFuture<Void> blockCreateCacheFuture = new CompletableFuture<>();
217+
218+
Mutable<URL> recursiveURL = new MutableObject<>();
219+
220+
doAnswer(invocationOnMock -> {
221+
// Signal that the background thread arrived in the cache creation call.
222+
arrivedInCreateCacheFuture.complete(null);
223+
recursiveURL.setValue(this.environment.getResource(resourceName));
224+
// Wait until the main thread unblocks the cache creation (but don't wait forever just to be safe).
225+
blockCreateCacheFuture.get(20, TimeUnit.SECONDS);
226+
return this.cache;
227+
}).when(this.cacheManager).createNewCache(any());
228+
229+
// Launch a background thread so we can test blocking in the creation of the cache.
230+
ExecutorService executor = Executors.newFixedThreadPool(1);
231+
232+
try {
233+
CompletableFuture<URL> outerCall =
234+
CompletableFuture.supplyAsync(() -> this.environment.getResource(resourceName), executor);
235+
236+
// Wait for the background thread to arrive in the cache creation call (but don't wait forever just to be
237+
// safe).
238+
arrivedInCreateCacheFuture.get(20, TimeUnit.SECONDS);
239+
240+
URL expectedURL = new URL("file:/resource");
241+
// Ensure that the cache creation doesn't block getting the resource URL.
242+
assertEquals(expectedURL, this.environment.getResource(resourceName));
243+
244+
// Unblock the cache creation call.
245+
blockCreateCacheFuture.complete(null);
246+
247+
// Ensure that the blocked call now got the value, too. Again, don't wait forever just in case.
248+
URL actualOuterURL = outerCall.get(20, TimeUnit.SECONDS);
249+
if (cached) {
250+
assertEquals(cachedURL, actualOuterURL);
251+
} else {
252+
assertEquals(expectedURL, actualOuterURL);
253+
}
254+
255+
// Assert that the recursive call also got the URL.
256+
assertEquals(expectedURL, recursiveURL.getValue());
257+
258+
// Only the outer call should have accessed the cache. If the cache didn't return the value, it should
259+
// have been stored.
260+
verify(this.cache).get(resourceName);
261+
if (cached) {
262+
verify(this.cache, never()).set(any(), any());
263+
} else {
264+
verify(this.cache).set(resourceName, Optional.of(expectedURL));
265+
}
266+
verify(servletContext, times(cached ? 2 : 3)).getResource(resourceName);
267+
} finally {
268+
executor.shutdownNow();
269+
}
126270
}
127271

128272
@Test

0 commit comments

Comments
 (0)