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. * *
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. * *
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. * *
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); } }