Files
asmhatre a9867c0423 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.
2026-09-12 05:35:13 +00:00

92 lines
3.8 KiB
Markdown

[← 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.`.