From a9867c042359e84603dc49dd37d83e9719409276 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sat, 12 Sep 2026 05:19:22 +0000 Subject: [PATCH] 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. --- README.md | 1 + caching/README.md | 103 ++++++++++++ caching/docs/01-what-caching-is.md | 45 ++++++ caching/docs/02-the-three-annotations.md | 88 ++++++++++ caching/docs/03-self-invocation.md | 91 +++++++++++ caching/docs/04-keys.md | 98 +++++++++++ caching/docs/05-eviction.md | 75 +++++++++ caching/docs/06-conditions-and-nulls.md | 55 +++++++ caching/docs/07-sync-and-async.md | 67 ++++++++ caching/docs/08-providers-and-ttl.md | 92 +++++++++++ caching/docs/09-versus-hibernate-l2.md | 68 ++++++++ caching/docs/10-transactions.md | 96 +++++++++++ caching/docs/11-diagnostics.md | 67 ++++++++ caching/docs/12-production-checklist.md | 56 +++++++ caching/docs/output/01-basics.txt | 15 ++ caching/docs/output/02-put-evict-clear.txt | 11 ++ caching/docs/output/03-self-invocation.txt | 13 ++ .../04-non-public-and-postconstruct.txt | 13 ++ caching/docs/output/05-key-shapes.txt | 14 ++ caching/docs/output/06-key-collision.txt | 25 +++ caching/docs/output/07-mutable-key.txt | 18 +++ caching/docs/output/08-evict-timing.txt | 17 ++ caching/docs/output/09-conditions.txt | 7 + caching/docs/output/10-nulls.txt | 16 ++ caching/docs/output/11-stampede.txt | 11 ++ caching/docs/output/12-async-return-types.txt | 12 ++ caching/docs/output/13-transactions.txt | 22 +++ caching/docs/output/14-cached-entity.txt | 16 ++ caching/docs/output/15-providers-and-ttl.txt | 24 +++ caching/docs/output/16-autoconfiguration.txt | 20 +++ .../output/17-async-cache-mode-missing.txt | 10 ++ caching/docs/output/18-provider-detection.txt | 33 ++++ caching/docs/output/19-transaction-aware.txt | 25 +++ .../docs/output/20-invalid-declarations.txt | 29 ++++ caching/docs/output/22-decorator-bytecode.txt | 49 ++++++ caching/docs/output/23-diagnostics.txt | 34 ++++ caching/pom.xml | 72 +++++++++ caching/scripts/capture-bytecode.sh | 31 ++++ caching/scripts/capture-diagnostics.sh | 32 ++++ caching/scripts/run-all.sh | 23 +++ .../java/com/ankurm/caching/CacheConfig.java | 86 ++++++++++ .../caching/CachingDemoApplication.java | 21 +++ .../java/com/ankurm/caching/basics/Book.java | 10 ++ .../caching/basics/BookRepositoryStub.java | 49 ++++++ .../ankurm/caching/basics/BookService.java | 43 +++++ .../caching/conditions/LookupService.java | 46 ++++++ .../diag/CacheDiagnosticsController.java | 84 ++++++++++ .../ankurm/caching/eviction/PriceService.java | 57 +++++++ .../java/com/ankurm/caching/jpa/Customer.java | 41 +++++ .../caching/jpa/CustomerRepository.java | 6 + .../ankurm/caching/jpa/CustomerService.java | 79 +++++++++ .../ankurm/caching/jpa/CustomerWorkflow.java | 44 +++++ .../com/ankurm/caching/jpa/DataSeeder.java | 30 ++++ .../java/com/ankurm/caching/jpa/Order.java | 31 ++++ .../ankurm/caching/keys/CollidingService.java | 68 ++++++++ .../ankurm/caching/keys/KeyShapeService.java | 37 +++++ .../caching/selfinvocation/CatalogReader.java | 26 +++ .../selfinvocation/CatalogService.java | 79 +++++++++ .../caching/sync/AsyncReportService.java | 31 ++++ .../ankurm/caching/sync/ReportService.java | 44 +++++ caching/src/main/resources/application.yml | 26 +++ .../ankurm/caching/AsyncCacheModeOffTest.java | 38 +++++ .../ankurm/caching/AsyncCacheModeOnTest.java | 45 ++++++ .../ankurm/caching/AutoConfigurationTest.java | 65 ++++++++ .../java/com/ankurm/caching/BasicsTest.java | 95 +++++++++++ .../caching/ConditionsAndNullsTest.java | 89 ++++++++++ .../ankurm/caching/EvictionTimingTest.java | 69 ++++++++ .../caching/InvalidDeclarationsTest.java | 153 ++++++++++++++++++ .../com/ankurm/caching/KeyGenerationTest.java | 134 +++++++++++++++ .../ankurm/caching/ProviderDetectionTest.java | 69 ++++++++ .../ankurm/caching/ProvidersAndTtlTest.java | 90 +++++++++++ .../ankurm/caching/SelfInvocationTest.java | 110 +++++++++++++ .../java/com/ankurm/caching/StampedeTest.java | 78 +++++++++ .../ankurm/caching/TransactionAwareTest.java | 70 ++++++++ .../com/ankurm/caching/TransactionsTest.java | 107 ++++++++++++ .../java/com/ankurm/caching/Transcript.java | 52 ++++++ 76 files changed, 3796 insertions(+) create mode 100644 caching/README.md create mode 100644 caching/docs/01-what-caching-is.md create mode 100644 caching/docs/02-the-three-annotations.md create mode 100644 caching/docs/03-self-invocation.md create mode 100644 caching/docs/04-keys.md create mode 100644 caching/docs/05-eviction.md create mode 100644 caching/docs/06-conditions-and-nulls.md create mode 100644 caching/docs/07-sync-and-async.md create mode 100644 caching/docs/08-providers-and-ttl.md create mode 100644 caching/docs/09-versus-hibernate-l2.md create mode 100644 caching/docs/10-transactions.md create mode 100644 caching/docs/11-diagnostics.md create mode 100644 caching/docs/12-production-checklist.md create mode 100644 caching/docs/output/01-basics.txt create mode 100644 caching/docs/output/02-put-evict-clear.txt create mode 100644 caching/docs/output/03-self-invocation.txt create mode 100644 caching/docs/output/04-non-public-and-postconstruct.txt create mode 100644 caching/docs/output/05-key-shapes.txt create mode 100644 caching/docs/output/06-key-collision.txt create mode 100644 caching/docs/output/07-mutable-key.txt create mode 100644 caching/docs/output/08-evict-timing.txt create mode 100644 caching/docs/output/09-conditions.txt create mode 100644 caching/docs/output/10-nulls.txt create mode 100644 caching/docs/output/11-stampede.txt create mode 100644 caching/docs/output/12-async-return-types.txt create mode 100644 caching/docs/output/13-transactions.txt create mode 100644 caching/docs/output/14-cached-entity.txt create mode 100644 caching/docs/output/15-providers-and-ttl.txt create mode 100644 caching/docs/output/16-autoconfiguration.txt create mode 100644 caching/docs/output/17-async-cache-mode-missing.txt create mode 100644 caching/docs/output/18-provider-detection.txt create mode 100644 caching/docs/output/19-transaction-aware.txt create mode 100644 caching/docs/output/20-invalid-declarations.txt create mode 100644 caching/docs/output/22-decorator-bytecode.txt create mode 100644 caching/docs/output/23-diagnostics.txt create mode 100644 caching/pom.xml create mode 100755 caching/scripts/capture-bytecode.sh create mode 100755 caching/scripts/capture-diagnostics.sh create mode 100755 caching/scripts/run-all.sh create mode 100644 caching/src/main/java/com/ankurm/caching/CacheConfig.java create mode 100644 caching/src/main/java/com/ankurm/caching/CachingDemoApplication.java create mode 100644 caching/src/main/java/com/ankurm/caching/basics/Book.java create mode 100644 caching/src/main/java/com/ankurm/caching/basics/BookRepositoryStub.java create mode 100644 caching/src/main/java/com/ankurm/caching/basics/BookService.java create mode 100644 caching/src/main/java/com/ankurm/caching/conditions/LookupService.java create mode 100644 caching/src/main/java/com/ankurm/caching/diag/CacheDiagnosticsController.java create mode 100644 caching/src/main/java/com/ankurm/caching/eviction/PriceService.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/Customer.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/CustomerRepository.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/CustomerService.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/CustomerWorkflow.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/DataSeeder.java create mode 100644 caching/src/main/java/com/ankurm/caching/jpa/Order.java create mode 100644 caching/src/main/java/com/ankurm/caching/keys/CollidingService.java create mode 100644 caching/src/main/java/com/ankurm/caching/keys/KeyShapeService.java create mode 100644 caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogReader.java create mode 100644 caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogService.java create mode 100644 caching/src/main/java/com/ankurm/caching/sync/AsyncReportService.java create mode 100644 caching/src/main/java/com/ankurm/caching/sync/ReportService.java create mode 100644 caching/src/main/resources/application.yml create mode 100644 caching/src/test/java/com/ankurm/caching/AsyncCacheModeOffTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/AsyncCacheModeOnTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/AutoConfigurationTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/BasicsTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/ConditionsAndNullsTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/EvictionTimingTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/InvalidDeclarationsTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/KeyGenerationTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/ProviderDetectionTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/ProvidersAndTtlTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/SelfInvocationTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/StampedeTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/TransactionAwareTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/TransactionsTest.java create mode 100644 caching/src/test/java/com/ankurm/caching/Transcript.java diff --git a/README.md b/README.md index 35b8437..8042fe7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ files. | [`kubernetes-deployment/`](kubernetes-deployment) | [Deploying Spring Boot 4 on Kubernetes](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/) | probe groups under a dependency outage, graceful shutdown under load four ways, JVM ergonomics per pod shape, CPU limits throttling GC, HPA on a Micrometer metric | | [`problem-details/`](problem-details) | [Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/) | thirteen failures under five handling setups, validation errors, i18n, content negotiation, errors outside MVC, silent 500s, decoding on the client | | [`resilience/`](resilience) | [Spring Framework 7's Built-in Resilience: @Retryable, @ConcurrencyLimit, and What's Left for Resilience4j](https://ankurm.com/spring-framework-7-retryable-concurrencylimit-resilience4j/) | `@Retryable` and `@ConcurrencyLimit` counted invocation by invocation, retries inside transactions, where Resilience4j still earns its place, migrating from Spring Retry | +| [`caching/`](caching) | [The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap](https://ankurm.com/spring-cache-abstraction-cacheable-cacheevict-self-invocation-trap/) | the self-invocation trap measured four ways, the key collision `SimpleKeyGenerator` makes easy, eviction timing under a thrown exception, a rollback the cache keeps, and where this sits next to Hibernate's L2 cache | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/caching/README.md b/caching/README.md new file mode 100644 index 0000000..8690d27 --- /dev/null +++ b/caching/README.md @@ -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). diff --git a/caching/docs/01-what-caching-is.md b/caching/docs/01-what-caching-is.md new file mode 100644 index 0000000..091987a --- /dev/null +++ b/caching/docs/01-what-caching-is.md @@ -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. diff --git a/caching/docs/02-the-three-annotations.md b/caching/docs/02-the-three-annotations.md new file mode 100644 index 0000000..e44fd8e --- /dev/null +++ b/caching/docs/02-the-three-annotations.md @@ -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. diff --git a/caching/docs/03-self-invocation.md b/caching/docs/03-self-invocation.md new file mode 100644 index 0000000..0fff74d --- /dev/null +++ b/caching/docs/03-self-invocation.md @@ -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 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.`. diff --git a/caching/docs/04-keys.md b/caching/docs/04-keys.md new file mode 100644 index 0000000..f210fce --- /dev/null +++ b/caching/docs/04-keys.md @@ -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. diff --git a/caching/docs/05-eviction.md b/caching/docs/05-eviction.md new file mode 100644 index 0000000..479b9be --- /dev/null +++ b/caching/docs/05-eviction.md @@ -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. diff --git a/caching/docs/06-conditions-and-nulls.md b/caching/docs/06-conditions-and-nulls.md new file mode 100644 index 0000000..496a52c --- /dev/null +++ b/caching/docs/06-conditions-and-nulls.md @@ -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"`. diff --git a/caching/docs/07-sync-and-async.md b/caching/docs/07-sync-and-async.md new file mode 100644 index 0000000..738dc76 --- /dev/null +++ b/caching/docs/07-sync-and-async.md @@ -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. diff --git a/caching/docs/08-providers-and-ttl.md b/caching/docs/08-providers-and-ttl.md new file mode 100644 index 0000000..ebe93d9 --- /dev/null +++ b/caching/docs/08-providers-and-ttl.md @@ -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`, 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. diff --git a/caching/docs/09-versus-hibernate-l2.md b/caching/docs/09-versus-hibernate-l2.md new file mode 100644 index 0000000..725613b --- /dev/null +++ b/caching/docs/09-versus-hibernate-l2.md @@ -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. diff --git a/caching/docs/10-transactions.md b/caching/docs/10-transactions.md new file mode 100644 index 0000000..90a747d --- /dev/null +++ b/caching/docs/10-transactions.md @@ -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. diff --git a/caching/docs/11-diagnostics.md b/caching/docs/11-diagnostics.md new file mode 100644 index 0000000..f6c153e --- /dev/null +++ b/caching/docs/11-diagnostics.md @@ -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. diff --git a/caching/docs/12-production-checklist.md b/caching/docs/12-production-checklist.md new file mode 100644 index 0000000..f276f70 --- /dev/null +++ b/caching/docs/12-production-checklist.md @@ -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. diff --git a/caching/docs/output/01-basics.txt b/caching/docs/output/01-basics.txt new file mode 100644 index 0000000..c629778 --- /dev/null +++ b/caching/docs/output/01-basics.txt @@ -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 diff --git a/caching/docs/output/02-put-evict-clear.txt b/caching/docs/output/02-put-evict-clear.txt new file mode 100644 index 0000000..ba2ea9a --- /dev/null +++ b/caching/docs/output/02-put-evict-clear.txt @@ -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 diff --git a/caching/docs/output/03-self-invocation.txt b/caching/docs/output/03-self-invocation.txt new file mode 100644 index 0000000..4aa5f8d --- /dev/null +++ b/caching/docs/output/03-self-invocation.txt @@ -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 diff --git a/caching/docs/output/04-non-public-and-postconstruct.txt b/caching/docs/output/04-non-public-and-postconstruct.txt new file mode 100644 index 0000000..2b0979b --- /dev/null +++ b/caching/docs/output/04-non-public-and-postconstruct.txt @@ -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. diff --git a/caching/docs/output/05-key-shapes.txt b/caching/docs/output/05-key-shapes.txt new file mode 100644 index 0000000..085de24 --- /dev/null +++ b/caching/docs/output/05-key-shapes.txt @@ -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. diff --git a/caching/docs/output/06-key-collision.txt b/caching/docs/output/06-key-collision.txt new file mode 100644 index 0000000..1ef39ef --- /dev/null +++ b/caching/docs/output/06-key-collision.txt @@ -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 diff --git a/caching/docs/output/07-mutable-key.txt b/caching/docs/output/07-mutable-key.txt new file mode 100644 index 0000000..ff56945 --- /dev/null +++ b/caching/docs/output/07-mutable-key.txt @@ -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. diff --git a/caching/docs/output/08-evict-timing.txt b/caching/docs/output/08-evict-timing.txt new file mode 100644 index 0000000..7060680 --- /dev/null +++ b/caching/docs/output/08-evict-timing.txt @@ -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 diff --git a/caching/docs/output/09-conditions.txt b/caching/docs/output/09-conditions.txt new file mode 100644 index 0000000..9358fe4 --- /dev/null +++ b/caching/docs/output/09-conditions.txt @@ -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. diff --git a/caching/docs/output/10-nulls.txt b/caching/docs/output/10-nulls.txt new file mode 100644 index 0000000..a008752 --- /dev/null +++ b/caching/docs/output/10-nulls.txt @@ -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) diff --git a/caching/docs/output/11-stampede.txt b/caching/docs/output/11-stampede.txt new file mode 100644 index 0000000..5505ffd --- /dev/null +++ b/caching/docs/output/11-stampede.txt @@ -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. diff --git a/caching/docs/output/12-async-return-types.txt b/caching/docs/output/12-async-return-types.txt new file mode 100644 index 0000000..0b6599c --- /dev/null +++ b/caching/docs/output/12-async-return-types.txt @@ -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. diff --git a/caching/docs/output/13-transactions.txt b/caching/docs/output/13-transactions.txt new file mode 100644 index 0000000..246e281 --- /dev/null +++ b/caching/docs/output/13-transactions.txt @@ -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. diff --git a/caching/docs/output/14-cached-entity.txt b/caching/docs/output/14-cached-entity.txt new file mode 100644 index 0000000..eb0ec44 --- /dev/null +++ b/caching/docs/output/14-cached-entity.txt @@ -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. diff --git a/caching/docs/output/15-providers-and-ttl.txt b/caching/docs/output/15-providers-and-ttl.txt new file mode 100644 index 0000000..681c62c --- /dev/null +++ b/caching/docs/output/15-providers-and-ttl.txt @@ -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. diff --git a/caching/docs/output/16-autoconfiguration.txt b/caching/docs/output/16-autoconfiguration.txt new file mode 100644 index 0000000..60febaa --- /dev/null +++ b/caching/docs/output/16-autoconfiguration.txt @@ -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. diff --git a/caching/docs/output/17-async-cache-mode-missing.txt b/caching/docs/output/17-async-cache-mode-missing.txt new file mode 100644 index 0000000..29c5374 --- /dev/null +++ b/caching/docs/output/17-async-cache-mode-missing.txt @@ -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. diff --git a/caching/docs/output/18-provider-detection.txt b/caching/docs/output/18-provider-detection.txt new file mode 100644 index 0000000..7ec1820 --- /dev/null +++ b/caching/docs/output/18-provider-detection.txt @@ -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. diff --git a/caching/docs/output/19-transaction-aware.txt b/caching/docs/output/19-transaction-aware.txt new file mode 100644 index 0000000..c1de23b --- /dev/null +++ b/caching/docs/output/19-transaction-aware.txt @@ -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. diff --git a/caching/docs/output/20-invalid-declarations.txt b/caching/docs/output/20-invalid-declarations.txt new file mode 100644 index 0000000..92a7179 --- /dev/null +++ b/caching/docs/output/20-invalid-declarations.txt @@ -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. diff --git a/caching/docs/output/22-decorator-bytecode.txt b/caching/docs/output/22-decorator-bytecode.txt new file mode 100644 index 0000000..25ecb49 --- /dev/null +++ b/caching/docs/output/22-decorator-bytecode.txt @@ -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."":(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. diff --git a/caching/docs/output/23-diagnostics.txt b/caching/docs/output/23-diagnostics.txt new file mode 100644 index 0000000..5159a0d --- /dev/null +++ b/caching/docs/output/23-diagnostics.txt @@ -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]" + } + } + } +} diff --git a/caching/pom.xml b/caching/pom.xml new file mode 100644 index 0000000..6043b44 --- /dev/null +++ b/caching/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + caching + 1.0.0 + caching + The Spring cache abstraction: Cacheable, CacheEvict, key generators and the self-invocation trap + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.github.ben-manes.caffeine + caffeine + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/caching/scripts/capture-bytecode.sh b/caching/scripts/capture-bytecode.sh new file mode 100755 index 0000000..4d2ea48 --- /dev/null +++ b/caching/scripts/capture-bytecode.sh @@ -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" diff --git a/caching/scripts/capture-diagnostics.sh b/caching/scripts/capture-diagnostics.sh new file mode 100755 index 0000000..e9c2e57 --- /dev/null +++ b/caching/scripts/capture-diagnostics.sh @@ -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" diff --git a/caching/scripts/run-all.sh b/caching/scripts/run-all.sh new file mode 100755 index 0000000..2d3ab48 --- /dev/null +++ b/caching/scripts/run-all.sh @@ -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 diff --git a/caching/src/main/java/com/ankurm/caching/CacheConfig.java b/caching/src/main/java/com/ankurm/caching/CacheConfig.java new file mode 100644 index 0000000..ed64273 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/CacheConfig.java @@ -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. + * + *

Note what {@code @EnableCaching} does not accept. It has exactly three attributes + * — {@code proxyTargetClass}, {@code mode} and {@code order} — verified with + * {@code javap} and captured in {@code docs/output/03-enablecaching-attributes.txt}. There is no + * {@code exposeProxy}, so the widely-copied {@code @EnableCaching(exposeProxy = true)} does not + * compile. Turning the ThreadLocal on takes the post-processor below. + * + * @see docs/03-self-invocation.md + */ +@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 docs/08-providers-and-ttl.md + */ + @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 not deferred, and it only helps inside a transaction. + * + * @see docs/10-transactions.md + */ + @Bean + @Primary + @Profile("txaware") + public CacheManager transactionAwareCacheManager() { + return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager()); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/CachingDemoApplication.java b/caching/src/main/java/com/ankurm/caching/CachingDemoApplication.java new file mode 100644 index 0000000..66d0bd8 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/CachingDemoApplication.java @@ -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. + * + *

Note what is not 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 docs/01-what-caching-is.md + */ +@SpringBootApplication +public class CachingDemoApplication { + public static void main(String[] args) { + SpringApplication.run(CachingDemoApplication.class, args); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/basics/Book.java b/caching/src/main/java/com/ankurm/caching/basics/Book.java new file mode 100644 index 0000000..88857b0 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/basics/Book.java @@ -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 { +} diff --git a/caching/src/main/java/com/ankurm/caching/basics/BookRepositoryStub.java b/caching/src/main/java/com/ankurm/caching/basics/BookRepositoryStub.java new file mode 100644 index 0000000..ee4865c --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/basics/BookRepositoryStub.java @@ -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 docs/01-what-caching-is.md + */ +@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 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(); + } + } +} diff --git a/caching/src/main/java/com/ankurm/caching/basics/BookService.java b/caching/src/main/java/com/ankurm/caching/basics/BookService.java new file mode 100644 index 0000000..dec9b1f --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/basics/BookService.java @@ -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 docs/02-the-three-annotations.md + */ +@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() { + } +} diff --git a/caching/src/main/java/com/ankurm/caching/conditions/LookupService.java b/caching/src/main/java/com/ankurm/caching/conditions/LookupService.java new file mode 100644 index 0000000..996b84c --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/conditions/LookupService.java @@ -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. + * + *

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 docs/06-conditions-and-nulls.md + */ +@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); } +} diff --git a/caching/src/main/java/com/ankurm/caching/diag/CacheDiagnosticsController.java b/caching/src/main/java/com/ankurm/caching/diag/CacheDiagnosticsController.java new file mode 100644 index 0000000..740d7b4 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/diag/CacheDiagnosticsController.java @@ -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. + * + *

