1. Overview

In this guide, we’ll learn about Infinispan, an in-memory key/value data store that ships with a more robust set of features than other tools of the same niche.

To understand how it works, we’ll build a simple project showcasing the most common features and check how they can be used.

2. Project Setup

To be able to use it this way, we’ll need to add it’s dependency in our pom.xml.

The latest version can be found in Maven Central repository:

<dependency>
    <groupId>org.infinispan</groupId>
    <artifactId>infinispan-core</artifactId>
    <version>9.1.5.Final</version>
</dependency>

All the necessary underlying infrastructure will be handled programmatically from now on.

3. CacheManager Setup

The CacheManager is the foundation of the majority of features that we’ll use. It acts as a container for all declared caches, controlling their lifecycle, and is responsible for the global configuration.

Infinispan ships with a really easy way to build the CacheManager:

public DefaultCacheManager cacheManager() {
    return new DefaultCacheManager();
}

Now we’re able to build our caches with it.

4. Caches Setup

A cache is defined by a name and a configuration. The necessary configuration can be built using the class ConfigurationBuilder, already available in our classpath.

To test our caches, we’ll build a simple method that simulates some heavy query:

public class HelloWorldRepository {
    public String getHelloWorld() {
        try {
            System.out.println("Executing some heavy query");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // ...
            e.printStackTrace();
        }
        return "Hello World!";
    }
}

Also, to be able to check for changes in our caches, Infinispan provides a simple annotation @Listener.

When defining our cache, we can pass some object interested in any event happening inside it, and Infinispan will notify it when handling the cache:

@Listener
public class CacheListener {
    @CacheEntryCreated
    public void entryCreated(CacheEntryCreatedEvent<String, String> event) {
        this.printLog("Adding key '" + event.getKey() 
          + "' to cache", event);
    }

    @CacheEntryExpired
    public void entryExpired(CacheEntryExpiredEvent<String, String> event) {
        this.printLog("Expiring key '" + event.getKey() 
          + "' from cache", event);
    }

    @CacheEntryVisited
    public void entryVisited(CacheEntryVisitedEvent<String, String> event) {
        this.printLog("Key '" + event.getKey() + "' was visited", event);
    }

    @CacheEntryActivated
    public void entryActivated(CacheEntryActivatedEvent<String, String> event) {
        this.printLog("Activating key '" + event.getKey() 
          + "' on cache", event);
    }

    @CacheEntryPassivated
    public void entryPassivated(CacheEntryPassivatedEvent<String, String> event) {
        this.printLog("Passivating key '" + event.getKey() 
          + "' from cache", event);
    }

    @CacheEntryLoaded
    public void entryLoaded(CacheEntryLoadedEvent<String, String> event) {
        this.printLog("Loading key '" + event.getKey() 
          + "' to cache", event);
    }

    @CacheEntriesEvicted
    public void entriesEvicted(CacheEntriesEvictedEvent<String, String> event) {
        StringBuilder builder = new StringBuilder();
        event.getEntries().forEach(
          (key, value) -> builder.append(key).append(", "));
        System.out.println("Evicting following entries from cache: " 
          + builder.toString());
    }

    private void printLog(String log, CacheEntryEvent event) {
        if (!event.isPre()) {
            System.out.println(log);
        }
    }
}

Before printing our message we check if the event being notified already has happened, because, for some event types, Infinispan sends two notifications: one before and one right after it has been processed.

Now let’s build a method to handle the cache creation for us:

private <K, V> Cache<K, V> buildCache(
  String cacheName, 
  DefaultCacheManager cacheManager, 
  CacheListener listener, 
  Configuration configuration) {

    cacheManager.defineConfiguration(cacheName, configuration);
    Cache<K, V> cache = cacheManager.getCache(cacheName);
    cache.addListener(listener);
    return cache;
}

Notice how we pass a configuration to CacheManager, and then use the same cacheName to get the object corresponding to the wanted cache. Note also how we inform the listener to the cache object itself.

We’ll now check five different cache configurations, and we’ll see how we can set them up and make the best use of them.

4.1. Simple Cache

The simplest type of cache can be defined in one line, using our method buildCache:

public Cache<String, String> simpleHelloWorldCache(
  DefaultCacheManager cacheManager, 
  CacheListener listener) {
    return this.buildCache(SIMPLE_HELLO_WORLD_CACHE, 
      cacheManager, listener, new ConfigurationBuilder().build());
}

We can now build a Service:

public String findSimpleHelloWorld() {
    String cacheKey = "simple-hello";
    return simpleHelloWorldCache
      .computeIfAbsent(cacheKey, k -> repository.getHelloWorld());
}

