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
+103
View File
@@ -0,0 +1,103 @@
# caching
Companion project for **[The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators
and the Self-Invocation Trap](https://ankurm.com/spring-cache-abstraction-cacheable-cacheevict-self-invocation-trap/)** on [ankurm.com](https://ankurm.com).
Every number, key dump, stack trace and error message quoted in the article came out of
`docs/output/`, and every one of those files is regenerated by one script. Most of them are
produced by the test suite, so if a claim stops being true the build goes red.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Caffeine | managed by Boot |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
./scripts/run-all.sh # regenerates every file under docs/output/
```
Run the application on its own to poke at the diagnostics endpoint:
```bash
java -jar target/caching-1.0.0.jar --spring.cache.type=simple
curl -s localhost:8080/diag/warm
curl -s localhost:8080/diag/caches | jq .
```
## Profiles
| Profile | What it changes |
|---|---|
| *(none)* | Boot's auto-detected provider. Caffeine is on the classpath, so that is what you get |
| `caffeine` | An explicit `CaffeineCacheManager` with `expireAfterWrite=400ms`, `maximumSize=3`, `recordStats()` and `setAsyncCacheMode(true)` |
| `txaware` | `TransactionAwareCacheManagerProxy` over `ConcurrentMapCacheManager` |
Most tests pin `spring.cache.type=simple` so the key dumps show a real `ConcurrentHashMap`.
## Endpoints
| Endpoint | Purpose |
|---|---|
| `GET /diag/warm` | Calls a few cached methods so there is something to look at |
| `GET /diag/caches` | Every cache, every key, with the runtime class of key and value |
| `GET /actuator/caches` | Boot's own view: cache names and their manager |
| `GET /actuator/metrics/cache.gets` | Hit/miss counters, where the provider reports them |
`/diag/*` is a debugging aid with no authorisation. Delete it before shipping anything.
## Documentation
| Chapter | Covers |
|---|---|
| [01 — What the cache abstraction is](docs/01-what-caching-is.md) | the interceptor and the `Cache` interface; when not to cache |
| [02 — The annotations, attribute by attribute](docs/02-the-three-annotations.md) | every attribute from `javap`; five declarations that are rejected and when |
| [03 — The self-invocation trap](docs/03-self-invocation.md) | why `this.method()` caches nothing, three fixes ranked, non-public methods, `@PostConstruct` |
| [04 — Keys](docs/04-keys.md) | `SimpleKeyGenerator`'s three rules, the collision it makes easy, the SpEL surface, mutable keys |
| [05 — Eviction](docs/05-eviction.md) | `beforeInvocation`, what a thrown exception does, `allEntries`, `CacheErrorHandler` |
| [06 — Conditions and nulls](docs/06-conditions-and-nulls.md) | `condition` vs `unless`, `NullValue`, cache penetration |
| [07 — `sync` and async](docs/07-sync-and-async.md) | the stampede measured, the four `sync=true` restrictions, `CompletableFuture` |
| [08 — Providers and TTL](docs/08-providers-and-ttl.md) | detection order, the dependency that silently changes your provider, per-provider properties |
| [09 — This is not the Hibernate L2 cache](docs/09-versus-hibernate-l2.md) | three layers compared; why caching an entity gives you a shared detached object |
| [10 — Transactions](docs/10-transactions.md) | a rollback that does not roll the cache back; `TransactionAwareCacheManagerProxy` and a correction to a widely repeated claim |
| [11 — Diagnostics](docs/11-diagnostics.md) | four ways to see what the cache is doing |
| [12 — Before production](docs/12-production-checklist.md) | the checklist, and whether to cache at all |
## Captured output
| File | What it shows |
|---|---|
| [`01-basics.txt`](docs/output/01-basics.txt) | 200 ms, then 0 ms, with the invocation counter |
| [`02-put-evict-clear.txt`](docs/output/02-put-evict-clear.txt) | the three annotations against one cache |
| [`03-self-invocation.txt`](docs/output/03-self-invocation.txt) | 4 vs 2 repository calls, four call styles |
| [`04-non-public-and-postconstruct.txt`](docs/output/04-non-public-and-postconstruct.txt) | a protected method silently uncached; `@EnableCaching`'s real attributes |
| [`05-key-shapes.txt`](docs/output/05-key-shapes.txt) | zero, one and two arguments in one map |
| [`06-key-collision.txt`](docs/output/06-key-collision.txt) | one method serving another method's answer |
| [`07-mutable-key.txt`](docs/output/07-mutable-key.txt) | two entries, one unreachable |
| [`08-evict-timing.txt`](docs/output/08-evict-timing.txt) | a failed update leaving a stale entry |
| [`09-conditions.txt`](docs/output/09-conditions.txt) | `condition` vetoing before the call |
| [`10-nulls.txt`](docs/output/10-nulls.txt) | `NullValue` in the map |
| [`11-stampede.txt`](docs/output/11-stampede.txt) | 16 invocations vs 1 |
| [`12-async-return-types.txt`](docs/output/12-async-return-types.txt) | `CompletableFuture` with async cache mode on |
| [`13-transactions.txt`](docs/output/13-transactions.txt) | the cache keeping a rolled-back value |
| [`14-cached-entity.txt`](docs/output/14-cached-entity.txt) | `LazyInitializationException` from a cached entity |
| [`15-providers-and-ttl.txt`](docs/output/15-providers-and-ttl.txt) | Caffeine expiry, size bound and stats |
| [`16-autoconfiguration.txt`](docs/output/16-autoconfiguration.txt) | what Boot wired, and where the auto-configuration lives in Boot 4 |
| [`17-async-cache-mode-missing.txt`](docs/output/17-async-cache-mode-missing.txt) | the runtime error a clean startup hides |
| [`18-provider-detection.txt`](docs/output/18-provider-detection.txt) | the provider nobody chose, and the detection order read out of the enum (it disagrees with the docs) |
| [`19-transaction-aware.txt`](docs/output/19-transaction-aware.txt) | the deferred put, and the evict that is not deferred |
| [`20-invalid-declarations.txt`](docs/output/20-invalid-declarations.txt) | five rejected declarations, four of them at the first call |
| [`22-decorator-bytecode.txt`](docs/output/22-decorator-bytecode.txt) | `javap` proving why `beforeInvocation` is not deferred |
| [`23-diagnostics.txt`](docs/output/23-diagnostics.txt) | every cache, every key, live |
## Licence
MIT — see [LICENSE](../LICENSE).
+45
View File
@@ -0,0 +1,45 @@
[← README](../README.md) · [next: the three annotations →](02-the-three-annotations.md)
# 1. What the Spring cache abstraction actually is
It is an interceptor and a map interface. That is the whole idea, and holding onto it explains
almost every surprise later.
When a bean carries `@Cacheable`, Spring does not modify the class. It places an AOP proxy in
front of it and puts a `CacheInterceptor` in the chain. On each call the interceptor:
1. asks a `KeyGenerator` for a key,
2. asks a `Cache` (looked up from a `CacheManager` by name) whether it holds that key,
3. returns the stored value if it does, and otherwise calls the real method and stores the result.
`org.springframework.cache.Cache` is a small interface — `get`, `put`, `evict`, `evictIfPresent`,
`clear`, `invalidate`, `retrieve`. Everything you associate with a cache product — expiry, size
limits, eviction policy, replication, persistence, statistics — lives behind that interface in a
provider. The abstraction itself has none of it. The reference documentation is explicit about
this in its "How can I set the TTL/TTI/eviction policy" section: you configure it on the provider.
## What that buys you
Portability of the *declaration*, not the behaviour. The same annotated method runs against a
`ConcurrentHashMap` in a unit test, Caffeine in one deployment and Redis in another, without the
service code changing. That is genuinely useful and it is the main reason to use it.
## When not to cache
- **The method is not slow.** A cache turns a 2 ms call into a 0.1 ms call and adds a correctness
problem. Measure first.
- **The data must be correct right now.** Balances, stock levels, permissions. A cache is a
deliberate decision to serve stale data; make it deliberately.
- **The hit rate will be low.** A cache keyed on something nearly unique — a search phrase, a
request id — is a memory leak wearing a performance costume.
- **The value is huge and the memory budget is not.** On the default provider nothing evicts.
`docs/output/01-basics.txt` has the smallest possible demonstration: 200 ms, then 0 ms, with the
repository's invocation counter proving the method body did not run the second time.
## The two caches people confuse
If you are using JPA, you already have caching whether you asked for it or not: the persistence
context (first level) and possibly Hibernate's second-level cache. They are a different thing
from this, at a different layer, with different failure modes.
[Chapter 9](09-versus-hibernate-l2.md) is the comparison.
+88
View File
@@ -0,0 +1,88 @@
[← what caching is](01-what-caching-is.md) · [next: self-invocation →](03-self-invocation.md)
# 2. The annotations, attribute by attribute
Verified against `spring-context` 7.0.9 with `javap`, so this is what the class files declare
rather than what the documentation summarises.
## `@Cacheable`
| Attribute | Type | Notes |
|---|---|---|
| `value` / `cacheNames` | `String[]` | Aliases. Several names means several caches are consulted and all of them written. |
| `key` | `String` | SpEL. Mutually exclusive with `keyGenerator`; setting both fails the context at startup. |
| `keyGenerator` | `String` | Bean name of a `KeyGenerator`. |
| `cacheManager` | `String` | Bean name, for when there is more than one. |
| `cacheResolver` | `String` | Full control over which caches this operation uses. Mutually exclusive with `cacheManager`. |
| `condition` | `String` | SpEL, evaluated **before** the call. False means no lookup and no write. |
| `unless` | `String` | SpEL, evaluated **after**. Can see `#result`. Vetoes the write only. |
| `sync` | `boolean` | One caller computes, the rest wait. Heavily restricted — see [chapter 7](07-sync-and-async.md). |
## `@CachePut`
Same attributes minus `sync`. Always invokes the method, always writes the result. Use it when
you already have the new value and want to avoid the miss that an eviction would cause.
Do not put `@CachePut` and `@Cacheable` on the same method. The framework does not stop you, and
the two have opposite intentions.
## `@CacheEvict`
Same as `@Cacheable` minus `unless` and `sync`, plus:
| Attribute | Type | Notes |
|---|---|---|
| `allEntries` | `boolean` | Clears the whole region in one operation instead of key by key. |
| `beforeInvocation` | `boolean` | Default `false` — evict after a *successful* return. See [chapter 5](05-eviction.md). |
`void` is fine here; the annotation is a trigger and the return value is ignored.
## `@Caching`
A container for several operations of the same type on one method:
```java
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames = "secondary", key = "#p0") })
public Book importBooks(String deposit, Date date) { ... }
```
## `@CacheConfig`
Class-level defaults for `cacheNames`, `keyGenerator`, `cacheManager` and `cacheResolver`. It
enables nothing on its own. Precedence runs global (`CachingConfigurer`) → class
(`@CacheConfig`) → operation, with the operation always winning.
## `@EnableCaching`
Exactly three attributes, and this is worth knowing because a popular piece of advice uses a
fourth that does not exist:
```
AdviceMode mode()
int order()
boolean proxyTargetClass()
```
There is no `exposeProxy`. `@EnableCaching(exposeProxy = true)` does not compile.
`docs/output/04-non-public-and-postconstruct.txt` prints the reflected attribute list.
Spring Boot's reference documentation advises against putting `@EnableCaching` on the main
application class, because it makes caching mandatory for every test slice too. Put it on a
`@Configuration` class you can exclude.
## Declarations that are rejected, and when
`docs/output/20-invalid-declarations.txt` runs five bad declarations through a real context. Only
the first fails at startup:
| Declaration | Fails |
|---|---|
| `key` and `keyGenerator` together | at startup |
| `sync = true` with `unless` | at the first call |
| `sync = true` across two caches | at the first call |
| `sync = true` combined with another cache operation | at the first call |
| a cache name not in `spring.cache.cache-names` | at the first call |
Four of the five start a healthy-looking application and throw on a code path that may not run
for hours. That is the single strongest argument for having a test that actually calls each
cached method.
+91
View File
@@ -0,0 +1,91 @@
[← the three annotations](02-the-three-annotations.md) · [next: keys →](04-keys.md)
# 3. The self-invocation trap
The symptom: `@Cacheable` is on the method, the application starts cleanly, nothing is logged,
and the cache is empty. Or worse, the cache works when the method is called from a controller and
does not when it is called from a sibling method three lines away.
## The mechanism
`@EnableCaching` registers an auto-proxy creator. The bean the container hands out is a proxy —
in this module a CGLIB subclass, printed in `docs/output/03-self-invocation.txt`:
```
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
is an AOP proxy? : true
target class : com.ankurm.caching.selfinvocation.CatalogService
```
The interceptor lives in the proxy. `this.lookup(...)` inside the target object is a plain
virtual call on `this`, which is the target, not the proxy. The interceptor is never reached.
Measured over four ISBNs with two repeats, so a working cache does two lookups:
```
this.lookup(..) -> 4 repository calls <- no caching at all
self.getObject().lookup(..) -> 2 repository calls
AopContext.currentProxy() -> 2 repository calls
a second bean calls lookup(..) -> 2 repository calls
```
## The three fixes, ranked
**1. Move the call to another bean.** The loop and the cached lookup belong to different
responsibilities anyway. No Spring-specific machinery, no cycle, testable in isolation. This is
the one to reach for.
**2. Inject yourself as an `ObjectProvider`.**
```java
private final ObjectProvider<CatalogService> self;
...
CatalogService proxy = self.getObject();
```
`ObjectProvider` defers the lookup, so there is no constructor cycle. `@Lazy CatalogService self`
works the same way. It is honest about what it is doing, which is more than can be said for the
next option.
**3. `AopContext.currentProxy()`.** Works, but only when the proxy was created with
`exposeProxy` on — and, as [chapter 2](02-the-three-annotations.md) notes, `@EnableCaching` has
no such attribute. `@EnableAspectJAutoProxy(exposeProxy = true)` is the usual advice and drags in
AspectJ. This module flips the flag on the creator `@EnableCaching` already registered:
```java
@Bean
static BeanFactoryPostProcessor exposeCachingProxy() {
return beanFactory -> {
if (beanFactory instanceof BeanDefinitionRegistry registry) {
AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry);
AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry);
}
};
}
```
The cost is a ThreadLocal read and a cast in business code, and it only works inside a call that
was itself intercepted.
## The fourth option: stop using proxies
`@EnableCaching(mode = AdviceMode.ASPECTJ)` with compile-time or load-time weaving modifies the
bytecode, so self-invocation and non-public methods are both intercepted. It is a real answer and
almost nobody takes it, because the build complexity is not worth it for caching alone.
## Two relatives of the same bug
`docs/output/04-non-public-and-postconstruct.txt`:
- **A non-public annotated method is never advised.** In proxy mode the annotation on a
`protected`, package-private or `private` method is silently ignored. Two calls, two repository
hits, no warning.
- **`@PostConstruct` runs before the proxy is in place.** A warm-up loop in an init method warms
nothing. The reference documentation says so, and it still catches people.
## How to tell in ten seconds
Inject the bean, print `AopUtils.isAopProxy(bean)` and `bean.getClass().getName()`. If the class
name has no `$$SpringCGLIB$$` or `$Proxy` in it, there is no interceptor and nothing downstream
matters. If it does, but the cache is still empty, the call is not going through it — look for
`this.`.
+98
View File
@@ -0,0 +1,98 @@
[← self-invocation](03-self-invocation.md) · [next: eviction →](05-eviction.md)
# 4. Keys
## What the default generator builds
`SimpleKeyGenerator` has three rules:
| Arguments | Key |
|---|---|
| none | `SimpleKey.EMPTY` |
| one | that argument, unwrapped |
| two or more | a `SimpleKey` holding all of them |
`docs/output/05-key-shapes.txt` shows all three in one map:
```
key abc [String] -> one:abc
key SimpleKey [abc, 7] [SimpleKey] -> two:abc:7
key SimpleKey [] [SimpleKey] -> zero
```
The method name and the declaring class appear nowhere. That is the single most consequential
fact about Spring cache keys.
Before Spring 4.0 the strategy hashed the arguments, which produced real collisions
(spring-framework#14870). `SimpleKey` holds the arguments and compares them with `equals`, so
arguments need working `equals` and `hashCode` — records and value objects are fine, JPA entities
with identity-based equality usually are not.
## The collision this makes easy
Two methods, one cache name, same argument type. From
`docs/output/06-key-collision.txt`:
```
countLetters("a1b2") -> letters=2 (calls: letters=1 digits=0)
countDigits("a1b2") -> letters=2 (calls: letters=1 digits=0)
```
`countDigits` never ran. It found key `"a1b2"` populated and returned the answer to a different
question. In production this looks like `findByIsbn` and `findByTitle` next to each other, both
annotated `@Cacheable("books")`, both taking a `String`.
No-argument methods collide harder still: every one of them keys on `SimpleKey.EMPTY`, so two
`@Cacheable("noargs")` methods with no parameters are guaranteed to shadow each other.
Three ways out, in order of preference:
1. **One cache name per method.** Free, obvious in a dump, and gives you per-method TTL and
per-method metrics as a bonus.
2. **Put the method into the key**: `key = "'letters:' + #input"`.
3. **A custom `KeyGenerator`** that includes `method.getName()`. Applies everywhere at once,
which is useful in a large codebase and a blunt instrument in a small one.
## SpEL you can use in `key`, `condition` and `unless`
| Expression | What it is |
|---|---|
| `#argName` | a method argument by name (needs `-parameters`, which Spring Boot's Maven parent sets) |
| `#a0`, `#p0` | the same argument by index, when names are unavailable |
| `#root.methodName`, `#root.method` | the method being invoked |
| `#root.target`, `#root.targetClass` | the target object and its class |
| `#root.args[0]` | arguments as an array |
| `#root.caches[0].name` | the caches this operation resolves to |
| `#result` | the return value — `unless`, `@CachePut` keys, and `@CacheEvict` with `beforeInvocation = false` only |
For an `Optional` return, `#result` is the unwrapped object, not the `Optional`.
## Keys you can lose
`docs/output/07-mutable-key.txt` passes an `ArrayList` and then mutates it:
```
cache "mutable":
key [java, spring] [ArrayList] -> tags=[java]
key [java, spring] [ArrayList] -> tags=[java, spring]
```
Two entries whose keys print identically, because they are the same object. The first entry sits
under a `hashCode` the map no longer agrees with: unreachable, unevictable, and it will still be
there at the next heap dump. Keys must be immutable. If an argument is a collection, copy it or
derive a string.
## Keys in a distributed cache
Everything above assumes an in-process map. Once the cache is Redis or Hazelcast, two more rules
apply:
- **The key has to survive being turned into a string or bytes.** This repository has no Redis
in it, so treat what follows as reasoning from the API rather than something measured here:
`RedisCache` converts the key object through a `ConversionService` before writing it, so a key
type it cannot convert fails at the call rather than at startup. An explicit `key` expression
that produces a short deterministic `String` sidesteps the question entirely, and it is what
you want in a shared cache anyway.
- **The key must be stable across deployments.** Anything derived from a hash code, an enum
ordinal, or a class name you might refactor will silently miss for the whole cache after a
rename, and the old entries will sit there until they expire.
+75
View File
@@ -0,0 +1,75 @@
[← keys](04-keys.md) · [next: conditions and nulls →](06-conditions-and-nulls.md)
# 5. Eviction, and when it happens
`@CacheEvict` defaults to `beforeInvocation = false`, which means: evict **after** the method
returns **normally**. A method that throws does not evict.
From `docs/output/08-evict-timing.txt`:
```
price("sku-1") -> 100 (stored price is 100)
updatePrice("sku-1", 250, fail=true) threw after writing the new price.
stored price now : 250
price("sku-1") : 100 <- the cache still serves the old value
```
The write to the real store happened. The exception came afterwards. The eviction did not run,
so the cache is now authoritative for a value that no longer exists anywhere else. Nothing will
correct it: the next reader gets 100, and the one after that, until something evicts the entry or
the process restarts.
`beforeInvocation = true` makes the same failure harmless:
```
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2") : 250 <- the entry went first, so the next read is honest
```
## Choosing between the three shapes
| Shape | Cost | Correct when the write fails? |
|---|---|---|
| `@CacheEvict` (default) | one miss on the next read | **no** — stale entry survives |
| `@CacheEvict(beforeInvocation = true)` | one miss, plus a window where two callers can both miss | yes |
| `@CachePut` | no miss at all | no — and it writes a value the store may not have |
`@CachePut` is the fastest and the most dangerous: it puts *your* computed value into the cache
without reading the store back, so any transformation the database applies — a trigger, a
default, a truncation, a generated column — is invisible to every subsequent reader. Use it when
the method's return value is definitively the new state.
`beforeInvocation = true` is the right default for anything that mutates. The extra miss costs
one lookup; the alternative costs an incident.
## `allEntries = true`
Clears the region in a single operation rather than key by key, which matters when the region is
large or remote. Two things to know:
- It is a blunt instrument in a shared cache: one bulk import evicts everything every other
caller warmed.
- On a distributed provider `clear()` may be an O(n) scan or a whole-keyspace operation. Check
what your provider does before putting it on a frequently called method.
## Errors from the cache itself
If the cache provider throws — a Redis timeout, a serialization failure — the default
`SimpleCacheErrorHandler` rethrows, so a cache outage becomes an application outage. A
`CacheErrorHandler` registered through `CachingConfigurer` can log and continue instead:
```java
@Configuration
@EnableCaching
class CacheConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() { /* log and swallow get/put/evict errors */ };
}
}
```
That is the right call for a read-through cache in front of a database, and the wrong call when
the eviction is what keeps two systems consistent — a swallowed evict error is a permanently
stale entry. Decide per cache, not per application.
+55
View File
@@ -0,0 +1,55 @@
[← eviction](05-eviction.md) · [next: sync and async →](07-sync-and-async.md)
# 6. `condition`, `unless`, and what a cached `null` is
## The two vetoes
`condition` is evaluated on the arguments **before** the method runs. A false condition skips the
lookup *and* the write — the method behaves as though it were not annotated.
`unless` is evaluated **after**, can see `#result`, and vetoes the write only. The lookup still
happened, so a cached value is still returned on a hit.
```java
@Cacheable(cacheNames = "terms", condition = "#term.length() <= 8")
public String search(String term) { ... }
```
`docs/output/09-conditions.txt`: six characters, two calls, one invocation. Twenty-five
characters, two calls, two invocations.
The useful pattern is exactly that one — refuse to cache inputs that will never repeat. A search
box keyed on free text has a hit rate close to zero and will happily fill the heap.
## `null`
By default a `null` return is cached. It is stored as a sentinel,
`org.springframework.cache.support.NullValue.INSTANCE`, so that a hit on `null` is
distinguishable from a miss. `docs/output/10-nulls.txt` shows it in the map:
```
cache "nulls":
key xyz -> null [org.springframework.cache.support.NullValue]
```
This is usually what you want. Caching "not found" is the cheap defence against a hot lookup for
a row that does not exist — the classic cache-penetration attack is a flood of requests for ids
that are not in the database, and a cache that refuses to store misses passes every one of them
straight through.
Turn it off when a `null` means "not loaded yet" rather than "not there":
```java
@Cacheable(cacheNames = "terms", unless = "#result == null")
```
Or at the manager: `ConcurrentMapCacheManager.setAllowNullValues(false)`, reachable through a
`CacheManagerCustomizer`. Redis has its own switch, `spring.cache.redis.cache-null-values`,
default `true`.
Note the asymmetry that catches people: `unless = "#result == null"` still performs the lookup,
so if a `null` got into the cache some other way it will still be served. `condition` cannot help
here — it cannot see the result.
For an `Optional`-returning method, `#result` is the unwrapped value, so the safe-navigation form
is what you want: `unless = "#result?.hardback"`.
+67
View File
@@ -0,0 +1,67 @@
[← conditions and nulls](06-conditions-and-nulls.md) · [next: providers and TTL →](08-providers-and-ttl.md)
# 7. `sync = true`, and the async return types
## The stampede
`docs/output/11-stampede.txt`, sixteen threads hitting one cold key, method sleeping 300 ms:
```
@Cacheable("reports") -> 16 invocations
@Cacheable("syncedReports", sync = true) -> 1 invocation
```
Nothing is wrong with the unsynchronised version — it is doing exactly what it was told. Every
thread that arrives during the 300 ms window finds a miss and runs the method. The problem is
*when* this happens: right after a deployment, right after an eviction, right when the cache
would have been most valuable. A cache that collapses under the load it was added to survive is
a well-known way to turn a slow endpoint into an outage.
`sync = true` makes one caller compute while the others block on the same computation. It is
implemented on top of `Cache.get(key, Callable)`, so the provider has to support it; all the
`CacheManager` implementations in the framework do.
## What `sync = true` will not tolerate
Four restrictions, all enforced at the **first call** rather than at startup. Real messages from
`docs/output/20-invalid-declarations.txt`:
| Declaration | Message |
|---|---|
| `unless` alongside `sync` | `A sync=true operation does not support the unless attribute on ...` |
| two cache names | `A sync=true operation is restricted to a single cache on ...` |
| combined with another cache operation | `A sync=true operation cannot be combined with other cache operations on ...` |
All three are `IllegalStateException`, thrown from the interceptor, on a context that started
cleanly. An integration test that calls the method once is the cheapest possible insurance.
## `CompletableFuture` and reactive types
Since Spring Framework 6.1 the cache annotations understand `CompletableFuture`, `Mono` and
`Flux`. The interceptor unwraps the container and caches the emitted value.
The cache has to support future-based retrieval. `ConcurrentMapCacheManager` adapts on its own.
`CaffeineCacheManager` does not, unless you say so — and the way it tells you is
`docs/output/17-async-cache-mode-missing.txt`:
```
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
The application started cleanly. Nothing warned about anything.
buildAsync("q3") ->
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)
```
The fix is one line on the manager:
```java
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setAsyncCacheMode(true);
```
With it on, `docs/output/12-async-return-types.txt` shows two calls and one supplier invocation.
Be careful how far you take this. The reference documentation's own caveat is worth quoting:
annotation-driven caching "is not appropriate for sophisticated reactive interactions involving
composition and back pressure" — a `@Cacheable` `Flux` stores a pre-collected list, which is
rarely what a streaming endpoint wanted.
+92
View File
@@ -0,0 +1,92 @@
[← sync and async](07-sync-and-async.md) · [next: versus Hibernate L2 →](09-versus-hibernate-l2.md)
# 8. Providers, TTL, and the dependency that changes your cache
## The abstraction has no TTL
None. No time-to-live, no time-to-idle, no maximum size, no eviction policy. Those are provider
features, and the reference documentation says so plainly.
On the default `simple` provider — a `ConcurrentHashMap` behind `ConcurrentMapCache` — an entry
stays until something evicts it by hand or the process ends. That is fine for a lookup table with
twelve rows and a slow leak for anything else.
With Caffeine configured for `expireAfterWrite=400ms, maximumSize=3`,
`docs/output/15-providers-and-ttl.txt`:
```
two calls, same key, immediately -> 1 repository calls
one more call 600 ms later -> 2 repository calls <- the entry expired
five distinct keys written, maximumSize = 3
estimated size after eviction settles : 3
stats : hits=1 misses=5 evictions=3
```
## Auto-detection, and why your provider changed
If there is no `CacheManager` bean and no `cacheResolver`, Spring Boot imports one configuration
per provider and the first one whose `@ConditionalOnClass` matches registers the manager; the
rest back off on `@ConditionalOnMissingBean`.
The order is worth taking from the enum rather than from prose. `CacheConfigurations` holds an
`EnumMap<CacheType, String>`, so the iteration order is the declaration order of
`org.springframework.boot.autoconfigure.cache.CacheType` — which on 4.1.1 is:
```
1 GENERIC 2 JCACHE 3 HAZELCAST 4 COUCHBASE 5 INFINISPAN
6 REDIS 7 CACHE2K 8 CAFFEINE 9 SIMPLE 10 NONE
```
Spring Boot's reference page lists this as Generic, JCache, Hazelcast, **Infinispan**,
**Couchbase**, Redis, **Caffeine**, **Cache2k**, Simple. Two pairs are swapped relative to what
ships, and the second swap is the one that can bite: with both libraries on the classpath you get
**Cache2k, not Caffeine**. `ProviderDetectionTest` reads `CacheType.values()` rather than
transcribing a list, so it will notice if this changes again.
One more curiosity for anyone chasing Boot 4 package moves: the configurations live in
`org.springframework.boot.cache.autoconfigure`, but `CacheType` itself stayed behind in
`org.springframework.boot.autoconfigure.cache`, in `spring-boot-autoconfigure`.
That has a consequence worth internalising. This module added Caffeine because one chapter needed
TTL. Every cache in the application moved off the simple provider as a result, and nothing said
so — `docs/output/18-provider-detection.txt` catches it:
```
spring.cache.type : (not set)
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
```
A transitive dependency on Hazelcast, added for something unrelated, will do the same thing and
outrank Redis while it is at it. **Set `spring.cache.type` explicitly in anything you deploy.**
## Per-provider configuration
| Property | Effect |
|---|---|
| `spring.cache.type` | `generic`, `jcache`, `hazelcast`, `infinispan`, `couchbase`, `redis`, `caffeine`, `cache2k`, `simple`, `none` |
| `spring.cache.cache-names` | Creates exactly these caches at startup; anything else fails at the call |
| `spring.cache.caffeine.spec` | e.g. `maximumSize=500,expireAfterAccess=600s` |
| `spring.cache.redis.time-to-live` | a `Duration` |
| `spring.cache.redis.cache-null-values` | default `true` |
| `spring.cache.redis.key-prefix`, `use-key-prefix` | keyspace hygiene in a shared Redis |
| `spring.cache.jcache.provider`, `spring.cache.jcache.config` | JSR-107 |
`spring.cache.type=none` gives a `NoOpCacheManager`: every method runs every time, and the
annotations stay where they are. Useful in tests, and the fastest way to answer "is the cache
causing this?".
`spring.cache.cache-names` is worth using in production: it turns a typo in a cache name from a
cache that silently never hits into an `IllegalArgumentException` at the first call.
One caveat — a single `spring.cache.caffeine.spec` applies to **every** cache. Per-cache expiry
needs your own `CaffeineCacheManager` (or several `CacheManager` beans and `cacheManager = "..."`
on the operations), which is another argument for one cache name per method.
## Where the auto-configuration lives in Boot 4
`org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration`, shipped in the
`spring-boot-cache` module. Boot 4 split `spring-boot-autoconfigure` into per-technology modules;
the Boot 3 coordinate `org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration` no
longer resolves. `docs/output/16-autoconfiguration.txt` checks both names against the running
classpath. It matters if you write `@ImportAutoConfiguration` or exclusions by class name.
+68
View File
@@ -0,0 +1,68 @@
[← providers and TTL](08-providers-and-ttl.md) · [next: transactions →](10-transactions.md)
# 9. This is not the Hibernate second-level cache
They are both called caching, they both make things faster, and they solve different problems at
different layers. Mixing them up produces designs that cache the wrong thing.
## Three layers, three scopes
| | Persistence context (L1) | Hibernate second-level cache (L2) | Spring cache abstraction |
|---|---|---|---|
| Scope | one `EntityManager` / transaction | one `SessionFactory`, all sessions | one `CacheManager`, whatever you annotate |
| Unit | managed entity instance | dehydrated entity state, by id | whatever object a method returned |
| Lookup by | identity map | entity id (plus query and collection regions) | a key the `KeyGenerator` built |
| Turned on by | always on | `hibernate.cache.use_second_level_cache` + `@Cache` | `@EnableCaching` + `@Cacheable` |
| Invalidated by | end of transaction | Hibernate, on write, automatically | you, with `@CacheEvict` |
| Knows about your database | yes | yes | **no** |
Ankur's Hibernate articles cover the first two in detail:
[the first-level cache](https://ankurm.com/master-hibernate-7-first-level-cache-the-secret-to-high-performance-java-apps/),
[the second-level cache and the three ways it stales your data](https://ankurm.com/mastering-hibernate-7-second-level-cache-boosting-performance-for-modern-java-apps/),
and [configuring Ehcache 3 under it](https://ankurm.com/master-hibernate-7-ehcache-3-configuration-high-performance-caching-with-jakarta-persistence/).
## The difference that bites
Hibernate's L2 stores *dehydrated state* — a flat array of column values keyed by entity id. On a
hit it rehydrates that state into a managed entity attached to the current session. Lazy
associations still work, because the resulting entity is live.
The Spring cache abstraction stores *the object your method returned*, as-is. If that object is a
JPA entity, what you cached is a detached entity with whatever its proxies were holding at the
moment the transaction closed. `docs/output/14-cached-entity.txt`:
```
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
second call returns the same instance? true
--- touching the lazy collection outside the session ---
org.hibernate.LazyInitializationException
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
```
Worse, it is the *same instance* every time. Every caller shares one mutable entity. One of them
calls a setter, and every other caller sees it — and if someone merges it back, they merge a
version number that may be stale.
## The rule
**Cache DTOs, not entities.** Map to a record inside the transaction and cache that. It is
immutable, it serialises cleanly to a distributed cache, it has no session affinity, and it makes
the cached shape an explicit decision rather than an accident of your mapping.
If you genuinely want entity caching — read-mostly reference data, keyed by id, invalidated
automatically on write — that is precisely what Hibernate's L2 is for, and it will do it better
than `@Cacheable` because it understands the writes.
## Which one to reach for
| You want to avoid | Use |
|---|---|
| re-loading the same entity by id across requests | Hibernate L2 (`@Cache` on the entity) |
| re-running the same query that returns entities | Hibernate query cache, carefully — it needs L2 for the entities too |
| re-running an expensive computation, HTTP call or aggregation | the Spring cache abstraction |
| re-building a response DTO from several sources | the Spring cache abstraction |
The two compose. A service method cached with `@Cacheable` that internally loads entities served
from L2 is a perfectly reasonable stack — just be clear about which layer is answering, because
only one of them will notice when the row changes.
+96
View File
@@ -0,0 +1,96 @@
[← versus Hibernate L2](09-versus-hibernate-l2.md) · [next: diagnostics →](11-diagnostics.md)
# 10. Caching and transactions
## The ordering
The caching interceptor runs **inside** the transaction interceptor. A cache write therefore
happens at method exit — before the commit, and without any knowledge of whether the commit will
succeed.
`docs/output/13-transactions.txt` puts an ordinary write-through update inside an outer
transaction that then fails:
```
nameOf(1) -> Alice
An outer @Transactional method calls the @CachePut update, which succeeds,
and then fails on the next step. The transaction rolls back.
what the cache serves : Alice Cooper
what the database has : Alice
```
The cache is now holding a name no transaction ever committed. Nothing will correct it until the
entry expires or something evicts it — and on the default provider nothing expires.
The same shape with `@CacheEvict` is self-healing:
```
after the rollback, nameOf(2) -> Bob
database loads: 2 <- the entry was evicted, so this one reloaded
```
That asymmetry is the practical takeaway. **An eviction that fires too early costs a lookup; a
put that fires too early costs correctness.** When in doubt, evict.
## `TransactionAwareCacheManagerProxy`
Wrapping the manager defers every put and evict to a post-commit synchronisation:
```java
@Bean
CacheManager cacheManager() {
return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
}
```
`docs/output/19-transaction-aware.txt` runs the identical rollback and the cache still says
`Alice`. Several `CacheManager` implementations expose the same thing as
`setTransactionAware(true)` (they extend `AbstractTransactionSupportingCacheManager`);
`TransactionAwareCacheManagerProxy` is the generic wrapper, and it lives in
**`spring-context-support`**, not `spring-context`.
### What it does not cover
- **Reads are never deferred.** A `@Cacheable` lookup inside the transaction sees whatever the
shared cache holds.
- **Outside a transaction it is a pass-through**, so the same method reached from an unmanaged
path writes immediately.
- **It does not make the cache transactional.** Two concurrent transactions still race at commit
time; last writer wins, and it may be the one whose value is older.
### A correction worth recording
`spring-framework#23192`, "@CacheEvict beforeInvocation with transaction does not work", reported
that `beforeInvocation = true` was swallowed by the transaction-aware decorator — the evict got
deferred to commit, which is the opposite of what the attribute asks for. It is still quoted as
current behaviour. The issue is closed against milestone 5.2 RC1, and on 7.0.9 it **does not
reproduce**. The measurement in
`docs/output/19-transaction-aware.txt` came back the other way round, and
`docs/output/22-decorator-bytecode.txt` shows why:
```
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
```
`AbstractCacheInvoker.doEvict(Cache, Object, boolean)` picks `evictIfPresent` for the immediate
path, and `TransactionAwareCacheDecorator` only registers a synchronisation in `evict`.
`evictIfPresent` delegates straight to the target cache.
This was written the wrong way round first and the run corrected it, which is a reasonable
advertisement for running the thing.
## The pattern that actually holds up
For anything that must not serve uncommitted state:
1. Evict, do not put — `@CacheEvict(beforeInvocation = true)`.
2. Make the cache transaction-aware if you also need the eviction not to happen on rollback.
3. Give every mutable cache a TTL, so the worst case is bounded even when all of the above is
wrong.
Related reading: [`@Transactional`: propagation, isolation and the silent
failures](https://ankurm.com/transactional-propagation-isolation-silent-failures/) covers the
interceptor ordering from the transaction side.
+67
View File
@@ -0,0 +1,67 @@
[← transactions](10-transactions.md) · [next: production checklist →](12-production-checklist.md)
# 11. Seeing what is actually happening
Most caching bugs stop being mysterious the moment you can see the keys. Four things to reach
for, in order of how quickly they answer the question.
## 1. Print the cache
The diagnostic endpoint in this module walks the `CacheManager` and dumps every entry with the
runtime class of the key and the value. `docs/output/23-diagnostics.txt`:
```json
"shapes": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"SimpleKey [] [SimpleKey]": "zero [String]",
"SimpleKey [abc, 7] [SimpleKey]": "two:abc:7 [String]",
"abc [String]": "one:abc [String]"
}
}
```
A `SimpleKey []` where you expected an id, two methods writing into one key space, or a
`NullValue` sitting where a record should be — all visible at a glance.
**Delete it before shipping.** It exposes cached data over HTTP with no authorisation. If you
want something permanent, put it behind Actuator's security and return key counts rather than
values.
## 2. Is the bean even proxied?
```java
AopUtils.isAopProxy(bean) // false -> nothing downstream matters
bean.getClass().getName() // ...$$SpringCGLIB$$0
AopUtils.getTargetClass(bean)
```
If it is proxied and the cache is still empty, the call is not going through the proxy. See
[chapter 3](03-self-invocation.md).
## 3. Actuator
`management.endpoints.web.exposure.include=caches` gives `/actuator/caches`, which lists the
cache names and their `CacheManager` — enough to confirm which provider is live and whether a
cache name is a typo. `DELETE /actuator/caches/{name}` clears one, which is a genuinely useful
operational lever.
`/actuator/metrics/cache.gets` and friends are populated automatically for providers Micrometer
can instrument. **Caffeine only reports statistics if the cache was built with `recordStats()`**
— without it the metrics exist and read zero, which looks exactly like a cache nobody is using.
## 4. Turn the cache off
```properties
spring.cache.type=none
```
A `NoOpCacheManager`: every method runs every time, annotations untouched. If the bug survives,
it was never the cache. This is the fastest bisect available and it takes one property.
## Logging
`logging.level.org.springframework.cache=TRACE` logs each operation the interceptor resolves.
It is noisy enough that it is a debugging tool rather than something to leave on, but it answers
"did the interceptor see this call at all" definitively.
+56
View File
@@ -0,0 +1,56 @@
[← diagnostics](11-diagnostics.md) · [README](../README.md)
# 12. Before this goes to production
## Should there be a cache here at all?
Be honest about the answer. A cache is a correctness liability you accept in exchange for
latency. If the method is not measurably slow, or the hit rate will be low, or the data must be
current, the right amount of caching is none. Half the caches in a typical codebase were added
without a measurement and are never revisited.
## The checklist
**Configuration**
- [ ] `spring.cache.type` is set explicitly, so a new dependency cannot change the provider
([chapter 8](08-providers-and-ttl.md))
- [ ] `spring.cache.cache-names` declares every cache, so a typo fails loudly
- [ ] Every mutable cache has a TTL. It bounds the damage from every other mistake on this list
- [ ] Every cache has a size bound, or the data set is provably small
- [ ] `@EnableCaching` is not on the main application class
**Correctness**
- [ ] One cache name per method, or an explicit `key` that includes the method
([chapter 4](04-keys.md))
- [ ] Keys are immutable and serialise to something stable
- [ ] Cached values are DTOs, not JPA entities ([chapter 9](09-versus-hibernate-l2.md))
- [ ] Cached values are immutable, or defensively copied — the map hands every caller the same
instance
- [ ] Mutating methods evict rather than put, with `beforeInvocation = true`
([chapter 5](05-eviction.md))
- [ ] Nothing relies on `this.cachedMethod(...)` ([chapter 3](03-self-invocation.md))
- [ ] Every cached method is called at least once by a test — four of the five invalid
declarations in [chapter 2](02-the-three-annotations.md) only fail at the first call
**Operations**
- [ ] Hit rate and eviction count are on a dashboard (`recordStats()` for Caffeine)
- [ ] There is a way to clear a cache without a deployment (`DELETE /actuator/caches/{name}`)
- [ ] A `CacheErrorHandler` decision has been made per cache, not inherited by accident
([chapter 5](05-eviction.md))
- [ ] The behaviour with `spring.cache.type=none` has been tried at least once
**Distributed caches only**
- [ ] Values are serializable and the format survives a rolling deployment — a changed DTO shape
with old entries still in Redis fails on read, per instance, at whatever hour
- [ ] `spring.cache.redis.key-prefix` keeps this application out of everyone else's keyspace
- [ ] The failure mode when the cache is unreachable has been decided: degrade or fail
- [ ] TTLs are short enough that a missed eviction self-corrects
## The one-line version
Give every cache a TTL, evict rather than put, cache DTOs, and set `spring.cache.type`. Those
four cover most of what goes wrong.
+15
View File
@@ -0,0 +1,15 @@
# A cache hit is a method that did not run
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
repository latency : 200 ms per lookup
--- first call (miss) ---
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed : 200 ms
repository calls : 1
--- second call (hit) ---
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed : 0 ms
repository calls : 1 <- still 1, the method body never ran
same object? : true
@@ -0,0 +1,11 @@
# @Cacheable, @CachePut and @CacheEvict on the same cache
after findBook : repository calls = 1
@CachePut wrote : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
next findBook returns : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
repository calls : 1 <- @CachePut refreshed the entry, no reload
after @CacheEvict : findBook -> Book[isbn=978-0134685991, title=Effective Java, year=2018]
repository calls : 2 <- the entry was gone, so the method ran again
after allEntries=true : repository calls = 5 <- both entries were dropped
@@ -0,0 +1,13 @@
# Four ways to call a @Cacheable method, one of which caches nothing
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
is an AOP proxy? : true
is a CGLIB proxy? : true
target class : com.ankurm.caching.selfinvocation.CatalogService
Four ISBNs, two of them repeats. A working cache does 2 lookups, not 4.
this.lookup(..) -> 4 repository calls <- no caching at all
self.getObject().lookup(..) -> 2 repository calls
AopContext.currentProxy() -> 2 repository calls
a second bean calls lookup(..) -> 2 repository calls
@@ -0,0 +1,13 @@
# Two more places the annotation is ignored without a warning
@Cacheable on a protected method, called twice -> 2 repository calls
No warning is logged. In proxy mode the annotation is only honoured on
public methods; a protected one is simply never advised.
--- @EnableCaching attributes, as the class file declares them ---
AdviceMode mode()
int order()
boolean proxyTargetClass()
There is no exposeProxy attribute, so @EnableCaching(exposeProxy = true)
- which a lot of answers recommend - does not compile.
+14
View File
@@ -0,0 +1,14 @@
# What SimpleKeyGenerator actually puts in the map
cache "shapes" after three calls with 0, 1 and 2 arguments:
cache "shapes":
key abc [String] -> one:abc
key SimpleKey [abc, 7] [SimpleKey] -> two:abc:7
key SimpleKey [] [SimpleKey] -> zero
Zero arguments -> the SimpleKey.EMPTY constant, printed as []
One argument -> that argument itself, unwrapped
Two or more -> a SimpleKey holding all of them
The method name and the declaring class appear nowhere in the key.
+25
View File
@@ -0,0 +1,25 @@
# The collision the default key generator makes easy
countLetters(String) and countDigits(String) both write into cache "shared".
countLetters("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
countDigits("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
countDigits never ran. It found the key "a1b2" already populated and
returned the answer to a different question.
cache "shared":
key a1b2 [String] -> letters=2
--- no-argument methods collide even harder ---
currentBanner() -> banner-from-currentBanner
currentFooter() -> banner-from-currentBanner <- both key on SimpleKey.EMPTY
cache "noargs":
key SimpleKey [] [SimpleKey] -> banner-from-currentBanner
--- the fix: put the method into the key ---
countLettersScoped("a1b2") -> letters=2
countDigitsScoped("a1b2") -> digits=2
cache "scoped":
key digits:a1b2 [String] -> digits=2
key letters:a1b2 [String] -> letters=2
+18
View File
@@ -0,0 +1,18 @@
# A mutable argument is an entry you cannot find again
first call : byList([java]) -> tags=[java]
cache "mutable":
key [java] [ArrayList] -> tags=[java]
the caller mutates the same list it passed in: [java, spring]
second call : byList([java, spring]) -> tags=[java, spring]
cache "mutable":
key [java, spring] [ArrayList] -> tags=[java]
key [java, spring] [ArrayList] -> tags=[java, spring]
Two entries, and their keys now print identically - because they are the
same object. The caller mutated the list it had already handed over as a
key, so the first entry sits in the map under a hashCode the map no longer
agrees with. Nothing will find it again and nothing will evict it: a leak
with a completely ordinary-looking cause.
+17
View File
@@ -0,0 +1,17 @@
# @CacheEvict runs after the method - unless you ask otherwise
price("sku-1") -> 100 (stored price is 100)
updatePrice("sku-1", 250, fail=true) threw after writing the new price.
stored price now : 250
price("sku-1") : 100 <- the cache still serves the old value
reads of the real store: 1
--- beforeInvocation = true ---
price("sku-2") -> 100
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2") : 250 <- the entry went first, so the next read is honest
--- @CachePut instead: write through, no miss ---
after @CachePut, price("sku-3") -> 400
reads of the real store: 1 -> 1 <- no reload was needed
+7
View File
@@ -0,0 +1,7 @@
# condition is checked before the call, unless after it
search("spring") twice, 6 characters -> 1 invocations
search(25 chars) twice, condition false -> 2 invocations
condition = "#term.length() <= 8" is evaluated on the arguments before the
method runs, so a false condition skips the lookup and the write.
+16
View File
@@ -0,0 +1,16 @@
# A cached null is a real entry called NullValue
searchCachingNulls("xyz") returns null, called twice -> 1 invocations
cache "nulls":
key xyz -> null [org.springframework.cache.support.NullValue]
The abstraction stores org.springframework.cache.support.NullValue.INSTANCE
so a hit on null is distinguishable from a miss. This is usually what you
want - it is the cheap defence against a hot lookup for a row that is not
there - and occasionally exactly what you do not want.
--- unless = "#result == null" ---
searchNullable("xyz") twice -> 2 invocations <- the null was never stored
cache "terms":
(empty)
+11
View File
@@ -0,0 +1,11 @@
# sync = true is the difference between one slow call and sixteen
16 threads call the same key at the same instant, cold cache.
The method sleeps 300 ms.
@Cacheable("reports") -> 16 invocations
@Cacheable("syncedReports", sync = true) -> 1 invocation
Without sync, every thread that arrives during the 300 ms window misses and
runs the method. That is a cache stampede, and it is worst exactly when the
cache matters most - right after a restart or an eviction.
@@ -0,0 +1,12 @@
# @Cacheable on a CompletableFuture-returning method
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
setAsyncCacheMode(true) was called on it.
first -> async-report:q3
second -> async-report:q3
supplier invocations: 1
Since Spring Framework 6.1 the interceptor unwraps CompletableFuture and
the reactive types. ConcurrentMapCacheManager adapts to future-based
retrieval on its own; CaffeineCacheManager has to be told.
+22
View File
@@ -0,0 +1,22 @@
# A rollback does not roll the cache back
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
nameOf(1) -> Alice
An outer @Transactional method calls the @CachePut update, which succeeds,
and then fails on the next step. The transaction rolls back.
what the cache serves : Alice Cooper
what the database has : Alice
The cache is now holding a name that no transaction ever committed. Nothing
will correct it until the entry expires or something evicts it.
--- the same shape with @CacheEvict ---
nameOf(2) -> Bob
after the rollback, nameOf(2) -> Bob
database loads: 2 <- the entry was evicted, so this one reloaded
An eviction that fires too early is self-healing: the next read goes to the
database and re-populates correctly. A @CachePut that fires too early is not.
+16
View File
@@ -0,0 +1,16 @@
# Caching an entity caches a detached object, lazy proxies and all
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
database loads: 1
second call returns the same instance? true
database loads: 1
--- touching the lazy collection outside the session ---
org.hibernate.LazyInitializationException
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
This is the line between the two caches. Hibernate's second-level cache
stores dehydrated entity state and rebuilds a managed entity inside a
session, so lazy associations still work. The Spring cache abstraction
stores the object your method returned, exactly as it was when the
transaction ended - detached, with whatever its proxies were holding.
@@ -0,0 +1,24 @@
# TTL and size bounds are the provider's job, not the abstraction's
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
configured : expireAfterWrite=400ms, maximumSize=3, recordStats
two calls, same key, immediately -> 1 repository calls
one more call 600 ms later -> 2 repository calls <- the entry expired
--- size bound ---
five distinct keys written, maximumSize = 3
estimated size after eviction settles : 3
stats : hits=1 misses=5 evictions=3
--- recordStats is not on by default ---
a Caffeine cache built without recordStats(), after 1 hit and 1 miss:
CacheStats{hitCount=0, missCount=0, loadSuccessCount=0, loadFailureCount=0, totalLoadTime=0, evictionCount=0, evictionWeight=0}
Every counter is zero. Micrometer's cache.gets and cache.evictions will
exist and report zero too, which looks exactly like a cache nobody uses.
The Spring cache abstraction has no TTL, no size limit and no eviction
policy of its own - it is an interface over whatever you plug in. On the
default simple provider, a ConcurrentHashMap, an entry stays until something
evicts it by hand or the process ends.
@@ -0,0 +1,20 @@
# What @EnableCaching and Boot's auto-configuration put in the context
CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
caches known at startup : [asyncReports]
bean cacheInterceptor present=true
bean cacheOperationSource present=true
bean cacheAdvisor present=false
bean org.springframework.cache.config.internalCacheAdvisor present=true
CacheInterceptor beans : [cacheInterceptor]
KeyGenerator beans : []
--- where the auto-configuration class lives ---
FOUND org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration
absent org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
Boot 4 split spring-boot-autoconfigure into per-technology modules. Caching
auto-configuration now ships in spring-boot-cache, which the
spring-boot-starter-cache starter pulls in.
@@ -0,0 +1,10 @@
# The same method on the auto-configured Caffeine manager
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
The application started cleanly. Nothing warned about anything.
buildAsync("q3") ->
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)
Thrown on the first invocation, in production, at whatever hour that
endpoint first gets traffic.
@@ -0,0 +1,33 @@
# Nothing in application.yml selects a provider. Something still chose one.
spring.cache.type : (not set)
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
Caffeine is on this module's classpath because a later chapter needs TTL and
size bounds. That single dependency moved every cache in the application off
the ConcurrentHashMap-backed 'simple' provider.
--- the detection order, read out of the enum rather than the documentation ---
1 GENERIC
2 JCACHE
3 HAZELCAST
4 COUCHBASE
5 INFINISPAN
6 REDIS
7 CACHE2K
8 CAFFEINE
9 SIMPLE
10 NONE
CacheConfigurations maps CacheType -> configuration class in an EnumMap, so
the configurations are imported in this declaration order and the first one
whose @ConditionalOnClass matches registers the CacheManager. The rest back
off on @ConditionalOnMissingBean.
Spring Boot's reference documentation lists this order as Generic, JCache,
Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Cache2k, Simple. On
4.1.1 the enum disagrees in two places: COUCHBASE comes before INFINISPAN,
and CACHE2K comes before CAFFEINE. The second one is the one that can bite:
with both on the classpath you get Cache2k, not Caffeine.
Nothing logs the decision at INFO. Set spring.cache.type explicitly.
@@ -0,0 +1,25 @@
# TransactionAwareCacheManagerProxy, and what it does not cover
cacheManager : org.springframework.cache.transaction.TransactionAwareCacheManagerProxy
nameOf(1) -> Alice
after the identical rollback, nameOf(1) -> Alice
The put was registered as a transaction synchronisation and dropped when the
transaction rolled back instead of committing.
--- what it does not cover: beforeInvocation = true ---
inside the same transaction, after an evict declared beforeInvocation=true,
a re-read returns : Bobby
Not the stale value. The eviction was NOT deferred, and the re-read went to
the database and saw the uncommitted row. The reason is in the bytecode:
AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent()
when immediate is true and evict() when it is false, and the decorator only
registers a post-commit synchronisation in evict() - evictIfPresent()
delegates straight to the target cache. See docs/output/22-decorator-bytecode.txt.
Two gaps do remain, and they are structural rather than measurable here:
reads are never deferred, so a @Cacheable lookup inside the transaction sees
whatever the shared cache holds; and outside a transaction the proxy is a
pass-through that writes immediately.
@@ -0,0 +1,29 @@
# Declarations that are rejected, and how late you find out
1. key and keyGenerator together
startup : FAILED - java.lang.IllegalStateException
Invalid cache annotation configuration on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$BothKeyAndGenerator$Svc.call(java.lang.String)'. Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: either set the SpEL expression used tocompute the key at runtime or set the name of the KeyGenerator bean to use.
--- 2. sync = true with unless ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation does not support the unless attribute on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncWithUnless$Svc.call(java.lang.String)] caches=[c] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='#result != null' | sync='true''
--- 3. sync = true across two caches ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation is restricted to a single cache on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncTwoCaches$Svc.call(java.lang.String)] caches=[c1, c2] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='true''
--- 4. @Cacheable and @CacheEvict on one method ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation cannot be combined with other cache operations on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$CacheableAndEvict$Svc.call(java.lang.String)'
--- 5. a cache name that spring.cache.cache-names does not declare ---
startup : clean
first call: java.lang.IllegalArgumentException
Cannot find cache named 'unknown' for Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$UndeclaredCache$Svc.call(java.lang.String)] caches=[unknown] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='false'
Only the first of these is a compile-time-shaped mistake. The rest start a
perfectly healthy application and throw on a code path that may not be hit
for hours.
@@ -0,0 +1,49 @@
# Why beforeInvocation=true is not deferred by TransactionAwareCacheManagerProxy
$ javap -c -p org.springframework.cache.interceptor.AbstractCacheInvoker # spring-context-7.0.9.jar
protected void doEvict(org.springframework.cache.Cache, java.lang.Object, boolean);
Code:
0: iload_3
1: ifeq 15
4: aload_1
5: aload_2
6: invokeinterface #83, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
11: pop
12: goto 22
15: aload_1
16: aload_2
17: invokeinterface #87, 2 // InterfaceMethod org/springframework/cache/Cache.evict:(Ljava/lang/Object;)V
22: goto 40
25: astore 4
$ javap -c -p org.springframework.cache.transaction.TransactionAwareCacheDecorator # spring-context-support-7.0.9.jar
public void evict(java.lang.Object);
Code:
0: invokestatic #48 // Method org/springframework/transaction/support/TransactionSynchronizationManager.isSynchronizationActive:()Z
3: ifeq 21
6: new #71 // class org/springframework/cache/transaction/TransactionAwareCacheDecorator$2
9: dup
10: aload_0
11: aload_1
12: invokespecial #73 // Method org/springframework/cache/transaction/TransactionAwareCacheDecorator$2."<init>":(Lorg/springframework/cache/transaction/TransactionAwareCacheDecorator;Ljava/lang/Object;)V
15: invokestatic #59 // Method org/springframework/transaction/support/TransactionSynchronizationManager.registerSynchronization:(Lorg/springframework/transaction/support/TransactionSynchronization;)V
18: goto 31
21: aload_0
22: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
25: aload_1
public boolean evictIfPresent(java.lang.Object);
Code:
0: aload_0
1: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
4: aload_1
5: invokeinterface #80, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
10: ireturn
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
spring-framework#23192 reported beforeInvocation=true being swallowed by the
transaction-aware decorator. On 7.0.9 it is not: the immediate path uses a method
the decorator does not intercept. Note also that the decorator ships in
spring-context-support, not spring-context.
+34
View File
@@ -0,0 +1,34 @@
# The live contents of every cache, keys included
$ curl -s localhost:8080/diag/warm
warmed: books, shapes, nulls
$ curl -s localhost:8080/diag/caches | jq .
{
"cacheManager": "org.springframework.cache.concurrent.ConcurrentMapCacheManager",
"caches": {
"nulls": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"xyz [String]": "null [NullValue]"
}
},
"books": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"978-0134685991 [String]": "Book[isbn=978-0134685991, title=Effective Java, year=2018] [Book]"
}
},
"shapes": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"SimpleKey [] [SimpleKey]": "zero [String]",
"SimpleKey [abc, 7] [SimpleKey]": "two:abc:7 [String]",
"abc [String]": "one:abc [String]"
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>caching</artifactId>
<version>1.0.0</version>
<name>caching</name>
<description>The Spring cache abstraction: Cacheable, CacheEvict, key generators and the self-invocation trap</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Caffeine is the provider used for TTL, size bounds, stats and async cache mode.
Its version is managed by spring-boot-dependencies. -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- JPA is here for exactly one reason: to show where the Spring cache abstraction sits
relative to the Hibernate second-level cache, and what a cached detached entity does. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Disassembles the two methods that decide whether a beforeInvocation=true eviction is deferred.
set -euo pipefail
cd "$(dirname "$0")/.."
M2="${HOME}/.m2/repository/org/springframework"
V=$(mvn -B -q help:evaluate -Dexpression=spring-framework.version -DforceStdout 2>/dev/null | tail -1)
CTX="${M2}/spring-context/${V}/spring-context-${V}.jar"
CS="${M2}/spring-context-support/${V}/spring-context-support-${V}.jar"
{
echo "# Why beforeInvocation=true is not deferred by TransactionAwareCacheManagerProxy"
echo
echo "\$ javap -c -p org.springframework.cache.interceptor.AbstractCacheInvoker # spring-context-${V}.jar"
javap -c -p -cp "$CTX" org.springframework.cache.interceptor.AbstractCacheInvoker \
| grep -v '^Picked up' | awk '/protected void doEvict/,/^$/' | head -14
echo
echo "\$ javap -c -p org.springframework.cache.transaction.TransactionAwareCacheDecorator # spring-context-support-${V}.jar"
javap -c -p -cp "$CS" org.springframework.cache.transaction.TransactionAwareCacheDecorator \
| grep -v '^Picked up' | awk '/public void evict\(java.lang.Object\)/,/^$/' | head -14
javap -c -p -cp "$CS" org.springframework.cache.transaction.TransactionAwareCacheDecorator \
| grep -v '^Picked up' | awk '/public boolean evictIfPresent/,/^$/' | head -8
echo
echo "doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache"
echo "doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit"
echo
echo "spring-framework#23192 reported beforeInvocation=true being swallowed by the"
echo "transaction-aware decorator. On ${V} it is not: the immediate path uses a method"
echo "the decorator does not intercept. Note also that the decorator ships in"
echo "spring-context-support, not spring-context."
} > docs/output/22-decorator-bytecode.txt
echo "wrote docs/output/22-decorator-bytecode.txt"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Starts the application on the simple provider, exercises a few cached methods, and dumps the
# live contents of every cache through /diag/caches.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT=8080
PIDFILE=/tmp/caching-demo.pid
mvn -B -q -DskipTests package
nohup java -jar target/caching-1.0.0.jar \
--spring.cache.type=simple --server.port=${PORT} > /tmp/caching-demo.log 2>&1 &
echo $! > "$PIDFILE"
for _ in $(seq 1 60); do
curl -sf "http://localhost:${PORT}/actuator/health" >/dev/null 2>&1 && break
sleep 1
done
{
echo "# The live contents of every cache, keys included"
echo
echo "\$ curl -s localhost:${PORT}/diag/warm"
curl -s "http://localhost:${PORT}/diag/warm"
echo
echo
echo "\$ curl -s localhost:${PORT}/diag/caches | jq ."
curl -s "http://localhost:${PORT}/diag/caches" | python3 -m json.tool
} > docs/output/23-diagnostics.txt
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
echo "wrote docs/output/23-diagnostics.txt"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25 and Maven 3.9. Everything except the two javap transcripts and the live
# diagnostics dump comes out of the test suite, which is the point: the numbers in the article
# are assertions that fail the build if they stop being true.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-20)"
mvn -B test
echo "== javap transcripts (22)"
./scripts/capture-bytecode.sh
echo "== live diagnostics endpoint (23)"
./scripts/capture-diagnostics.sh
echo
echo "docs/output:"
ls -1 docs/output
@@ -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);
}
}