Multiple TTL caches in Spring Boot

Spring Framework provides a comprehensive abstraction for common caching scenarios without coupling to any of supported cache implementations. However, declaration of expiration time for a particular storage is not a part of this abstraction. If we want to set Time To Live of a cache, the configuration of the chosen cache provider must be tuned. From this post you will learn how to prepare setup for several Caffeine caches with different TTL configurations.

1. Study case

Let’s begin with the definition of a problem. Our imaginary application requires caches for two different REST endpoints, but one of them should expire more often than the other. Consider the following facade implementation:

@Service
class ForeignEndpointGateway {

    private RestTemplate restTemplate;

    ForeignEndpointGateway(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }
    
    @Cacheable("messages")
    public Message findMessage(long id) {
        String url = "http://somedomain.com/messages/" + id;
        return restTemplate.getForObject(url, Message.class);
    }

    @Cacheable("notifications")
    public Notification findNotification(long id) {
        String url = "http://somedomain.com/notifications/" + id;
        return restTemplate.getForObject(url, Notification.class);
    }

}

The @Cacheable annotation marks methods for Spring’s caching mechanism. It is worth mentioning that cached methods must be public. Each annotation specifies the name of a corresponding cache which should be used for particular methods.

A cache instance is nothing more than a simple key-value container. In our case the key is created based on the input parameter and the value is the outcome of the method, but it does not have to be that simple. The cache abstraction provided by Spring allows for much more, but this is a topic for a different post. If you are interested in details, I refer you to the documentations. Let’s stick to our main goal which is definition of different TTL values for both declared caches.

2. Common cache setup

Putting the @Cacheable annotation on a method is not the only thing required to run the cache mechanize in your application. Depending on a chosen provider there might be several additional steps.

2.1. Turn on Spring caching

No matter which provider you pick, the starting point of the setup is always adding the @EnableCaching annotation to one of your configuration classes, usually the main application class. This registers all required components in your Spring context.

@SpringBootApplication
@EnableCaching
public class TtlCacheApplication {
    // content omitted for clarity
}

2.2. Required dependencies

In a regular Spring application using the @EnableCaching annotation requires a developer to provide a bean of type CacheManager. Fortunately, Spring Boot cache starter supplies the default manager and creates an appropriate cache provider based on dependencies available on the class path, which in our case is the Caffeine library.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

2.3. Basic configuration

The majority of the cache providers supported by Spring Boot can be adjusted using dedicated application properties. To setup TTL for both caches required by our demo application, we can use the following values:

spring.cache.cache-names=messages,notifications
spring.cache.caffeine.spec=maximumSize=100,expireAfterAccess=1800s

In a very simple way we set TTL of the caches for 30 minutes and their capacity to 100. However, the main issue with such configuration is the fact that all caches use the same setup. It is not possible to set different spec for each cache. They all need to share the global one. If you do not mind such limitation, you can go with the basic setup. Otherwise, you should keep reading the next part.

3. Differentiate caches

Spring Boot effectively deals with popular configurations, but our scenario does not belong to this lucky group. In order to customize caches for our needs, we need to go beyond the predefined beans and write some custom initialization code.

3.1. Custom cache manager

There is no need to disable the default configuration provided by Spring Boot as we can override only one necessary object. By defining the bean with name cacheManager we replace the one supplied by Spring Boot. Below we create two caches. The first is called messages and its expiration time equals to 30 minutes. The other one named notifications stores values for 60 minutes. When you create a custom cache manager, the settings from application.properties (presented before in the basic sample) are no longer utilized and can be safely removed.

@Bean
public CacheManager cacheManager(Ticker ticker) {
    CaffeineCache messageCache = buildCache("messages", ticker, 30);
    CaffeineCache notificationCache = buildCache("notifications", ticker, 60);
    SimpleCacheManager manager = new SimpleCacheManager();
    manager.setCaches(Arrays.asList(messageCache, notificationCache));
    return manager;
}

private CaffeineCache buildCache(String name, Ticker ticker, int minutesToExpire) {
    return new CaffeineCache(name, Caffeine.newBuilder()
                .expireAfterWrite(minutesToExpire, TimeUnit.MINUTES)
                .maximumSize(100)
                .ticker(ticker)
                .build());
}

@Bean
public Ticker ticker() {
    return Ticker.systemTicker();
}

The Caffeine library comes with a convenient cache builder. In our demo we focus only on different TTL values, but other options can also be customized if necessary (e.g. capacity, or very useful expiration after access).

In the above example, we also created the ticker bean, which we share by our caches. The ticker is responsible for keeping track of the passage of time. Actually, it is not mandatory to pass the instance of the Ticker type to the cache builder, Caffeine creates one if nothing is provided. However, if we want to write tests for our solution, the separate bean will be easier to stub.