Note how we use the cache, first checking if the wanted entry is already cached. If it isn’t, we’ll need to call our Repository and then cache it.

Let’s add a simple method in our tests to time our methods:

protected <T> long timeThis(Supplier<T> supplier) {
    long millis = System.currentTimeMillis();
    supplier.get();
    return System.currentTimeMillis() - millis;
}

Testing it, we can check the time between executing two method calls:

@Test
public void whenGetIsCalledTwoTimes_thenTheSecondShouldHitTheCache() {
    assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld()))
      .isLessThan(100);
}

4.2. Expiration Cache

We can define a cache in which all entries have a lifespan, in other words, elements will be removed from the cache after a given period. The configuration is quite simple:

private Configuration expiringConfiguration() {
    return new ConfigurationBuilder().expiration()
      .lifespan(1, TimeUnit.SECONDS)
      .build();
}

Now we build our cache using the above configuration:

public Cache<String, String> expiringHelloWorldCache(
  DefaultCacheManager cacheManager, 
  CacheListener listener) {
    
    return this.buildCache(EXPIRING_HELLO_WORLD_CACHE, 
      cacheManager, listener, expiringConfiguration());
}

And finally, use it in a similar method from our simple cache above:

public String findSimpleHelloWorldInExpiringCache() {
    String cacheKey = "simple-hello";
    String helloWorld = expiringHelloWorldCache.get(cacheKey);
    if (helloWorld == null) {
        helloWorld = repository.getHelloWorld();
        expiringHelloWorldCache.put(cacheKey, helloWorld);
    }
    return helloWorld;
}

Let’s test our times again:

@Test
public void whenGetIsCalledTwoTimesQuickly_thenTheSecondShouldHitTheCache() {
    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isLessThan(100);
}

Running it, we see that in quick succession the cache hits. To showcase that the expiration is relative to its entry put time, let’s force it in our entry:

@Test
public void whenGetIsCalledTwiceSparsely_thenNeitherHitsTheCache()
  throws InterruptedException {

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    Thread.sleep(1100);

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);
}

After running the test, note how after the given time our entry was expired from the cache. We can confirm this by looking at the printed log lines from our listener:

Executing some heavy query
Adding key 'simple-hello' to cache
Expiring key 'simple-hello' from cache
Executing some heavy query
Adding key 'simple-hello' to cache

Note that the entry is expired when we try to access it. Infinispan checks for an expired entry in two moments: when we try to access it or when the reaper thread scans the cache.

We can use expiration even in caches without it in their main configuration. The method put accepts more arguments:

simpleHelloWorldCache.put(cacheKey, helloWorld, 10, TimeUnit.SECONDS);

Or, instead of a fixed lifespan, we can give our entry a maximum idleTime:

simpleHelloWorldCache.put(cacheKey, helloWorld, -1, TimeUnit.SECONDS, 10, TimeUnit.SECONDS);

Using -1 to the lifespan attribute, the cache won’t suffer expiration from it, but when we combine it with 10 seconds of idleTime, we tell Infinispan to expire this entry unless it is visited in this timeframe.

4.3. Cache Eviction

In Infinispan we can limit the number of entries in a given cache with the eviction configuration:

private Configuration evictingConfiguration() {
    return new ConfigurationBuilder()
      .memory().evictionType(EvictionType.COUNT).size(1)
      .build();
}

In this example, we’re limiting the maximum entries in this cache to one, meaning that, if we try to enter another one, it’ll be evicted from our cache.

Again, the method is similar to the already presented here:

public String findEvictingHelloWorld(String key) {
    String value = evictingHelloWorldCache.get(key);
    if(value == null) {
        value = repository.getHelloWorld();
        evictingHelloWorldCache.put(key, value);
    }
    return value;
}

Let’s build our test:

@Test
public void whenTwoAreAdded_thenFirstShouldntBeAvailable() {

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 2")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);
}

Running the test, we can look at our listener log of activities:

Executing some heavy query
Adding key 'key 1' to cache
Executing some heavy query
Evicting following entries from cache: key 1, 
Adding key 'key 2' to cache
Executing some heavy query
Evicting following entries from cache: key 2, 
Adding key 'key 1' to cache

Check how the first key was automatically removed from the cache when we inserted the second one, and then, the second one removed also to give room for our first key again.

4.4. Passivation Cache

The cache passivation is one of the powerful features of Infinispan. By combining passivation and eviction, we can create a cache that doesn’t occupy a lot of memory, without losing information.

Let’s have a look at a passivation configuration:

private Configuration passivatingConfiguration() {
    return new ConfigurationBuilder()
      .memory().evictionType(EvictionType.COUNT).size(1)
      .persistence() 
      .passivation(true)    // activating passivation
      .addSingleFileStore() // in a single file
      .purgeOnStartup(true) // clean the file on startup
      .location(System.getProperty("java.io.tmpdir")) 
      .build();
}

