Skip to content

Commit

Permalink
HHH-17997 Querying an Entity with CacheConcurrencyStrategy.READONLY t…
Browse files Browse the repository at this point in the history
…hrows UnsupportedOperationException: Can't update readonly object
  • Loading branch information
dreab8 authored and beikov committed Aug 7, 2024
1 parent 20acd52 commit 54c1c4a
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.hibernate.annotations.NotFoundAction;
import org.hibernate.bytecode.enhance.spi.LazyPropertyInitializer;
import org.hibernate.bytecode.enhance.spi.interceptor.EnhancementAsProxyLazinessInterceptor;
import org.hibernate.cache.spi.access.AccessType;
import org.hibernate.cache.spi.access.EntityDataAccess;
import org.hibernate.cache.spi.entry.CacheEntry;
import org.hibernate.engine.spi.EntityEntry;
Expand Down Expand Up @@ -1389,24 +1390,34 @@ private void putInCache(
// we need to be careful not to clobber the lock here in the cache so that it can be rolled back if need be
final EventManager eventManager = session.getEventManager();
if ( persistenceContext.wasInsertedDuringTransaction( data.concreteDescriptor, data.entityKey.getIdentifier() ) ) {
boolean update = false;
boolean cacheContentChanged = false;
final HibernateMonitoringEvent cachePutEvent = eventManager.beginCachePutEvent();
try {
update = cacheAccess.update(
session,
cacheKey,
data.concreteDescriptor.getCacheEntryStructure().structure( cacheEntry ),
version,
version
);
// Updating the cache entry for entities that were inserted in this transaction
// only makes sense for transactional caches. Other implementations no-op for #update
// Since #afterInsert will run at the end of the transaction,
// the state of an entity will be stored in the cache eventually.
// Refreshing an inserted entity is a potential concern,
// because one might think that we are missing to store the refreshed data in the cache.
// Actually an entity is evicted from the cache on refresh for non-transactional caches
// via CachedDomainDataAccess#unlockItem after transaction completion, so all is fine.
if ( cacheAccess.getAccessType() == AccessType.TRANSACTIONAL ) {
cacheContentChanged = cacheAccess.update(
session,
cacheKey,
data.concreteDescriptor.getCacheEntryStructure().structure( cacheEntry ),
version,
version
);
}
}
finally {
eventManager.completeCachePutEvent(
cachePutEvent,
session,
cacheAccess,
data.concreteDescriptor,
update,
cacheContentChanged,
EventManager.CacheActionDescription.ENTITY_UPDATE
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,20 @@
import jakarta.persistence.Id;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertFalse;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@JiraKey("HHH-17997")
@Jpa(
annotatedClasses = {
ChacheReadOnlyStartegyTest.TestEntity.class
CacheReadOnlyStrategyTest.TestEntity.class
},
integrationSettings = {
@Setting(name = AvailableSettings.USE_SECOND_LEVEL_CACHE, value = "true"),
@Setting(name = AvailableSettings.USE_QUERY_CACHE, value = "false"),
}
)
public class ChacheReadOnlyStartegyTest {
public class CacheReadOnlyStrategyTest {

@AfterEach
public void tearDown(EntityManagerFactoryScope scope) {
Expand All @@ -43,7 +43,7 @@ public void tearDown(EntityManagerFactoryScope scope) {

@Test
public void testPersistThenClearAndQuery(EntityManagerFactoryScope scope) {
final long testEntityId = 1l;
final long testEntityId = 1L;

scope.inTransaction(
entityManager -> {
Expand Down Expand Up @@ -71,26 +71,21 @@ public void testPersistThenClearAndQuery(EntityManagerFactoryScope scope) {

@Test
public void testPersistThenClearAndQueryWithRollback(EntityManagerFactoryScope scope) {
final long testEntityId = 1l;
final long testEntityId = 1L;

scope.inEntityManager(
scope.inTransaction(
entityManager -> {
entityManager.getTransaction().begin();
try {
TestEntity entity = new TestEntity( testEntityId, "test" );
entityManager.persist( entity );
entityManager.flush();
entityManager.clear();
List<TestEntity> results = entityManager.createQuery(
"select t from TestEntity t where t.id = :id",
TestEntity.class
).setParameter( "id", testEntityId ).getResultList();

assertThat( results.size() ).isEqualTo( 1 );
}
finally {
entityManager.getTransaction().rollback();
}
TestEntity entity = new TestEntity( testEntityId, "test" );
entityManager.persist( entity );
entityManager.flush();
entityManager.clear();
List<TestEntity> results = entityManager.createQuery(
"select t from TestEntity t where t.id = :id",
TestEntity.class
).setParameter( "id", testEntityId ).getResultList();

entityManager.getTransaction().setRollbackOnly();
assertThat( results.size() ).isEqualTo( 1 );
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package org.hibernate.orm.test.caching;

import java.time.Instant;
import java.util.List;

import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.dialect.PostgreSQLDialect;

import org.hibernate.testing.orm.junit.EntityManagerFactoryScope;
import org.hibernate.testing.orm.junit.JiraKey;
import org.hibernate.testing.orm.junit.Jpa;
import org.hibernate.testing.orm.junit.RequiresDialect;
import org.hibernate.testing.orm.junit.Setting;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@JiraKey("HHH-17997")
@Jpa(
annotatedClasses = {
CachingWithTriggerTest.TestEntity.class
},
integrationSettings = {
@Setting(name = AvailableSettings.USE_SECOND_LEVEL_CACHE, value = "true"),
@Setting(name = AvailableSettings.USE_QUERY_CACHE, value = "false"),
}
)
@RequiresDialect(value = PostgreSQLDialect.class, comment = "To write a trigger only once")
public class CachingWithTriggerTest {

private static final String TRIGGER = "begin NEW.lastUpdatedAt = current_timestamp; return NEW; end;";

@BeforeEach
public void prepare(EntityManagerFactoryScope scope) {
scope.inTransaction(
s -> {
s.createNativeQuery( "create function update_ts_func() returns trigger language plpgsql as $$ " + TRIGGER + " $$" )
.executeUpdate();
s.createNativeQuery( "create trigger update_ts before insert on TestEntity for each row execute procedure update_ts_func()" )
.executeUpdate();
}
);
}

@AfterEach
public void cleanup(EntityManagerFactoryScope scope) {
scope.inTransaction(
s -> {
s.createNativeQuery( "drop trigger if exists update_ts on TestEntity" )
.executeUpdate();
s.createNativeQuery( "drop function if exists update_ts_func()" )
.executeUpdate();
s.createQuery( "delete from TestEntity" ).executeUpdate();
}
);
}

@Test
public void testPersistThenRefresh(EntityManagerFactoryScope scope) {
final long testEntityId = 1L;

scope.inTransaction(
entityManager -> {
TestEntity entity = new TestEntity( testEntityId, "test" );
entityManager.persist( entity );
entityManager.flush();
// No reload happens
assertThat( entity.lastUpdatedAt ).isNull();
}
);
scope.inTransaction(
entityManager -> {
TestEntity entity = entityManager.find( TestEntity.class, testEntityId );
entityManager.refresh( entity );
// On refresh, we want the actual data from the database
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);
scope.inTransaction(
entityManager -> {
TestEntity entity = entityManager.find( TestEntity.class, testEntityId );
// Ensure that we don't get stale data
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);
}

@Test
public void testPersistThenRefreshInTransaction(EntityManagerFactoryScope scope) {
final long testEntityId = 1L;

scope.inTransaction(
entityManager -> {
TestEntity entity = new TestEntity( testEntityId, "test" );
entityManager.persist( entity );
entityManager.flush();
entityManager.refresh( entity );
// On refresh, we want the actual data from the database
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);

scope.inTransaction(
entityManager -> {
TestEntity entity = entityManager.find( TestEntity.class, testEntityId );
// Ensure that we don't get stale data
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);
}

@Test
public void testPersistThenRefreshClearAndQueryInTransaction(EntityManagerFactoryScope scope) {
final long testEntityId = 1L;

scope.inTransaction(
entityManager -> {
TestEntity entity = new TestEntity( testEntityId, "test" );
entityManager.persist( entity );
entityManager.flush();
entityManager.refresh( entity );
// On refresh, we want the actual data from the database
assertThat( entity.lastUpdatedAt ).isNotNull();
entityManager.clear();

entity = entityManager.find( TestEntity.class, testEntityId );
// Ensure that we don't get stale data
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);

scope.inTransaction(
entityManager -> {
TestEntity entity = entityManager.find( TestEntity.class, testEntityId );
// Ensure that we don't get stale data
assertThat( entity.lastUpdatedAt ).isNotNull();
}
);
}

@Entity(name = "TestEntity")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public static class TestEntity {
@Id
private Long id;

private String name;

private Instant lastUpdatedAt;

public TestEntity() {
}

public TestEntity(Long id, String name) {
this.id = id;
this.name = name;
}
}

}

0 comments on commit 54c1c4a

Please sign in to comment.