distributed-cache: Spring Cache abstraction over Hazelcast, Infinispan and Redis (post 29)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
backend=hazelcast
|
||||
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
|
||||
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
|
||||
@@ -0,0 +1,3 @@
|
||||
backend=hazelcast
|
||||
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
|
||||
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
|
||||
@@ -0,0 +1,3 @@
|
||||
backend=infinispan
|
||||
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
|
||||
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
|
||||
@@ -0,0 +1,3 @@
|
||||
backend=infinispan
|
||||
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
|
||||
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
|
||||
@@ -0,0 +1,3 @@
|
||||
backend=redis
|
||||
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
|
||||
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
|
||||
@@ -0,0 +1,3 @@
|
||||
backend=redis
|
||||
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
|
||||
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>distributed-cache-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>distributed-cache-demo</name>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<testcontainers.version>2.0.5</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Hazelcast: embedded member + its Spring CacheManager adapter -->
|
||||
<dependency>
|
||||
<groupId>com.hazelcast</groupId>
|
||||
<artifactId>hazelcast-spring</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Infinispan: embedded Spring Boot 4 starter, confirmed to exist on
|
||||
Maven Central under this exact artifactId (not guessed). Its
|
||||
auto-configuration unconditionally registers an
|
||||
InfinispanCacheMeterBinderProvider bean, whose
|
||||
@Lookup-annotated method references
|
||||
io.micrometer.core.instrument.binder.MeterBinder - a class
|
||||
the starter itself does not pull in. Without micrometer-core
|
||||
on the classpath this fails bean creation at startup with
|
||||
NoClassDefFoundError, even when this profile is never
|
||||
activated (the starter's auto-configuration runs regardless
|
||||
of @Profile). Confirmed by reading the real stack trace, not
|
||||
assumed from the starter's docs, which don't mention it. -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.infinispan</groupId>
|
||||
<artifactId>infinispan-spring-boot4-starter-embedded</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis: real server via Testcontainers, not embedded -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<!-- Infinispan's auto-configured embedded CacheManager registers
|
||||
a JMX MBean under the fixed domain "org.infinispan" with no
|
||||
per-instance disambiguation. That auto-configuration runs
|
||||
unconditionally, in every Spring context this test JVM
|
||||
creates, regardless of which profile is active - so with
|
||||
Surefire's default of reusing one forked JVM across test
|
||||
classes, the Hazelcast and Redis profile contexts each try
|
||||
to register that same MBean domain a second/third time and
|
||||
fail with ISPN000034 (confirmed from the real stack trace).
|
||||
reuseForks=false gives each test class its own JVM, which
|
||||
is the one reliable fix - there is no public toggle on
|
||||
InfinispanEmbeddedConfigurationProperties to disable JMX. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<reuseForks>false</reuseForks>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
public class DistributedCacheDemoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DistributedCacheDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Stands in for a slow upstream call (a real rate provider, a legacy
|
||||
* mainframe, whatever). The counter is the whole point: it only goes up
|
||||
* when the method body actually runs, so a test can prove a second call
|
||||
* for the same key was served from the cache without ever calling this
|
||||
* class directly. The @Cacheable/@CacheEvict annotations are the entire
|
||||
* caching story here - which CacheManager bean backs "exchangeRates" is
|
||||
* decided elsewhere, by which Spring profile is active, and this class
|
||||
* neither knows nor cares which one that is.
|
||||
*/
|
||||
@Service
|
||||
public class ExchangeRateLookupService {
|
||||
|
||||
private final AtomicInteger callCount = new AtomicInteger();
|
||||
|
||||
@Cacheable("exchangeRates")
|
||||
public BigDecimal rateFor(String currencyPair) {
|
||||
callCount.incrementAndGet();
|
||||
return switch (currencyPair) {
|
||||
case "USD/INR" -> new BigDecimal("87.42");
|
||||
case "USD/EUR" -> new BigDecimal("0.92");
|
||||
default -> throw new IllegalArgumentException("unknown pair: " + currencyPair);
|
||||
};
|
||||
}
|
||||
|
||||
@CacheEvict("exchangeRates")
|
||||
public void evict(String currencyPair) {
|
||||
// no-op body; the annotation does the work
|
||||
}
|
||||
|
||||
public int callCount() {
|
||||
return callCount.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import com.hazelcast.config.Config;
|
||||
import com.hazelcast.core.Hazelcast;
|
||||
import com.hazelcast.core.HazelcastInstance;
|
||||
import com.hazelcast.spring.cache.HazelcastCacheManager;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
/**
|
||||
* Active only under the "hazelcast" profile. A single embedded
|
||||
* HazelcastInstance plays the role a real cluster member would - in
|
||||
* production this is the same class, just joining other real members
|
||||
* over the network instead of running alone in the test JVM.
|
||||
* HazelcastCacheManager is Hazelcast's own adapter to Spring's
|
||||
* CacheManager interface; ExchangeRateLookupService never sees it
|
||||
* directly, it only sees the "exchangeRates" cache name.
|
||||
*
|
||||
* {@code @Primary} is required here, not decorative: the Infinispan
|
||||
* starter on the classpath auto-configures its own CacheManager bean
|
||||
* unconditionally (see InfinispanEmbeddedCacheManagerAutoConfiguration),
|
||||
* regardless of which profile is active, so without @Primary Spring
|
||||
* would see two CacheManager beans and this one would not reliably win.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("hazelcast")
|
||||
public class HazelcastCacheConfig {
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public HazelcastInstance hazelcastInstance() {
|
||||
Config config = new Config();
|
||||
config.setClusterName("distributed-cache-demo-" + System.nanoTime());
|
||||
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
|
||||
config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false);
|
||||
return Hazelcast.newHazelcastInstance(config);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public CacheManager cacheManager(HazelcastInstance hazelcastInstance) {
|
||||
return new HazelcastCacheManager(hazelcastInstance);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.infinispan.configuration.cache.ConfigurationBuilder;
|
||||
import org.infinispan.manager.EmbeddedCacheManager;
|
||||
import org.infinispan.spring.starter.embedded.InfinispanCacheConfigurer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Unlike Hazelcast's HazelcastCacheManager and Spring Data Redis's
|
||||
* RedisCacheManager, Infinispan's embedded CacheManager does not create
|
||||
* an undeclared cache on first use - calling getCache("exchangeRates")
|
||||
* without this configurer throws ISPN000436 ("no matching cache
|
||||
* configuration exists"), confirmed by the real exception a first,
|
||||
* configurer-less version of this class produced. An
|
||||
* InfinispanCacheConfigurer bean is the starter's documented extension
|
||||
* point for declaring a cache programmatically instead of in an
|
||||
* external infinispan.xml. This bean is not @Profile-gated because the
|
||||
* starter's own auto-configuration isn't either - it always creates an
|
||||
* embedded CacheManager regardless of which profile is active, so this
|
||||
* just makes sure the one cache this demo needs exists on it.
|
||||
*/
|
||||
@Configuration
|
||||
public class InfinispanCacheConfig {
|
||||
|
||||
@Bean
|
||||
public InfinispanCacheConfigurer exchangeRatesCacheConfigurer() {
|
||||
return (EmbeddedCacheManager cacheManager) ->
|
||||
cacheManager.defineConfiguration("exchangeRates", new ConfigurationBuilder().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
|
||||
/**
|
||||
* Active only under the "redis" profile. Unlike the Hazelcast and
|
||||
* Infinispan members, this CacheManager talks to a real out-of-process
|
||||
* Redis server - here a Testcontainers container in tests, any real
|
||||
* Redis instance (standalone or cluster) in production. The cache
|
||||
* survives this application restarting; the embedded members' caches do
|
||||
* not, which is the actual distinction this post is making, not just a
|
||||
* difference in configuration style. @Primary disambiguates from the
|
||||
* Infinispan starter's own unconditional auto-configured CacheManager
|
||||
* bean - see the note on HazelcastCacheConfig for why that's needed.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("redis")
|
||||
public class RedisCacheConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
|
||||
return RedisCacheManager.create(connectionFactory);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The same two behaviours, asserted identically, against whichever
|
||||
* CacheManager the active Spring profile wired up. That's deliberate:
|
||||
* the point of the Spring Cache abstraction is that ExchangeRateLookupService
|
||||
* and this test never change when the backend does - only the
|
||||
* subclass's @ActiveProfiles (and, for Redis, the container) changes.
|
||||
*
|
||||
* ExchangeRateLookupService's callCount() is a singleton counter shared
|
||||
* across every @Test method in a subclass's Spring context (JUnit does
|
||||
* not create a fresh context per test method), so each test reads the
|
||||
* counter before and after its own calls and asserts the *delta* rather
|
||||
* than an absolute value - that way test execution order inside one
|
||||
* class can never affect the result.
|
||||
*/
|
||||
abstract class CacheHitAndEvictionContractTest {
|
||||
|
||||
@Autowired
|
||||
ExchangeRateLookupService service;
|
||||
|
||||
abstract String backendName();
|
||||
|
||||
@Test
|
||||
void secondCallForSameKeyIsServedFromCache() {
|
||||
int before = service.callCount();
|
||||
|
||||
service.rateFor("USD/INR");
|
||||
int afterFirstCall = service.callCount();
|
||||
|
||||
service.rateFor("USD/INR");
|
||||
int afterSecondCall = service.callCount();
|
||||
|
||||
Transcript t = new Transcript("29-" + backendName() + "-cache-hit.txt");
|
||||
t.line("backend=%s", backendName());
|
||||
t.line("rateFor(\"USD/INR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
|
||||
t.line("rateFor(\"USD/INR\") call 2 -> served from cache, callCount stays %d", afterSecondCall);
|
||||
t.write();
|
||||
|
||||
assertThat(afterFirstCall).isEqualTo(before + 1);
|
||||
assertThat(afterSecondCall).isEqualTo(afterFirstCall);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictingAKeyForcesTheNextCallToRecompute() {
|
||||
int before = service.callCount();
|
||||
|
||||
service.rateFor("USD/EUR");
|
||||
int afterFirstCall = service.callCount();
|
||||
|
||||
service.evict("USD/EUR");
|
||||
service.rateFor("USD/EUR");
|
||||
int afterEvictAndRecall = service.callCount();
|
||||
|
||||
Transcript t = new Transcript("29-" + backendName() + "-cache-evict.txt");
|
||||
t.line("backend=%s", backendName());
|
||||
t.line("rateFor(\"USD/EUR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
|
||||
t.line("evict(\"USD/EUR\") then rateFor(\"USD/EUR\") again -> method body ran again, callCount %d -> %d",
|
||||
afterFirstCall, afterEvictAndRecall);
|
||||
t.write();
|
||||
|
||||
assertThat(afterFirstCall).isEqualTo(before + 1);
|
||||
assertThat(afterEvictAndRecall).isEqualTo(afterFirstCall + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
|
||||
@ActiveProfiles("hazelcast")
|
||||
class HazelcastCacheTest extends CacheHitAndEvictionContractTest {
|
||||
@Override
|
||||
String backendName() {
|
||||
return "hazelcast";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* No custom CacheManager bean exists for this profile at all - the
|
||||
* Infinispan starter auto-configures its own SpringEmbeddedCacheManager
|
||||
* unconditionally once it's on the classpath (confirmed by decompiling
|
||||
* InfinispanEmbeddedCacheManagerAutoConfiguration), and nothing under
|
||||
* the "hazelcast" or "redis" profiles is active to out-rank it with
|
||||
* @Primary. The "infinispan" profile name exists only so this test
|
||||
* class's intent reads the same way as its two siblings.
|
||||
*/
|
||||
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
|
||||
@ActiveProfiles("infinispan")
|
||||
class InfinispanCacheTest extends CacheHitAndEvictionContractTest {
|
||||
@Override
|
||||
String backendName() {
|
||||
return "infinispan";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
|
||||
@ActiveProfiles("redis")
|
||||
class RedisCacheTest extends CacheHitAndEvictionContractTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection(name = "redis")
|
||||
static GenericContainer<?> redis =
|
||||
new GenericContainer<>(DockerImageName.parse("redis:8.8.3")).withExposedPorts(6379);
|
||||
|
||||
@Override
|
||||
String backendName() {
|
||||
return "redis";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.distributedcache;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Writes docs/output/NN-*.txt while a test asserts the same numbers, so
|
||||
* every figure quoted in the blog post is backed by an assertion that
|
||||
* fails the build when it stops being true.
|
||||
*/
|
||||
final class Transcript {
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final Path file;
|
||||
|
||||
Transcript(String name) {
|
||||
this.file = Path.of("docs/output/" + name);
|
||||
}
|
||||
|
||||
Transcript line(String s) {
|
||||
lines.add(s);
|
||||
System.out.println(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
Transcript line(String fmt, Object... args) {
|
||||
return line(fmt.formatted(args));
|
||||
}
|
||||
|
||||
void write() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, String.join("\n", lines) + "\n");
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user