Blog

Quarkus Cache with Redis

Category
Software development
Quarkus Cache with Redis

When evaluating base frameworks for your new Java enterprise app, or a microservice, there are some table stakes that you simply expect to be there. Like, relational DB support via a good ORM, pub-sub messaging backed by RabbitMQ, Kafka, or the like… And caching support backed by the internal or external cache, to name a few. 

Quarkus Cache Support

Quarkus, a tech stack we’re already using in production, greets you with an inconvenient surprise here and there. The last one we tackled in Notch is one of those table stakes mentioned above – the external cache support. The level of cache support in Redis’ case at the time of our planned usage was not where we wanted it to be.

There was a Redisson client available and a Quarkus Redis client (a very comprehensive one). Still, no way to use convenient annotations to instruct the cache manager to do caching and evictions. You would have to do it all manually, i.e., have business code directly invoking your caching layer – built on top of the either aforementioned client. That right there is extra coupling. Given that we at Notch like loose coupling everywhere, we didn’t like that. That’s why we built our own library, which is the topic of this blog.

Meanwhile, a few months after we wrote our small library and deployed it to production, the Quarkus team worked hard and developed an extension that does pretty much what we did in this blog. The first commit of the extension was on the 21st of March, 2023.

Kudos to the Quarkus team for listening to the community!

But let’s get back to our endeavor and show you how to build support for caching with Redis via annotations. We’ll use the Redisson client under the hood.

Quarkus Cache Constraints

Before we start, let’s look at some of the constraints and building blocks Quarkus brings to the table.

Caching in Quarkus is implemented with standard Jakarta interceptors, i.e., javax.interceptor.Interceptor. The interceptor will intercept invocations annotated with special annotations – ones annotated with @InterceptorBinding, if and only if the interceptor is annotated with that special annotation.

Let’s make it more clear with an example:

@InterceptorBinding // MyCacheResult annotation can now be used to create an interception point
@Retention(RetentionPolicy.RUNTIME)
@Target({TYPE, METHOD})
public @interface MyCacheResult {...}
@Interceptor
@Priority(BaseCacheInterceptor.BASE_PRIORITY + 2)
@MyCacheResult(cacheName = "") // this interceptor will intercept invocations of a method annotated with @MyCacheResult
public class MyCacheResultInterceptor {...}Code language: PHP (php)

This is standard Jakarta stuff; no Quarkus or Redis or caching here yet.

Quarkus Annotations

There are four such special annotations in the Quarkus cache extension, namely: 

  • CacheResult
  • CacheKey
  • CacheInvalidate
  • CacheInvalidateAll

Right next to the annotations, you also have associated interceptors defined in that same extension. They all work nicely together using a Caffeine cache manager.

Swapping Cache Managers to Redis

Okay, time to plug in Redis somewhere. But where? Let’s take a look at what’s in the cache extension.

There’s a CacheManager interface, as well as Cache. The first thing on your mind might be to replace the Caffeine implementations with Redis one while continuing to use annotations provided by the extension. 

Unfortunately, because of Quarkus’ CDI implementation, that’s not possible. The CacheManager that ships with the cache extension is the @Default bean, and there’s no way to make some other CacheManager @Default and the one from the extension @Alternative. This is in stark contrast to Spring, where you can declare a bean @Primary and use the secondary bean via a proper qualifier.

Can We Reuse Anything From The Quarkus Extension?

Well, we can reuse the approach. As for code – not really 🙁

Annotations from the cache extension work only with interceptors that are bound to Caffeine and interceptors are bound to those annotations. Also, when you look at Quarkus’ Infinispan extension, it works with its own annotations and interceptors that have nothing to do with the cache extension. Only the names of the annotations are the same, but their packages are different, and there’s no inheritance here. So to the application, they’re a completely different set of annotations and interceptors.

So, if you want to use a new provider, the only option you have at your disposal is to create a new set of everything. You could do without a cache manager; nothing is forcing you to use one – and surely, there’s nothing forcing you to use the one from the cache extension. But having one is a good design decision, so we’ll implement our own one in this blog.

Quarkus-Cache Upgrades in 3.0.0

In Quarkus version 3.0.0, the cache extension got an upgrade as well. Now it’s got an SPI that allows for plugging in different caching backends. This is proper support for any caching provider you might need now, and we wholeheartedly welcome this addition even though there’s no official guide for that yet.

Creating an Annotation and an Interceptor for Quarkus Cache

First, we’ll create a @CacheResult annotation and the accompanying interceptor.

package com.ag04.quarkus.cache.annotation;
import javax.enterprise.util.Nonbinding;
import javax.interceptor.InterceptorBinding;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.ag04.quarkus.cache.CacheKeyGenerator;
import com.ag04.quarkus.cache.UndefinedCacheKeyGenerator;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({TYPE, METHOD})
public @interface CacheResult {
   /**
    * The name of the cache.
    */
   @Nonbinding String cacheName();
   /**
    * The {@link CacheKeyGenerator} implementation to use to generate a cache key.
    */
   @Nonbinding
   Class<? extends CacheKeyGenerator> keyGenerator() default UndefinedCacheKeyGenerator.class;
}Code language: CSS (css)