We’re again forcing just one entry in our cache memory, but telling Infinispan to passivate the remaining entries, instead of just removing them.

Let’s see what happens when we try to fill more than one entry:

public String findPassivatingHelloWorld(String key) {
    return passivatingHelloWorldCache.computeIfAbsent(key, k -> 
      repository.getHelloWorld());
}

Let’s build our test and run it:

@Test
public void whenTwoAreAdded_thenTheFirstShouldBeAvailable() {

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 2")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 1")))
      .isLessThan(100);
}

Now let’s look at our listener activities:

Executing some heavy query
Adding key 'key 1' to cache
Executing some heavy query
Passivating key 'key 1' from cache
Evicting following entries from cache: key 1, 
Adding key 'key 2' to cache
Passivating key 'key 2' from cache
Evicting following entries from cache: key 2, 
Loading key 'key 1' to cache
Activating key 'key 1' on cache
Key 'key 1' was visited

Note how many steps did it take to keep our cache with only one entry. Also, note the order of steps – passivation, eviction and then loading followed by activation. Let’s see what those steps mean:

  • Passivation – our entry is stored in another place, away from the mains storage of Infinispan (in this case, the memory)
  • Eviction – the entry is removed, to free memory and to keep the configured maximum number of entries in the cache
  • Loading – when trying to reach our passivated entry, Infinispan checks it’s stored contents and load the entry to the memory again
  • Activation – the entry is now accessible in Infinispan again

4.5. Transactional Cache

Infinispan ships with a powerful transaction control. Like the database counterpart, it is useful in maintaining integrity while more than one thread is trying to write the same entry.

Let’s see how we can define a cache with transactional capabilities:

private Configuration transactionalConfiguration() {
    return new ConfigurationBuilder()
      .transaction().transactionMode(TransactionMode.TRANSACTIONAL)
      .lockingMode(LockingMode.PESSIMISTIC)
      .build();
}

To make it possible to test it, let’s build two methods – one that finishes its transaction rapidly, and one that takes a while:

public Integer getQuickHowManyVisits() {
    TransactionManager tm = transactionalCache
      .getAdvancedCache().getTransactionManager();
    tm.begin();
    Integer howManyVisits = transactionalCache.get(KEY);
    howManyVisits++;
    System.out.println("I'll try to set HowManyVisits to " + howManyVisits);
    StopWatch watch = new StopWatch();
    watch.start();
    transactionalCache.put(KEY, howManyVisits);
    watch.stop();
    System.out.println("I was able to set HowManyVisits to " + howManyVisits + 
      " after waiting " + watch.getTotalTimeSeconds() + " seconds");

    tm.commit();
    return howManyVisits;
}
public void startBackgroundBatch() {
    TransactionManager tm = transactionalCache
      .getAdvancedCache().getTransactionManager();
    tm.begin();
    transactionalCache.put(KEY, 1000);
    System.out.println("HowManyVisits should now be 1000, " +
      "but we are holding the transaction");
    Thread.sleep(1000L);
    tm.rollback();
    System.out.println("The slow batch suffered a rollback");
}

Now let’s create a test that executes both methods and check how Infinispan will behave:

@Test
public void whenLockingAnEntry_thenItShouldBeInaccessible() throws InterruptedException {
    Runnable backGroundJob = () -> transactionalService.startBackgroundBatch();
    Thread backgroundThread = new Thread(backGroundJob);
    transactionalService.getQuickHowManyVisits();
    backgroundThread.start();
    Thread.sleep(100); //lets wait our thread warm up

    assertThat(timeThis(() -> transactionalService.getQuickHowManyVisits()))
      .isGreaterThan(500).isLessThan(1000);
}

Executing it, we’ll see the following activities in our console again:

Adding key 'key' to cache
Key 'key' was visited
Ill try to set HowManyVisits to 1
I was able to set HowManyVisits to 1 after waiting 0.001 seconds
HowManyVisits should now be 1000, but we are holding the transaction
Key 'key' was visited
Ill try to set HowManyVisits to 2
I was able to set HowManyVisits to 2 after waiting 0.902 seconds
The slow batch suffered a rollback

Check the time on the main thread, waiting for the end of the transaction created by the slow method.

5. Conclusion

In this article, we’ve seen what Infinispan is, and it’s leading features and capabilities as a cache within an application.

As always, the code can be found over on Github.

Course – LSD (cat=Persistence)

Get started with Spring Data JPA through the reference Learn Spring Data JPA course:

>> CHECK OUT THE COURSE
res – Persistence (eBook) (cat=Persistence)
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.