Skip to content

Commit

Permalink
Introduce CacheKeyGenerator
Browse files Browse the repository at this point in the history
  • Loading branch information
gwenneg committed May 29, 2022
1 parent 3d9c64f commit 1319577
Show file tree
Hide file tree
Showing 14 changed files with 549 additions and 38 deletions.
130 changes: 118 additions & 12 deletions docs/src/main/asciidoc/cache.adoc
Expand Up @@ -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 <<cache-keys-building-logic>> 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.

Expand All @@ -308,8 +306,7 @@ See <<negative-cache,more on this topic below>>.
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 <<cache-keys-building-logic>> 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
Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <<cache-keys-building-logic>> 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.

Expand Down
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -87,12 +89,18 @@ AnnotationsTransformerBuildItem annotationsTransformer() {
@BuildStep
void validateCacheAnnotationsAndProduceCacheNames(CombinedIndexBuildItem combinedIndex,
List<AdditionalCacheNameBuildItem> additionalCacheNames, BuildProducer<ValidationErrorBuildItem> validationErrors,
BuildProducer<CacheNamesBuildItem> cacheNames) {
BuildProducer<CacheNamesBuildItem> cacheNames, BuildProducer<UnremovableBeanBuildItem> unremovableBeans) {

// Validation errors produced by this build step.
List<Throwable> throwables = new ArrayList<>();
// Cache names produced by this build step.
Set<String> 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<DotName> keyGenerators = new HashSet<>();

/*
* First, for each non-repeated cache interceptor binding:
Expand All @@ -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
Expand All @@ -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());
}
/*
Expand Down Expand Up @@ -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<Throwable> validateInterceptorBindingTarget(AnnotationInstance binding, AnnotationTarget target) {
Expand Down Expand Up @@ -186,6 +207,16 @@ private List<Throwable> validateInterceptorBindingTarget(AnnotationInstance bind
return throwables;
}

private Optional<DotName> 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,
Expand Down
@@ -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;
}
}
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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 <T extends RuntimeException> Stream<T> filterSuppressed(Throwable t, Class<T> filterClass) {
return stream(t.getSuppressed()).filter(filterClass::isInstance).map(filterClass::cast);
}
Expand Down Expand Up @@ -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);
}
}
}
Expand Up @@ -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 {

Expand Down Expand Up @@ -44,6 +46,11 @@ public String cacheName() {
public long lockTimeout() {
return 0;
}

@Override
public Class<? extends CacheKeyGenerator> keyGenerator() {
return UndefinedCacheKeyGenerator.class;
}
});
});
assertThrows(UnsupportedOperationException.class, () -> {
Expand Down

0 comments on commit 1319577

Please sign in to comment.