Add Hibernate 7 batches 2-6 and batch 7: 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, and Hibernate Validator CDI integration (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EQNA6DJ9VgCtW6zhCE8Xud
This commit is contained in:
+195
@@ -0,0 +1,195 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Bulk HQL mutations bypass cache: a bulk update leaves
|
||||
* an already-cached entity stale until it is manually evicted" -- against what actually happens.
|
||||
* The claim is FALSE both for HQL/JPQL bulk statements and, once actually measured, for native
|
||||
* SQL bulk statements too: a fresh session sees the new value immediately in both cases, with no
|
||||
* manual eviction step needed for either.
|
||||
*
|
||||
* <p>For {@code Session.createMutationQuery(...).executeUpdate()}, verified by disassembling
|
||||
* {@code hibernate-core-7.4.5.Final.jar}: every bulk HQL/JPQL {@code update}/{@code delete}
|
||||
* registers a {@code org.hibernate.action.internal.BulkOperationCleanupAction} as an
|
||||
* after-transaction-completion process. Its {@code EntityCleanup} inner class calls {@code
|
||||
* EntityDataAccess.lockRegion()} then {@code EntityDataAccess.removeAll(session)} for every
|
||||
* entity type the statement's "query spaces" (tables) touch. Hibernate knows which entity types
|
||||
* are affected because {@code createMutationQuery} parses the HQL/JPQL itself.
|
||||
*
|
||||
* <p>The natural next question -- and the original hypothesis behind the second test below --
|
||||
* was whether a bulk update fired as raw/native SQL escapes this, since Hibernate's HQL parser
|
||||
* never sees it and so cannot name the affected "query spaces" for {@code
|
||||
* BulkOperationCleanupAction} to key off of. Measuring it disproves the hypothesis: Hibernate's
|
||||
* actual default for an unqualified native DML statement is the conservative opposite of "do
|
||||
* nothing" -- unable to prove which regions are safe, it invalidates every region it knows about.
|
||||
* The entity cache still ends up empty after the statement, just reached by a different path.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class BulkUpdateBypassesCacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:bulkbypass;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkHqlUpdate_doesNotLeaveTheL2EntityCacheStale_becauseHibernateAutoEvictsTheRegion() {
|
||||
boot();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
|
||||
try (Session warm = sessionFactory.openSession()) {
|
||||
CacheProduct p = warm.get(CacheProduct.class, id);
|
||||
assertThat(p.getPrice()).isEqualTo(1199.0);
|
||||
}
|
||||
|
||||
// Bulk HQL/JPQL UPDATE: Hibernate parses this itself, knows CacheProduct's table is a
|
||||
// "query space" the statement touches, and registers a BulkOperationCleanupAction that
|
||||
// evicts CacheProduct's entire L2 region right after this transaction commits.
|
||||
try (Session bulk = sessionFactory.openSession()) {
|
||||
Transaction tx = bulk.beginTransaction();
|
||||
int updated = bulk.createMutationQuery("update CacheProduct set price = :newPrice where id = :id")
|
||||
.setParameter("newPrice", 999.0)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate();
|
||||
tx.commit();
|
||||
assertThat(updated).isEqualTo(1);
|
||||
}
|
||||
|
||||
// A brand-new session's get() -- does it see the new price, or the stale cached one?
|
||||
Double priceSeenAfterBulkUpdate;
|
||||
try (Session after = sessionFactory.openSession()) {
|
||||
priceSeenAfterBulkUpdate = after.get(CacheProduct.class, id).getPrice();
|
||||
}
|
||||
|
||||
System.out.println("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=" + priceSeenAfterBulkUpdate
|
||||
+ " -- 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.");
|
||||
|
||||
assertThat(priceSeenAfterBulkUpdate)
|
||||
.as("corrected finding: a fresh session sees the real 999.0 immediately -- the bulk HQL "
|
||||
+ "update did not leave a stale cache entry behind")
|
||||
.isEqualTo(999.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkNativeSqlUpdate_alsoDoesNotLeaveTheCacheStale_hibernateInvalidatesEverythingItCannotParse() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
// Populate/confirm the L2 region with the original price via a plain get() in its own session.
|
||||
try (Session warm = sessionFactory.openSession()) {
|
||||
CacheProduct p = warm.get(CacheProduct.class, id);
|
||||
assertThat(p.getPrice()).isEqualTo(1199.0);
|
||||
}
|
||||
long l2PutsAfterWarm = stats.getSecondLevelCachePutCount();
|
||||
|
||||
// Raw/native SQL UPDATE: Hibernate's HQL parser never sees this statement, so it cannot
|
||||
// name the specific "query spaces" (tables) it touches the way createMutationQuery does.
|
||||
// The hypothesis going in was that this means NO cache eviction happens at all -- that
|
||||
// hypothesis is wrong, and disproven by the assertions below. Hibernate's actual behavior
|
||||
// for an unqualified native DML statement is the conservative opposite: since it cannot
|
||||
// prove which regions are safe, it treats every known cache region as a possibly-affected
|
||||
// query space and invalidates all of them, exactly as if the statement had touched
|
||||
// everything. This is confirmed here by getSecondLevelCacheHitCount() staying at zero on
|
||||
// the read after the native update, even though the entity was demonstrably cached going
|
||||
// in (l2PutsAfterWarm is 1, not 0).
|
||||
try (Session bulk = sessionFactory.openSession()) {
|
||||
Transaction tx = bulk.beginTransaction();
|
||||
int updated = bulk.createNativeQuery("update CacheProduct set price = :newPrice where id = :id")
|
||||
.setParameter("newPrice", 999.0)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate();
|
||||
tx.commit();
|
||||
assertThat(updated).isEqualTo(1);
|
||||
}
|
||||
|
||||
long l2HitsBeforeRead = stats.getSecondLevelCacheHitCount();
|
||||
Double priceSeenAfterNativeUpdate;
|
||||
try (Session after = sessionFactory.openSession()) {
|
||||
priceSeenAfterNativeUpdate = after.get(CacheProduct.class, id).getPrice();
|
||||
}
|
||||
long l2HitsAfterRead = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("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="
|
||||
+ priceSeenAfterNativeUpdate + " | L2 puts recorded before the native update=" + l2PutsAfterWarm
|
||||
+ " | L2 hits before/after the post-update read=" + l2HitsBeforeRead + "/" + l2HitsAfterRead
|
||||
+ " -- 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.");
|
||||
|
||||
assertThat(l2PutsAfterWarm)
|
||||
.as("the entity really was put into the L2 region by the warm-up get()")
|
||||
.isEqualTo(1L);
|
||||
assertThat(priceSeenAfterNativeUpdate)
|
||||
.as("corrected finding: a native SQL update does not leave the cache stale either -- "
|
||||
+ "Hibernate's conservative default invalidates the region instead")
|
||||
.isEqualTo(999.0);
|
||||
assertThat(l2HitsAfterRead)
|
||||
.as("and the read after the native update is not served from L2 at all -- it is a "
|
||||
+ "genuine re-query, not a lucky freshly-recomputed cache entry")
|
||||
.isEqualTo(l2HitsBeforeRead);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The original article claimed: "Without the jakarta classifier, Ehcache 3 ships with the older
|
||||
* javax.cache JCache API. Hibernate 7 requires the jakarta.cache namespace. Using the wrong
|
||||
* artifact causes a ClassNotFoundException or NoSuchMethodError at startup." That claim is
|
||||
* checked here directly against the classpath rather than repeated.
|
||||
*
|
||||
* <p>JSR-107 (JCache) was never migrated to the Jakarta namespace by its spec maintainers --
|
||||
* unlike JPA, Bean Validation, or Servlet. {@code javax.cache.Caching} is the one and only API
|
||||
* class, with or without Ehcache's own "jakarta" classifier. That classifier is Ehcache's own
|
||||
* internal choice of JAXB runtime major version (used to parse its own {@code ehcache.xml}), not
|
||||
* a JCache API namespace switch -- confirmed by comparing the two classifier jars' Gradle module
|
||||
* metadata (one depends on {@code jaxb-runtime [2.2,3)}, the other on {@code [3,3.1)}) and by
|
||||
* disassembling {@code ConfigurationParser.class} in both jars, which import
|
||||
* {@code javax.xml.bind} and {@code jakarta.xml.bind} respectively -- never {@code javax.cache}
|
||||
* or a {@code jakarta.cache} package, because the latter does not exist.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class CacheApiNamespaceTest {
|
||||
|
||||
@Test
|
||||
void javaxCacheApi_isOnTheClasspath_regardlessOfEhcachesJakartaClassifier() throws ClassNotFoundException {
|
||||
Class<?> caching = Class.forName("javax.cache.Caching");
|
||||
Class<?> cacheManager = Class.forName("javax.cache.CacheManager");
|
||||
|
||||
System.out.println("RESULT[cache-api-namespace]: javax.cache.Caching loads fine from this classpath "
|
||||
+ "(jar: " + caching.getProtectionDomain().getCodeSource().getLocation() + ")");
|
||||
|
||||
assertThat(caching).isNotNull();
|
||||
assertThat(cacheManager).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jakartaCacheNamespace_doesNotExist_onThisClasspathOrAnyOther() {
|
||||
Throwable thrown = catchClassNotFound("jakarta.cache.Cache");
|
||||
|
||||
System.out.println("RESULT[cache-api-no-jakarta-namespace]: Class.forName(\"jakarta.cache.Cache\") -> "
|
||||
+ thrown.getClass().getSimpleName()
|
||||
+ " -- JSR-107 was never renamed to a jakarta.cache package, with or without Ehcache's "
|
||||
+ "own \"jakarta\" classifier on org.ehcache:ehcache.");
|
||||
|
||||
assertThatThrownBy(() -> Class.forName("jakarta.cache.Cache"))
|
||||
.as("no jakarta.cache package has ever existed -- JCache (JSR-107) kept the javax.cache "
|
||||
+ "namespace even after Jakarta EE 9's javax->jakarta rename")
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
}
|
||||
|
||||
private static Throwable catchClassNotFound(String className) {
|
||||
try {
|
||||
Class.forName(className);
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The ordinary case the original article's {@code Product} class was built around: PK lookups
|
||||
* of a {@code @Cacheable}/{@code @Cache}-annotated entity, served from Ehcache after the first
|
||||
* session closes, following the same isolated-SessionFactory pattern as chapter 06's
|
||||
* {@code NaturalIdL2CacheTest} so this never touches the shared Spring context.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class EntityL2CacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:entityl2;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_fromABrandNewSession_isServedFromL2WithNoSql() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
Long id;
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
CacheProduct p = new CacheProduct("Laptop Pro", 1199.0);
|
||||
seed.persist(p);
|
||||
tx.commit();
|
||||
id = p.getId();
|
||||
}
|
||||
|
||||
stats.clear();
|
||||
try (Session s1 = sessionFactory.openSession()) {
|
||||
CacheProduct p = s1.get(CacheProduct.class, id);
|
||||
assertThat(p.getName()).isEqualTo("Laptop Pro");
|
||||
}
|
||||
long queriesAfterSession1 = stats.getPrepareStatementCount();
|
||||
|
||||
try (Session s2 = sessionFactory.openSession()) {
|
||||
CacheProduct p = s2.get(CacheProduct.class, id);
|
||||
assertThat(p.getName()).isEqualTo("Laptop Pro");
|
||||
}
|
||||
long queriesAfterSession2 = stats.getPrepareStatementCount();
|
||||
long l2HitsAfterSession2 = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("RESULT[cache-entity-l2]: session1 (first get() after persist+commit) cumulative queries="
|
||||
+ queriesAfterSession1 + " | session2 (brand-new session, same id) cumulative queries="
|
||||
+ queriesAfterSession2 + ", L2 entity cache hits=" + l2HitsAfterSession2);
|
||||
|
||||
// Surprise, and the same one chapter 06 already documented for @NaturalIdCache: persist()
|
||||
// + commit() populates the L2 entity cache region itself, before anyone ever calls get().
|
||||
// So "session1" here is ALREADY a cache hit, not a cold DB read -- there is no query left
|
||||
// for session1's own get() to save. This generalizes chapter 06's natural-id finding to
|
||||
// plain @Cacheable/@Cache entity caching too, not just @NaturalIdCache.
|
||||
assertThat(queriesAfterSession1)
|
||||
.as("persist()+commit() already populated the L2 region -- session1's get() fires no SQL")
|
||||
.isZero();
|
||||
assertThat(queriesAfterSession2)
|
||||
.as("session2, a brand-new session, must not fire any query either -- served from L2")
|
||||
.isEqualTo(queriesAfterSession1);
|
||||
assertThat(l2HitsAfterSession2)
|
||||
.as("both get() calls (session1 and session2) register as L2 entity cache hits")
|
||||
.isEqualTo(2L);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Missing default-update-timestamps-region: Required for
|
||||
* the Query Cache -- omitting it causes startup errors" -- by actually omitting it, rather than
|
||||
* repeating the warning unverified. The claim is FALSE for this stack: {@code SessionFactory}
|
||||
* builds with no error at all.
|
||||
*
|
||||
* <p>{@code hibernate-jcache}'s default {@code MissingCacheStrategy} is {@code CREATE_WARN}
|
||||
* (external representation {@code "create-warn"}, confirmed by disassembling {@code
|
||||
* MissingCacheStrategy.class} in {@code hibernate-jcache-7.4.5.Final.jar}): a missing region is
|
||||
* created on the fly with provider-specific default policies, and Hibernate only logs {@code
|
||||
* HHH90001006}. Setting {@code hibernate.javax.cache.missing_cache_strategy} to {@code "fail"}
|
||||
* turns this into the hard startup error the article describes; {@code "create"} keeps the
|
||||
* auto-create behavior but silences the warning.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class MissingUpdateTimestampsRegionTest {
|
||||
|
||||
@Test
|
||||
void queryCacheEnabled_withNoUpdateTimestampsRegionInEhcacheXml_buildsFineWithOnlyAWarning() {
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestamps;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml")
|
||||
.build();
|
||||
try {
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
|
||||
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
|
||||
|
||||
System.out.println("RESULT[cache-missing-timestamps-region]: "
|
||||
+ (thrown == null
|
||||
? "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"
|
||||
: thrown.getClass().getName() + ": " + rootMessage(thrown)));
|
||||
|
||||
assertThat(thrown)
|
||||
.as("corrected finding: with the default missing_cache_strategy, a missing "
|
||||
+ "default-update-timestamps-region does NOT fail SessionFactory startup")
|
||||
.isNull();
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingMissingCacheStrategyToFail_reproducesTheHardStartupErrorTheArticleDescribed() {
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:missingtimestampsfail;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18-missing-timestamps.xml")
|
||||
.applySetting("hibernate.javax.cache.missing_cache_strategy", "fail")
|
||||
.build();
|
||||
try {
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(CacheProduct.class)
|
||||
.buildMetadata();
|
||||
|
||||
Throwable thrown = catchThrowableFromBuildSessionFactory(metadata);
|
||||
|
||||
System.out.println("RESULT[cache-missing-timestamps-region-strict]: "
|
||||
+ "hibernate.javax.cache.missing_cache_strategy=fail -> "
|
||||
+ (thrown == null ? "SessionFactory built with NO error"
|
||||
: thrown.getClass().getName() + ": " + rootMessage(thrown))
|
||||
+ " -- this is how to opt into the hard-failure behavior the original article assumed "
|
||||
+ "was the default.");
|
||||
|
||||
assertThat(thrown)
|
||||
.as("with missing_cache_strategy explicitly set to fail, a missing "
|
||||
+ "default-update-timestamps-region does cause a startup error -- this is an "
|
||||
+ "opt-in, not the default")
|
||||
.isNotNull();
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable catchThrowableFromBuildSessionFactory(Metadata metadata) {
|
||||
try {
|
||||
metadata.buildSessionFactory().close();
|
||||
return null;
|
||||
} catch (Throwable t) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable cause = t;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getMessage();
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.ankurm.hibernatedemo.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.Transaction;
|
||||
import org.hibernate.boot.Metadata;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.query.Query;
|
||||
import org.hibernate.stat.Statistics;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Checks the original article's claim -- "Query Cache without Entity Cache causes N+1 selects" --
|
||||
* by actually measuring it rather than repeating it. The claim does NOT hold: with {@link
|
||||
* UncachedProduct} carrying no {@code @Cacheable}/{@code @Cache} at all, a second, brand-new
|
||||
* session repeating the same {@code setCacheable(true)} query fires ZERO SQL statements, not five.
|
||||
*
|
||||
* <p>The query cache region does not store only the row ids. It stores the full hydrated tuple
|
||||
* state of each result row (id plus every mapped column) at the moment the query first ran.
|
||||
* Hibernate reconstitutes {@code UncachedProduct} instances directly from that stored tuple data
|
||||
* on a cache hit -- no re-query of the rows, and (confirmed below via {@code
|
||||
* getSecondLevelCacheHitCount()}) no dependency on the entity's own L2 region at all, because
|
||||
* {@link UncachedProduct} does not have one. This test does not rule out an N+1 appearing for a
|
||||
* query that returns associations Hibernate must still initialize per row, or for a partial-hit
|
||||
* scenario after individual cache entries are evicted -- only the specific, simple case the
|
||||
* article described (a flat entity, no associations, a repeated identical query) is measured and
|
||||
* corrected here.
|
||||
*
|
||||
* <p>Docs: docs/18-ehcache-l2-configuration.md
|
||||
*/
|
||||
class QueryCacheWithoutEntityCacheTest {
|
||||
|
||||
private StandardServiceRegistry registry;
|
||||
private SessionFactory sessionFactory;
|
||||
|
||||
private void boot() {
|
||||
registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting("hibernate.connection.driver_class", "org.h2.Driver")
|
||||
.applySetting("hibernate.connection.url", "jdbc:h2:mem:querycachenoentity;DB_CLOSE_DELAY=-1")
|
||||
.applySetting("hibernate.connection.username", "sa")
|
||||
.applySetting("hibernate.connection.password", "")
|
||||
.applySetting("hibernate.hbm2ddl.auto", "create")
|
||||
.applySetting("hibernate.generate_statistics", "true")
|
||||
.applySetting("hibernate.cache.use_second_level_cache", "true")
|
||||
.applySetting("hibernate.cache.use_query_cache", "true")
|
||||
.applySetting("hibernate.cache.region.factory_class", "jcache")
|
||||
.applySetting("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
|
||||
.applySetting("hibernate.javax.cache.uri", "ehcache-chapter18.xml")
|
||||
.build();
|
||||
Metadata metadata = new MetadataSources(registry)
|
||||
.addAnnotatedClass(UncachedProduct.class)
|
||||
.buildMetadata();
|
||||
sessionFactory = metadata.buildSessionFactory();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (sessionFactory != null) {
|
||||
sessionFactory.close();
|
||||
}
|
||||
if (registry != null) {
|
||||
StandardServiceRegistryBuilder.destroy(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachedQuery_overUncachedEntities_repeatRunIsWorseThanNoCachingAtAll() {
|
||||
boot();
|
||||
Statistics stats = sessionFactory.getStatistics();
|
||||
|
||||
try (Session seed = sessionFactory.openSession()) {
|
||||
Transaction tx = seed.beginTransaction();
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
seed.persist(new UncachedProduct("Widget " + i));
|
||||
}
|
||||
tx.commit();
|
||||
}
|
||||
|
||||
String hql = "select p from UncachedProduct p order by p.id";
|
||||
|
||||
stats.clear();
|
||||
List<UncachedProduct> first;
|
||||
try (Session s1 = sessionFactory.openSession()) {
|
||||
Query<UncachedProduct> q = s1.createQuery(hql, UncachedProduct.class);
|
||||
q.setCacheable(true);
|
||||
first = q.list();
|
||||
}
|
||||
long queriesForFirstRun = stats.getPrepareStatementCount();
|
||||
long queryCacheMissesAfterFirst = stats.getQueryCacheMissCount();
|
||||
|
||||
stats.clear();
|
||||
List<UncachedProduct> second;
|
||||
try (Session s2 = sessionFactory.openSession()) {
|
||||
Query<UncachedProduct> q = s2.createQuery(hql, UncachedProduct.class);
|
||||
q.setCacheable(true);
|
||||
second = q.list();
|
||||
}
|
||||
long queriesForSecondRun = stats.getPrepareStatementCount();
|
||||
long queryCacheHitsAfterSecond = stats.getQueryCacheHitCount();
|
||||
long l2EntityHitsAfterSecond = stats.getSecondLevelCacheHitCount();
|
||||
|
||||
System.out.println("RESULT[cache-query-without-entity-cache]: first run (cold, new session) SQL statements="
|
||||
+ queriesForFirstRun + ", query-cache misses=" + queryCacheMissesAfterFirst
|
||||
+ " | second run (new session, query-cache HIT) SQL statements=" + queriesForSecondRun
|
||||
+ ", query-cache hits=" + queryCacheHitsAfterSecond
|
||||
+ ", L2 entity-cache hits=" + l2EntityHitsAfterSecond
|
||||
+ " -- 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.");
|
||||
|
||||
assertThat(first).hasSize(5);
|
||||
assertThat(second).hasSize(5);
|
||||
assertThat(queriesForFirstRun)
|
||||
.as("the first, cold run executes the query itself as ONE statement")
|
||||
.isEqualTo(1);
|
||||
assertThat(queryCacheHitsAfterSecond)
|
||||
.as("the second run IS a genuine query-cache hit")
|
||||
.isEqualTo(1L);
|
||||
assertThat(queriesForSecondRun)
|
||||
.as("corrected finding: the second run costs ZERO SQL statements, not the 5 individual "
|
||||
+ "SELECTs the original article claimed -- the query cache reconstitutes the "
|
||||
+ "entities directly from its own stored tuple data")
|
||||
.isZero();
|
||||
assertThat(l2EntityHitsAfterSecond)
|
||||
.as("and none of that reconstruction is an L2 entity-cache hit -- UncachedProduct has "
|
||||
+ "no L2 region to hit, so the mechanism is the query cache's own stored data, "
|
||||
+ "not a secretly-enabled entity cache")
|
||||
.isZero();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user