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:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user