Support for multi-threaded cache access

Previously, if a `@Cacheable` method was accessed with the same key by
multiple threads, the underlying method was invoked several times instead
of blocking the threads while the value is computed. This scenario
typically affects users that enable caching to avoid calling a costly
method too often. When said method can be invoked by an arbitrary number
of clients on startup, caching has close to no effect.

This commit adds a new method on `Cache` that implements the read-through
pattern:

```
<T> T get(Object key, Callable<T> valueLoader);
```

If an entry for a given key is not found, the specified `Callable` is
invoked to "load" the value and cache it before returning it to the
caller. Because the entire operation is managed by the underlying cache
provider, it is much more easier to guarantee that the loader (e.g. the
annotated method) will be called only once in case of concurrent access.

A new `sync` attribute to the `@Cacheable` annotation has been addded.
When this flag is enabled, the caching abstraction invokes the new
`Cache` method define above. This new mode bring a set of limitations:

* It can't be combined with other cache operations
* Only one `@Cacheable` operation can be specified
* Only one cache is allowed
* `condition` and `unless` attribute are not supported

The rationale behind those limitations is that the underlying Cache is
taking care of the actual caching operation so we can't really apply
any SpEL or multiple caches handling there.

Issue: SPR-9254
This commit is contained in:
Stephane Nicoll
2015-11-05 10:42:07 +01:00
parent 15c7dcd11a
commit 19d97c4253
33 changed files with 938 additions and 146 deletions

View File

@@ -0,0 +1,218 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cache;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.UUID;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import static org.hamcrest.core.Is.*;
import static org.junit.Assert.*;
/**
* @author Stephane Nicoll
*/
public abstract class AbstractCacheTests<T extends Cache> {
@Rule
public final ExpectedException thrown = ExpectedException.none();
protected final static String CACHE_NAME = "testCache";
protected abstract T getCache();
protected abstract Object getNativeCache();
@Test
public void testCacheName() throws Exception {
assertEquals(CACHE_NAME, getCache().getName());
}
@Test
public void testNativeCache() throws Exception {
assertSame(getNativeCache(), getCache().getNativeCache());
}
@Test
public void testCachePut() throws Exception {
T cache = getCache();
String key = createRandomKey();
Object value = "george";
assertNull(cache.get(key));
assertNull(cache.get(key, String.class));
assertNull(cache.get(key, Object.class));
cache.put(key, value);
assertEquals(value, cache.get(key).get());
assertEquals(value, cache.get(key, String.class));
assertEquals(value, cache.get(key, Object.class));
assertEquals(value, cache.get(key, (Class<?>) null));
cache.put(key, null);
assertNotNull(cache.get(key));
assertNull(cache.get(key).get());
assertNull(cache.get(key, String.class));
assertNull(cache.get(key, Object.class));
}
@Test
public void testCachePutIfAbsent() throws Exception {
T cache = getCache();
String key = createRandomKey();
Object value = "initialValue";
assertNull(cache.get(key));
assertNull(cache.putIfAbsent(key, value));
assertEquals(value, cache.get(key).get());
assertEquals("initialValue", cache.putIfAbsent(key, "anotherValue").get());
assertEquals(value, cache.get(key).get()); // not changed
}
@Test
public void testCacheRemove() throws Exception {
T cache = getCache();
String key = createRandomKey();
Object value = "george";
assertNull(cache.get(key));
cache.put(key, value);
}
@Test
public void testCacheClear() throws Exception {
T cache = getCache();
assertNull(cache.get("enescu"));
cache.put("enescu", "george");
assertNull(cache.get("vlaicu"));
cache.put("vlaicu", "aurel");
cache.clear();
assertNull(cache.get("vlaicu"));
assertNull(cache.get("enescu"));
}
@Test
public void testCacheGetCallable() {
doTestCacheGetCallable("test");
}
@Test
public void testCacheGetCallableWithNull() {
doTestCacheGetCallable(null);
}
private void doTestCacheGetCallable(Object returnValue) {
T cache = getCache();
String key = createRandomKey();
assertNull(cache.get(key));
Object value = cache.get(key, () -> returnValue );
assertEquals(returnValue, value);
assertEquals(value, cache.get(key).get());
}
@Test
public void testCacheGetCallableNotInvokedWithHit() {
doTestCacheGetCallableNotInvokedWithHit("existing");
}
@Test
public void testCacheGetCallableNotInvokedWithHitNull() {
doTestCacheGetCallableNotInvokedWithHit(null);
}
private void doTestCacheGetCallableNotInvokedWithHit(Object initialValue) {
T cache = getCache();
String key = createRandomKey();
cache.put(key, initialValue);
Object value = cache.get(key, () -> {
throw new IllegalStateException("Should not have been invoked");
});
assertEquals(initialValue, value);
}
@Test
public void testCacheGetCallableFail() {
T cache = getCache();
String key = createRandomKey();
assertNull(cache.get(key));
try {
cache.get(key, () -> {
throw new UnsupportedOperationException("Expected exception");
});
}
catch (Cache.ValueRetrievalException ex) {
assertNotNull(ex.getCause());
assertEquals(UnsupportedOperationException.class, ex.getCause().getClass());
}
}
/**
* Test that a call to get with a Callable concurrently properly synchronize the
* invocations.
*/
@Test
public void testCacheGetSynchronized() throws InterruptedException {
T cache = getCache();
final AtomicInteger counter = new AtomicInteger();
final List<Object> results = new CopyOnWriteArrayList<>();
final CountDownLatch latch = new CountDownLatch(10);
String key = createRandomKey();
Runnable run = () -> {
try {
Integer value = cache.get(key, () -> {
Thread.sleep(50); // make sure the thread will overlap
return counter.incrementAndGet();
});
results.add(value);
}
finally {
latch.countDown();
}
};
for (int i = 0; i < 10; i++) {
new Thread(run).start();
}
latch.await();
assertEquals(10, results.size());
results.forEach(r -> assertThat(r, is(1))); // Only one method got invoked
}
private String createRandomKey() {
return UUID.randomUUID().toString();
}
}

