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:
@@ -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
|
||||
* — {@code proxyTargetClass}, {@code mode} and {@code order} — 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 — 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user