We’re creating everything from scratch, even the CacheKeyGenerator and the UndefinedCacheKeyGenerator, even though the ones from the cache extension would be just fine for this purpose. This is because the Quarkus’ cache extension is just one jar, and you either pull in everything (including the CacheManager mentioned above and all the autoconfiguration) or nothing. We opted for nothing here because it’s one less dependency to worry about. Also, this dependency has its own autoconfiguration and beans, and the less you have to worry about library version upgrades, the better off you are.

Next, let’s look at the interceptor that proceeds with the invocation and caches the result.

@Interceptor
@Priority(BaseCacheInterceptor.BASE_PRIORITY + 2)
@CacheResult(cacheName = "")
public class CacheResultInterceptor extends BaseCacheInterceptor {
   protected static final String OPTIONAL_CLASSNAME = "java.util.Optional";
   @Inject
   Logger log;
   @AroundInvoke
   <T> Object intercept(InvocationContext context) throws Throwable {
       CacheResult cachedAnnotation = context.getMethod().getAnnotation(CacheResult.class);
       String cacheName = cachedAnnotation.cacheName();
       Class keyGenerator = cachedAnnotation.keyGenerator();
              
       Object key = getCacheKey(cacheName, keyGenerator, context);
       log.debugf("Loading entry with key [%s] from cache [%s]", key, cacheName);
       String resultType = context.getMethod().getReturnType().getCanonicalName();
       Cache cache = cacheManager.getCache(cacheName);
       if (cache == null) {
           log.warnf("(SKIPPING) No cache with name '%s' found >> NO VALUE will be CACHED", cacheName);
       } else {
           Object cachedValue = cache.get(key);
           if (cachedValue != null) {
               log.infof("Cache hit for key: %s", key);
               if (OPTIONAL_CLASSNAME.equals(resultType)) {
                   return Optional.of(cachedValue);
               }
               return cachedValue;
           }
       }
       log.infof("Cache miss for key: %s", key);
       Object data = context.proceed();
       if (data != null) {
           if (OPTIONAL_CLASSNAME.equals(resultType)) {
               Optional opVal = (Optional) data;
               if (!opVal.isEmpty()) {
                   cache.put(key, opVal.get());
               }
           } else {
               cache.put(key, data);
           }
       }
       return data;
   }
}
Code language: JavaScript (javascript)

The interceptor is quite self-explanatory. It first looks for method annotations to see what cache it should read from and write to. It uses the provided key generator to create the cache key, and there’s a small extra logic to handle Optional with a focus on the value it holds rather than on the container.

Now let’s take a look at how the cache key is generated. The code is located in the abstract superclass, the BaseCacheInterceptor.

