diff --git a/docs/src/main/asciidoc/cache.adoc b/docs/src/main/asciidoc/cache.adoc index c95ce7dca88a3..69314891356db 100644 --- a/docs/src/main/asciidoc/cache.adoc +++ b/docs/src/main/asciidoc/cache.adoc @@ -282,9 +282,7 @@ They will work fine with any other access modifier including package-private (no Loads a method result from the cache without executing the method body whenever possible. When a method annotated with `@CacheResult` is invoked, Quarkus will compute a cache key and use it to check in the cache whether the method has been already invoked. -If the method has one or more arguments, the key computation is done from all the method arguments if none of them is annotated with `@CacheKey`, or all the arguments annotated with `@CacheKey` otherwise. -Each non-primitive method argument that is part of the key must implement `equals()` and `hashCode()` correctly for the cache to work as expected. -This annotation can also be used on a method with no arguments, a default key derived from the cache name is used in that case. +See the <> section of this guide to learn how the cache key is computed. If a value is found in the cache, it is returned and the annotated method is never actually executed. If no value is found, the annotated method is invoked and the returned value is stored in the cache using the computed key. @@ -308,8 +306,7 @@ See <>. Removes an entry from the cache. When a method annotated with `@CacheInvalidate` is invoked, Quarkus will compute a cache key and use it to try to remove an existing entry from the cache. -If the method has one or more arguments, the key computation is done from all the method arguments if none of them is annotated with `@CacheKey`, or all the arguments annotated with `@CacheKey` otherwise. -This annotation can also be used on a method with no arguments, a default key derived from the cache name is used in that case. +See the <> section of this guide to learn how the cache key is computed. If the key does not identify any cache entry, nothing will happen. === @CacheInvalidateAll @@ -323,7 +320,22 @@ method annotated with `@CacheResult` or `@CacheInvalidate`. This annotation is optional and should only be used when some of the method arguments are NOT part of the cache key. -=== Composite cache key building logic +[#cache-keys-building-logic] +=== Cache keys building logic + +Cache keys are built by the annotations API using the following logic: + +* If an `io.quarkus.cache.CacheKeyGenerator` is declared in a `@CacheResult` or a `@CacheInvalidate` annotation, then it is used to generate the cache key. The `@CacheKey` annotations that might be present on some of the method arguments are ignored. +* Otherwise, if the method has no arguments, then the cache key is an instance of `io.quarkus.cache.DefaultCacheKey` built from the cache name. +* Otherwise, if the method has exactly one argument, then that argument is the cache key. +* Otherwise, if the method has multiple arguments but only one annotated with `@CacheKey`, then that annotated argument is the cache key. +* Otherwise, if the method has multiple arguments annotated with `@CacheKey`, then the cache key is an instance of `io.quarkus.cache.CompositeCacheKey` built from these annotated arguments. +* Otherwise, the cache key is an instance of `io.quarkus.cache.CompositeCacheKey` built from all the method arguments. + +[WARNING] +==== +Each non-primitive method argument that is part of the key must implement `equals()` and `hashCode()` correctly for the cache to work as expected. +==== When a cache key is built from several method arguments, whether they are explicitly identified with `@CacheKey` or not, the building logic depends on the order of these arguments in the method signature. On the other hand, the arguments names are not used at all and do not have any effect on the cache key. @@ -366,6 +378,104 @@ public class CachedService { <3> Calling this method WILL invalidate values cached by the `load` method because the key elements order is the same. <4> Calling this method WILL NOT invalidate values cached by the `load` method because the key elements order is different. +=== Generating a cache key with `CacheKeyGenerator` + +You may want to include more than the annotated method arguments into a cache key. +This can be done by implementing the `io.quarkus.cache.CacheKeyGenerator` interface and declaring that implementation in the `keyGenerator` field of a `@CacheResult` or `@CacheInvalidate` annotation. + +If a CDI scope is declared on a cache key generator, then that generator will be injected as a CDI bean during the cache key computation. +Otherwise, the generator will be instantiated using its default constructor. +All CDI scopes supported by Quarkus can be used on a cache key generator. + +[WARNING] +==== +All cache key generators must have a default no-arg constructor. +If that constructor is missing, an exception will be thrown at build time. +==== + +The following cache key generator will be injected as a CDI bean: + +[source,java] +---- +package org.acme.cache; + +import java.lang.reflect.Method; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import io.quarkus.cache.CacheKeyGenerator; +import io.quarkus.cache.CompositeCacheKey; + +@ApplicationScoped +public class ApplicationScopedKeyGen implements CacheKeyGenerator { + + @Inject + AnythingYouNeedHere anythingYouNeedHere; <1> + + @Override + public Object generate(Method method, Object... methodParams) { <2> + return new CompositeCacheKey(anythingYouNeedHere.getData(), methodParams[1]); <3> + } +} +---- +<1> External data can be included into the cache key by injecting a CDI bean in the key generator. +<2> Be careful while using `Method`, some of its methods can be expensive. +<3> Make sure the method has enough arguments before accessing them from their index. +Otherwise, an `IndexOutOfBoundsException` may be thrown during the cache key computation. + +The following cache key generator will be instantiated using its default constructor: + +[source,java] +---- +package org.acme.cache; + +import java.lang.reflect.Method; + +import io.quarkus.cache.CacheKeyGenerator; +import io.quarkus.cache.CompositeCacheKey; + +public class NotABeanKeyGen implements CacheKeyGenerator { + + // CDI injections won't work here because it's not a CDI bean. + + @Override + public Object generate(Method method, Object... methodParams) { + return new CompositeCacheKey(method.getName(), methodParams[0]); <1> + } +} +---- +<1> Including the method name into the cache key is not expensive, unlike other methods from `Method`. + +Both kinds of cache key generators can be used in a similar way: + +[source,java] +---- +package org.acme.cache; + +import javax.enterprise.context.ApplicationScoped; + +import org.acme.cache.ApplicationScopedKeyGen; +import org.acme.cache.NotABeanKeyGen; + +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheResult; + +@ApplicationScoped +public class CachedService { + + @CacheResult(cacheName = "foo", keyGenerator = ApplicationScopedKeyGen.class) <1> + public Object load(Object notUsedInKey, String keyElement) { + // Call expensive service here. + } + + @CacheInvalidate(cacheName = "foo", keyGenerator = NotABeanKeyGen.class) <2> + public void invalidate(Object keyElement) { + } +} +---- +<1> This key generator is a CDI bean. +<2> This key generator is not a CDI bean. + [#programmatic-api] == Caching using the programmatic API @@ -451,12 +561,8 @@ public class CacheClearer { === Building a programmatic cache key -Before building a programmatic cache key, you need to know how cache keys are built by the annotations API when an annotated method is invoked: - -* If the method has no arguments, then the cache key is an instance of `io.quarkus.cache.DefaultCacheKey` built from the cache name. -* If the method has exactly one argument, then this argument is the cache key. -* If the method has multiple arguments but only one annotated with `@CacheKey`, then this annotated argument is the cache key. -* In all other cases, the cache key is an instance of `io.quarkus.cache.CompositeCacheKey` built from multiple method arguments (annotated with `@CacheKey` or not). +Before building a programmatic cache key, you need to know how cache keys are built by the annotations API when an annotated method is invoked. +This is explained in the <> section of this guide. Now, if you want to retrieve or delete, using the programmatic API, a cache value that was stored using the annotations API, you just need to make sure the same key is used with both APIs. diff --git a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java index a4a94a585341f..a56e1d04256eb 100644 --- a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java +++ b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java @@ -31,6 +31,7 @@ import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.AnnotationTarget.Kind; +import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; @@ -44,6 +45,7 @@ import io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem; import io.quarkus.cache.CacheManager; import io.quarkus.cache.deployment.exception.ClassTargetException; +import io.quarkus.cache.deployment.exception.KeyGeneratorConstructorException; import io.quarkus.cache.deployment.exception.PrivateMethodTargetException; import io.quarkus.cache.deployment.exception.UnsupportedRepeatedAnnotationException; import io.quarkus.cache.deployment.exception.VoidReturnTypeTargetException; @@ -87,12 +89,18 @@ AnnotationsTransformerBuildItem annotationsTransformer() { @BuildStep void validateCacheAnnotationsAndProduceCacheNames(CombinedIndexBuildItem combinedIndex, List additionalCacheNames, BuildProducer validationErrors, - BuildProducer cacheNames) { + BuildProducer cacheNames, BuildProducer unremovableBeans) { // Validation errors produced by this build step. List throwables = new ArrayList<>(); // Cache names produced by this build step. Set names = new HashSet<>(); + /* + * The cache key generators can be injected as CDI beans. ArC may consider these beans unused and remove them which is + * why they need to be marked as unremovable. This set is used to collect the generators types that will later be used + * to produce an UnremovableBeanBuildItem. + */ + Set keyGenerators = new HashSet<>(); /* * First, for each non-repeated cache interceptor binding: @@ -102,6 +110,7 @@ void validateCacheAnnotationsAndProduceCacheNames(CombinedIndexBuildItem combine for (DotName bindingName : INTERCEPTOR_BINDINGS) { for (AnnotationInstance binding : combinedIndex.getIndex().getAnnotations(bindingName)) { throwables.addAll(validateInterceptorBindingTarget(binding, binding.target())); + findCacheKeyGenerator(binding, binding.target()).ifPresent(keyGenerators::add); if (binding.target().kind() == METHOD) { /* * Cache names from the interceptor bindings placed on cache interceptors must not be collected to prevent @@ -117,6 +126,7 @@ void validateCacheAnnotationsAndProduceCacheNames(CombinedIndexBuildItem combine for (AnnotationInstance container : combinedIndex.getIndex().getAnnotations(containerName)) { for (AnnotationInstance binding : container.value("value").asNestedArray()) { throwables.addAll(validateInterceptorBindingTarget(binding, container.target())); + findCacheKeyGenerator(binding, container.target()).ifPresent(keyGenerators::add); names.add(binding.value(CACHE_NAME_PARAM).asString()); } /* @@ -151,9 +161,20 @@ void validateCacheAnnotationsAndProduceCacheNames(CombinedIndexBuildItem combine for (AdditionalCacheNameBuildItem additionalCacheName : additionalCacheNames) { names.add(additionalCacheName.getName()); } + cacheNames.produce(new CacheNamesBuildItem(names)); + + if (!keyGenerators.isEmpty()) { + // Key generators must have a default constructor. + for (DotName keyGenClassName : keyGenerators) { + ClassInfo keyGenClassInfo = combinedIndex.getIndex().getClassByName(keyGenClassName); + if (!keyGenClassInfo.hasNoArgsConstructor()) { + throwables.add(new KeyGeneratorConstructorException(keyGenClassInfo)); + } + } + unremovableBeans.produce(UnremovableBeanBuildItem.beanTypes(keyGenerators.toArray(new DotName[0]))); + } validationErrors.produce(new ValidationErrorBuildItem(throwables.toArray(new Throwable[0]))); - cacheNames.produce(new CacheNamesBuildItem(names)); } private List validateInterceptorBindingTarget(AnnotationInstance binding, AnnotationTarget target) { @@ -186,6 +207,16 @@ private List validateInterceptorBindingTarget(AnnotationInstance bind return throwables; } + private Optional findCacheKeyGenerator(AnnotationInstance binding, AnnotationTarget target) { + if (target.kind() == METHOD && (CACHE_RESULT.equals(binding.name()) || CACHE_INVALIDATE.equals(binding.name()))) { + AnnotationValue keyGenerator = binding.value("keyGenerator"); + if (keyGenerator != null) { + return Optional.of(keyGenerator.asClass().name()); + } + } + return Optional.empty(); + } + @BuildStep @Record(STATIC_INIT) SyntheticBeanBuildItem configureCacheManagerSyntheticBean(CacheNamesBuildItem cacheNames, CacheConfig config, diff --git a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/exception/KeyGeneratorConstructorException.java b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/exception/KeyGeneratorConstructorException.java new file mode 100644 index 0000000000000..5c89f76509b23 --- /dev/null +++ b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/exception/KeyGeneratorConstructorException.java @@ -0,0 +1,17 @@ +package io.quarkus.cache.deployment.exception; + +import org.jboss.jandex.ClassInfo; + +public class KeyGeneratorConstructorException extends RuntimeException { + + private ClassInfo classInfo; + + public KeyGeneratorConstructorException(ClassInfo classInfo) { + super("No default constructor found in cache key generator [class=" + classInfo.name() + "]"); + this.classInfo = classInfo; + } + + public ClassInfo getClassInfo() { + return classInfo; + } +} diff --git a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/deployment/DeploymentExceptionsTest.java b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/deployment/DeploymentExceptionsTest.java index eb85a0da37dca..508bc2f84ea0b 100644 --- a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/deployment/DeploymentExceptionsTest.java +++ b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/deployment/DeploymentExceptionsTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; +import java.lang.reflect.Method; +import java.util.UUID; import java.util.stream.Stream; import javax.enterprise.context.ApplicationScoped; @@ -17,9 +19,11 @@ import io.quarkus.cache.Cache; import io.quarkus.cache.CacheInvalidate; import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheKeyGenerator; import io.quarkus.cache.CacheName; import io.quarkus.cache.CacheResult; import io.quarkus.cache.deployment.exception.ClassTargetException; +import io.quarkus.cache.deployment.exception.KeyGeneratorConstructorException; import io.quarkus.cache.deployment.exception.PrivateMethodTargetException; import io.quarkus.cache.deployment.exception.VoidReturnTypeTargetException; import io.quarkus.test.QuarkusUnitTest; @@ -38,12 +42,16 @@ public class DeploymentExceptionsTest { .withApplicationRoot((jar) -> jar.addClasses(TestResource.class, TestBean.class)) .assertException(t -> { assertEquals(DeploymentException.class, t.getClass()); - assertEquals(7, t.getSuppressed().length); + assertEquals(11, t.getSuppressed().length); assertPrivateMethodTargetException(t, "shouldThrowPrivateMethodTargetException", 1); assertPrivateMethodTargetException(t, "shouldAlsoThrowPrivateMethodTargetException", 2); assertVoidReturnTypeTargetException(t, "showThrowVoidReturnTypeTargetException"); assertClassTargetException(t, TestResource.class, 1); assertClassTargetException(t, TestBean.class, 2); + assertKeyGeneratorConstructorException(t, KeyGen1.class); + assertKeyGeneratorConstructorException(t, KeyGen2.class); + assertKeyGeneratorConstructorException(t, KeyGen3.class); + assertKeyGeneratorConstructorException(t, KeyGen4.class); }); private static void assertPrivateMethodTargetException(Throwable t, String expectedMethodName, long expectedCount) { @@ -61,6 +69,11 @@ private static void assertClassTargetException(Throwable t, Class expectedCla .filter(s -> expectedClassName.getName().equals(s.getClassName().toString())).count()); } + private static void assertKeyGeneratorConstructorException(Throwable t, Class expectedClassName) { + assertEquals(1, filterSuppressed(t, KeyGeneratorConstructorException.class) + .filter(s -> expectedClassName.getName().equals(s.getClassInfo().name().toString())).count()); + } + private static Stream filterSuppressed(Throwable t, Class filterClass) { return stream(t.getSuppressed()).filter(filterClass::isInstance).map(filterClass::cast); } @@ -108,5 +121,51 @@ public TestBean(@CacheName(UNKNOWN_CACHE_2) Cache cache) { public void setCache(@CacheName(UNKNOWN_CACHE_3) Cache cache) { } + + @CacheResult(cacheName = "should-throw-key-generator-constructor-exception", keyGenerator = KeyGen1.class) + public String shouldThrowKeyGeneratorConstructorException() { + return new String(); + } + + @CacheInvalidate(cacheName = "should-throw-key-generator-constructor-exception", keyGenerator = KeyGen2.class) + public void shouldAlsoThrowKeyGeneratorConstructorException() { + } + + @CacheInvalidate(cacheName = "should-throw-key-generator-constructor-exception", keyGenerator = KeyGen3.class) + @CacheInvalidate(cacheName = "should-throw-key-generator-constructor-exception", keyGenerator = KeyGen4.class) + public void shouldThrowKeyGeneratorConstructorExceptionAsWell() { + } + } + + private static class KeyGen1 implements CacheKeyGenerator { + + public KeyGen1(String arg) { + } + + @Override + public Object generate(Method method, Object... methodParams) { + return UUID.randomUUID(); // Not used. + } + } + + private static class KeyGen2 extends KeyGen1 { + + public KeyGen2(String arg) { + super(arg); + } + } + + private static class KeyGen3 extends KeyGen2 { + + public KeyGen3(String arg) { + super(arg); + } + } + + private static class KeyGen4 extends KeyGen3 { + + public KeyGen4(String arg) { + super(arg); + } } } diff --git a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptionContextTest.java b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptionContextTest.java index 2c79c35879156..6cf0377e24a3c 100644 --- a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptionContextTest.java +++ b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptionContextTest.java @@ -7,8 +7,10 @@ import org.junit.jupiter.api.Test; +import io.quarkus.cache.CacheKeyGenerator; import io.quarkus.cache.CacheResult; import io.quarkus.cache.runtime.CacheInterceptionContext; +import io.quarkus.cache.runtime.UndefinedCacheKeyGenerator; public class CacheInterceptionContextTest { @@ -44,6 +46,11 @@ public String cacheName() { public long lockTimeout() { return 0; } + + @Override + public Class keyGenerator() { + return UndefinedCacheKeyGenerator.class; + } }); }); assertThrows(UnsupportedOperationException.class, () -> { diff --git a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptorTest.java b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptorTest.java index edd2c3cda06ec..5e22082500aff 100644 --- a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptorTest.java +++ b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheInterceptorTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -9,10 +10,12 @@ import org.junit.jupiter.api.Test; import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheKeyGenerator; import io.quarkus.cache.CaffeineCache; import io.quarkus.cache.CompositeCacheKey; import io.quarkus.cache.DefaultCacheKey; import io.quarkus.cache.runtime.CacheInterceptor; +import io.quarkus.cache.runtime.UndefinedCacheKeyGenerator; import io.quarkus.cache.runtime.caffeine.CaffeineCacheImpl; import io.quarkus.cache.runtime.caffeine.CaffeineCacheInfo; @@ -68,18 +71,21 @@ public void testImplicitCompositeKey() { } private Object getCacheKey(Cache cache, List cacheKeyParameterPositions, Object[] methodParameterValues) { - return TEST_CACHE_INTERCEPTOR.getCacheKey(cache, cacheKeyParameterPositions, methodParameterValues); + return TEST_CACHE_INTERCEPTOR.getCacheKey(cache, UndefinedCacheKeyGenerator.class, cacheKeyParameterPositions, null, + methodParameterValues); } private Object getCacheKey(List cacheKeyParameterPositions, Object[] methodParameterValues) { - return TEST_CACHE_INTERCEPTOR.getCacheKey(null, cacheKeyParameterPositions, methodParameterValues); + return TEST_CACHE_INTERCEPTOR.getCacheKey(null, UndefinedCacheKeyGenerator.class, cacheKeyParameterPositions, null, + methodParameterValues); } // This inner class changes the CacheInterceptor#getCacheKey method visibility to public. private static class TestCacheInterceptor extends CacheInterceptor { @Override - public Object getCacheKey(Cache cache, List cacheKeyParameterPositions, Object[] methodParameterValues) { - return super.getCacheKey(cache, cacheKeyParameterPositions, methodParameterValues); + public Object getCacheKey(Cache cache, Class keyGeneratorClass, + List cacheKeyParameterPositions, Method method, Object[] methodParameterValues) { + return super.getCacheKey(cache, keyGeneratorClass, cacheKeyParameterPositions, method, methodParameterValues); } } } diff --git a/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheKeyGeneratorTest.java b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheKeyGeneratorTest.java new file mode 100644 index 0000000000000..8c5617244ca0c --- /dev/null +++ b/extensions/cache/deployment/src/test/java/io/quarkus/cache/test/runtime/CacheKeyGeneratorTest.java @@ -0,0 +1,176 @@ +package io.quarkus.cache.test.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.security.SecureRandom; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.RequestScoped; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheKeyGenerator; +import io.quarkus.cache.CacheResult; +import io.quarkus.cache.CompositeCacheKey; +import io.quarkus.test.QuarkusUnitTest; + +public class CacheKeyGeneratorTest { + + private static final String ASPARAGUS = "asparagus"; + private static final String CAULIFLOWER = "cauliflower"; + private static final Object OBJECT = new Object(); + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().withApplicationRoot(jar -> jar + .addAsResource(new StringAsset("cache-key-element=" + ASPARAGUS), "application.properties") + .addClass(CachedService.class)); + + @Inject + CachedService cachedService; + + @Test + @ActivateRequestContext + public void testAllCacheKeyGeneratorKinds() { + String value1 = cachedService.cachedMethod1(OBJECT, /* Not used */ null); + String value2 = cachedService.cachedMethod1(OBJECT, /* Not used */ null); + assertSame(value1, value2); + + BigInteger value3 = cachedService.cachedMethod2(); + BigInteger value4 = cachedService.cachedMethod2(); + assertSame(value3, value4); + + cachedService.invalidate1(CAULIFLOWER, OBJECT); + + String value5 = cachedService.cachedMethod1(OBJECT, /* Not used */ null); + assertNotSame(value2, value5); + + BigInteger value6 = cachedService.cachedMethod2(); + assertNotSame(value4, value6); + + // If this fails, the interceptor may be leaking @Dependent beans by not destroying them when it should. + assertEquals(0, DependentKeyGen.livingBeans); + + Object value7 = cachedService.cachedMethod3(/* Not used */ null, /* Not used */ null); + Object value8 = cachedService.cachedMethod3(/* Not used */ null, /* Not used */ null); + assertSame(value7, value8); + + cachedService.invalidate2(CAULIFLOWER, /* Not used */ null, "cachedMethod3"); + + Object value9 = cachedService.cachedMethod3(/* Not used */ null, /* Not used */ null); + assertNotSame(value8, value9); + } + + @ApplicationScoped + static class CachedService { + + private static final String CACHE_NAME = "test-cache"; + + // This method is used to test a CDI injection into a cache key generator. + public String getCauliflower() { + return CAULIFLOWER; + } + + @CacheResult(cacheName = CACHE_NAME, keyGenerator = SingletonKeyGen.class) + public String cachedMethod1(/* Key element */ Object param0, /* Not used */ Integer param1) { + return new String(); + } + + @CacheResult(cacheName = CACHE_NAME, keyGenerator = DependentKeyGen.class) + public BigInteger cachedMethod2() { + return BigInteger.valueOf(new SecureRandom().nextInt()); + } + + // The cache key elements will vary depending on which annotation is evaluated during the interception. + @CacheInvalidate(cacheName = CACHE_NAME, keyGenerator = RequestScopedKeyGen.class) + @CacheInvalidate(cacheName = CACHE_NAME) + public void invalidate1(@CacheKey String param0, Object param1) { + } + + @CacheResult(cacheName = CACHE_NAME, keyGenerator = ApplicationScopedKeyGen.class) + public Object cachedMethod3(/* Not used */ Object param0, /* Not used */ String param1) { + return new Object(); + } + + @CacheInvalidate(cacheName = CACHE_NAME, keyGenerator = NotABeanKeyGen.class) + public void invalidate2(/* Key element */ String param0, /* Not used */ Long param1, /* Key element */ String param2) { + } + } + + @Singleton + public static class SingletonKeyGen implements CacheKeyGenerator { + + @ConfigProperty(name = "cache-key-element") + String cacheKeyElement; + + @Override + public Object generate(Method method, Object... methodParams) { + return new CompositeCacheKey(cacheKeyElement, methodParams[0]); + } + } + + @ApplicationScoped + public static class ApplicationScopedKeyGen implements CacheKeyGenerator { + + @Inject + CachedService cachedService; + + @Override + public Object generate(Method method, Object... methodParams) { + return new CompositeCacheKey(method.getName(), cachedService.getCauliflower()); + } + } + + @RequestScoped + public static class RequestScopedKeyGen implements CacheKeyGenerator { + + @Override + public Object generate(Method method, Object... methodParams) { + return new CompositeCacheKey(ASPARAGUS, methodParams[1]); + } + } + + @Dependent + public static class DependentKeyGen implements CacheKeyGenerator { + + // This counts how many beans of this key generator are currently alive. + public static volatile int livingBeans; + + @PostConstruct + void postConstruct() { + livingBeans++; + } + + @PreDestroy + void preDestroy() { + livingBeans--; + } + + @Override + public Object generate(Method method, Object... methodParams) { + return CAULIFLOWER; + } + } + + public static class NotABeanKeyGen implements CacheKeyGenerator { + + @Override + public Object generate(Method method, Object... methodParams) { + return new CompositeCacheKey(methodParams[2], methodParams[0]); + } + } +} diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheInvalidate.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheInvalidate.java index 69a2f2c6ab4d8..cddf7e91d6b3e 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheInvalidate.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheInvalidate.java @@ -10,13 +10,27 @@ import javax.interceptor.InterceptorBinding; import io.quarkus.cache.CacheInvalidate.List; +import io.quarkus.cache.runtime.UndefinedCacheKeyGenerator; /** * When a method annotated with {@link CacheInvalidate} is invoked, Quarkus will compute a cache key and use it to try to - * remove an existing entry from the cache. If the method has one or more arguments, the key computation is done from all the - * method arguments if none of them is annotated with {@link CacheKey}, or all the arguments annotated with {@link CacheKey} - * otherwise. This annotation can also be used on a method with no arguments, a default key derived from the cache name is - * used in that case. If the key does not identify any cache entry, nothing will happen. + * remove an existing entry from the cache. + *

+ * The cache key is computed using the following logic: + *

    + *
  • If a {@link CacheKeyGenerator} is specified with this annotation, then it is used to generate the cache key. The + * {@link CacheKey @CacheKey} annotations that might be present on some of the method arguments are ignored.
  • + *
  • Otherwise, if the method has no arguments, then the cache key is an instance of {@link DefaultCacheKey} built from the + * cache name.
  • + *
  • Otherwise, if the method has exactly one argument, then that argument is the cache key.
  • + *
  • Otherwise, if the method has multiple arguments but only one annotated with {@link CacheKey @CacheKey}, then that + * annotated argument is the cache key.
  • + *
  • Otherwise, if the method has multiple arguments annotated with {@link CacheKey @CacheKey}, then the cache key is an + * instance of {@link CompositeCacheKey} built from these annotated arguments.
  • + *
  • Otherwise, the cache key is an instance of {@link CompositeCacheKey} built from all the method arguments.
  • + *
+ *

+ * If the key does not identify any cache entry, nothing will happen. *

* This annotation can be combined with multiple other caching annotations on a single method. Caching operations will always * be executed in the same order: {@link CacheInvalidateAll} first, then {@link CacheInvalidate} and finally @@ -36,6 +50,12 @@ @Nonbinding String cacheName(); + /** + * The {@link CacheKeyGenerator} implementation to use to generate a cache key. + */ + @Nonbinding + Class keyGenerator() default UndefinedCacheKeyGenerator.class; + @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @interface List { diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheKeyGenerator.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheKeyGenerator.java new file mode 100644 index 0000000000000..4c4942e1dd5a7 --- /dev/null +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheKeyGenerator.java @@ -0,0 +1,20 @@ +package io.quarkus.cache; + +import java.lang.reflect.Method; + +/** + * Implement this interface to generate a cache key based on the cached method, its parameters or any data available from within + * the generator. The implementation is injected as a CDI bean if possible or is instantiated using the default constructor + * otherwise. + */ +public interface CacheKeyGenerator { + + /** + * Generates a cache key. + * + * @param method the cached method + * @param methodParams the method parameters + * @return cache key + */ + Object generate(Method method, Object... methodParams); +} diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheResult.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheResult.java index 30357c2364941..3347feabe8f63 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheResult.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheResult.java @@ -8,14 +8,28 @@ import javax.enterprise.util.Nonbinding; import javax.interceptor.InterceptorBinding; +import io.quarkus.cache.runtime.UndefinedCacheKeyGenerator; + /** * When a method annotated with {@link CacheResult} is invoked, Quarkus will compute a cache key and use it to check in the - * cache whether the method has been already invoked. If the method has one or more arguments, the key computation is done from - * all the method arguments if none of them is annotated with {@link CacheKey}, or all the arguments annotated with - * {@link CacheKey} otherwise. This annotation can also be used on a method with no arguments, a default key derived from the - * cache name is used in that case. If a value is found in the cache, it is returned and the annotated method is never actually - * executed. If no value is found, the annotated method is invoked and the returned value is stored in the cache using the - * computed key. + * cache whether the method has been already invoked. + *

+ * The cache key is computed using the following logic: + *

    + *
  • If a {@link CacheKeyGenerator} is specified with this annotation, then it is used to generate the cache key. The + * {@link CacheKey @CacheKey} annotations that might be present on some of the method arguments are ignored.
  • + *
  • Otherwise, if the method has no arguments, then the cache key is an instance of {@link DefaultCacheKey} built from the + * cache name.
  • + *
  • Otherwise, if the method has exactly one argument, then that argument is the cache key.
  • + *
  • Otherwise, if the method has multiple arguments but only one annotated with {@link CacheKey @CacheKey}, then that + * annotated argument is the cache key.
  • + *
  • Otherwise, if the method has multiple arguments annotated with {@link CacheKey @CacheKey}, then the cache key is an + * instance of {@link CompositeCacheKey} built from these annotated arguments.
  • + *
  • Otherwise, the cache key is an instance of {@link CompositeCacheKey} built from all the method arguments.
  • + *
+ *

+ * If a value is found in the cache, it is returned and the annotated method is never actually executed. If no value is found, + * the annotated method is invoked and the returned value is stored in the cache using the computed key. *

* A method annotated with {@link CacheResult} is protected by a lock on cache miss mechanism. If several concurrent * invocations try to retrieve a cache value from the same missing key, the method will only be invoked once. The first @@ -48,4 +62,10 @@ */ @Nonbinding long lockTimeout() default 0; + + /** + * The {@link CacheKeyGenerator} implementation to use to generate a cache key. + */ + @Nonbinding + Class keyGenerator() default UndefinedCacheKeyGenerator.class; } diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInterceptor.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInterceptor.java index fe86e0cfce9d0..fad53289a1c8e 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInterceptor.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInterceptor.java @@ -1,6 +1,8 @@ package io.quarkus.cache.runtime; import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.List; @@ -15,10 +17,13 @@ import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; import io.quarkus.arc.runtime.InterceptorBindings; import io.quarkus.cache.Cache; import io.quarkus.cache.CacheException; import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheKeyGenerator; import io.quarkus.cache.CacheManager; import io.quarkus.cache.CompositeCacheKey; import io.smallrye.mutiny.Uni; @@ -112,8 +117,11 @@ private T cast(Annotation annotation, Class intercepto return (T) annotation; } - protected Object getCacheKey(Cache cache, List cacheKeyParameterPositions, Object[] methodParameterValues) { - if (methodParameterValues == null || methodParameterValues.length == 0) { + protected Object getCacheKey(Cache cache, Class keyGeneratorClass, + List cacheKeyParameterPositions, Method method, Object[] methodParameterValues) { + if (keyGeneratorClass != UndefinedCacheKeyGenerator.class) { + return generateKey(keyGeneratorClass, method, methodParameterValues); + } else if (methodParameterValues == null || methodParameterValues.length == 0) { // If the intercepted method doesn't have any parameter, then the default cache key will be used. return cache.getDefaultKey(); } else if (cacheKeyParameterPositions.size() == 1) { @@ -138,6 +146,30 @@ protected Object getCacheKey(Cache cache, List cacheKeyParameterPositions } } + private Object generateKey(Class keyGeneratorClass, Method method, + Object[] methodParameterValues) { + InstanceHandle keyGeneratorHandle = Arc.container().instance(keyGeneratorClass); + if (keyGeneratorHandle.isAvailable()) { + LOGGER.tracef("Using cache key generator bean from Arc [class=%s]", keyGeneratorClass.getName()); + try { + return keyGeneratorHandle.get().generate(method, methodParameterValues); + } finally { + keyGeneratorHandle.close(); + } + } else { + try { + LOGGER.tracef("Creating a new cache key generator instance [class=%s]", keyGeneratorClass.getName()); + return keyGeneratorClass.getConstructor().newInstance().generate(method, methodParameterValues); + } catch (NoSuchMethodException e) { + // This should never be thrown because the default constructor availability is checked at build time. + throw new CacheException("No default constructor found in cache key generator [class=" + + keyGeneratorClass.getName() + "]", e); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new CacheException("Cache key generator instantiation failed", e); + } + } + } + protected static ReturnType determineReturnType(Class returnType) { if (Uni.class.isAssignableFrom(returnType)) { return ReturnType.Uni; diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInvalidateInterceptor.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInvalidateInterceptor.java index 62cf500d32af6..46f2c8addab9b 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInvalidateInterceptor.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheInvalidateInterceptor.java @@ -49,8 +49,7 @@ private Object invalidateNonBlocking(InvocationContext invocationContext, .onItem().transformToUniAndMerge(new Function>() { @Override public Uni apply(CacheInvalidate binding) { - return invalidate(binding, interceptionContext.getCacheKeyParameterPositions(), - invocationContext.getParameters()); + return invalidate(binding, interceptionContext.getCacheKeyParameterPositions(), invocationContext); } }) .onItem().ignoreAsUni() @@ -71,15 +70,16 @@ private Object invalidateBlocking(InvocationContext invocationContext, CacheInterceptionContext interceptionContext) throws Exception { LOGGER.trace("Invalidating cache entries in a blocking way"); for (CacheInvalidate binding : interceptionContext.getInterceptorBindings()) { - invalidate(binding, interceptionContext.getCacheKeyParameterPositions(), invocationContext.getParameters()) - .await().indefinitely(); + invalidate(binding, interceptionContext.getCacheKeyParameterPositions(), invocationContext).await().indefinitely(); } return invocationContext.proceed(); } - private Uni invalidate(CacheInvalidate binding, List cacheKeyParameterPositions, Object[] parameters) { + private Uni invalidate(CacheInvalidate binding, List cacheKeyParameterPositions, + InvocationContext invocationContext) { Cache cache = cacheManager.getCache(binding.cacheName()).get(); - Object key = getCacheKey(cache, cacheKeyParameterPositions, parameters); + Object key = getCacheKey(cache, binding.keyGenerator(), cacheKeyParameterPositions, invocationContext.getMethod(), + invocationContext.getParameters()); LOGGER.debugf("Invalidating entry with key [%s] from cache [%s]", key, binding.cacheName()); return cache.invalidate(key); } diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheResultInterceptor.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheResultInterceptor.java index 722595d0f1989..e1c6e94bcbd51 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheResultInterceptor.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheResultInterceptor.java @@ -46,7 +46,8 @@ public Object intercept(InvocationContext invocationContext) throws Throwable { CacheResult binding = interceptionContext.getInterceptorBindings().get(0); AbstractCache cache = (AbstractCache) cacheManager.getCache(binding.cacheName()).get(); - Object key = getCacheKey(cache, interceptionContext.getCacheKeyParameterPositions(), invocationContext.getParameters()); + Object key = getCacheKey(cache, binding.keyGenerator(), interceptionContext.getCacheKeyParameterPositions(), + invocationContext.getMethod(), invocationContext.getParameters()); LOGGER.debugf("Loading entry with key [%s] from cache [%s]", key, binding.cacheName()); try { diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/UndefinedCacheKeyGenerator.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/UndefinedCacheKeyGenerator.java new file mode 100644 index 0000000000000..105851b7f3a82 --- /dev/null +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/UndefinedCacheKeyGenerator.java @@ -0,0 +1,16 @@ +package io.quarkus.cache.runtime; + +import java.lang.reflect.Method; + +import io.quarkus.cache.CacheKeyGenerator; + +/** + * This {@link CacheKeyGenerator} implementation is ignored by {@link CacheInterceptor} when a cache key is computed. + */ +public class UndefinedCacheKeyGenerator implements CacheKeyGenerator { + + @Override + public Object generate(Method method, Object... methodParams) { + throw new UnsupportedOperationException("This cache key generator should never be invoked"); + } +}