Add caching: the Spring cache abstraction, keys, eviction timing and the self-invocation trap

A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 20 tests
producing 22 transcripts under docs/output/, plus 12 documentation chapters.

Findings the build pins:
- @EnableCaching has no exposeProxy attribute; the widely-copied
  @EnableCaching(exposeProxy = true) does not compile.
- Two methods sharing a cache name and an argument type share a key space, and one
  silently serves the other's answers.
- The documented cache-provider detection order does not match CacheType's enum
  order in 4.1.1: COUCHBASE before INFINISPAN, and CACHE2K before CAFFEINE.
- beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on
  7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept.
- Four of five invalid declarations start a clean context and throw at the first call.
- Caffeine on the classpath silently displaces the simple provider.
This commit is contained in:
2026-09-12 05:35:13 +00:00
parent 7e1676c763
commit a9867c0423
76 changed files with 3796 additions and 0 deletions
@@ -0,0 +1,86 @@
package com.ankurm.caching;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.aop.config.AopConfigUtils;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
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.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy;
import java.time.Duration;
/**
* Turns the cache annotations on.
*
* <p>Note what {@code @EnableCaching} does <em>not</em> accept. It has exactly three attributes
* &mdash; {@code proxyTargetClass}, {@code mode} and {@code order} &mdash; verified with
* {@code javap} and captured in {@code docs/output/03-enablecaching-attributes.txt}. There is no
* {@code exposeProxy}, so the widely-copied {@code @EnableCaching(exposeProxy = true)} does not
* compile. Turning the ThreadLocal on takes the post-processor below.
*
* @see <a href="../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
*/
@Configuration
@EnableCaching
public class CacheConfig {
/**
* Makes {@link org.springframework.aop.framework.AopContext#currentProxy()} work, which is
* one of the three ways out of the self-invocation trap in
* {@link com.ankurm.caching.selfinvocation.CatalogService}. {@code @EnableAspectJAutoProxy(
* exposeProxy = true)} is the usual advice, but it pulls in AspectJ; this does the same job
* by flipping the flag on the auto-proxy creator {@code @EnableCaching} already registered.
*/
@Bean
static BeanFactoryPostProcessor exposeCachingProxy() {
return beanFactory -> {
if (beanFactory instanceof BeanDefinitionRegistry registry) {
AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry);
AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry);
}
};
}
/**
* A Caffeine manager used only by the {@code caffeine} profile, so the TTL and size-bound
* demonstrations have a provider that actually implements them. Without a profile the
* application runs on Boot's auto-configured {@code simple} provider — a
* {@code ConcurrentHashMap} with no expiry at all.
*
* @see <a href="../../../../../docs/08-providers-and-ttl.md">docs/08-providers-and-ttl.md</a>
*/
@Bean
@Primary
@Profile("caffeine")
public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMillis(400))
.maximumSize(3)
.recordStats());
// Required before @Cacheable on a CompletableFuture-returning method will work.
manager.setAsyncCacheMode(true);
return manager;
}
/**
* Defers every put and evict to after the transaction commits, so a rollback takes the cache
* write with it. Reads are <em>not</em> deferred, and it only helps inside a transaction.
*
* @see <a href="../../../docs/10-transactions.md">docs/10-transactions.md</a>
*/
@Bean
@Primary
@Profile("txaware")
public CacheManager transactionAwareCacheManager() {
return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
}
}
@@ -0,0 +1,21 @@
package com.ankurm.caching;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Companion application for the ankurm.com article on the Spring cache abstraction.
*
* <p>Note what is <em>not</em> here: {@code @EnableCaching}. Spring Boot's reference
* documentation explicitly advises against putting it on the main application class,
* because that makes caching mandatory for every test slice too. It lives on
* {@link com.ankurm.caching.CacheConfig} instead.
*
* @see <a href="../../../../../docs/01-what-caching-is.md">docs/01-what-caching-is.md</a>
*/
@SpringBootApplication
public class CachingDemoApplication {
public static void main(String[] args) {
SpringApplication.run(CachingDemoApplication.class, args);
}
}
@@ -0,0 +1,10 @@
package com.ankurm.caching.basics;
import java.io.Serializable;
/**
* A value object. Records give you {@code equals} and {@code hashCode} for free, which matters
* more than it looks: the default key generator puts method arguments straight into a hash map.
*/
public record Book(String isbn, String title, int year) implements Serializable {
}
@@ -0,0 +1,49 @@
package com.ankurm.caching.basics;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Stands in for whatever is actually slow — a database, an HTTP call, a report.
* Every lookup sleeps and increments a counter, which is how every claim in the article
* about "the method did not run" is measured rather than asserted.
*
* @see <a href="../../../../../../docs/01-what-caching-is.md">docs/01-what-caching-is.md</a>
*/
@Component
public class BookRepositoryStub {
/** Roughly what a cold index lookup over a network costs. */
public static final long LOOKUP_MILLIS = 200;
private final AtomicInteger calls = new AtomicInteger();
private static final Map<String, Book> DATA = Map.of(
"978-0134685991", new Book("978-0134685991", "Effective Java", 2018),
"978-1617294945", new Book("978-1617294945", "Spring in Action", 2022),
"978-0596009205", new Book("978-0596009205", "Head First Design Patterns", 2004));
public Book load(String isbn) {
calls.incrementAndGet();
sleep();
return DATA.get(isbn);
}
public int callCount() {
return calls.get();
}
public void reset() {
calls.set(0);
}
private static void sleep() {
try {
Thread.sleep(LOOKUP_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,43 @@
package com.ankurm.caching.basics;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* The smallest useful caching example, and the three annotations that do 95% of the work.
*
* @see <a href="../../../../../../docs/02-the-three-annotations.md">docs/02-the-three-annotations.md</a>
*/
@Service
public class BookService {
private final BookRepositoryStub repository;
public BookService(BookRepositoryStub repository) {
this.repository = repository;
}
/** Cache {@code books}, key = the single argument, because SimpleKeyGenerator says so. */
@Cacheable("books")
public Book findBook(String isbn) {
return repository.load(isbn);
}
/** Always runs, then writes the result into the cache under the same key. */
@CachePut(cacheNames = "books", key = "#book.isbn")
public Book save(Book book) {
return book;
}
/** Removes one entry. The method body can be empty; the annotation is the point. */
@CacheEvict(cacheNames = "books", key = "#isbn")
public void delete(String isbn) {
}
/** Clears the whole region in one operation instead of key by key. */
@CacheEvict(cacheNames = "books", allEntries = true)
public void reload() {
}
}
@@ -0,0 +1,46 @@
package com.ankurm.caching.conditions;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@code condition} is evaluated before the method runs and can veto both the lookup and the
* write. {@code unless} is evaluated after, sees {@code #result}, and can only veto the write.
*
* <p>Also here: what happens to {@code null}. The abstraction stores a {@code NullValue}
* sentinel by default, so "not found" is cached like any other answer — which is usually what
* you want for a hot miss, and occasionally exactly what you do not want.
*
* @see <a href="../../../../../../docs/06-conditions-and-nulls.md">docs/06-conditions-and-nulls.md</a>
*/
@Service
public class LookupService {
private final AtomicInteger calls = new AtomicInteger();
/** Long search terms are one-off; caching them only evicts the useful entries. */
@Cacheable(cacheNames = "terms", condition = "#term.length() <= 8")
public String search(String term) {
calls.incrementAndGet();
return "hits-for-" + term;
}
/** Cache the answer unless it is empty. */
@Cacheable(cacheNames = "terms", unless = "#result == null")
public String searchNullable(String term) {
calls.incrementAndGet();
return term.startsWith("x") ? null : "hits-for-" + term;
}
/** No {@code unless}: the null is cached as NullValue and the method never runs again. */
@Cacheable("nulls")
public String searchCachingNulls(String term) {
calls.incrementAndGet();
return term.startsWith("x") ? null : "hits-for-" + term;
}
public int calls() { return calls.get(); }
public void reset() { calls.set(0); }
}
@@ -0,0 +1,84 @@
package com.ankurm.caching.diag;
import com.ankurm.caching.basics.BookService;
import com.ankurm.caching.conditions.LookupService;
import com.ankurm.caching.keys.KeyShapeService;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
/**
* Prints what is actually in the cache, key object by key object, with the runtime class of each
* key. Almost every confusing caching bug becomes obvious the moment you can see the keys:
* a {@code SimpleKey []} where you expected a string, two methods writing into one key space,
* or a {@code NullValue} sitting where a record should be.
*
* <p>Delete this before shipping. It exposes cached data over HTTP with no authorisation.
*
* @see <a href="../../../../../../docs/11-diagnostics.md">docs/11-diagnostics.md</a>
*/
@RestController
@RequestMapping("/diag")
public class CacheDiagnosticsController {
private final CacheManager cacheManager;
private final BookService books;
private final KeyShapeService shapes;
private final LookupService lookups;
public CacheDiagnosticsController(CacheManager cacheManager, BookService books,
KeyShapeService shapes, LookupService lookups) {
this.cacheManager = cacheManager;
this.books = books;
this.shapes = shapes;
this.lookups = lookups;
}
/** Calls a handful of cached methods so {@code /diag/caches} has something to show. */
@GetMapping("/warm")
public String warm() {
books.findBook("978-0134685991");
shapes.zeroArgs();
shapes.oneArg("abc");
shapes.twoArgs("abc", 7);
lookups.searchCachingNulls("xyz");
return "warmed: books, shapes, nulls";
}
@GetMapping("/caches")
public Map<String, Object> caches() {
Map<String, Object> report = new LinkedHashMap<>();
report.put("cacheManager", cacheManager.getClass().getName());
Map<String, Object> caches = new LinkedHashMap<>();
for (String name : cacheManager.getCacheNames()) {
caches.put(name, describe(cacheManager.getCache(name)));
}
report.put("caches", caches);
return report;
}
private Map<String, Object> describe(Cache cache) {
Map<String, Object> info = new LinkedHashMap<>();
if (cache == null) {
return info;
}
info.put("implementation", cache.getClass().getName());
Object native_ = cache.getNativeCache();
info.put("nativeStore", native_.getClass().getName());
if (cache instanceof ConcurrentMapCache map) {
Map<String, String> entries = new TreeMap<>();
map.getNativeCache().forEach((k, v) -> entries.put(
k + " [" + k.getClass().getSimpleName() + "]",
v + " [" + v.getClass().getSimpleName() + "]"));
info.put("entries", entries);
}
return info;
}
}
@@ -0,0 +1,57 @@
package com.ankurm.caching.eviction;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Eviction timing. {@code @CacheEvict} defaults to <em>after</em> a successful invocation, which
* means a method that throws leaves the stale entry in place — and the next reader gets a value
* the database no longer has.
*
* @see <a href="../../../../../../docs/05-eviction.md">docs/05-eviction.md</a>
*/
@Service
public class PriceService {
private final AtomicInteger reads = new AtomicInteger();
private int storedPrice = 100;
@Cacheable("prices")
public int price(String sku) {
reads.incrementAndGet();
return storedPrice;
}
/** Default timing: evict after the method returns normally. */
@CacheEvict(cacheNames = "prices", key = "#sku")
public void updatePrice(String sku, int newPrice, boolean fail) {
storedPrice = newPrice;
if (fail) {
throw new IllegalStateException("audit log write failed after the price was updated");
}
}
/** Evict first, whatever happens next. Costs a cache miss; buys correctness on failure. */
@CacheEvict(cacheNames = "prices", key = "#sku", beforeInvocation = true)
public void updatePriceEvictFirst(String sku, int newPrice, boolean fail) {
storedPrice = newPrice;
if (fail) {
throw new IllegalStateException("audit log write failed after the price was updated");
}
}
/** Writes through instead of evicting: one fewer miss, but the value must be the real one. */
@CachePut(cacheNames = "prices", key = "#sku")
public int updatePriceWriteThrough(String sku, int newPrice) {
storedPrice = newPrice;
return newPrice;
}
public int reads() { return reads.get(); }
public void reset(int price) { reads.set(0); storedPrice = price; }
public int storedPrice() { return storedPrice; }
}
@@ -0,0 +1,41 @@
package com.ankurm.caching.jpa;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
/**
* A deliberately ordinary entity with one lazy collection, used to show the boundary between
* the Spring cache abstraction (which caches whatever object a method returned) and the
* Hibernate second-level cache (which caches entity state Hibernate can rehydrate).
*
* @see <a href="../../../../../../docs/09-versus-hibernate-l2.md">docs/09-versus-hibernate-l2.md</a>
*/
@Entity
public class Customer {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Order> orders = new ArrayList<>();
protected Customer() {
}
public Customer(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Order> getOrders() { return orders; }
}
@@ -0,0 +1,6 @@
package com.ankurm.caching.jpa;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
@@ -0,0 +1,79 @@
package com.ankurm.caching.jpa;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Caching and transactions. The caching interceptor runs inside the transaction interceptor,
* so a cache write happens at method exit — before the commit, and regardless of whether the
* commit succeeds.
*
* @see <a href="../../../../../../docs/10-transactions.md">docs/10-transactions.md</a>
*/
@Service
public class CustomerService {
private final CustomerRepository repository;
private final AtomicInteger loads = new AtomicInteger();
public CustomerService(CustomerRepository repository) {
this.repository = repository;
}
@Cacheable("customers")
@Transactional(readOnly = true)
public String nameOf(Long id) {
loads.incrementAndGet();
return repository.findById(id).map(Customer::getName).orElse(null);
}
/**
* An ordinary, correct-looking write-through update. It succeeds; the caller is what fails.
* Joins the caller's transaction, so the row is rolled back with it.
*/
@CachePut(cacheNames = "customers", key = "#id")
@Transactional
public String rename(Long id, String newName) {
Customer customer = repository.findById(id).orElseThrow();
customer.setName(newName);
repository.saveAndFlush(customer);
return newName;
}
/**
* Evicts before the body runs. Under a transaction-aware cache manager the evict is still
* deferred to commit, which is measured in {@code docs/output/19-transaction-aware.txt}.
*/
@CacheEvict(cacheNames = "customers", key = "#id", beforeInvocation = true)
@Transactional
public void renameEvictingFirst(Long id, String newName) {
Customer customer = repository.findById(id).orElseThrow();
customer.setName(newName);
repository.saveAndFlush(customer);
}
/** The same update expressed as an eviction rather than a write-through. */
@CacheEvict(cacheNames = "customers", key = "#id")
@Transactional
public void renameEvicting(Long id, String newName) {
Customer customer = repository.findById(id).orElseThrow();
customer.setName(newName);
repository.saveAndFlush(customer);
}
/** Returns a managed entity that becomes detached the moment the transaction ends. */
@Cacheable("entities")
@Transactional(readOnly = true)
public Customer loadEntity(Long id) {
loads.incrementAndGet();
return repository.findById(id).orElseThrow();
}
public int loads() { return loads.get(); }
public void reset() { loads.set(0); }
}
@@ -0,0 +1,44 @@
package com.ankurm.caching.jpa;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The outer transaction. {@code rename} is a perfectly ordinary cached write that succeeds; the
* work after it fails. The database change is rolled back and the cache write is not, because
* the caching interceptor sits inside the transaction interceptor and fires at method exit.
*
* @see <a href="../../../../../../docs/10-transactions.md">docs/10-transactions.md</a>
*/
@Service
public class CustomerWorkflow {
private final CustomerService customers;
public CustomerWorkflow(CustomerService customers) {
this.customers = customers;
}
@Transactional
public void renameAndThenFail(Long id, String newName) {
customers.rename(id, newName);
throw new IllegalStateException("the step after the rename failed");
}
@Transactional
public void evictAndThenFail(Long id, String newName) {
customers.renameEvicting(id, newName);
throw new IllegalStateException("the step after the rename failed");
}
/**
* Evicts before the inner method body, then reads the cache again while still inside the
* same transaction. On a plain cache manager the entry is already gone; on a
* transaction-aware one it is not, because the evict was deferred to commit.
*/
@Transactional
public String evictFirstThenReadInSameTransaction(Long id, String newName) {
customers.renameEvictingFirst(id, newName);
return customers.nameOf(id);
}
}
@@ -0,0 +1,30 @@
package com.ankurm.caching.jpa;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.Transactional;
/**
* Two customers and two orders, so the JPA demonstrations have something to load.
*/
@Configuration
public class DataSeeder {
@Bean
ApplicationRunner seed(CustomerRepository repository) {
return args -> seedData(repository);
}
@Transactional
void seedData(CustomerRepository repository) {
if (repository.count() > 0) {
return;
}
Customer alice = new Customer(1L, "Alice");
alice.getOrders().add(new Order(10L, "keyboard", alice));
alice.getOrders().add(new Order(11L, "monitor", alice));
repository.save(alice);
repository.save(new Customer(2L, "Bob"));
}
}
@@ -0,0 +1,31 @@
package com.ankurm.caching.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
private String item;
@ManyToOne
private Customer customer;
protected Order() {
}
public Order(Long id, String item, Customer customer) {
this.id = id;
this.item = item;
this.customer = customer;
}
public Long getId() { return id; }
public String getItem() { return item; }
public Customer getCustomer() { return customer; }
}
@@ -0,0 +1,68 @@
package com.ankurm.caching.keys;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
/**
* The default key generator uses the arguments and <em>nothing else</em> — not the method name,
* not the declaring class. Two methods that share a cache name and take the same argument types
* therefore share a key space, and the second one silently serves the first one's values.
*
* <p>{@code countLetters} and {@code countDigits} below are deliberately obvious. The real bug
* looks like {@code findByIsbn} and {@code findByTitle} sitting next to each other in a service.
*
* @see <a href="../../../../../../docs/04-keys.md">docs/04-keys.md</a>
*/
@Service
public class CollidingService {
private final AtomicInteger letterCalls = new AtomicInteger();
private final AtomicInteger digitCalls = new AtomicInteger();
private final AtomicInteger noArgCalls = new AtomicInteger();
@Cacheable("shared")
public String countLetters(String input) {
letterCalls.incrementAndGet();
return "letters=" + input.chars().filter(Character::isLetter).count();
}
@Cacheable("shared")
public String countDigits(String input) {
digitCalls.incrementAndGet();
return "digits=" + input.chars().filter(Character::isDigit).count();
}
/** No arguments means the key is {@code SimpleKey.EMPTY} — a single shared constant. */
@Cacheable("noargs")
public String currentBanner() {
noArgCalls.incrementAndGet();
return "banner-from-currentBanner";
}
/** Also no arguments, also {@code SimpleKey.EMPTY}, also in cache {@code noargs}. */
@Cacheable("noargs")
public String currentFooter() {
noArgCalls.incrementAndGet();
return "footer-from-currentFooter";
}
/** The fix: make the key say which method it belongs to. */
@Cacheable(cacheNames = "scoped", key = "'letters:' + #input")
public String countLettersScoped(String input) {
letterCalls.incrementAndGet();
return "letters=" + input.chars().filter(Character::isLetter).count();
}
@Cacheable(cacheNames = "scoped", key = "'digits:' + #input")
public String countDigitsScoped(String input) {
digitCalls.incrementAndGet();
return "digits=" + input.chars().filter(Character::isDigit).count();
}
public int letterCalls() { return letterCalls.get(); }
public int digitCalls() { return digitCalls.get(); }
public int noArgCalls() { return noArgCalls.get(); }
public void reset() { letterCalls.set(0); digitCalls.set(0); noArgCalls.set(0); }
}
@@ -0,0 +1,37 @@
package com.ankurm.caching.keys;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Prints the key object the abstraction actually built, for zero, one and several arguments,
* and shows what a mutable argument does to a key.
*
* @see <a href="../../../../../../docs/04-keys.md">docs/04-keys.md</a>
*/
@Service
public class KeyShapeService {
@Cacheable("shapes")
public String zeroArgs() {
return "zero";
}
@Cacheable("shapes")
public String oneArg(String a) {
return "one:" + a;
}
@Cacheable("shapes")
public String twoArgs(String a, int b) {
return "two:" + a + ":" + b;
}
/** A mutable argument is a key you can lose. */
@Cacheable("mutable")
public String byList(List<String> tags) {
return "tags=" + tags;
}
}
@@ -0,0 +1,26 @@
package com.ankurm.caching.selfinvocation;
import com.ankurm.caching.basics.Book;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Fix 3, and the one worth reaching for: the loop lives in a different bean, so the call to
* {@code lookup} is an ordinary external call and goes through the proxy like any other.
*
* @see <a href="../../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
*/
@Service
public class CatalogReader {
private final CatalogService catalog;
public CatalogReader(CatalogService catalog) {
this.catalog = catalog;
}
public List<Book> byCollaborator(List<String> isbns) {
return isbns.stream().map(catalog::lookup).toList();
}
}
@@ -0,0 +1,79 @@
package com.ankurm.caching.selfinvocation;
import com.ankurm.caching.basics.Book;
import com.ankurm.caching.basics.BookRepositoryStub;
import jakarta.annotation.PostConstruct;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Four ways to call a {@code @Cacheable} method from inside the same bean. One of them caches
* nothing, and it is the one everybody writes first.
*
* <p>The mechanism: {@code @EnableCaching} does not rewrite {@code CatalogService}. It puts a
* proxy in front of it, and the caching interceptor lives in the proxy. {@code this.lookup(..)}
* is a plain virtual call on the target object; it never crosses the proxy, so no interceptor
* runs.
*
* @see <a href="../../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
*/
@Service
public class CatalogService {
private final BookRepositoryStub repository;
/** A provider, not the bean itself: injecting the proxy into its own constructor is a cycle. */
private final ObjectProvider<CatalogService> self;
public CatalogService(BookRepositoryStub repository, ObjectProvider<CatalogService> self) {
this.repository = repository;
this.self = self;
}
@Cacheable("catalog")
public Book lookup(String isbn) {
return repository.load(isbn);
}
/** Broken: {@code this.lookup} bypasses the proxy, so every ISBN hits the repository. */
public List<Book> byInternalCall(List<String> isbns) {
return isbns.stream().map(this::lookup).toList();
}
/** Fix 1: go back out through the proxy that the container is holding. */
public List<Book> bySelfInjection(List<String> isbns) {
CatalogService proxy = self.getObject();
return isbns.stream().map(proxy::lookup).toList();
}
/**
* Fix 2: {@code @EnableCaching(exposeProxy = true)} binds the current proxy to a ThreadLocal.
* Works, but it couples the code to Spring AOP and only inside an intercepted call.
*/
public List<Book> byExposedProxy(List<String> isbns) {
CatalogService proxy = (CatalogService) AopContext.currentProxy();
return isbns.stream().map(proxy::lookup).toList();
}
/**
* Silently uncached for a second, independent reason: in proxy mode the annotation is only
* honoured on public methods. No warning is logged.
*/
@Cacheable("catalog")
protected Book protectedLookup(String isbn) {
return repository.load(isbn);
}
public Book callProtected(String isbn) {
return protectedLookup(isbn);
}
/** The proxy is not in place yet during {@code @PostConstruct}. Documented, still surprising. */
@PostConstruct
void warmUpThatDoesNotWarmAnything() {
lookup("978-0134685991");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.caching.sync;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Since Spring Framework 6.1 the cache annotations understand {@code CompletableFuture} and
* reactive return types. The cache has to support future-based retrieval: {@code
* ConcurrentMapCacheManager} adapts automatically, {@code CaffeineCacheManager} needs
* {@code setAsyncCacheMode(true)}.
*
* @see <a href="../../../../../../docs/07-sync-and-async.md">docs/07-sync-and-async.md</a>
*/
@Service
public class AsyncReportService {
private final AtomicInteger calls = new AtomicInteger();
@Cacheable("asyncReports")
public CompletableFuture<String> buildAsync(String name) {
return CompletableFuture.supplyAsync(() -> {
calls.incrementAndGet();
return "async-report:" + name;
});
}
public int calls() { return calls.get(); }
}
@@ -0,0 +1,44 @@
package com.ankurm.caching.sync;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Cache stampede. Without {@code sync = true}, N threads that miss at the same instant all run
* the method; with it, one runs and the rest block on the same computation.
*
* @see <a href="../../../../../../docs/07-sync-and-async.md">docs/07-sync-and-async.md</a>
*/
@Service
public class ReportService {
private final AtomicInteger unsyncedCalls = new AtomicInteger();
private final AtomicInteger syncedCalls = new AtomicInteger();
@Cacheable("reports")
public String buildReport(String name) {
unsyncedCalls.incrementAndGet();
sleep(300);
return "report:" + name;
}
@Cacheable(cacheNames = "syncedReports", sync = true)
public String buildReportSynced(String name) {
syncedCalls.incrementAndGet();
sleep(300);
return "report:" + name;
}
public int unsyncedCalls() { return unsyncedCalls.get(); }
public int syncedCalls() { return syncedCalls.get(); }
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,26 @@
spring:
application:
name: caching
jpa:
hibernate:
ddl-auto: create-drop
open-in-view: false
properties:
hibernate:
cache:
# Explicit: this module is about the *application* cache, not Hibernate's L2.
# See docs/09-versus-hibernate-l2.md for what the difference actually buys you.
use_second_level_cache: false
sql:
init:
mode: never
management:
endpoints:
web:
exposure:
include: caches,metrics,health
logging:
level:
org.springframework.cache: INFO
@@ -0,0 +1,38 @@
package com.ankurm.caching;
import com.ankurm.caching.sync.AsyncReportService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* The same method on Boot's auto-configured {@code CaffeineCacheManager}, which does not have
* async cache mode enabled. The application starts cleanly and fails at the first call.
*/
@SpringBootTest
class AsyncCacheModeOffTest {
@Autowired AsyncReportService asyncReports;
@Autowired CacheManager cacheManager;
@Test
void failsAtTheFirstCallNotAtStartup() {
try (Transcript t = new Transcript("17-async-cache-mode-missing.txt",
"The same method on the auto-configured Caffeine manager")) {
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("The application started cleanly. Nothing warned about anything.");
t.line("");
t.line("buildAsync(\"q3\") ->");
assertThatThrownBy(() -> asyncReports.buildAsync("q3"))
.isInstanceOf(IllegalStateException.class)
.satisfies(e -> t.line(" %s: %s", e.getClass().getName(), e.getMessage()));
t.line("");
t.line("Thrown on the first invocation, in production, at whatever hour that");
t.line("endpoint first gets traffic.");
}
}
}
@@ -0,0 +1,45 @@
package com.ankurm.caching;
import com.ankurm.caching.sync.AsyncReportService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.ActiveProfiles;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code @Cacheable} on a {@code CompletableFuture}-returning method, with a cache that supports
* future-based retrieval.
*/
@SpringBootTest
@ActiveProfiles("caffeine")
class AsyncCacheModeOnTest {
@Autowired AsyncReportService asyncReports;
@Autowired CacheManager cacheManager;
@Test
void worksWhenAsyncCacheModeIsOn() throws Exception {
try (Transcript t = new Transcript("12-async-return-types.txt",
"@Cacheable on a CompletableFuture-returning method")) {
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("setAsyncCacheMode(true) was called on it.");
t.line("");
String first = asyncReports.buildAsync("q3").get(5, TimeUnit.SECONDS);
String second = asyncReports.buildAsync("q3").get(5, TimeUnit.SECONDS);
t.line("first -> %s", first);
t.line("second -> %s", second);
t.line("supplier invocations: %d", asyncReports.calls());
t.line("");
t.line("Since Spring Framework 6.1 the interceptor unwraps CompletableFuture and");
t.line("the reactive types. ConcurrentMapCacheManager adapts to future-based");
t.line("retrieval on its own; CaffeineCacheManager has to be told.");
assertThat(asyncReports.calls()).isEqualTo(1);
}
}
}
@@ -0,0 +1,65 @@
package com.ankurm.caching;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.cache.interceptor.CacheInterceptor;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.ApplicationContext;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
/**
* What Spring Boot actually put in the context, and where its caching auto-configuration lives
* in Boot 4 - it moved out of {@code org.springframework.boot.autoconfigure.cache} into its own
* {@code spring-boot-cache} module.
*/
@SpringBootTest
class AutoConfigurationTest {
@Autowired ApplicationContext context;
@Autowired CacheManager cacheManager;
@Test
void whatBootWired() {
try (Transcript t = new Transcript("16-autoconfiguration.txt",
"What @EnableCaching and Boot's auto-configuration put in the context")) {
t.line("CacheManager bean : %s", cacheManager.getClass().getName());
t.line("caches known at startup : %s", cacheManager.getCacheNames());
t.line("");
for (String name : new String[]{"cacheInterceptor", "cacheOperationSource",
"cacheAdvisor", "org.springframework.cache.config.internalCacheAdvisor"}) {
t.line("bean %-52s present=%b", name, context.containsBean(name));
}
t.line("");
t.line("CacheInterceptor beans : %s",
Arrays.toString(context.getBeanNamesForType(CacheInterceptor.class)));
t.line("KeyGenerator beans : %s",
Arrays.toString(context.getBeanNamesForType(KeyGenerator.class)));
t.section("where the auto-configuration class lives");
Class<?> autoConfig = null;
for (String candidate : new String[]{
"org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration",
"org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration"}) {
try {
autoConfig = Class.forName(candidate);
t.line("FOUND %s", candidate);
} catch (ClassNotFoundException e) {
t.line("absent %s", candidate);
}
}
t.line("");
t.line("Boot 4 split spring-boot-autoconfigure into per-technology modules. Caching");
t.line("auto-configuration now ships in spring-boot-cache, which the");
t.line("spring-boot-starter-cache starter pulls in.");
assertThat(autoConfig).isNotNull();
assertThat(autoConfig.getName())
.isEqualTo("org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration");
}
}
}
@@ -0,0 +1,95 @@
package com.ankurm.caching;
import com.ankurm.caching.basics.Book;
import com.ankurm.caching.basics.BookRepositoryStub;
import com.ankurm.caching.basics.BookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 1 and 2: does it cache at all, and what the three annotations do.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class BasicsTest {
@Autowired BookService books;
@Autowired BookRepositoryStub repository;
@Autowired CacheManager cacheManager;
private static final String ISBN = "978-0134685991";
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
repository.reset();
}
@Test
void secondCallDoesNotRunTheMethod() {
try (Transcript t = new Transcript("01-basics.txt",
"A cache hit is a method that did not run")) {
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("repository latency : %d ms per lookup", BookRepositoryStub.LOOKUP_MILLIS);
t.section("first call (miss)");
long t1 = System.nanoTime();
Book first = books.findBook(ISBN);
long ms1 = (System.nanoTime() - t1) / 1_000_000;
t.line("returned : %s", first);
t.line("elapsed : %d ms", ms1);
t.line("repository calls : %d", repository.callCount());
t.section("second call (hit)");
long t2 = System.nanoTime();
Book second = books.findBook(ISBN);
long ms2 = (System.nanoTime() - t2) / 1_000_000;
t.line("returned : %s", second);
t.line("elapsed : %d ms", ms2);
t.line("repository calls : %d <- still 1, the method body never ran", repository.callCount());
t.line("same object? : %b", first == second);
assertThat(repository.callCount()).isEqualTo(1);
assertThat(first).isSameAs(second);
assertThat(ms2).isLessThan(BookRepositoryStub.LOOKUP_MILLIS);
}
}
@Test
void putEvictAndClear() {
try (Transcript t = new Transcript("02-put-evict-clear.txt",
"@Cacheable, @CachePut and @CacheEvict on the same cache")) {
books.findBook(ISBN);
t.line("after findBook : repository calls = %d", repository.callCount());
Book patched = new Book(ISBN, "Effective Java (3rd ed.)", 2018);
books.save(patched);
t.line("@CachePut wrote : %s", patched);
t.line("next findBook returns : %s", books.findBook(ISBN));
t.line("repository calls : %d <- @CachePut refreshed the entry, no reload", repository.callCount());
assertThat(books.findBook(ISBN).title()).isEqualTo("Effective Java (3rd ed.)");
assertThat(repository.callCount()).isEqualTo(1);
books.delete(ISBN);
t.line("");
t.line("after @CacheEvict : findBook -> %s", books.findBook(ISBN));
t.line("repository calls : %d <- the entry was gone, so the method ran again", repository.callCount());
assertThat(repository.callCount()).isEqualTo(2);
books.findBook("978-1617294945");
books.reload();
books.findBook(ISBN);
books.findBook("978-1617294945");
t.line("");
t.line("after allEntries=true : repository calls = %d <- both entries were dropped",
repository.callCount());
assertThat(repository.callCount()).isEqualTo(5);
}
}
}
@@ -0,0 +1,89 @@
package com.ankurm.caching;
import com.ankurm.caching.conditions.LookupService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 6: condition vs unless, and what a cached null looks like on the inside.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class ConditionsAndNullsTest {
@Autowired LookupService lookups;
@Autowired CacheManager cacheManager;
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
lookups.reset();
}
@Test
void conditionVetoesBeforeUnlessVetoesAfter() {
try (Transcript t = new Transcript("09-conditions.txt",
"condition is checked before the call, unless after it")) {
lookups.search("spring");
lookups.search("spring");
t.line("search(\"spring\") twice, 6 characters -> %d invocations", lookups.calls());
assertThat(lookups.calls()).isEqualTo(1);
lookups.reset();
lookups.search("a-very-long-search-phrase");
lookups.search("a-very-long-search-phrase");
t.line("search(25 chars) twice, condition false -> %d invocations", lookups.calls());
t.line("");
t.line("condition = \"#term.length() <= 8\" is evaluated on the arguments before the");
t.line("method runs, so a false condition skips the lookup and the write.");
assertThat(lookups.calls()).isEqualTo(2);
}
}
@Test
void nullIsCachedAsASentinelUnlessYouSayOtherwise() {
try (Transcript t = new Transcript("10-nulls.txt",
"A cached null is a real entry called NullValue")) {
lookups.reset();
lookups.searchCachingNulls("xyz");
lookups.searchCachingNulls("xyz");
t.line("searchCachingNulls(\"xyz\") returns null, called twice -> %d invocations",
lookups.calls());
t.line("");
dump(t, "nulls");
t.line("");
t.line("The abstraction stores org.springframework.cache.support.NullValue.INSTANCE");
t.line("so a hit on null is distinguishable from a miss. This is usually what you");
t.line("want - it is the cheap defence against a hot lookup for a row that is not");
t.line("there - and occasionally exactly what you do not want.");
assertThat(lookups.calls()).isEqualTo(1);
t.section("unless = \"#result == null\"");
lookups.reset();
cacheManager.getCache("terms").clear();
lookups.searchNullable("xyz");
lookups.searchNullable("xyz");
t.line("searchNullable(\"xyz\") twice -> %d invocations <- the null was never stored",
lookups.calls());
dump(t, "terms");
assertThat(lookups.calls()).isEqualTo(2);
}
}
private void dump(Transcript t, String cacheName) {
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache(cacheName);
t.line(" cache \"%s\":", cacheName);
if (cache.getNativeCache().isEmpty()) {
t.line(" (empty)");
}
cache.getNativeCache().forEach((k, v) -> t.line(" key %-10s -> %s [%s]",
k, v, v.getClass().getName()));
}
}
@@ -0,0 +1,69 @@
package com.ankurm.caching;
import com.ankurm.caching.eviction.PriceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Chapter 5: when the eviction actually happens, and what a thrown exception does to it.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class EvictionTimingTest {
@Autowired PriceService prices;
@Autowired CacheManager cacheManager;
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
prices.reset(100);
}
@Test
void aFailedUpdateLeavesTheStaleEntryInPlace() {
try (Transcript t = new Transcript("08-evict-timing.txt",
"@CacheEvict runs after the method - unless you ask otherwise")) {
t.line("price(\"sku-1\") -> %d (stored price is %d)", prices.price("sku-1"), prices.storedPrice());
assertThatThrownBy(() -> prices.updatePrice("sku-1", 250, true))
.isInstanceOf(IllegalStateException.class);
t.line("");
t.line("updatePrice(\"sku-1\", 250, fail=true) threw after writing the new price.");
t.line("stored price now : %d", prices.storedPrice());
t.line("price(\"sku-1\") : %d <- the cache still serves the old value", prices.price("sku-1"));
t.line("reads of the real store: %d", prices.reads());
assertThat(prices.price("sku-1")).isEqualTo(100);
assertThat(prices.reads()).isEqualTo(1);
t.section("beforeInvocation = true");
prices.reset(100);
cacheManager.getCache("prices").clear();
t.line("price(\"sku-2\") -> %d", prices.price("sku-2"));
assertThatThrownBy(() -> prices.updatePriceEvictFirst("sku-2", 250, true))
.isInstanceOf(IllegalStateException.class);
t.line("updatePriceEvictFirst(\"sku-2\", 250, fail=true) threw the same way.");
t.line("price(\"sku-2\") : %d <- the entry went first, so the next read is honest",
prices.price("sku-2"));
assertThat(prices.price("sku-2")).isEqualTo(250);
t.section("@CachePut instead: write through, no miss");
prices.reset(100);
cacheManager.getCache("prices").clear();
prices.price("sku-3");
int readsBefore = prices.reads();
prices.updatePriceWriteThrough("sku-3", 400);
t.line("after @CachePut, price(\"sku-3\") -> %d", prices.price("sku-3"));
t.line("reads of the real store: %d -> %d <- no reload was needed",
readsBefore, prices.reads());
assertThat(prices.price("sku-3")).isEqualTo(400);
assertThat(prices.reads()).isEqualTo(readsBefore);
}
}
}
@@ -0,0 +1,153 @@
package com.ankurm.caching;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Declarations the abstraction rejects, and when it tells you. Some of these fail while the
* context is still starting, which is the good case; others wait for the first call.
*
* <p>Every message below is the framework's own, captured from a real failed context.
*/
class InvalidDeclarationsTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
.withPropertyValues("spring.cache.type=simple");
@Test
void whatTheAbstractionRefusesAndWhen() {
try (Transcript t = new Transcript("20-invalid-declarations.txt",
"Declarations that are rejected, and how late you find out")) {
t.line("1. key and keyGenerator together");
runner.withUserConfiguration(BothKeyAndGenerator.class).run(context -> {
report(t, context.getStartupFailure());
if (context.getStartupFailure() == null) {
probe(t, context::getBean, BothKeyAndGenerator.Svc.class);
}
});
t.section("2. sync = true with unless");
runner.withUserConfiguration(SyncWithUnless.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, SyncWithUnless.Svc.class);
});
t.section("3. sync = true across two caches");
runner.withUserConfiguration(SyncTwoCaches.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, SyncTwoCaches.Svc.class);
});
t.section("4. @Cacheable and @CacheEvict on one method");
runner.withUserConfiguration(CacheableAndEvict.class).run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, CacheableAndEvict.Svc.class);
});
t.section("5. a cache name that spring.cache.cache-names does not declare");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
.withPropertyValues("spring.cache.type=simple", "spring.cache.cache-names=known")
.withUserConfiguration(UndeclaredCache.class)
.run(context -> {
report(t, context.getStartupFailure());
probe(t, context::getBean, UndeclaredCache.Svc.class);
});
t.line("");
t.line("Only the first of these is a compile-time-shaped mistake. The rest start a");
t.line("perfectly healthy application and throw on a code path that may not be hit");
t.line("for hours.");
}
}
private void report(Transcript t, Throwable startupFailure) {
if (startupFailure == null) {
t.line(" startup : clean");
return;
}
Throwable cause = root(startupFailure);
t.line(" startup : FAILED - %s", cause.getClass().getName());
t.line(" %s", cause.getMessage());
}
private static Throwable root(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause;
}
private <T> void probe(Transcript t, java.util.function.Function<Class<T>, T> lookup, Class<T> type) {
try {
T bean = lookup.apply(type);
Object result = type.getMethod("call", String.class).invoke(bean, "k");
t.line(" first call: returned %s", result);
} catch (Exception e) {
Throwable cause = root(e);
t.line(" first call: %s", cause.getClass().getName());
t.line(" %s", cause.getMessage());
}
}
@Configuration
@EnableCaching
static class BothKeyAndGenerator {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", key = "#a", keyGenerator = "simpleKeyGenerator")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class SyncWithUnless {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", sync = true, unless = "#result != null")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class SyncTwoCaches {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = {"c1", "c2"}, sync = true)
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class CacheableAndEvict {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable(cacheNames = "c", sync = true)
@CacheEvict(cacheNames = "c")
public String call(String a) { return "v:" + a; }
}
}
@Configuration
@EnableCaching
static class UndeclaredCache {
@Bean Svc svc() { return new Svc(); }
static class Svc {
@Cacheable("unknown")
public String call(String a) { return "v:" + a; }
}
}
}
@@ -0,0 +1,134 @@
package com.ankurm.caching;
import com.ankurm.caching.keys.CollidingService;
import com.ankurm.caching.keys.KeyShapeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 4: what the default key generator builds, and the collision it makes easy.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class KeyGenerationTest {
@Autowired CollidingService colliding;
@Autowired KeyShapeService shapes;
@Autowired CacheManager cacheManager;
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
colliding.reset();
}
@Test
void theDefaultKeyIsBuiltFromArgumentsAlone() {
try (Transcript t = new Transcript("05-key-shapes.txt",
"What SimpleKeyGenerator actually puts in the map")) {
shapes.zeroArgs();
shapes.oneArg("abc");
shapes.twoArgs("abc", 7);
t.line("cache \"shapes\" after three calls with 0, 1 and 2 arguments:");
t.line("");
dump(t, "shapes");
t.line("");
t.line("Zero arguments -> the SimpleKey.EMPTY constant, printed as []");
t.line("One argument -> that argument itself, unwrapped");
t.line("Two or more -> a SimpleKey holding all of them");
t.line("");
t.line("The method name and the declaring class appear nowhere in the key.");
}
}
@Test
void twoMethodsSharingACacheNameServeEachOthersValues() {
try (Transcript t = new Transcript("06-key-collision.txt",
"The collision the default key generator makes easy")) {
t.line("countLetters(String) and countDigits(String) both write into cache \"shared\".");
t.line("");
String letters = colliding.countLetters("a1b2");
t.line("countLetters(\"a1b2\") -> %s (repository calls: letters=%d digits=%d)",
letters, colliding.letterCalls(), colliding.digitCalls());
String digits = colliding.countDigits("a1b2");
t.line("countDigits(\"a1b2\") -> %s (repository calls: letters=%d digits=%d)",
digits, colliding.letterCalls(), colliding.digitCalls());
t.line("");
t.line("countDigits never ran. It found the key \"a1b2\" already populated and");
t.line("returned the answer to a different question.");
t.line("");
dump(t, "shared");
assertThat(digits).isEqualTo("letters=2");
assertThat(colliding.digitCalls()).isZero();
t.section("no-argument methods collide even harder");
String banner = colliding.currentBanner();
String footer = colliding.currentFooter();
t.line("currentBanner() -> %s", banner);
t.line("currentFooter() -> %s <- both key on SimpleKey.EMPTY", footer);
dump(t, "noargs");
assertThat(footer).isEqualTo("banner-from-currentBanner");
t.section("the fix: put the method into the key");
colliding.reset();
String l2 = colliding.countLettersScoped("a1b2");
String d2 = colliding.countDigitsScoped("a1b2");
t.line("countLettersScoped(\"a1b2\") -> %s", l2);
t.line("countDigitsScoped(\"a1b2\") -> %s", d2);
dump(t, "scoped");
assertThat(d2).isEqualTo("digits=2");
}
}
@Test
void aMutableKeyLosesItsEntry() {
try (Transcript t = new Transcript("07-mutable-key.txt",
"A mutable argument is an entry you cannot find again")) {
List<String> tags = new ArrayList<>(List.of("java"));
t.line("first call : byList(%s) -> %s", tags, shapes.byList(tags));
dump(t, "mutable");
tags.add("spring");
t.line("");
t.line("the caller mutates the same list it passed in: %s", tags);
t.line("second call : byList(%s) -> %s", tags, shapes.byList(tags));
t.line("");
dump(t, "mutable");
t.line("");
t.line("Two entries, and their keys now print identically - because they are the");
t.line("same object. The caller mutated the list it had already handed over as a");
t.line("key, so the first entry sits in the map under a hashCode the map no longer");
t.line("agrees with. Nothing will find it again and nothing will evict it: a leak");
t.line("with a completely ordinary-looking cause.");
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache("mutable");
assertThat(cache.getNativeCache()).hasSize(2);
}
}
private void dump(Transcript t, String cacheName) {
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache(cacheName);
t.line(" cache \"%s\":", cacheName);
Map<Object, Object> store = cache.getNativeCache();
if (store.isEmpty()) {
t.line(" (empty)");
}
store.forEach((k, v) -> t.line(" key %-22s [%s] -> %s",
k, k.getClass().getSimpleName(), v));
}
}
@@ -0,0 +1,69 @@
package com.ankurm.caching;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheType;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Which provider you get when you say nothing at all. The answer is decided by what is on the
* classpath, in a fixed order, and adding a library for an unrelated reason changes it.
*
* <p>The order is not transcribed from the documentation here. {@code CacheConfigurations} holds
* an {@code EnumMap<CacheType, String>}, so the iteration order is the declaration order of the
* {@link CacheType} enum &mdash; which is what this test prints.
*/
@SpringBootTest
class ProviderDetectionTest {
@Autowired CacheManager cacheManager;
@Test
void classpathDecidesTheProvider() {
try (Transcript t = new Transcript("18-provider-detection.txt",
"Nothing in application.yml selects a provider. Something still chose one.")) {
t.line("spring.cache.type : (not set)");
t.line("resolved CacheManager bean : %s", cacheManager.getClass().getName());
t.line("");
t.line("Caffeine is on this module's classpath because a later chapter needs TTL and");
t.line("size bounds. That single dependency moved every cache in the application off");
t.line("the ConcurrentHashMap-backed 'simple' provider.");
t.section("the detection order, read out of the enum rather than the documentation");
List<CacheType> order = Arrays.asList(CacheType.values());
for (int i = 0; i < order.size(); i++) {
t.line(" %d %s", i + 1, order.get(i));
}
t.line("");
t.line("CacheConfigurations maps CacheType -> configuration class in an EnumMap, so");
t.line("the configurations are imported in this declaration order and the first one");
t.line("whose @ConditionalOnClass matches registers the CacheManager. The rest back");
t.line("off on @ConditionalOnMissingBean.");
t.line("");
t.line("Spring Boot's reference documentation lists this order as Generic, JCache,");
t.line("Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Cache2k, Simple. On");
t.line("4.1.1 the enum disagrees in two places: COUCHBASE comes before INFINISPAN,");
t.line("and CACHE2K comes before CAFFEINE. The second one is the one that can bite:");
t.line("with both on the classpath you get Cache2k, not Caffeine.");
t.line("");
t.line("Nothing logs the decision at INFO. Set spring.cache.type explicitly.");
assertThat(cacheManager.getClass().getName())
.isEqualTo("org.springframework.cache.caffeine.CaffeineCacheManager");
assertThat(order).containsExactly(
CacheType.GENERIC, CacheType.JCACHE, CacheType.HAZELCAST, CacheType.COUCHBASE,
CacheType.INFINISPAN, CacheType.REDIS, CacheType.CACHE2K, CacheType.CAFFEINE,
CacheType.SIMPLE, CacheType.NONE);
assertThat(order.indexOf(CacheType.CACHE2K))
.as("Cache2k is checked before Caffeine, unlike what the reference docs list")
.isLessThan(order.indexOf(CacheType.CAFFEINE));
}
}
}
@@ -0,0 +1,90 @@
package com.ankurm.caching;
import com.ankurm.caching.basics.BookRepositoryStub;
import com.ankurm.caching.basics.BookService;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 8: what a real provider adds. The default provider has no expiry and no size bound;
* Caffeine has both, and reports whether any of it is working.
*/
@SpringBootTest
@ActiveProfiles("caffeine")
class ProvidersAndTtlTest {
@Autowired BookService books;
@Autowired BookRepositoryStub repository;
@Autowired CacheManager cacheManager;
@Test
void caffeineExpiresAndBoundsWhereTheDefaultProviderDoesNeither() throws Exception {
try (Transcript t = new Transcript("15-providers-and-ttl.txt",
"TTL and size bounds are the provider's job, not the abstraction's")) {
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("configured : expireAfterWrite=400ms, maximumSize=3, recordStats");
t.line("");
repository.reset();
cacheManager.getCache("books").clear();
books.findBook("978-0134685991");
books.findBook("978-0134685991");
t.line("two calls, same key, immediately -> %d repository calls", repository.callCount());
assertThat(repository.callCount()).isEqualTo(1);
Thread.sleep(600);
books.findBook("978-0134685991");
t.line("one more call 600 ms later -> %d repository calls <- the entry expired",
repository.callCount());
assertThat(repository.callCount()).isEqualTo(2);
t.section("size bound");
cacheManager.getCache("books").clear();
repository.reset();
for (String isbn : new String[]{"978-0134685991", "978-1617294945", "978-0596009205"}) {
books.findBook(isbn);
}
books.save(new com.ankurm.caching.basics.Book("x-1", "Filler One", 2020));
books.save(new com.ankurm.caching.basics.Book("x-2", "Filler Two", 2020));
Thread.sleep(120);
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("books");
long size = cache.getNativeCache().estimatedSize();
CacheStats stats = cache.getNativeCache().stats();
t.line("five distinct keys written, maximumSize = 3");
t.line("estimated size after eviction settles : %d", size);
t.line("stats : hits=%d misses=%d evictions=%d",
stats.hitCount(), stats.missCount(), stats.evictionCount());
t.section("recordStats is not on by default");
com.github.benmanes.caffeine.cache.Cache<String, String> unrecorded =
Caffeine.newBuilder().build();
unrecorded.put("a", "1");
unrecorded.getIfPresent("a");
unrecorded.getIfPresent("missing");
t.line("a Caffeine cache built without recordStats(), after 1 hit and 1 miss:");
t.line(" %s", unrecorded.stats());
t.line("");
t.line("Every counter is zero. Micrometer's cache.gets and cache.evictions will");
t.line("exist and report zero too, which looks exactly like a cache nobody uses.");
assertThat(unrecorded.stats().hitCount()).isZero();
assertThat(unrecorded.stats().missCount()).isZero();
t.line("");
t.line("The Spring cache abstraction has no TTL, no size limit and no eviction");
t.line("policy of its own - it is an interface over whatever you plug in. On the");
t.line("default simple provider, a ConcurrentHashMap, an entry stays until something");
t.line("evicts it by hand or the process ends.");
assertThat(size).isLessThanOrEqualTo(3);
}
}
}
@@ -0,0 +1,110 @@
package com.ankurm.caching;
import com.ankurm.caching.basics.BookRepositoryStub;
import com.ankurm.caching.selfinvocation.CatalogReader;
import com.ankurm.caching.selfinvocation.CatalogService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 3: the self-invocation trap, measured four ways plus two silent variants.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class SelfInvocationTest {
@Autowired CatalogService catalog;
@Autowired CatalogReader reader;
@Autowired BookRepositoryStub repository;
@Autowired CacheManager cacheManager;
private static final List<String> ISBNS =
List.of("978-0134685991", "978-1617294945", "978-0134685991", "978-1617294945");
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
repository.reset();
}
@Test
void internalCallSkipsTheCacheAndThreeFixesDoNot() {
try (Transcript t = new Transcript("03-self-invocation.txt",
"Four ways to call a @Cacheable method, one of which caches nothing")) {
t.line("injected bean class : %s", catalog.getClass().getName());
t.line("is an AOP proxy? : %b", AopUtils.isAopProxy(catalog));
t.line("is a CGLIB proxy? : %b", AopUtils.isCglibProxy(catalog));
t.line("target class : %s", AopUtils.getTargetClass(catalog).getName());
t.line("");
t.line("Four ISBNs, two of them repeats. A working cache does 2 lookups, not 4.");
repository.reset();
catalog.byInternalCall(ISBNS);
int internal = repository.callCount();
t.line("");
t.line("this.lookup(..) -> %d repository calls <- no caching at all", internal);
cacheManager.getCache("catalog").clear();
repository.reset();
catalog.bySelfInjection(ISBNS);
int selfInjected = repository.callCount();
t.line("self.getObject().lookup(..) -> %d repository calls", selfInjected);
cacheManager.getCache("catalog").clear();
repository.reset();
catalog.byExposedProxy(ISBNS);
int exposed = repository.callCount();
t.line("AopContext.currentProxy() -> %d repository calls", exposed);
cacheManager.getCache("catalog").clear();
repository.reset();
reader.byCollaborator(ISBNS);
int collaborator = repository.callCount();
t.line("a second bean calls lookup(..) -> %d repository calls", collaborator);
assertThat(internal).isEqualTo(4);
assertThat(selfInjected).isEqualTo(2);
assertThat(exposed).isEqualTo(2);
assertThat(collaborator).isEqualTo(2);
}
}
@Test
void protectedMethodIsSilentlyNotCached() {
try (Transcript t = new Transcript("04-non-public-and-postconstruct.txt",
"Two more places the annotation is ignored without a warning")) {
repository.reset();
catalog.callProtected("978-0596009205");
catalog.callProtected("978-0596009205");
t.line("@Cacheable on a protected method, called twice -> %d repository calls",
repository.callCount());
t.line("No warning is logged. In proxy mode the annotation is only honoured on");
t.line("public methods; a protected one is simply never advised.");
assertThat(repository.callCount()).isEqualTo(2);
t.section("@EnableCaching attributes, as the class file declares them");
Method[] attrs = EnableCaching.class.getDeclaredMethods();
Arrays.sort(attrs, (a, b) -> a.getName().compareTo(b.getName()));
for (Method m : attrs) {
t.line(" %s %s()", m.getReturnType().getSimpleName(), m.getName());
}
t.line("");
t.line("There is no exposeProxy attribute, so @EnableCaching(exposeProxy = true)");
t.line("- which a lot of answers recommend - does not compile.");
assertThat(Arrays.stream(attrs).map(Method::getName))
.containsExactlyInAnyOrder("proxyTargetClass", "mode", "order");
}
}
}
@@ -0,0 +1,78 @@
package com.ankurm.caching;
import com.ankurm.caching.sync.ReportService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Chapter 7: sync = true, and CompletableFuture support.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class StampedeTest {
@Autowired ReportService reports;
@Autowired CacheManager cacheManager;
private static final int THREADS = 16;
@Test
void syncTrueCollapsesConcurrentMisses() throws Exception {
try (Transcript t = new Transcript("11-stampede.txt",
"sync = true is the difference between one slow call and sixteen")) {
t.line("%d threads call the same key at the same instant, cold cache.", THREADS);
t.line("The method sleeps 300 ms.");
t.line("");
int unsynced = race(() -> reports.buildReport("q3"));
t.line("@Cacheable(\"reports\") -> %d invocations", unsynced);
int synced = race(() -> reports.buildReportSynced("q3"));
t.line("@Cacheable(\"syncedReports\", sync = true) -> %d invocation%s",
synced, synced == 1 ? "" : "s");
t.line("");
t.line("Without sync, every thread that arrives during the 300 ms window misses and");
t.line("runs the method. That is a cache stampede, and it is worst exactly when the");
t.line("cache matters most - right after a restart or an eviction.");
assertThat(unsynced).isGreaterThan(1);
assertThat(synced).isEqualTo(1);
}
}
private int race(Runnable call) throws Exception {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(THREADS);
int unsyncedBefore = reports.unsyncedCalls();
int syncedBefore = reports.syncedCalls();
try (ExecutorService pool = Executors.newFixedThreadPool(THREADS)) {
for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
try {
start.await();
call.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
done.await(30, TimeUnit.SECONDS);
}
int unsyncedDelta = reports.unsyncedCalls() - unsyncedBefore;
int syncedDelta = reports.syncedCalls() - syncedBefore;
return unsyncedDelta + syncedDelta;
}
}
@@ -0,0 +1,70 @@
package com.ankurm.caching;
import com.ankurm.caching.jpa.CustomerService;
import com.ankurm.caching.jpa.CustomerWorkflow;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* The same rollback, with the cache manager wrapped in
* {@link org.springframework.cache.transaction.TransactionAwareCacheManagerProxy}.
*/
@SpringBootTest
@ActiveProfiles("txaware")
class TransactionAwareTest {
@Autowired CustomerService customers;
@Autowired CustomerWorkflow workflow;
@Autowired CacheManager cacheManager;
@Test
void deferringThePutToAfterCommitFixesIt() {
try (Transcript t = new Transcript("19-transaction-aware.txt",
"TransactionAwareCacheManagerProxy, and what it does not cover")) {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
customers.reset();
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("");
t.line("nameOf(1) -> %s", customers.nameOf(1L));
assertThatThrownBy(() -> workflow.renameAndThenFail(1L, "Alice Cooper"))
.isInstanceOf(IllegalStateException.class);
String cached = customers.nameOf(1L);
t.line("after the identical rollback, nameOf(1) -> %s", cached);
t.line("");
t.line("The put was registered as a transaction synchronisation and dropped when the");
t.line("transaction rolled back instead of committing.");
assertThat(cached).isEqualTo("Alice");
t.section("what it does not cover: beforeInvocation = true");
cacheManager.getCache("customers").clear();
customers.reset();
customers.nameOf(2L);
String seenInsideTx = workflow.evictFirstThenReadInSameTransaction(2L, "Bobby");
t.line("inside the same transaction, after an evict declared beforeInvocation=true,");
t.line("a re-read returns : %s", seenInsideTx);
t.line("");
t.line("Not the stale value. The eviction was NOT deferred, and the re-read went to");
t.line("the database and saw the uncommitted row. The reason is in the bytecode:");
t.line("AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent()");
t.line("when immediate is true and evict() when it is false, and the decorator only");
t.line("registers a post-commit synchronisation in evict() - evictIfPresent()");
t.line("delegates straight to the target cache. See docs/output/22-decorator-bytecode.txt.");
t.line("");
t.line("Two gaps do remain, and they are structural rather than measurable here:");
t.line("reads are never deferred, so a @Cacheable lookup inside the transaction sees");
t.line("whatever the shared cache holds; and outside a transaction the proxy is a");
t.line("pass-through that writes immediately.");
assertThat(seenInsideTx).isEqualTo("Bobby");
}
}
}
@@ -0,0 +1,107 @@
package com.ankurm.caching;
import com.ankurm.caching.jpa.Customer;
import com.ankurm.caching.jpa.CustomerService;
import com.ankurm.caching.jpa.CustomerWorkflow;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Chapter 10: the caching interceptor runs inside the transaction interceptor, so a cache write
* happens before the commit and survives a rollback.
*/
@SpringBootTest(properties = "spring.cache.type=simple")
class TransactionsTest {
@Autowired CustomerService customers;
@Autowired CustomerWorkflow workflow;
@Autowired CacheManager cacheManager;
@BeforeEach
void clear() {
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
customers.reset();
}
@Test
void aRolledBackTransactionLeavesTheCacheUpdated() {
try (Transcript t = new Transcript("13-transactions.txt",
"A rollback does not roll the cache back")) {
t.line("cacheManager : %s", cacheManager.getClass().getName());
t.line("");
t.line("nameOf(1) -> %s", customers.nameOf(1L));
assertThatThrownBy(() -> workflow.renameAndThenFail(1L, "Alice Cooper"))
.isInstanceOf(IllegalStateException.class);
t.line("");
t.line("An outer @Transactional method calls the @CachePut update, which succeeds,");
t.line("and then fails on the next step. The transaction rolls back.");
t.line("");
String cached = customers.nameOf(1L);
cacheManager.getCache("customers").clear();
String inDatabase = customers.nameOf(1L);
t.line("what the cache serves : %s", cached);
t.line("what the database has : %s", inDatabase);
t.line("");
t.line("The cache is now holding a name that no transaction ever committed. Nothing");
t.line("will correct it until the entry expires or something evicts it.");
assertThat(cached).isEqualTo("Alice Cooper");
assertThat(inDatabase).isEqualTo("Alice");
t.section("the same shape with @CacheEvict");
cacheManager.getCache("customers").clear();
customers.reset();
t.line("nameOf(2) -> %s", customers.nameOf(2L));
assertThatThrownBy(() -> workflow.evictAndThenFail(2L, "Bobby"))
.isInstanceOf(IllegalStateException.class);
t.line("after the rollback, nameOf(2) -> %s", customers.nameOf(2L));
t.line("database loads: %d <- the entry was evicted, so this one reloaded",
customers.loads());
t.line("");
t.line("An eviction that fires too early is self-healing: the next read goes to the");
t.line("database and re-populates correctly. A @CachePut that fires too early is not.");
assertThat(customers.nameOf(2L)).isEqualTo("Bob");
}
}
@Test
void aCachedEntityIsDetachedAndItsLazyCollectionIsGone() {
try (Transcript t = new Transcript("14-cached-entity.txt",
"Caching an entity caches a detached object, lazy proxies and all")) {
Customer first = customers.loadEntity(1L);
t.line("loadEntity(1) -> %s (%s)", first.getName(), first.getClass().getName());
t.line("database loads: %d", customers.loads());
Customer second = customers.loadEntity(1L);
t.line("second call returns the same instance? %b", first == second);
t.line("database loads: %d", customers.loads());
assertThat(second).isSameAs(first);
assertThat(customers.loads()).isEqualTo(1);
t.section("touching the lazy collection outside the session");
try {
int size = second.getOrders().size();
t.line("orders.size() -> %d", size);
} catch (RuntimeException e) {
t.line("%s", e.getClass().getName());
t.line(" %s", e.getMessage());
}
t.line("");
t.line("This is the line between the two caches. Hibernate's second-level cache");
t.line("stores dehydrated entity state and rebuilds a managed entity inside a");
t.line("session, so lazy associations still work. The Spring cache abstraction");
t.line("stores the object your method returned, exactly as it was when the");
t.line("transaction ended - detached, with whatever its proxies were holding.");
}
}
}
@@ -0,0 +1,52 @@
package com.ankurm.caching;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code docs/output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("docs", "output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}