protected Object getCacheKey(
   String cacheName,
   Class<? extends CacheKeyGenerator> keyGeneratorClass,
   InvocationContext context) {
   
   Object[] methodParameterValues = context.getParameters();
   if (keyGeneratorClass != UndefinedCacheKeyGenerator.class) {
       return generateKey(keyGeneratorClass, context.getMethod(), 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 getDefaultKey(cacheName);
   } else if (isAtLeastOneParamAnnotated(context.getMethod().getParameterAnnotations())) {
          return new CompositeCacheKey(extractAnnotatedParamValues(context));
   } else if (methodParameterValues.length == 1) {
       // If the intercepted method has exactly one parameter, then this parameter will be used as the cache key.
       return methodParameterValues[0];
   } else {
       // If the intercepted method has two or more parameters, then a composite cache key built from all these parameters
       // will be used.
       return new CompositeCacheKey(methodParameterValues);
   }
}
Code language: PHP (php)

Granted, some branches depend on the method params, but nothing unexpected. One thing that would be nice to have is some warning when the key generator and @CacheKey annotation are both used on one method. That usage scenario is not supported – as you can see if a KeyGenerator is declared, it always takes precedence.

You can see the rest of the code with other interceptors and annotations for yourself in the GitHub repo.

Setting Up Cache Manager in Redis

To get off the ground, you have to set up the CacheManager and the Redisson client. Luckily, the Redisson client is easy to set up, and the cache manager is just a simple bean with no particular initialization logic. All of this is captured in the RedisConfig.

@Singleton
public class RedisConfig {
   private final Logger log = Logger.getLogger(RedisConfig.class);
   public static final Long MINUTE = 60L;
   public static final Long HALF_HOUR = 30*60L;
   public static final Long HOUR = 60*60L;

   @ConfigProperty(name = "redis.default-ttl")
   Long defaultTtl;
   @Inject
   RedissonClient redisson;
   void onStart(@Observes StartupEvent ev) {              
       log.info("Creating Redis caches ...");
       this.createCacheManager();
   }
   @Produces
   @ApplicationScoped
   public CacheManager createCacheManager() {
       RedisCacheManager cacheManager = new RedisCacheManager(this.redisson, this.defaultTtl);
       cacheManager.createCache(CountryDTO.class.getCanonicalName(), HOUR);
       cacheManager.createCache(CurrencyDTO.class.getCanonicalName(), HOUR);
       return cacheManager;
   }
}Code language: JavaScript (javascript)

Usage of Cache Manager

The style of usage is also what you’d expect. Do you need to cache something? Annotate the method with @CacheResult. 

   @Override
   @CacheResult(cacheName = "com.ag04.geodata.service.dto.CountryDTO")
   public Optional<CountryDTO> findOne(Long id) {
       log.debug("Request to get Country : {}", id);
       return Country.findByIdOptional(id).map(country -> countryMapper.toDto((Country) country));
   }Code language: CSS (css)

Do you need to invalidate a cache or a single entry cause you just wrote something to the database table whose content you’re caching? Just annotate with @CacheInvalidate or @CacheInvalidateAll.

   @Override
   @Transactional
   @CacheInvalidate(cacheName = "com.ag04.geodata.service.dto.CountryDTO", keyGenerator = GetIdCacheKeyGenerator.class)
   public CountryDTO persistOrUpdate(CountryDTO countryDTO) {
       log.debug("Request to save Country : {}", countryDTO);
       var country = countryMapper.toEntity(countryDTO);
       country = Country.persistOrUpdate(country);
       return countryMapper.toDto(country);
   }Code language: JavaScript (javascript)

You have multiple params on a method whose result you want to cache but only want to use some and not all of them. Annotate the params you want to use in cache key calculation with @CacheKey.

Native Image Considerations

What would a Quarkus blog be without some native image troubles? 🙂

application.properties

The build mechanism apparently traverses application.properties and removes the properties from it that it deems unnecessary. Redisson properties are like that, and we need to add the inclusion pattern for application.properties to resources-config.json so that it finally looks like this:

{
 "resources": {
   "includes": [
     {"pattern": "jwt/privateKey.pem"},
     {"pattern": "\\Qprotostream/common-java-types.proto\\E"},
     {"pattern": "\\Qprotostream/common-java-container-types.proto\\E"},
     {"pattern": "application.properties"}
   ]
 }
}Code language: JSON / JSON with Comments (json)

This prevents cleaning of the application.properties, and the application can now start.

serialization-config.json

The default serialization mechanism that Redisson uses is standard Java serialization. When trying to store a CountryDTO instance to Redis, you’ll be greeted with the following exception: 

com.oracle.svm.core.jdk.UnsupportedFeatureError: SerializationConstructorAccessor class not found for declaring class: com.ag04.geodata.service.dto.CountryDTO (targetConstructorClass: java.lang.Object). Usually adding com.ag04.geodata.service.dto.CountryDTO to serialization-config.json fixes the problem

To fix it, add serialization-config.json file to src/main/resources/META-INF/native-image/org. Redisson/redisson folder, and paste the following content into it:

[
 {
   "name":"com.ag04.geodata.service.dto.CountryDTO",
   "customTargetConstructorClass":"java.lang.Object"
 }
]Code language: JSON / JSON with Comments (json)

Docker Compose for Sample Application

While not central to this blog, we also have a docker-compose app.yml to start the sample application and a Redis container. If you decide to go that route, we must warn you that the Quarkus native app will start faster than Redis. So to avoid the Quarkus app from crashing with ConnectException: Connection refused, you need to add a depends_on to the geodatarestquarkus-app service to hold off the start of the Quarkus app until Redis is ready.

depends_on:
 - redis

Limitations of this method

The reactive paradigm is not supported in this implementation, and cache invalidation is only done after a successful invocation of the method. If the method throws an exception, it won’t clear the cache. This is quite fine for most use cases. With Spring, that behavior is configurable; here, it is not. That said, it’s quite easy to implement it by adding another annotation parameter and using it in the interceptor.

Notch ending thoughts

Coming from a Spring world, it surprised both of us (authors) that the cache extension itself was not that configurable. We’re used to having various configuration helper classes and configureres and configuring adapters. Spring indeed spoiled us, so setting up and building Redis caching support was an effort. Not to mention we had to test everything extensively by ourselves. However, the end result is quite satisfying, and it’s been serving our needs for months.

GitHub repositories:

If you want to read more from Darko Spoljaric, our Principal Engineer, take a look at the What Makes a Good Scrum Master and Why You Shouldn’t Put Your Devs into this Role and read one of founders Domagoj Madunić’s Firebase Cloud Messaging Setup for JHipster Generated Angular 11 Application.

CONTACT US

Exceptional ideas need experienced partners.