View File

@@ -1,115 +0,0 @@
/*
* Copyright 2002-2014 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cache.concurrent;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.junit.Before;
import org.junit.Test;
import org.springframework.cache.Cache;
import static org.junit.Assert.*;
/**
* @author Costin Leau
* @author Juergen Hoeller
* @author Stephane Nicoll
*/
public class ConcurrentCacheTests {
protected final static String CACHE_NAME = "testCache";
protected ConcurrentMap<Object, Object> nativeCache;
protected Cache cache;
@Before
public void setUp() throws Exception {
nativeCache = new ConcurrentHashMap<Object, Object>();
cache = new ConcurrentMapCache(CACHE_NAME, nativeCache, true);
cache.clear();
}
@Test
public void testCacheName() throws Exception {
assertEquals(CACHE_NAME, cache.getName());
}
@Test
public void testNativeCache() throws Exception {
assertSame(nativeCache, cache.getNativeCache());
}
@Test
public void testCachePut() throws Exception {
Object key = "enescu";
Object value = "george";
assertNull(cache.get(key));
assertNull(cache.get(key, String.class));
assertNull(cache.get(key, Object.class));
cache.put(key, value);
assertEquals(value, cache.get(key).get());
assertEquals(value, cache.get(key, String.class));
assertEquals(value, cache.get(key, Object.class));
assertEquals(value, cache.get(key, null));
cache.put(key, null);
assertNotNull(cache.get(key));
assertNull(cache.get(key).get());
assertNull(cache.get(key, String.class));
assertNull(cache.get(key, Object.class));
}
@Test
public void testCachePutIfAbsent() throws Exception {
Object key = new Object();
Object value = "initialValue";
assertNull(cache.get(key));
assertNull(cache.putIfAbsent(key, value));
assertEquals(value, cache.get(key).get());
assertEquals("initialValue", cache.putIfAbsent(key, "anotherValue").get());
assertEquals(value, cache.get(key).get()); // not changed
}
@Test
public void testCacheRemove() throws Exception {
Object key = "enescu";
Object value = "george";
assertNull(cache.get(key));
cache.put(key, value);
}
@Test
public void testCacheClear() throws Exception {
assertNull(cache.get("enescu"));
cache.put("enescu", "george");
assertNull(cache.get("vlaicu"));
cache.put("vlaicu", "aurel");
cache.clear();
assertNull(cache.get("vlaicu"));
assertNull(cache.get("enescu"));
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cache.concurrent;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.junit.Before;
import org.junit.Ignore;
import org.springframework.cache.AbstractCacheTests;
/**
* @author Costin Leau
* @author Juergen Hoeller
* @author Stephane Nicoll
*/
public class ConcurrentMapCacheTests extends AbstractCacheTests<ConcurrentMapCache> {
protected ConcurrentMap<Object, Object> nativeCache;
protected ConcurrentMapCache cache;
@Before
public void setUp() throws Exception {
nativeCache = new ConcurrentHashMap<Object, Object>();
cache = new ConcurrentMapCache(CACHE_NAME, nativeCache, true);
cache.clear();
}
@Override
protected ConcurrentMapCache getCache() {
return this.cache;
}
@Override
protected ConcurrentMap<Object, Object> getNativeCache() {
return this.nativeCache;
}
}

View File

@@ -105,6 +105,32 @@ public abstract class AbstractCacheAnnotationTests {
assertNull("Cached value should be null", r3);
}
public void testCacheableSync(CacheableService<?> service) throws Exception {
Object o1 = new Object();
Object r1 = service.cacheSync(o1);
Object r2 = service.cacheSync(o1);
Object r3 = service.cacheSync(o1);
assertSame(r1, r2);
assertSame(r1, r3);
}
public void testCacheableSyncNull(CacheableService<?> service) throws Exception {
Object o1 = new Object();
assertNull(cm.getCache("testCache").get(o1));
Object r1 = service.cacheSyncNull(o1);
Object r2 = service.cacheSyncNull(o1);
Object r3 = service.cacheSyncNull(o1);
assertSame(r1, r2);
assertSame(r1, r3);
assertEquals(r3, cm.getCache("testCache").get(o1).get());
assertNull("Cached value should be null", r3);
}
public void testEvict(CacheableService<?> service) throws Exception {
Object o1 = new Object();
@@ -225,6 +251,18 @@ public abstract class AbstractCacheAnnotationTests {
assertSame(r3, r4);
}
public void testConditionalExpressionSync(CacheableService<?> service) throws Exception {
Object r1 = service.conditionalSync(4);
Object r2 = service.conditionalSync(4);
assertNotSame(r1, r2);
Object r3 = service.conditionalSync(3);
Object r4 = service.conditionalSync(3);
assertSame(r3, r4);
}
public void testUnlessExpression(CacheableService<?> service) throws Exception {
Cache cache = cm.getCache("testCache");
cache.clear();
@@ -311,6 +349,28 @@ public abstract class AbstractCacheAnnotationTests {
}
}
public void testCheckedThrowableSync(CacheableService<?> service) throws Exception {
String arg = UUID.randomUUID().toString();
try {
service.throwCheckedSync(arg);
fail("Excepted exception");
} catch (Exception ex) {
ex.printStackTrace();
assertEquals("Wrong exception type", IOException.class, ex.getClass());
assertEquals(arg, ex.getMessage());
}
}
public void testUncheckedThrowableSync(CacheableService<?> service) throws Exception {
try {
service.throwUncheckedSync(Long.valueOf(1));
fail("Excepted exception");
} catch (RuntimeException ex) {
assertEquals("Wrong exception type", UnsupportedOperationException.class, ex.getClass());
assertEquals("1", ex.getMessage());
}
}
public void testNullArg(CacheableService<?> service) {
Object r1 = service.cache(null);
assertSame(r1, service.cache(null));
@@ -483,6 +543,16 @@ public abstract class AbstractCacheAnnotationTests {
testCacheableNull(cs);
}
@Test
public void testCacheableSync() throws Exception {
testCacheableSync(cs);
}
@Test
public void testCacheableSyncNull() throws Exception {
testCacheableSyncNull(cs);
}
@Test
public void testInvalidate() throws Exception {
testEvict(cs);
@@ -518,6 +588,11 @@ public abstract class AbstractCacheAnnotationTests {
testConditionalExpression(cs);
}
@Test
public void testConditionalExpressionSync() throws Exception {
testConditionalExpressionSync(cs);
}
@Test
public void testUnlessExpression() throws Exception {
testUnlessExpression(cs);
@@ -677,6 +752,16 @@ public abstract class AbstractCacheAnnotationTests {
testCheckedThrowable(ccs);
}
@Test
public void testCheckedExceptionSync() throws Exception {
testCheckedThrowableSync(cs);
}
@Test
public void testClassCheckedExceptionSync() throws Exception {
testCheckedThrowableSync(ccs);
}
@Test
public void testUncheckedException() throws Exception {
testUncheckedThrowable(cs);
@@ -687,6 +772,16 @@ public abstract class AbstractCacheAnnotationTests {
testUncheckedThrowable(ccs);
}
@Test
public void testUncheckedExceptionSync() throws Exception {
testUncheckedThrowableSync(cs);
}
@Test
public void testClassUncheckedExceptionSync() throws Exception {
testUncheckedThrowableSync(ccs);
}
@Test
public void testUpdate() {
testCacheUpdate(cs);

View File

@@ -46,11 +46,28 @@ public class AnnotatedClassCacheableService implements CacheableService<Object>
return null;
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Object cacheSync(Object arg1) {
return counter.getAndIncrement();
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Object cacheSyncNull(Object arg1) {
return null;
}
@Override
public Object conditional(int field) {
return null;
}
@Override
public Object conditionalSync(int field) {
return null;
}
@Override
@Cacheable(cacheNames = "testCache", unless = "#result > 10")
public Object unless(int arg) {
@@ -171,6 +188,18 @@ public class AnnotatedClassCacheableService implements CacheableService<Object>
throw new UnsupportedOperationException(arg1.toString());
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Object throwCheckedSync(Object arg1) throws Exception {
throw new IOException(arg1.toString());
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Object throwUncheckedSync(Object arg1) {
throw new UnsupportedOperationException(arg1.toString());
}
// multi annotations
@Override

View File

@@ -29,6 +29,10 @@ public interface CacheableService<T> {
T cacheNull(Object arg1);
T cacheSync(Object arg1);
T cacheSyncNull(Object arg1);
void invalidate(Object arg1);
void evictEarly(Object arg1);
@@ -43,6 +47,8 @@ public interface CacheableService<T> {
T conditional(int field);
T conditionalSync(int field);
T unless(int arg);
T key(Object arg1, Object arg2);
@@ -73,6 +79,10 @@ public interface CacheableService<T> {
T throwUnchecked(Object arg1);
T throwCheckedSync(Object arg1) throws Exception;
T throwUncheckedSync(Object arg1);
// multi annotations
T multiCache(Object arg1);

View File

@@ -48,6 +48,18 @@ public class DefaultCacheableService implements CacheableService<Long> {
return null;
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Long cacheSync(Object arg1) {
return counter.getAndIncrement();
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Long cacheSyncNull(Object arg1) {
return null;
}
@Override
@CacheEvict("testCache")
public void invalidate(Object arg1) {
@@ -82,11 +94,17 @@ public class DefaultCacheableService implements CacheableService<Long> {
}
@Override
@Cacheable(cacheNames = "testCache", condition = "#classField == 3")
@Cacheable(cacheNames = "testCache", condition = "#p0 == 3")
public Long conditional(int classField) {
return counter.getAndIncrement();
}
@Override
@Cacheable(cacheNames = "testCache", sync = true, condition = "#p0 == 3")
public Long conditionalSync(int classField) {
return counter.getAndIncrement();
}
@Override
@Cacheable(cacheNames = "testCache", unless = "#result > 10")
public Long unless(int arg) {
@@ -177,6 +195,18 @@ public class DefaultCacheableService implements CacheableService<Long> {
throw new UnsupportedOperationException(arg1.toString());
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Long throwCheckedSync(Object arg1) throws Exception {
throw new IOException(arg1.toString());
}
@Override
@Cacheable(cacheNames = "testCache", sync = true)
public Long throwUncheckedSync(Object arg1) {
throw new UnsupportedOperationException(arg1.toString());
}
// multi annotations
@Override

View File

@@ -0,0 +1,158 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cache.interceptor;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.cache.CacheManager;
import org.springframework.cache.CacheTestUtils;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Provides various failure scenario linked to the use of {@link Cacheable#sync()}.
*
* @author Stephane Nicoll
* @since 4.3
*/
public class CacheSyncFailureTests {
@Rule
public final ExpectedException thrown = ExpectedException.none();
private ConfigurableApplicationContext context;
private SimpleService simpleService;
@Before
public void setUp() {
this.context = new AnnotationConfigApplicationContext(Config.class);
this.simpleService = context.getBean(SimpleService.class);
}
@After
public void closeContext() {
if (this.context != null) {
this.context.close();
}
}
@Test
public void unlessSync() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("@Cacheable(sync = true) does not support unless attribute");
this.simpleService.unlessSync("key");
}
@Test
public void severalCachesSync() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("@Cacheable(sync = true) only allows a single cache");
this.simpleService.severalCachesSync("key");
}
@Test
public void severalCachesWithResolvedSync() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("@Cacheable(sync = true) only allows a single cache");
this.simpleService.severalCachesWithResolvedSync("key");
}
@Test
public void syncWithAnotherOperation() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("@Cacheable(sync = true) cannot be combined with other cache operations");
this.simpleService.syncWithAnotherOperation("key");
}
@Test
public void syncWithTwoGetOperations() {
thrown.expect(IllegalStateException.class);
thrown.expectMessage("Only one @Cacheable(sync = true) entry is allowed");
this.simpleService.syncWithTwoGetOperations("key");
}
static class SimpleService {
private final AtomicLong counter = new AtomicLong();
@Cacheable(cacheNames = "testCache", sync = true, unless = "#result > 10")
public Object unlessSync(Object arg1) {
return this.counter.getAndIncrement();
}
@Cacheable(cacheNames = {"testCache", "anotherTestCache"}, sync = true)
public Object severalCachesSync(Object arg1) {
return this.counter.getAndIncrement();
}
@Cacheable(cacheResolver = "testCacheResolver", sync = true)
public Object severalCachesWithResolvedSync(Object arg1) {
return this.counter.getAndIncrement();
}
@Cacheable(cacheNames = "testCache", sync = true)
@CacheEvict(cacheNames = "anotherTestCache", key = "#arg1")
public Object syncWithAnotherOperation(Object arg1) {
return this.counter.getAndIncrement();
}
@Caching(cacheable = {
@Cacheable(cacheNames = "testCache", sync = true),
@Cacheable(cacheNames = "anotherTestCache", sync = true)
})
public Object syncWithTwoGetOperations(Object arg1) {
return this.counter.getAndIncrement();
}
}
@Configuration
@EnableCaching
static class Config extends CachingConfigurerSupport {
@Override
@Bean
public CacheManager cacheManager() {
return CacheTestUtils.createSimpleCacheManager("testCache", "anotherTestCache");
}
@Bean
public CacheResolver testCacheResolver() {
return new NamedCacheResolver(cacheManager(), "testCache", "anotherTestCache");
}
@Bean
public SimpleService simpleService() {
return new SimpleService();
}
}
}