3.2. TTL cache testing

The first thing that we need in our integration test is a configuration class with a fake ticker, which will allow to simulate the time passage. The Caffeine library does not provide such ticker by itself, but the documentation refers to guava-testlib which we need to declare as a dependency of our project.

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava-testlib</artifactId>
    <version>20.0</version>
    <scope>test</scope>
</dependency>

The @SpringBootTest annotation added in Spring Boot 1.4.0 automatically detects and utilizes an inner static configuration class if one is present inside the test class. By importing the main configuration class we keep the original project setup and replace only the ticker instance with the fake.

@RunWith(SpringRunner.class)
@SpringBootTest
public class MessageRepositoryTest {

    @Configuration
    @Import(TtlCacheApplication.class)
    public static class TestConfig {

        static FakeTicker fakeTicker = new FakeTicker();

        @Bean
        public Ticker ticker() {
            return fakeTicker::read;
        }

    }
}

We are going to use a spy on the RestTemplate instance utilized by our cached gateway class to observe the number of possible calls to the real REST endpoints. The spy should return some stub values to prevent actual calls from happening.

private static final long MESSAGE_ID = 1;
private static final long NOTIFICATION_ID = 2;

@SpyBean
private RestTemplate restTemplate;
@Autowired
private ForeignEndpointGateway gateway;

@Before
public void setUp() throws Exception {
    Message message = stubMessage(MESSAGE_ID);
    Notification notification = stubNotification(NOTIFICATION_ID);
    doReturn(message)
            .when(restTemplate)
            .getForObject(anyString(), eq(Message.class));
    doReturn(notification)
            .when(restTemplate)
            .getForObject(anyString(), eq(Notification.class));
}

Finally, we can write a test with our happy path scenario to confirm whether the TTL configuration acts as we expect.

@Test
public void shouldUseCachesWithDifferentTTL() throws Exception {
    // 0 minutes
    foreignEndpointGateway.findMessage(MESSAGE_ID);
    foreignEndpointGateway.findNotification(NOTIFICATION_ID);
    verify(restTemplate, times(1)).getForObject(anyString(), eq(Message.class));
    verify(restTemplate, times(1)).getForObject(anyString(), eq(Notification.class));
    // after 5 minutes
    TestConfig.fakeTicker.advance(5, TimeUnit.MINUTES);
    foreignEndpointGateway.findMessage(MESSAGE_ID);
    verify(restTemplate, times(1)).getForObject(anyString(), eq(Message.class));
    // after 35 minutes
    TestConfig.fakeTicker.advance(30, TimeUnit.MINUTES);
    foreignEndpointGateway.findMessage(MESSAGE_ID);
    foreignEndpointGateway.findNotification(NOTIFICATION_ID);
    verify(restTemplate, times(2)).getForObject(anyString(), eq(Message.class));
    verify(restTemplate, times(1)).getForObject(anyString(), eq(Notification.class));
    // after 65 minutes
    TestConfig.fakeTicker.advance(30, TimeUnit.MINUTES);
    foreignEndpointGateway.findNotification(NOTIFICATION_ID);
    verify(restTemplate, times(2)).getForObject(anyString(), eq(Notification.class));
}

At the beginning, both Message and Notification objects are fetched from the endpoints and placed in the cache.
After 5 minutes, another call for the Message object is made. Since the message cache TTL is configured for 30 minutes, we expect the value will be taken from the cache and the call to the endpoint will not be made.
After another 30 minutes we expect the cached message is expired which we confirm by another call to the endpoint. Yet, the notification cache has been configured to keep values for 60 minutes. By trying to fetch the notification again, we confirm the other cache is still valid.
At the end, the ticker advances by another 30 minutes, which makes 65 minutes in total from the start of the test. We verify that the notification is also expired and removed from the cache.

3. TTL with other cache providers

As already mentioned, the main drawback of Caffeine is the lack of possibility to differentiate all caches. The spec from the spring.cache.caffeine.spec applies globally. Hopefully, the setup of multiple caches will be simplified in future releases, but for now we need to stick to the manual configuration.

For other cache providers the situation is fortunately much easier. EhCache, Hazelcast, and Infinitspan use a dedicated XML configuration file where each cache can be configured in isolation. The details can be found in the official Spring Boot samples.

4. Summary

Although Spring Boot does an excellent job in solving mundane configuration for us, sometimes we need to be in the driver’s seat. The default setup of Caffeine cache might be sufficient in simple cases, yet, it pales in comparison with other supported cache providers. After reading this post you should know how to prepare the basic and more complex custom configuration of the Caffeine caching library. The working example can be found in the GitHub repository. If you run into any issue with the presented solution or have any question related to the described topic, feel encouraged to leave a comment.

Facebooktwittergoogle_pluslinkedinmail