Delete this before shipping. It exposes cached data over HTTP with no authorisation. + * + * @see docs/11-diagnostics.md + */ +@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 caches() { + Map report = new LinkedHashMap<>(); + report.put("cacheManager", cacheManager.getClass().getName()); + Map caches = new LinkedHashMap<>(); + for (String name : cacheManager.getCacheNames()) { + caches.put(name, describe(cacheManager.getCache(name))); + } + report.put("caches", caches); + return report; + } + + private Map describe(Cache cache) { + Map 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 entries = new TreeMap<>(); + map.getNativeCache().forEach((k, v) -> entries.put( + k + " [" + k.getClass().getSimpleName() + "]", + v + " [" + v.getClass().getSimpleName() + "]")); + info.put("entries", entries); + } + return info; + } +} diff --git a/caching/src/main/java/com/ankurm/caching/eviction/PriceService.java b/caching/src/main/java/com/ankurm/caching/eviction/PriceService.java new file mode 100644 index 0000000..77122a4 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/eviction/PriceService.java @@ -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 after 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 docs/05-eviction.md + */ +@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; } +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/Customer.java b/caching/src/main/java/com/ankurm/caching/jpa/Customer.java new file mode 100644 index 0000000..993983e --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/Customer.java @@ -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 docs/09-versus-hibernate-l2.md + */ +@Entity +public class Customer { + + @Id + private Long id; + private String name; + + @OneToMany(mappedBy = "customer", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private List 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 getOrders() { return orders; } +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/CustomerRepository.java b/caching/src/main/java/com/ankurm/caching/jpa/CustomerRepository.java new file mode 100644 index 0000000..3c4c4b6 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/CustomerRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.caching.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerRepository extends JpaRepository { +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/CustomerService.java b/caching/src/main/java/com/ankurm/caching/jpa/CustomerService.java new file mode 100644 index 0000000..f757b13 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/CustomerService.java @@ -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 docs/10-transactions.md + */ +@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); } +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/CustomerWorkflow.java b/caching/src/main/java/com/ankurm/caching/jpa/CustomerWorkflow.java new file mode 100644 index 0000000..6e07914 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/CustomerWorkflow.java @@ -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 docs/10-transactions.md + */ +@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); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/DataSeeder.java b/caching/src/main/java/com/ankurm/caching/jpa/DataSeeder.java new file mode 100644 index 0000000..2d3fbd9 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/DataSeeder.java @@ -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")); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/jpa/Order.java b/caching/src/main/java/com/ankurm/caching/jpa/Order.java new file mode 100644 index 0000000..28fe604 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/jpa/Order.java @@ -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; } +} diff --git a/caching/src/main/java/com/ankurm/caching/keys/CollidingService.java b/caching/src/main/java/com/ankurm/caching/keys/CollidingService.java new file mode 100644 index 0000000..e431cd0 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/keys/CollidingService.java @@ -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 nothing else — 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. + * + *

