mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-10-21 03:39:03 +00:00
Merge pull request #3812 from dragkes/feature/invalidate-one-to-one-and-collection-cache
Invalidate one to one and collection cache (address #3811)
This commit is contained in:
@@ -56,6 +56,11 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
|
||||
private Object idValue;
|
||||
private boolean statelessUpdate;
|
||||
private boolean notifyCache;
|
||||
/**
|
||||
* Snapshot of origValues taken before intercept.setLoaded() clears them.
|
||||
* Used so that cache update code can access old FK values after setLoaded().
|
||||
*/
|
||||
private Object[] capturedOrigValues;
|
||||
/**
|
||||
* Flag used to detect when only many properties where updated via a cascade. Used to ensure
|
||||
* appropriate caches are updated in that case.
|
||||
@@ -710,7 +715,11 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
|
||||
* Return the original / old value for the given property.
|
||||
*/
|
||||
public Object origValue(BeanProperty prop) {
|
||||
return intercept.origValue(prop.propertyIndex());
|
||||
int idx = prop.propertyIndex();
|
||||
if (capturedOrigValues != null && idx < capturedOrigValues.length) {
|
||||
return capturedOrigValues[idx];
|
||||
}
|
||||
return intercept.origValue(idx);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -867,6 +876,15 @@ public final class PersistRequestBean<T> extends PersistRequest implements BeanP
|
||||
if (type == Type.UPDATE) {
|
||||
// get the dirty properties for notify cache & orphanRemoval of vanilla collection detection
|
||||
dirtyProperties = intercept.dirtyProperties();
|
||||
// snapshot orig values before setLoaded() clears them - needed by cache FK-eviction logic
|
||||
if (dirtyProperties != null && notifyCache) {
|
||||
capturedOrigValues = new Object[dirtyProperties.length];
|
||||
for (int i = 0; i < dirtyProperties.length; i++) {
|
||||
if (dirtyProperties[i]) {
|
||||
capturedOrigValues[i] = intercept.origValue(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isChangeLog) {
|
||||
changeLog();
|
||||
|
||||
+70
-9
@@ -60,6 +60,12 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
* Set to true if delete changes need to notify cache.
|
||||
*/
|
||||
private boolean cacheNotifyOnDelete;
|
||||
/**
|
||||
* Set to true if this bean type has owning OneToOne properties pointing to cached targets.
|
||||
* When true, all persist events must evict the target bean caches regardless of whether
|
||||
* this bean type itself has a bean cache (bypasses the cacheRegion.isEnabled() gate).
|
||||
*/
|
||||
private boolean cacheNotifyOneToOneOwner;
|
||||
|
||||
static <T> BeanDescriptorCacheHelp<T> create(BeanDescriptor<T> desc, SpiCacheManager cacheManager, CacheOptions cacheOptions,
|
||||
boolean cacheSharableBeans, BeanPropertyAssocOne<?>[] propertiesOneImported) {
|
||||
@@ -110,15 +116,25 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
void deriveNotifyFlags() {
|
||||
cacheNotifyOnAll = (invalidateQueryCache || hasBeanCache() || hasQueryCache());
|
||||
cacheNotifyOnDelete = !cacheNotifyOnAll && isNotifyOnDeletes();
|
||||
cacheNotifyOneToOneOwner = hasOwningOneToOneWithCachedTarget();
|
||||
if (log.isLoggable(DEBUG)) {
|
||||
if (cacheNotifyOnAll || cacheNotifyOnDelete) {
|
||||
String notifyMode = cacheNotifyOnAll ? "All" : "Delete";
|
||||
if (cacheNotifyOnAll || cacheNotifyOnDelete || cacheNotifyOneToOneOwner) {
|
||||
String notifyMode = cacheNotifyOnAll ? "All" : (cacheNotifyOnDelete ? "Delete" : "OneToOneOwner");
|
||||
log.log(DEBUG, "l2 caching on {0} - beanCaching:{1} queryCaching:{2} notifyMode:{3} ",
|
||||
desc.fullName(), isBeanCaching(), isQueryCaching(), notifyMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasOwningOneToOneWithCachedTarget() {
|
||||
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
|
||||
if (imported.isCacheNotifyOwningOneToOne()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if there is an imported bi-directional relationship to a bea
|
||||
* that does have bean caching enabled.
|
||||
@@ -139,6 +155,9 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
if (hasImmutableCaches()) {
|
||||
return true;
|
||||
}
|
||||
if (cacheNotifyOneToOneOwner) {
|
||||
return true;
|
||||
}
|
||||
return cacheRegion.isEnabled()
|
||||
&& (cacheNotifyOnAll || cacheNotifyOnDelete && (type == PersistRequest.Type.DELETE || type == PersistRequest.Type.DELETE_PERMANENT));
|
||||
}
|
||||
@@ -751,7 +770,7 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
}
|
||||
|
||||
void cacheUpdateQuery(boolean update, SpiTransaction transaction) {
|
||||
if (invalidateQueryCache || cacheNotifyOnAll || (!update && cacheNotifyOnDelete)) {
|
||||
if (invalidateQueryCache || cacheNotifyOnAll || cacheNotifyOneToOneOwner || (!update && cacheNotifyOnDelete)) {
|
||||
transaction.event().add(desc.baseTable(), false, update, !update);
|
||||
}
|
||||
}
|
||||
@@ -771,6 +790,7 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
changeSet.addBeanRemoveMany(desc, ids);
|
||||
}
|
||||
cacheDeleteImported(true, null, changeSet);
|
||||
cacheClearImportedOneToOne(changeSet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,6 +809,7 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
changeSet.addBeanRemove(desc, id);
|
||||
}
|
||||
cacheDeleteImported(true, deleteRequest.entityBean(), changeSet);
|
||||
cacheDeleteImportedOneToOne(deleteRequest.entityBean(), changeSet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +822,7 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
} else {
|
||||
queryCacheClear(changeSet);
|
||||
cacheDeleteImported(false, insertRequest.entityBean(), changeSet);
|
||||
cacheDeleteImportedOneToOne(insertRequest.entityBean(), changeSet);
|
||||
changeSet.addBeanInsert(desc.baseTable());
|
||||
}
|
||||
}
|
||||
@@ -811,6 +833,42 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the target bean cache for each owning OneToOne property on insert or delete.
|
||||
*/
|
||||
private void cacheDeleteImportedOneToOne(EntityBean entityBean, CacheChangeSet changeSet) {
|
||||
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
|
||||
imported.cacheDeleteOneToOneOwned(entityBean, changeSet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the target bean cache for each owning OneToOne property on bulk delete-by-ids.
|
||||
*/
|
||||
private void cacheClearImportedOneToOne(CacheChangeSet changeSet) {
|
||||
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
|
||||
imported.cacheClearOneToOneOwned(changeSet);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict inverse caches for any imported FK properties whose value changed in this update.
|
||||
* Handles collection-ids caches (ManyToOne FK reassignment) and bean caches (owning OneToOne FK change).
|
||||
*/
|
||||
private void cacheUpdateImportedFKs(PersistRequestBean<T> updateRequest, CacheChangeSet changeSet) {
|
||||
boolean[] dirty = updateRequest.dirtyProperties();
|
||||
if (dirty == null) {
|
||||
return;
|
||||
}
|
||||
EntityBean entityBean = updateRequest.entityBean();
|
||||
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
|
||||
int propIdx = imported.propertyIndex();
|
||||
if (propIdx < dirty.length && dirty[propIdx]) {
|
||||
imported.cacheUpdateFKchange(entityBean, updateRequest.origValue(imported), changeSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add appropriate changes to support update.
|
||||
*/
|
||||
@@ -823,6 +881,7 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
|
||||
} else {
|
||||
queryCacheClear(changeSet);
|
||||
cacheUpdateImportedFKs(updateRequest, changeSet);
|
||||
if (!hasBeanCache()) {
|
||||
// query caching only
|
||||
return;
|
||||
@@ -853,15 +912,17 @@ abstract class BeanDescriptorCacheHelp<T> {
|
||||
changeSet.addInvalidate(desc);
|
||||
return;
|
||||
}
|
||||
if (noCaching) {
|
||||
if (noCaching && !cacheNotifyOneToOneOwner) {
|
||||
return;
|
||||
}
|
||||
changeSet.addClearQuery(desc);
|
||||
// inserts don't invalidate the bean cache
|
||||
if (tableIUD.isUpdateOrDelete()) {
|
||||
changeSet.addClearBean(desc);
|
||||
if (!noCaching) {
|
||||
changeSet.addClearQuery(desc);
|
||||
// inserts don't invalidate the bean cache
|
||||
if (tableIUD.isUpdateOrDelete()) {
|
||||
changeSet.addClearBean(desc);
|
||||
}
|
||||
}
|
||||
// any change invalidates the collection IDs cache
|
||||
// any change invalidates the collection IDs cache and owning OneToOne target bean caches
|
||||
for (BeanPropertyAssocOne<?> imported : propertiesOneImported) {
|
||||
imported.cacheClear(changeSet);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
|
||||
private String deleteByParentIdInSql;
|
||||
private BeanPropertyAssocMany<?> relationshipProperty;
|
||||
private boolean cacheNotifyRelationship;
|
||||
private boolean cacheNotifyOwningOneToOne;
|
||||
|
||||
/**
|
||||
* Create based on deploy information of an EmbeddedId.
|
||||
@@ -143,6 +144,7 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
|
||||
*/
|
||||
void initialisePostTarget() {
|
||||
this.cacheNotifyRelationship = isCacheNotifyRelationship();
|
||||
this.cacheNotifyOwningOneToOne = oneToOne && !oneToOneExported && targetDescriptor.isBeanCaching();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,18 +167,31 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the L2 relationship cache for this property.
|
||||
* Return true if this owning OneToOne property requires target bean cache eviction on change.
|
||||
*/
|
||||
boolean isCacheNotifyOwningOneToOne() {
|
||||
return cacheNotifyOwningOneToOne;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the L2 relationship cache for this property (used from beanCacheApplyInvalidate).
|
||||
*/
|
||||
void cacheClear() {
|
||||
if (cacheNotifyRelationship) {
|
||||
targetDescriptor.cacheManyPropClear(relationshipProperty.name());
|
||||
}
|
||||
if (cacheNotifyOwningOneToOne) {
|
||||
targetDescriptor.clearBeanCache();
|
||||
}
|
||||
}
|
||||
|
||||
void cacheClear(CacheChangeSet changeSet) {
|
||||
if (cacheNotifyRelationship) {
|
||||
changeSet.addManyClear(targetDescriptor, relationshipProperty.name());
|
||||
}
|
||||
if (cacheNotifyOwningOneToOne) {
|
||||
changeSet.addClearBean(targetDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +214,66 @@ public class BeanPropertyAssocOne<T> extends BeanPropertyAssoc<T> implements STr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the target's bean cache entry when this owning OneToOne bean is inserted or deleted.
|
||||
*/
|
||||
void cacheDeleteOneToOneOwned(EntityBean bean, CacheChangeSet changeSet) {
|
||||
if (cacheNotifyOwningOneToOne) {
|
||||
Object assocBean = getValue(bean);
|
||||
if (assocBean != null) {
|
||||
Object targetId = targetDescriptor.id(assocBean);
|
||||
if (targetId != null) {
|
||||
changeSet.addBeanRemove(targetDescriptor, targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the target's entire bean cache when owning OneToOne beans are deleted by IDs.
|
||||
*/
|
||||
void cacheClearOneToOneOwned(CacheChangeSet changeSet) {
|
||||
if (cacheNotifyOwningOneToOne) {
|
||||
changeSet.addClearBean(targetDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the inverse caches for BOTH old and new targets when the FK changes on update.
|
||||
* Handles collection-ids caches (ManyToOne) and bean caches (owning OneToOne).
|
||||
*/
|
||||
void cacheUpdateFKchange(EntityBean bean, Object origAssocBean, CacheChangeSet changeSet) {
|
||||
Object newAssocBean = getValue(bean);
|
||||
if (cacheNotifyRelationship) {
|
||||
if (origAssocBean != null) {
|
||||
Object oldId = targetDescriptor.id(origAssocBean);
|
||||
if (oldId != null) {
|
||||
changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(oldId));
|
||||
}
|
||||
}
|
||||
if (newAssocBean != null) {
|
||||
Object newId = targetDescriptor.id(newAssocBean);
|
||||
if (newId != null) {
|
||||
changeSet.addManyRemove(targetDescriptor, relationshipProperty.name(), targetDescriptor.cacheKey(newId));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cacheNotifyOwningOneToOne) {
|
||||
if (origAssocBean != null) {
|
||||
Object oldId = targetDescriptor.id(origAssocBean);
|
||||
if (oldId != null) {
|
||||
changeSet.addBeanRemove(targetDescriptor, oldId);
|
||||
}
|
||||
}
|
||||
if (newAssocBean != null) {
|
||||
Object newId = targetDescriptor.id(newAssocBean);
|
||||
if (newId != null) {
|
||||
changeSet.addBeanRemove(targetDescriptor, newId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
Object naturalKeyVal(Map<String, Object> values) {
|
||||
EntityBean bean = (EntityBean) values.get(name);
|
||||
|
||||
@@ -48,7 +48,7 @@ public final class TableModState implements QueryCacheEntryValidate, ServerCache
|
||||
boolean isValid(Set<String> tables, Instant sinceTime) {
|
||||
for (String tableName : tables) {
|
||||
final var modTime = tableModStamp.get(tableName);
|
||||
if (modTime != null && !modTime.isBefore(sinceTime)) {
|
||||
if (modTime != null && modTime.compareTo(sinceTime) > 0) {
|
||||
if (log.isLoggable(TRACE)) {
|
||||
log.log(TRACE, "Invalidate on table:{0}", tableName);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ class TableModStateTest {
|
||||
private final TableModState tableModState = new TableModState();
|
||||
|
||||
@Test
|
||||
void isValid() {
|
||||
void isValid() throws InterruptedException {
|
||||
Instant before = Instant.now();
|
||||
Thread.sleep(5);
|
||||
|
||||
tableModState.touch(setOf("one", "two", "three"));
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package org.tests.cache;
|
||||
|
||||
import io.ebean.CacheMode;
|
||||
import io.ebean.DB;
|
||||
import io.ebean.SqlUpdate;
|
||||
import io.ebean.cache.ServerCache;
|
||||
import io.ebean.xtest.BaseTestCase;
|
||||
import io.ebeaninternal.server.cache.CachedManyIds;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.tests.model.basic.OCachedBean;
|
||||
import org.tests.model.basic.OCachedBeanChild;
|
||||
import org.tests.model.cache.o2o.OCachedO2ODetail;
|
||||
import org.tests.model.cache.o2o.OCachedO2ODetailNoCached;
|
||||
import org.tests.model.cache.o2o.OCachedO2OOwner;
|
||||
import org.tests.model.cache.o2o.OCachedO2OOwnerNoCachedDetail;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies that inverse association L2 caches are evicted when the owning side changes,
|
||||
* without evicting unrelated cache entries (precision), and that the fix is a no-op when
|
||||
* the target entity has no bean cache.
|
||||
* <ul>
|
||||
* <li>collection-ids cache (parent) when a child's {@code @ManyToOne} FK is reassigned</li>
|
||||
* <li>bean cache (detail) when an owning {@code @OneToOne} FK is inserted, updated, or deleted</li>
|
||||
* </ul>
|
||||
*/
|
||||
class TestInverseAssociationCache extends BaseTestCase {
|
||||
|
||||
// ---- collection-ids cache: ManyToOne FK reassignment (reparenting) ----
|
||||
|
||||
/**
|
||||
* When a child's ManyToOne FK is updated to point to a different parent (reparenting),
|
||||
* both the old and new parent's collection-ids caches must be evicted.
|
||||
*/
|
||||
@Test
|
||||
void collectionIds_whenChildReparented_bothParentsEvicted() {
|
||||
OCachedBean p1 = new OCachedBean();
|
||||
p1.setName("p1-reparent");
|
||||
OCachedBean p2 = new OCachedBean();
|
||||
p2.setName("p2-reparent");
|
||||
DB.save(p1);
|
||||
DB.save(p2);
|
||||
|
||||
OCachedBeanChild child = new OCachedBeanChild();
|
||||
child.setCachedBean(p1);
|
||||
DB.save(child);
|
||||
|
||||
// populate both parents' collection-ids caches
|
||||
assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren()).hasSize(1);
|
||||
assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren()).isEmpty();
|
||||
|
||||
// reparent the child from p1 to p2 (FK update) — ebean did not evict either cache before this fix
|
||||
OCachedBeanChild childRef = DB.find(OCachedBeanChild.class, child.getId());
|
||||
childRef.setCachedBean(DB.reference(OCachedBean.class, p2.getId()));
|
||||
DB.save(childRef);
|
||||
awaitL2Cache();
|
||||
|
||||
assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren())
|
||||
.as("old parent collection should be empty after reparent").isEmpty();
|
||||
assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren())
|
||||
.as("new parent collection should contain the moved child").hasSize(1);
|
||||
|
||||
// cleanup
|
||||
DB.delete(childRef);
|
||||
DB.delete(p1);
|
||||
DB.delete(p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Precision: reparenting a child only evicts the old and new parent's collection-ids entry;
|
||||
* a third parent's collection-ids cache entry must remain untouched.
|
||||
*/
|
||||
@Test
|
||||
void collectionIds_whenChildReparented_unrelatedParentCacheUnaffected() {
|
||||
OCachedBean p1 = new OCachedBean();
|
||||
p1.setName("p1-prec");
|
||||
OCachedBean p2 = new OCachedBean();
|
||||
p2.setName("p2-prec");
|
||||
OCachedBean p3 = new OCachedBean();
|
||||
p3.setName("p3-prec");
|
||||
DB.save(p1);
|
||||
DB.save(p2);
|
||||
DB.save(p3);
|
||||
|
||||
OCachedBeanChild childOfP1 = new OCachedBeanChild();
|
||||
childOfP1.setCachedBean(p1);
|
||||
OCachedBeanChild childOfP3 = new OCachedBeanChild();
|
||||
childOfP3.setCachedBean(p3);
|
||||
DB.save(childOfP1);
|
||||
DB.save(childOfP3);
|
||||
|
||||
// populate all three collection caches
|
||||
assertThat(DB.find(OCachedBean.class, p1.getId()).getChildren()).hasSize(1);
|
||||
assertThat(DB.find(OCachedBean.class, p2.getId()).getChildren()).isEmpty();
|
||||
assertThat(DB.find(OCachedBean.class, p3.getId()).getChildren()).hasSize(1);
|
||||
|
||||
// verify p3's cache entry is actually present before the reparent
|
||||
ServerCache collCache = DB.cacheManager().collectionIdsCache(OCachedBean.class, "children");
|
||||
CachedManyIds p3Before = (CachedManyIds) collCache.get(String.valueOf(p3.getId()));
|
||||
assertThat(p3Before).as("p3 collection-ids cache populated before reparent").isNotNull();
|
||||
|
||||
// reparent childOfP1 from p1 to p2 (only p1 and p2 should be affected)
|
||||
OCachedBeanChild childRef = DB.find(OCachedBeanChild.class, childOfP1.getId());
|
||||
childRef.setCachedBean(DB.reference(OCachedBean.class, p2.getId()));
|
||||
DB.save(childRef);
|
||||
awaitL2Cache();
|
||||
|
||||
// p3's collection-ids cache entry must still be present (only p1 and p2 were evicted)
|
||||
CachedManyIds p3After = (CachedManyIds) collCache.get(String.valueOf(p3.getId()));
|
||||
assertThat(p3After).as("p3 collection-ids cache must not be evicted by unrelated reparent").isNotNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(childRef);
|
||||
DB.delete(childOfP3);
|
||||
DB.delete(p1);
|
||||
DB.delete(p2);
|
||||
DB.delete(p3);
|
||||
}
|
||||
|
||||
// ---- exported OneToOne: bean cache eviction ----
|
||||
|
||||
/**
|
||||
* When a new owning OneToOne bean is inserted pointing at a detail, the detail's
|
||||
* bean cache must be evicted so the next read reflects the newly established link.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerInserted_detailBeanCacheEvicted() {
|
||||
OCachedO2ODetail d = new OCachedO2ODetail("d-ins");
|
||||
DB.save(d);
|
||||
long dId = d.getId();
|
||||
|
||||
// populate detail's bean cache with owner=null
|
||||
DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially no owner").isNull();
|
||||
|
||||
// insert a new owner pointing at d — must evict d's bean cache
|
||||
OCachedO2OOwner newOwner = new OCachedO2OOwner("o-ins", DB.reference(OCachedO2ODetail.class, dId));
|
||||
DB.save(newOwner);
|
||||
awaitL2Cache();
|
||||
|
||||
OCachedO2OOwner ownerAfter = DB.find(OCachedO2ODetail.class, dId).getOwner();
|
||||
assertThat(ownerAfter).as("detail owner should be present after owner insert").isNotNull();
|
||||
assertThat(ownerAfter.getId()).isEqualTo(newOwner.getId());
|
||||
|
||||
// cleanup
|
||||
DB.delete(newOwner);
|
||||
DB.delete(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the owning OneToOne FK is changed to point to a different detail (repoint),
|
||||
* both the old detail's and new detail's bean caches must be evicted.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerFkRepointed_bothDetailBeanCachesEvicted() {
|
||||
OCachedO2ODetail d1 = new OCachedO2ODetail("d1-repoint");
|
||||
OCachedO2ODetail d2 = new OCachedO2ODetail("d2-repoint");
|
||||
DB.save(d1);
|
||||
DB.save(d2);
|
||||
OCachedO2OOwner owner = new OCachedO2OOwner("o-repoint", d1);
|
||||
DB.save(owner);
|
||||
long d1Id = d1.getId();
|
||||
long d2Id = d2.getId();
|
||||
|
||||
// populate both details' bean caches: d1 with owner reference, d2 with owner=null
|
||||
DB.find(OCachedO2ODetail.class).setId(d1Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
DB.find(OCachedO2ODetail.class).setId(d2Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, d1Id).getOwner()).as("d1 initially owned").isNotNull();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, d2Id).getOwner()).as("d2 initially has no owner").isNull();
|
||||
|
||||
// repoint owner from d1 to d2 — must evict both d1's and d2's bean caches
|
||||
OCachedO2OOwner ownerRef = DB.find(OCachedO2OOwner.class, owner.getId());
|
||||
ownerRef.setDetail(DB.reference(OCachedO2ODetail.class, d2Id));
|
||||
DB.save(ownerRef);
|
||||
awaitL2Cache();
|
||||
|
||||
assertThat(DB.find(OCachedO2ODetail.class, d1Id).getOwner())
|
||||
.as("d1 owner after repoint (should be null)").isNull();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, d2Id).getOwner())
|
||||
.as("d2 owner after repoint (should be the owner)").isNotNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(ownerRef);
|
||||
DB.delete(d1);
|
||||
DB.delete(d2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Precision: repointng only evicts the old and new detail's bean cache entry;
|
||||
* a third, unrelated detail's bean cache entry must remain untouched.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerFkRepointed_unrelatedDetailCacheUnaffected() {
|
||||
OCachedO2ODetail d1 = new OCachedO2ODetail("d1-prec");
|
||||
OCachedO2ODetail d2 = new OCachedO2ODetail("d2-prec");
|
||||
OCachedO2ODetail d3 = new OCachedO2ODetail("d3-prec");
|
||||
DB.save(d1);
|
||||
DB.save(d2);
|
||||
DB.save(d3);
|
||||
OCachedO2OOwner owner = new OCachedO2OOwner("o-prec", d1);
|
||||
DB.save(owner);
|
||||
long d1Id = d1.getId();
|
||||
long d2Id = d2.getId();
|
||||
long d3Id = d3.getId();
|
||||
|
||||
// populate all three details in bean cache
|
||||
DB.find(OCachedO2ODetail.class).setId(d1Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
DB.find(OCachedO2ODetail.class).setId(d2Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
DB.find(OCachedO2ODetail.class).setId(d3Id).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
|
||||
// confirm d3 is in bean cache before the repoint
|
||||
ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class);
|
||||
assertThat(beanCache.get(String.valueOf(d3Id))).as("d3 bean cache populated before repoint").isNotNull();
|
||||
|
||||
// repoint owner from d1 to d2 (only d1 and d2 should be evicted from bean cache)
|
||||
OCachedO2OOwner ownerRef = DB.find(OCachedO2OOwner.class, owner.getId());
|
||||
ownerRef.setDetail(DB.reference(OCachedO2ODetail.class, d2Id));
|
||||
DB.save(ownerRef);
|
||||
awaitL2Cache();
|
||||
|
||||
// d3's bean cache entry must still be present
|
||||
assertThat(beanCache.get(String.valueOf(d3Id)))
|
||||
.as("d3 bean cache must not be evicted by unrelated repoint").isNotNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(ownerRef);
|
||||
DB.delete(d1);
|
||||
DB.delete(d2);
|
||||
DB.delete(d3);
|
||||
}
|
||||
|
||||
/**
|
||||
* When an owning OneToOne bean is deleted (by bean), the detail's bean cache
|
||||
* must be evicted so the next read reflects that the link is gone.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerDeletedByBean_detailBeanCacheEvicted() {
|
||||
OCachedO2ODetail d = new OCachedO2ODetail("d-del-bean");
|
||||
DB.save(d);
|
||||
OCachedO2OOwner owner = new OCachedO2OOwner("o-del-bean", d);
|
||||
DB.save(owner);
|
||||
long dId = d.getId();
|
||||
|
||||
// populate detail's bean cache with the owner reference
|
||||
DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull();
|
||||
|
||||
// delete the owner by bean — must evict d's bean cache
|
||||
DB.delete(DB.find(OCachedO2OOwner.class, owner.getId()));
|
||||
awaitL2Cache();
|
||||
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner())
|
||||
.as("detail owner should be null after owner delete (by bean)").isNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* When an owning OneToOne bean is deleted by id (no bean loaded), the detail's bean
|
||||
* cache must be cleared so the next read reflects that the link is gone.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerDeletedById_detailBeanCacheCleared() {
|
||||
OCachedO2ODetail d = new OCachedO2ODetail("d-del-id");
|
||||
DB.save(d);
|
||||
OCachedO2OOwner owner = new OCachedO2OOwner("o-del-id", d);
|
||||
DB.save(owner);
|
||||
long dId = d.getId();
|
||||
|
||||
// populate detail's bean cache with the owner reference
|
||||
DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull();
|
||||
|
||||
// delete the owner by id (no bean loaded) — must clear d's entire bean cache
|
||||
DB.delete(OCachedO2OOwner.class, owner.getId());
|
||||
awaitL2Cache();
|
||||
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner())
|
||||
.as("detail owner should be null after owner delete (by id)").isNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(d);
|
||||
}
|
||||
|
||||
// ---- non-cached target: no errors, no unintended eviction ----
|
||||
|
||||
/**
|
||||
* When the target detail has no {@code @Cache}, {@code cacheNotifyOwningOneToOne} is false
|
||||
* and all persist operations are no-ops for cache purposes — no exception, no unintended clearing.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_withNonCachedDetail_insertUpdateDelete_noErrors() {
|
||||
OCachedO2ODetailNoCached d = new OCachedO2ODetailNoCached("d-nc");
|
||||
DB.save(d);
|
||||
|
||||
// insert owner → no cache eviction, no error
|
||||
OCachedO2OOwnerNoCachedDetail owner = new OCachedO2OOwnerNoCachedDetail("o-nc", d);
|
||||
DB.save(owner);
|
||||
|
||||
// update owner FK → no cache eviction, no error
|
||||
OCachedO2ODetailNoCached d2 = new OCachedO2ODetailNoCached("d2-nc");
|
||||
DB.save(d2);
|
||||
OCachedO2OOwnerNoCachedDetail ownerRef = DB.find(OCachedO2OOwnerNoCachedDetail.class, owner.getId());
|
||||
ownerRef.setDetail(d2);
|
||||
DB.save(ownerRef);
|
||||
|
||||
// delete owner → no cache eviction, no error
|
||||
DB.delete(ownerRef);
|
||||
|
||||
// delete-by-id → no cache eviction, no error
|
||||
OCachedO2OOwnerNoCachedDetail o2 = new OCachedO2OOwnerNoCachedDetail("o2-nc", d2);
|
||||
DB.save(o2);
|
||||
DB.delete(OCachedO2OOwnerNoCachedDetail.class, o2.getId());
|
||||
|
||||
// verify the cached detail's bean cache is NOT affected by the non-cached owner operations
|
||||
OCachedO2ODetail cachedDetail = new OCachedO2ODetail("still-cached");
|
||||
DB.save(cachedDetail);
|
||||
DB.find(OCachedO2ODetail.class).setId(cachedDetail.getId()).setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class);
|
||||
assertThat(beanCache.get(String.valueOf(cachedDetail.getId())))
|
||||
.as("unrelated cached detail's bean cache must not be wiped by non-cached owner operations").isNotNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(cachedDetail);
|
||||
DB.delete(d);
|
||||
DB.delete(d2);
|
||||
}
|
||||
|
||||
// ---- SQL/bulk update path (persistTableIUD) ----
|
||||
|
||||
/**
|
||||
* When the owner table is updated via a raw SQL update, the detail's bean cache
|
||||
* must be cleared because we cannot know which FKs changed.
|
||||
*/
|
||||
@Test
|
||||
void oneToOne_whenOwnerTableBulkUpdated_detailBeanCacheCleared() {
|
||||
OCachedO2ODetail d = new OCachedO2ODetail("d-sql");
|
||||
DB.save(d);
|
||||
OCachedO2OOwner owner = new OCachedO2OOwner("o-sql", d);
|
||||
DB.save(owner);
|
||||
long dId = d.getId();
|
||||
|
||||
// populate detail's bean cache with owner reference
|
||||
DB.find(OCachedO2ODetail.class).setId(dId).fetch("owner").setBeanCacheMode(CacheMode.PUT).findOne();
|
||||
assertThat(DB.find(OCachedO2ODetail.class, dId).getOwner()).as("initially owned").isNotNull();
|
||||
|
||||
// raw SQL update on the owner table — triggers persistTableIUD which must clear detail's bean cache
|
||||
SqlUpdate update = DB.sqlUpdate("update o_cached_o2o_owner set name = 'sql-updated' where id = :id");
|
||||
update.setParameter("id", owner.getId());
|
||||
update.execute();
|
||||
awaitL2Cache();
|
||||
|
||||
// detail's bean cache should have been cleared (entire cache, since we don't know which FKs changed)
|
||||
ServerCache beanCache = DB.cacheManager().beanCache(OCachedO2ODetail.class);
|
||||
assertThat(beanCache.get(String.valueOf(dId)))
|
||||
.as("detail bean cache should be cleared after bulk SQL update on owner table").isNull();
|
||||
|
||||
// cleanup
|
||||
DB.delete(owner);
|
||||
DB.delete(d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.tests.model.cache.o2o;
|
||||
|
||||
import io.ebean.annotation.Cache;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* Cached entity that is the inverse (exported) side of a OneToOne.
|
||||
* Used to verify that when the owning side's FK changes, this entity's bean cache entry is evicted.
|
||||
*/
|
||||
@Cache
|
||||
@Entity
|
||||
@Table(name = "o_cached_o2o_detail")
|
||||
public class OCachedO2ODetail {
|
||||
|
||||
@Id
|
||||
Long id;
|
||||
|
||||
String name;
|
||||
|
||||
@OneToOne(mappedBy = "detail")
|
||||
OCachedO2OOwner owner;
|
||||
|
||||
public OCachedO2ODetail(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public OCachedO2OOwner getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(OCachedO2OOwner owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package org.tests.model.cache.o2o;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* Detail entity WITHOUT {@code @Cache} — the owning {@link OCachedO2OOwnerNoCachedDetail}
|
||||
* must not trigger any bean cache eviction because there is no bean cache to evict.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "o_cached_o2o_detail_nc")
|
||||
public class OCachedO2ODetailNoCached {
|
||||
|
||||
@Id
|
||||
Long id;
|
||||
|
||||
String name;
|
||||
|
||||
@OneToOne(mappedBy = "detail")
|
||||
OCachedO2OOwnerNoCachedDetail owner;
|
||||
|
||||
public OCachedO2ODetailNoCached(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public OCachedO2OOwnerNoCachedDetail getOwner() {
|
||||
return owner;
|
||||
}
|
||||
|
||||
public void setOwner(OCachedO2OOwnerNoCachedDetail owner) {
|
||||
this.owner = owner;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.tests.model.cache.o2o;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* Owning side of a OneToOne targeting a cached entity (OCachedO2ODetail).
|
||||
* When this bean is inserted, updated, or deleted the target's bean cache must be evicted.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "o_cached_o2o_owner")
|
||||
public class OCachedO2OOwner {
|
||||
|
||||
@Id
|
||||
Long id;
|
||||
|
||||
String name;
|
||||
|
||||
@OneToOne
|
||||
OCachedO2ODetail detail;
|
||||
|
||||
public OCachedO2OOwner(String name, OCachedO2ODetail detail) {
|
||||
this.name = name;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public OCachedO2ODetail getDetail() {
|
||||
return detail;
|
||||
}
|
||||
|
||||
public void setDetail(OCachedO2ODetail detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package org.tests.model.cache.o2o;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* Owning side of a OneToOne whose target ({@link OCachedO2ODetailNoCached}) has no {@code @Cache}.
|
||||
* Used to verify that when the target is not cached, no eviction (and no error) occurs.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "o_cached_o2o_owner_nc")
|
||||
public class OCachedO2OOwnerNoCachedDetail {
|
||||
|
||||
@Id
|
||||
Long id;
|
||||
|
||||
String name;
|
||||
|
||||
@OneToOne
|
||||
OCachedO2ODetailNoCached detail;
|
||||
|
||||
public OCachedO2OOwnerNoCachedDetail(String name, OCachedO2ODetailNoCached detail) {
|
||||
this.name = name;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public OCachedO2ODetailNoCached getDetail() {
|
||||
return detail;
|
||||
}
|
||||
|
||||
public void setDetail(OCachedO2ODetailNoCached detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user