From 8c77a744e0573cb8b698f0f0f064c683fe5be9dd Mon Sep 17 00:00:00 2001 From: asmhatre Date: Thu, 15 Oct 2026 02:30:04 +0000 Subject: [PATCH] distributed-cache: Spring Cache abstraction over Hazelcast, Infinispan and Redis (post 29) --- README.md | 2 +- .../docs/output/29-hazelcast-cache-evict.txt | 3 + .../docs/output/29-hazelcast-cache-hit.txt | 3 + .../docs/output/29-infinispan-cache-evict.txt | 3 + .../docs/output/29-infinispan-cache-hit.txt | 3 + .../docs/output/29-redis-cache-evict.txt | 3 + .../docs/output/29-redis-cache-hit.txt | 3 + distributed-cache/pom.xml | 119 ++++++++++++++++++ .../DistributedCacheDemoApplication.java | 13 ++ .../ExchangeRateLookupService.java | 43 +++++++ .../HazelcastCacheConfig.java | 46 +++++++ .../InfinispanCacheConfig.java | 31 +++++ .../distributedcache/RedisCacheConfig.java | 31 +++++ .../CacheHitAndEvictionContractTest.java | 70 +++++++++++ .../distributedcache/HazelcastCacheTest.java | 13 ++ .../distributedcache/InfinispanCacheTest.java | 22 ++++ .../distributedcache/RedisCacheTest.java | 25 ++++ .../ankurm/distributedcache/Transcript.java | 40 ++++++ 18 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 distributed-cache/docs/output/29-hazelcast-cache-evict.txt create mode 100644 distributed-cache/docs/output/29-hazelcast-cache-hit.txt create mode 100644 distributed-cache/docs/output/29-infinispan-cache-evict.txt create mode 100644 distributed-cache/docs/output/29-infinispan-cache-hit.txt create mode 100644 distributed-cache/docs/output/29-redis-cache-evict.txt create mode 100644 distributed-cache/docs/output/29-redis-cache-hit.txt create mode 100644 distributed-cache/pom.xml create mode 100644 distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java create mode 100644 distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java create mode 100644 distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java create mode 100644 distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java create mode 100644 distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java create mode 100644 distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java create mode 100644 distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java create mode 100644 distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java create mode 100644 distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java create mode 100644 distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java diff --git a/README.md b/README.md index 5d41519..d79f992 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin | [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) | | [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB | | [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 | -| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis | +| [`distributed-cache/`](./distributed-cache) | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast 5.5.0 (embedded), Infinispan 16.1.4 (embedded), Redis 8.8.3 (Testcontainers) | Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself. diff --git a/distributed-cache/docs/output/29-hazelcast-cache-evict.txt b/distributed-cache/docs/output/29-hazelcast-cache-evict.txt new file mode 100644 index 0000000..85217f5 --- /dev/null +++ b/distributed-cache/docs/output/29-hazelcast-cache-evict.txt @@ -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 diff --git a/distributed-cache/docs/output/29-hazelcast-cache-hit.txt b/distributed-cache/docs/output/29-hazelcast-cache-hit.txt new file mode 100644 index 0000000..03a2f66 --- /dev/null +++ b/distributed-cache/docs/output/29-hazelcast-cache-hit.txt @@ -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 diff --git a/distributed-cache/docs/output/29-infinispan-cache-evict.txt b/distributed-cache/docs/output/29-infinispan-cache-evict.txt new file mode 100644 index 0000000..f7eed6f --- /dev/null +++ b/distributed-cache/docs/output/29-infinispan-cache-evict.txt @@ -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 diff --git a/distributed-cache/docs/output/29-infinispan-cache-hit.txt b/distributed-cache/docs/output/29-infinispan-cache-hit.txt new file mode 100644 index 0000000..610babd --- /dev/null +++ b/distributed-cache/docs/output/29-infinispan-cache-hit.txt @@ -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 diff --git a/distributed-cache/docs/output/29-redis-cache-evict.txt b/distributed-cache/docs/output/29-redis-cache-evict.txt new file mode 100644 index 0000000..7185dd6 --- /dev/null +++ b/distributed-cache/docs/output/29-redis-cache-evict.txt @@ -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 diff --git a/distributed-cache/docs/output/29-redis-cache-hit.txt b/distributed-cache/docs/output/29-redis-cache-hit.txt new file mode 100644 index 0000000..dd69c4d --- /dev/null +++ b/distributed-cache/docs/output/29-redis-cache-hit.txt @@ -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 diff --git a/distributed-cache/pom.xml b/distributed-cache/pom.xml new file mode 100644 index 0000000..ccb5250 --- /dev/null +++ b/distributed-cache/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + distributed-cache-demo + 1.0.0 + distributed-cache-demo + + + 25 + 2.0.5 + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-cache + + + + + com.hazelcast + hazelcast-spring + + + + + io.micrometer + micrometer-core + + + org.infinispan + infinispan-spring-boot4-starter-embedded + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java new file mode 100644 index 0000000..435c741 --- /dev/null +++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java @@ -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); + } +} diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java new file mode 100644 index 0000000..419e875 --- /dev/null +++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java @@ -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(); + } +} diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java new file mode 100644 index 0000000..2a96bd0 --- /dev/null +++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java @@ -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); + } +} diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java new file mode 100644 index 0000000..8b50825 --- /dev/null +++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java @@ -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()); + } +} diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java new file mode 100644 index 0000000..230c676 --- /dev/null +++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java @@ -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); + } +} diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java new file mode 100644 index 0000000..e46ef32 --- /dev/null +++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java @@ -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); + } +} diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java new file mode 100644 index 0000000..45d9bba --- /dev/null +++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java @@ -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"; + } +} diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java new file mode 100644 index 0000000..124308b --- /dev/null +++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java @@ -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"; + } +} diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java new file mode 100644 index 0000000..25c3d97 --- /dev/null +++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java @@ -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"; + } +} diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java new file mode 100644 index 0000000..66e6e1c --- /dev/null +++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java @@ -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 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); + } + } +}