{@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 docs/04-keys.md + */ +@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); } +} diff --git a/caching/src/main/java/com/ankurm/caching/keys/KeyShapeService.java b/caching/src/main/java/com/ankurm/caching/keys/KeyShapeService.java new file mode 100644 index 0000000..d891afc --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/keys/KeyShapeService.java @@ -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 docs/04-keys.md + */ +@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 tags) { + return "tags=" + tags; + } +} diff --git a/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogReader.java b/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogReader.java new file mode 100644 index 0000000..9d2ce27 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogReader.java @@ -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 docs/03-self-invocation.md + */ +@Service +public class CatalogReader { + + private final CatalogService catalog; + + public CatalogReader(CatalogService catalog) { + this.catalog = catalog; + } + + public List byCollaborator(List isbns) { + return isbns.stream().map(catalog::lookup).toList(); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogService.java b/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogService.java new file mode 100644 index 0000000..1fd33e6 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/selfinvocation/CatalogService.java @@ -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. + * + *

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 docs/03-self-invocation.md + */ +@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 self; + + public CatalogService(BookRepositoryStub repository, ObjectProvider 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 byInternalCall(List isbns) { + return isbns.stream().map(this::lookup).toList(); + } + + /** Fix 1: go back out through the proxy that the container is holding. */ + public List bySelfInjection(List 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 byExposedProxy(List 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"); + } +} diff --git a/caching/src/main/java/com/ankurm/caching/sync/AsyncReportService.java b/caching/src/main/java/com/ankurm/caching/sync/AsyncReportService.java new file mode 100644 index 0000000..09905dd --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/sync/AsyncReportService.java @@ -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 docs/07-sync-and-async.md + */ +@Service +public class AsyncReportService { + + private final AtomicInteger calls = new AtomicInteger(); + + @Cacheable("asyncReports") + public CompletableFuture buildAsync(String name) { + return CompletableFuture.supplyAsync(() -> { + calls.incrementAndGet(); + return "async-report:" + name; + }); + } + + public int calls() { return calls.get(); } +} diff --git a/caching/src/main/java/com/ankurm/caching/sync/ReportService.java b/caching/src/main/java/com/ankurm/caching/sync/ReportService.java new file mode 100644 index 0000000..3a2fc07 --- /dev/null +++ b/caching/src/main/java/com/ankurm/caching/sync/ReportService.java @@ -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 docs/07-sync-and-async.md + */ +@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(); + } + } +} diff --git a/caching/src/main/resources/application.yml b/caching/src/main/resources/application.yml new file mode 100644 index 0000000..d580397 --- /dev/null +++ b/caching/src/main/resources/application.yml @@ -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 diff --git a/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOffTest.java b/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOffTest.java new file mode 100644 index 0000000..ca4dd90 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOffTest.java @@ -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."); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOnTest.java b/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOnTest.java new file mode 100644 index 0000000..c225d6d --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/AsyncCacheModeOnTest.java @@ -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); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/AutoConfigurationTest.java b/caching/src/test/java/com/ankurm/caching/AutoConfigurationTest.java new file mode 100644 index 0000000..295d5b2 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/AutoConfigurationTest.java @@ -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"); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/BasicsTest.java b/caching/src/test/java/com/ankurm/caching/BasicsTest.java new file mode 100644 index 0000000..5f6e13b --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/BasicsTest.java @@ -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); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/ConditionsAndNullsTest.java b/caching/src/test/java/com/ankurm/caching/ConditionsAndNullsTest.java new file mode 100644 index 0000000..2ed5f9c --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/ConditionsAndNullsTest.java @@ -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())); + } +} diff --git a/caching/src/test/java/com/ankurm/caching/EvictionTimingTest.java b/caching/src/test/java/com/ankurm/caching/EvictionTimingTest.java new file mode 100644 index 0000000..fe531c0 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/EvictionTimingTest.java @@ -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); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/InvalidDeclarationsTest.java b/caching/src/test/java/com/ankurm/caching/InvalidDeclarationsTest.java new file mode 100644 index 0000000..4965f42 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/InvalidDeclarationsTest.java @@ -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. + * + *

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 void probe(Transcript t, java.util.function.Function, T> lookup, Class 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; } + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/KeyGenerationTest.java b/caching/src/test/java/com/ankurm/caching/KeyGenerationTest.java new file mode 100644 index 0000000..39525e9 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/KeyGenerationTest.java @@ -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 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 store = cache.getNativeCache(); + if (store.isEmpty()) { + t.line(" (empty)"); + } + store.forEach((k, v) -> t.line(" key %-22s [%s] -> %s", + k, k.getClass().getSimpleName(), v)); + } +} diff --git a/caching/src/test/java/com/ankurm/caching/ProviderDetectionTest.java b/caching/src/test/java/com/ankurm/caching/ProviderDetectionTest.java new file mode 100644 index 0000000..b0eb057 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/ProviderDetectionTest.java @@ -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. + * + *

The order is not transcribed from the documentation here. {@code CacheConfigurations} holds + * an {@code EnumMap}, so the iteration order is the declaration order of the + * {@link CacheType} enum — which is what this test prints. + */ +@SpringBootTest +class ProviderDetectionTest { + + @Autowired CacheManager cacheManager; + + @Test + void classpathDecidesTheProvider() { + try (Transcript t = new Transcript("18-provider-detection.txt", + "Nothing in application.yml selects a provider. Something still chose one.")) { + + t.line("spring.cache.type : (not set)"); + t.line("resolved CacheManager bean : %s", cacheManager.getClass().getName()); + t.line(""); + t.line("Caffeine is on this module's classpath because a later chapter needs TTL and"); + t.line("size bounds. That single dependency moved every cache in the application off"); + t.line("the ConcurrentHashMap-backed 'simple' provider."); + + t.section("the detection order, read out of the enum rather than the documentation"); + List 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)); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/ProvidersAndTtlTest.java b/caching/src/test/java/com/ankurm/caching/ProvidersAndTtlTest.java new file mode 100644 index 0000000..138abef --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/ProvidersAndTtlTest.java @@ -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 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); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/SelfInvocationTest.java b/caching/src/test/java/com/ankurm/caching/SelfInvocationTest.java new file mode 100644 index 0000000..bcf813f --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/SelfInvocationTest.java @@ -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 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"); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/StampedeTest.java b/caching/src/test/java/com/ankurm/caching/StampedeTest.java new file mode 100644 index 0000000..c8796c5 --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/StampedeTest.java @@ -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; + } +} diff --git a/caching/src/test/java/com/ankurm/caching/TransactionAwareTest.java b/caching/src/test/java/com/ankurm/caching/TransactionAwareTest.java new file mode 100644 index 0000000..a21255e --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/TransactionAwareTest.java @@ -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"); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/TransactionsTest.java b/caching/src/test/java/com/ankurm/caching/TransactionsTest.java new file mode 100644 index 0000000..163066f --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/TransactionsTest.java @@ -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."); + } + } +} diff --git a/caching/src/test/java/com/ankurm/caching/Transcript.java b/caching/src/test/java/com/ankurm/caching/Transcript.java new file mode 100644 index 0000000..55a5abc --- /dev/null +++ b/caching/src/test/java/com/ankurm/caching/Transcript.java @@ -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); + } +}