Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)

This commit is contained in:
2026-09-20 06:06:42 +00:00
committed by Claude
commit 8568c0ce6c
330 changed files with 23668 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
# 18 — Ehcache 3 second-level cache configuration
[← Previous: 17 — Bootstrapping EntityManager](17-entitymanager-bootstrap.md) | [Back to README →](../README.md)
Backs the rewrite of ankurm.com post 4884 (Ehcache 3 L2 cache configuration).
Chapter 06 already turned on the L2 cache once, for `@NaturalIdCache`. This chapter is the
general case -- plain entity caching, query caching, and the four specific pitfalls the original
article warned about -- each one actually reproduced (or, in three of the four cases, disproven)
rather than repeated as received wisdom. Every test here boots its own isolated, raw
`StandardServiceRegistry`/`SessionFactory` (the same pattern chapter 09 established), never the
shared Spring context, so none of this leaks into any other chapter's tests.
## The dependency question this chapter does NOT re-litigate
The original article's warning -- "without the `jakarta` classifier, Ehcache 3 ships the older
`javax.cache` JCache API; Hibernate 7 requires `jakarta.cache`; using the wrong artifact causes a
`ClassNotFoundException`" -- is false, and chapter 06 already established why:
[JSR-107 (JCache) was never migrated to the Jakarta namespace](06-natural-ids.md#turning-on-l2-hibernate-jcache--ehcache),
unlike JPA or Bean Validation. `javax.cache.Caching` is the one and only JCache API entry point,
with or without Ehcache's own `jakarta` classifier.
This chapter re-verifies that claim independently rather than just linking to it, because it's
central enough to the post to deserve its own primary-source check:
[`CacheApiNamespaceTest`](../src/test/java/com/ankurm/hibernatedemo/cache/CacheApiNamespaceTest.java)
confirms `javax.cache.Caching` loads fine on this classpath, and that `jakarta.cache.Cache` throws
`ClassNotFoundException` -- there is no such package, ever, regardless of classifier:
```
RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath (jar: file:/.../cache-api-1.1.1.jar)
RESULT[cache-api-no-jakarta-namespace]: Class.forName("jakarta.cache.Cache") -> ClassNotFoundException -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's own "jakarta" classifier on org.ehcache:ehcache.
```
[(full transcript)](output/18-cache-api-namespace.txt)
So what *is* the `jakarta` classifier for? Comparing `ehcache-3.10.8.jar` and
`ehcache-3.10.8-jakarta.jar` directly -- their Gradle module metadata, their `.pom` (identical,
both declare `javax.cache:cache-api` as a dependency), and disassembled classes in
`org.ehcache.xml.model` and `ConfigurationParser.class` -- shows the difference is entirely about
which JAXB runtime major version Ehcache uses **internally, to parse its own `ehcache.xml`
config file**: the plain jar imports `javax.xml.bind`, the `jakarta` jar imports
`jakarta.xml.bind`. Nothing in either jar touches `javax.cache` vs. a `jakarta.cache` package,
because the latter doesn't exist. Pick the classifier that matches whichever JAXB runtime is
already on your classpath (Jakarta EE 9+ stacks, including Spring Boot 3+/4+, want the `jakarta`
classifier) -- not because of JCache.
## The smallest thing that works: caching one entity
```java
@Entity
@jakarta.persistence.Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "productCache")
public class CacheProduct {
@Id @GeneratedValue private Long id;
@Column(nullable = false) private String name;
private Double price;
// ...
}
```
[`CacheProduct.java`](../src/main/java/com/ankurm/hibernatedemo/cache/CacheProduct.java)
Two annotations, both required: `@jakarta.persistence.Cacheable` is the portable JPA marker that
says "this entity may be cached"; `@org.hibernate.annotations.Cache` is Hibernate's own annotation
that actually turns caching on and picks the concurrency strategy and region name. Leaving either
one off means no L2 caching happens for this entity at all.
The Ehcache config backing `productCache` lives in
[`ehcache-chapter18.xml`](../src/test/resources/ehcache-chapter18.xml): a 30-minute TTL, 1000
heap entries, 10MB offheap. `default-update-timestamps-region` and `default-query-results-region`
are also declared explicitly -- more on why that matters below.
### Surprise: `persist()` + `commit()` already puts the entity in the cache
The natural mental model is "L2 fills on the first cache *miss* -- so the very first `get()`
after inserting a row still costs one SELECT, a cold read, before later reads become free." That
model is wrong, and this chapter is the second time this exact repository has caught it: chapter
06 already documented it for `@NaturalIdCache`, and
[`EntityL2CacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/EntityL2CacheTest.java)
confirms it generalizes to plain `@Cacheable`/`@Cache` entity caching too. `persist()` +
`commit()` populates the L2 region itself, so even the "first" `get()` for that id afterward,
from a brand-new session, is already a cache hit with zero SQL statements:
```
RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries=0 | session2 (brand-new session, same id) cumulative queries=0, L2 entity cache hits=2
```
[(full transcript)](output/18-entity-l2-cache.txt)
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
<strong>Trap:</strong> if you're benchmarking L2 cache savings by comparing a "cold" first
<code>get()</code> against a "warm" second one, you're measuring the wrong thing for any entity
you just inserted in the same test. The real cold read only happens for a row that existed in the
database <em>before</em> the current process ever touched it in a session -- reboot the
<code>SessionFactory</code> (or evict the region) between the insert and the "cold" read if you
want a fair baseline.
</blockquote>
## Query cache without entity cache: NOT the N+1 the article claimed
The article's second warning: "Query Cache without Entity Cache causes N+1 selects." The scenario
is a query with `setCacheable(true)` over an entity that has no `@Cacheable`/`@Cache` of its own
-- [`UncachedProduct`](../src/main/java/com/ankurm/hibernatedemo/cache/UncachedProduct.java),
built specifically to have no L2 region at all. The claim was that resolving the query cache's
remembered id list from a brand-new session would cost one SELECT per row.
Measured directly, that's false. A second, brand-new session repeating the identical
`setCacheable(true)` query costs **zero** SQL statements, not five:
```
RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements=1, query-cache misses=1 | second run (new session, query-cache HIT) SQL statements=0, query-cache hits=1, L2 entity-cache hits=0 -- contrary to the original article, the second run costs ZERO SQL statements: the query cache stores the full row tuples, not just the 5 ids, and Hibernate rebuilds UncachedProduct instances straight from that stored data. The zero L2 entity-cache hits confirm this does not depend on UncachedProduct having its own L2 region at all -- it has none.
```
[(full transcript)](output/18-query-cache-without-entity-cache.txt), source:
[`QueryCacheWithoutEntityCacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/QueryCacheWithoutEntityCacheTest.java)
The mechanism: the query cache region doesn't store just the matching row ids. It stores the full
hydrated tuple data for each result row at the moment the query first ran, and Hibernate
reconstitutes entity instances directly from that stored data on a cache hit -- confirmed here by
`getSecondLevelCacheHitCount()` staying at zero even on the hit, which rules out a secretly
entity-cached explanation. This test doesn't rule out an N+1 appearing for a query returning
associations Hibernate must still initialize per row, or a partial-hit scenario after individual
entries are evicted -- only the specific, simple case the article described is measured and
corrected here.
- Going deeper: [Hibernate's own query cache documentation](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#caching-query) (`rel="nofollow"`) on how query-cache regions store result tuples.
## Bulk mutations: also not what the article claimed, in two different ways
The article's third warning: "Bulk HQL mutations bypass cache -- a bulk `update` executes direct
SQL, so an already-cached entity goes stale until manually evicted." Measured with
[`BulkUpdateBypassesCacheTest`](../src/test/java/com/ankurm/hibernatedemo/cache/BulkUpdateBypassesCacheTest.java),
this is false for both the HQL/JPQL form and, once actually tested, the native SQL form too.
**HQL/JPQL bulk update, via `Session.createMutationQuery(...).executeUpdate()`:** a fresh session
sees the new value immediately.
```
RESULT[cache-bulk-update-hql-not-stale]: price in the database after the bulk HQL update=999.0 | price a brand-new session's get() actually returns=999.0 -- contrary to the original article, a bulk HQL update via createMutationQuery does NOT leave the L2 cache stale. BulkOperationCleanupAction evicts CacheProduct's entire region automatically once the statement's transaction commits, so no manual sessionFactory.getCache().evictEntityData(...) call is needed for this case.
```
[(full transcript)](output/18-bulk-update-hql.txt)
Verified by disassembling `hibernate-core-7.4.5.Final.jar`: every bulk HQL/JPQL `update`/`delete`
registers an `org.hibernate.action.internal.BulkOperationCleanupAction` as an
after-transaction-completion process. Its `EntityCleanup` inner class calls
`EntityDataAccess.lockRegion()` then `EntityDataAccess.removeAll(session)` for every entity type
the statement's parsed "query spaces" (tables) touch -- the entire region, not just the one row.
Hibernate can do this because `createMutationQuery` parses the HQL/JPQL itself before running it.
The natural follow-up question -- and the working hypothesis going into the second test below --
was whether raw/native SQL escapes this, since Hibernate's HQL parser never sees a native
statement and so can't name the affected query spaces. Measuring it disproves that hypothesis
too:
```
RESULT[cache-bulk-update-native-not-stale]: price in the database after the native SQL update=999.0 | price a brand-new session's get() actually returns=999.0 | L2 puts recorded before the native update=1 | L2 hits before/after the post-update read=1/1 -- the original hypothesis (native SQL bypasses query-space tracking, so the L2 entry survives stale) was WRONG. The entity WAS cached (one L2 put), yet the post-update read is not an L2 hit at all: Hibernate cannot verify a native statement's affected tables, so it conservatively invalidates every region it knows about rather than none of them.
```
[(full transcript)](output/18-bulk-update-native.txt)
Hibernate's actual default for an unqualified native DML statement is the conservative *opposite*
of "do nothing": unable to prove which regions the statement leaves safe, it invalidates every
region it knows about. The entity cache ends up empty either way -- an HQL bulk update reaches
that outcome by naming exactly what it evicts, a native statement reaches the same outcome by
assuming it must evict everything.
<blockquote style="border-left:4px solid #d97757;background:#fdf3ee;padding:0.75rem 1.25rem;margin:1.5rem 0;">
<strong>Trap this creates in the other direction:</strong> that "invalidate everything" default
means a native SQL statement executed through Hibernate can quietly evict L2 regions for entities
that statement never touched, in a high-traffic app with many cached entity types. If you need to
avoid that blast radius, narrow it explicitly with
<code>NativeQuery#addSynchronizedEntityClass()</code>/<code>addSynchronizedQuerySpace()</code>
rather than relying on the conservative default.
</blockquote>
- Going deeper: `docs/output/18-bulk-update-hql.txt` and `18-bulk-update-native.txt` both include
the manual `sessionFactory.getCache().evictEntityData(...)` call and its result, for the case
where you do need to force an eviction explicitly.
## The missing-region warning that isn't a startup failure
The article's fourth warning: "Missing `default-update-timestamps-region`: required for the Query
Cache -- omitting it causes startup errors." Also false, with the defaults this repo pins.
[`MissingUpdateTimestampsRegionTest`](../src/test/java/com/ankurm/hibernatedemo/cache/MissingUpdateTimestampsRegionTest.java)
boots a query-cache-enabled `SessionFactory` against
[`ehcache-chapter18-missing-timestamps.xml`](../src/test/resources/ehcache-chapter18-missing-timestamps.xml),
which deliberately omits that region entirely:
```
RESULT[cache-missing-timestamps-region]: SessionFactory built with NO error -- contrary to the original article, the default MissingCacheStrategy (CREATE_WARN) auto-creates the missing default-update-timestamps-region and only logs HHH90001006
```
[(full transcript)](output/18-missing-timestamps-region.txt)
`hibernate-jcache`'s default `MissingCacheStrategy` is `CREATE_WARN` (external representation
`"create-warn"`, confirmed by disassembling `MissingCacheStrategy.class` in
`hibernate-jcache-7.4.5.Final.jar`, which also confirms the other two values, `FAIL` and
`CREATE`): a missing region is created on the fly with provider-specific default policies, and
Hibernate only logs `HHH90001006`. If you actually want the hard failure the article describes --
useful in CI, to catch a forgotten region definition before production -- it's an explicit
opt-in, not the default:
```java
.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
```
```
RESULT[cache-missing-timestamps-region-strict]: hibernate.javax.cache.missing_cache_strategy=fail -> org.hibernate.service.spi.ServiceException: On-the-fly creation of JCache Cache objects is not supported [default-update-timestamps-region] -- this is how to opt into the hard-failure behavior the original article assumed was the default.
```
[(full transcript)](output/18-missing-timestamps-region-strict.txt)
- Going deeper: set `missing_cache_strategy` to `create` (rather than the default `create-warn`)
if you want the auto-create behavior without the WARN log line at all.
## Production checklist
- Pin `hibernate.cache.use_second_level_cache: false` at the application-wide level if you're
not deliberately turning L2 on everywhere -- see [chapter 09](09-testing-in-memory-databases.md#hibernate-jcache-on-the-classpath-turns-on-l2-for-everyone-whether-you-asked-or-not)
for why having `hibernate-jcache` merely on the classpath is enough to auto-enable it via
ServiceLoader discovery, with zero explicit configuration.
- Match the Ehcache classifier to your JAXB runtime (`jakarta` for Jakarta EE 9+/Spring Boot
3+/4+), not to any assumption about the JCache API namespace -- there is only one, `javax.cache`.
- Decide `missing_cache_strategy` deliberately: `create-warn` (the default) is forgiving in dev,
`fail` catches a missing region definition in CI before it reaches production.
- If you rely on native SQL DML for bulk changes, know that Hibernate's conservative
cache-invalidation default means a native statement can evict L2 regions for entities it never
touched -- narrow the blast radius with `addSynchronizedEntityClass()` if that matters at your
scale.
- Remember the persist-then-get() surprise when benchmarking: a row inserted in the same session
or process is already cached by the time you "cold" `get()` it.
[← Previous: 17 — Bootstrapping EntityManager](17-entitymanager-bootstrap.md) | [Back to README →](../README.md)