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:
Rob Bygrave
2026-07-01 21:21:19 +12:00
committed by GitHub
10 changed files with 729 additions and 13 deletions
@@ -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();
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}