This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 3b4778feb3 [#12376] fix(core): fence batchGet write-back against 
in-flight cache invalidation (#13374)
3b4778feb3 is described below

commit 3b4778feb3023c97b880ee5fba65ecabc5c4c43e
Author: Qi Yu <[email protected]>
AuthorDate: Tue Sep 22 19:24:16 2026 +0800

    [#12376] fix(core): fence batchGet write-back against in-flight cache 
invalidation (#13374)
    
    ### What changes were proposed in this pull request?
    
    - `RelationalEntityStore` keeps an invalidation epoch that every cache
    invalidation and clear advances. All of the store's own invalidations go
    through `invalidateCache()` / `clearCache()`, and the change-log
    listener now takes a narrow `EntityCacheChangeLogListener.Target`
    provided by the store instead of the raw `EntityCache`, so replayed
    invalidations advance the epoch too. The existing
    `EntityCacheChangeLogListener(EntityCache)` constructor is kept.
    - `RelationalEntityStore.batchGet` samples the epoch before the backend
    read and, under each entry's cache lock, writes the entity back only if
    the epoch is unchanged.
    - Tests: `TestRelationalEntityStoreBatchGetLateFill` — write-back
    succeeds normally; is skipped when a change-log invalidation, a local
    `delete`, or the listener's clear fallback lands during the backend
    read.
    
    ### Why are the changes needed?
    
    `get()` holds the entry's segment lock across the backend read and the
    write-back, so it cannot race with the poller's `invalidate`.
    `batchGet()` did not: it read the backend and called `cache.put()`
    unlocked, so an invalidation landing in between was overwritten by the
    stale copy, which then survived until the cache TTL (default 1h) instead
    of one poll interval. This is on the authorization hot path
    (`MetadataAuthzHelper`, `JcasbinAuthorizer`).
    
    Holding one lock per key across the batch DB round trip was rejected:
    with the default 16 segments it would stall unrelated cache reads on the
    node. The epoch is cache-wide, so a batch fill that coincides with any
    invalidation is skipped (the next `get` refills); it can never resurrect
    a stale value.
    
    The residual window where a *hierarchical* invalidation (e.g. schema
    drop) removes a child key without taking the child's lock is shared with
    `get()` and unchanged here.
    
    Part of: #12376 (the fresh-read channel and the action-sensitive call
    sites are handled under #12150).
    
    Fixed: #12376
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The `EntityCache` SPI is unchanged; everything lives in
    `storage.relational`.
    
    ### How was this patch tested?
    
    New unit tests above; `./gradlew :core:test --tests
    'org.apache.gravitino.cache.*' --tests
    'org.apache.gravitino.storage.relational.TestRelationalEntityStore*'
    --tests 'org.apache.gravitino.storage.relational.TestEntityCache*'
    --tests 'org.apache.gravitino.storage.relational.TestEntityChangeLog*'
    -PskipITs` (126 tests green).
---
 .../relational/EntityCacheChangeLogListener.java   |  52 ++++-
 .../storage/relational/RelationalEntityStore.java  |  94 ++++++--
 .../TestRelationalEntityStoreBatchGetLateFill.java | 260 +++++++++++++++++++++
 3 files changed, 388 insertions(+), 18 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityCacheChangeLogListener.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityCacheChangeLogListener.java
index ed7b942e00..ad9d568f10 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/EntityCacheChangeLogListener.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/EntityCacheChangeLogListener.java
@@ -61,16 +61,58 @@ public class EntityCacheChangeLogListener implements 
EntityChangeLogListener {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(EntityCacheChangeLogListener.class);
 
-  private final EntityCache cache;
+  /**
+   * The two invalidation entry points this listener needs from the cache it 
keeps coherent. The
+   * entity store hands in its own implementation so that it observes every 
invalidation, including
+   * the ones replayed from other nodes (see {@code 
RelationalEntityStore#batchGet}).
+   */
+  public interface Target {
+    /**
+     * Invalidates the cache entry of the given entity, see {@link 
EntityCache#invalidate}.
+     *
+     * @param ident the identifier of the changed entity
+     * @param type the type of the changed entity
+     */
+    void invalidate(NameIdentifier ident, EntityType type);
+
+    /** Clears the whole cache, see {@link EntityCache#clear()}. */
+    void clear();
+  }
+
+  private final Target target;
 
   /**
-   * Creates a listener that invalidates the given entity store cache.
+   * Creates a listener that invalidates the given entity store cache directly.
    *
    * @param cache the per-node entity store cache to keep coherent
    */
   public EntityCacheChangeLogListener(EntityCache cache) {
+    this(asTarget(cache));
+  }
+
+  /**
+   * Creates a listener that invalidates through the given target.
+   *
+   * @param target the invalidation entry points of the per-node cache to keep 
coherent
+   */
+  public EntityCacheChangeLogListener(Target target) {
+    Preconditions.checkArgument(target != null, "target cannot be null");
+    this.target = target;
+  }
+
+  private static Target asTarget(EntityCache cache) {
     Preconditions.checkArgument(cache != null, "cache cannot be null");
-    this.cache = cache;
+    return new Target() {
+      @Override
+      public void invalidate(NameIdentifier ident, EntityType type) {
+        cache.invalidate(ident, type);
+      }
+
+      @Override
+      public void clear() {
+        cache.clear();
+      }
+    };
   }
 
   @Override
@@ -86,7 +128,7 @@ public class EntityCacheChangeLogListener implements 
EntityChangeLogListener {
 
       try {
         LOG.debug("Invalidating entity cache due to entity change log: {} 
({})", ident, type);
-        cache.invalidate(ident, type);
+        target.invalidate(ident, type);
       } catch (RuntimeException e) {
         // Dropping a single invalidation would leave this node serving that 
entity stale until it
         // expires. Clearing the whole cache is the safe superset, and it also 
covers the rest of
@@ -97,7 +139,7 @@ public class EntityCacheChangeLogListener implements 
EntityChangeLogListener {
             ident,
             type,
             e);
-        cache.clear();
+        target.clear();
         return;
       }
     }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index 50572c5b9d..186d0b8cc8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -29,6 +29,7 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import javax.annotation.Nullable;
@@ -46,6 +47,7 @@ import org.apache.gravitino.RelationQuery;
 import org.apache.gravitino.RelationUpdate;
 import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.cache.BaseEntityCache;
 import org.apache.gravitino.cache.CacheFactory;
 import org.apache.gravitino.cache.CachedEntityIdResolver;
 import org.apache.gravitino.cache.Coherence;
@@ -75,6 +77,11 @@ public class RelationalEntityStore
   private EntityChangeLogCleaner entityChangeLogCleaner;
   private EntityCache cache;
 
+  // Advanced before every invalidation observed by this store, whether local 
or replayed from the
+  // change log. A shared cache without a local change-log listener needs its 
own distributed
+  // version check: this counter cannot detect changes made on another node.
+  private final AtomicLong cacheInvalidationEpoch = new AtomicLong();
+
   // Non-null only for a LOCAL_PER_NODE cache, which needs cross-node 
invalidation. SHARED and NONE
   // caches have no per-node copy to invalidate, so no listener is registered.
   @Nullable private EntityCacheChangeLogListener entityCacheChangeLogListener;
@@ -127,10 +134,32 @@ public class RelationalEntityStore
       return;
     }
 
-    this.entityCacheChangeLogListener = new 
EntityCacheChangeLogListener(cache);
+    this.entityCacheChangeLogListener = newCacheChangeLogListener();
     this.entityChangeLogPoller.registerListener(entityCacheChangeLogListener);
   }
 
+  /**
+   * Creates the change-log listener that keeps this store's cache coherent 
with changes made on
+   * other nodes.
+   *
+   * @return a listener whose invalidations go through this store, see {@link 
#batchGet}
+   */
+  @VisibleForTesting
+  EntityCacheChangeLogListener newCacheChangeLogListener() {
+    return new EntityCacheChangeLogListener(
+        new EntityCacheChangeLogListener.Target() {
+          @Override
+          public void invalidate(NameIdentifier ident, Entity.EntityType type) 
{
+            invalidateCache(ident, type);
+          }
+
+          @Override
+          public void clear() {
+            clearCache();
+          }
+        });
+  }
+
   private RelationalBackend createRelationalEntityBackend(Config config) {
     String backendName = config.get(ENTITY_RELATIONAL_STORE);
     String className =
@@ -177,7 +206,7 @@ public class RelationalEntityStore
       // An overwrite is resolved by the database, which may keep the identity 
and version of the
       // row it already had. Caching the copy handed in here would publish 
values the stored row
       // does not carry, so the next read is served from the backend instead.
-      cache.invalidate(e.nameIdentifier(), e.type());
+      invalidateCache(e.nameIdentifier(), e.type());
     } else {
       cache.put(e);
     }
@@ -188,7 +217,7 @@ public class RelationalEntityStore
       NameIdentifier ident, Class<E> type, Entity.EntityType entityType, 
Function<E, E> updater)
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
     E updatedEntity = backend.update(ident, entityType, updater);
-    cache.invalidate(ident, entityType);
+    invalidateCache(ident, entityType);
     return updatedEntity;
   }
 
@@ -223,9 +252,35 @@ public class RelationalEntityStore
                   return entity.isEmpty();
                 })
             .toList();
+    // Unlike get(), the backend read is not done under the entries' cache 
locks: holding one lock
+    // per key across a batch DB round trip would stall unrelated reads on the 
same segments. So an
+    // invalidation can land between the read and the write-back. The epoch 
sampled here detects
+    // that and skips the write-back, otherwise the stale copy would survive 
until the TTL. The
+    // per-key lock makes the check and the put atomic against an invalidation 
of the same key.
+    long epochBeforeRead = cacheInvalidationEpoch.get();
     List<E> fetchEntities = backend.batchGet(noCacheIdents, entityType);
     for (E entity : fetchEntities) {
-      cache.put(entity);
+      if (cache instanceof BaseEntityCache && 
!BaseEntityCache.isCacheable(entity.type())) {
+        // BaseEntityCache.put may invalidate a related entry even when it 
does not cache this
+        // entity. Keep that hook, but avoid taking a key lock for a value 
that cannot be cached.
+        if (cacheInvalidationEpoch.get() == epochBeforeRead) {
+          cache.put(entity);
+        }
+        allEntities.add(entity);
+        continue;
+      }
+      cache.withCacheLock(
+          EntityCacheKey.of(entity.nameIdentifier(), entity.type()),
+          () -> {
+            if (cacheInvalidationEpoch.get() == epochBeforeRead) {
+              cache.put(entity);
+              // A whole-cache clear can run while this key lock is held. If 
it happened during
+              // put, remove the value we may have written after the clear.
+              if (cacheInvalidationEpoch.get() != epochBeforeRead) {
+                cache.invalidate(entity.nameIdentifier(), entity.type());
+              }
+            }
+          });
       allEntities.add(entity);
     }
     return allEntities;
@@ -240,7 +295,7 @@ public class RelationalEntityStore
     } catch (NoSuchEntityException e) {
       return false;
     } finally {
-      cache.invalidate(ident, entityType);
+      invalidateCache(ident, entityType);
     }
   }
 
@@ -254,7 +309,7 @@ public class RelationalEntityStore
     try {
       return backend.deleteAndGet(ident, entityType, clazz, postDeleteAction);
     } finally {
-      cache.invalidate(ident, entityType);
+      invalidateCache(ident, entityType);
     }
   }
 
@@ -358,8 +413,8 @@ public class RelationalEntityStore
     // relation write can change data materialized into the endpoint entity. 
Note this is not free —
     // EntityCache#invalidate cascades over the identifier hierarchy, so 
invalidating a catalog also
     // drops every cached schema and table beneath it.
-    cache.invalidate(srcIdentifier, srcType);
-    cache.invalidate(dstIdentifier, dstType);
+    invalidateCache(srcIdentifier, srcType);
+    invalidateCache(dstIdentifier, dstType);
   }
 
   @Override
@@ -379,9 +434,9 @@ public class RelationalEntityStore
     // Invalidate both endpoints for the same reason as insertRelation, 
including the hierarchy
     // cascade noted there.
     for (NameIdentifier ident : srcIdentifiers) {
-      cache.invalidate(ident, srcType);
+      invalidateCache(ident, srcType);
     }
-    cache.invalidate(dstIdentifier, dstType);
+    invalidateCache(dstIdentifier, dstType);
   }
 
   @Override
@@ -407,7 +462,7 @@ public class RelationalEntityStore
         backend.updateEntityRelations(
             relType, srcEntityIdent, srcEntityType, destEntitiesToAdd, 
destEntitiesToRemove);
     Entity.EntityType targetEntityType = relationUpdateTargetType(relType);
-    cache.invalidate(srcEntityIdent, srcEntityType);
+    invalidateCache(srcEntityIdent, srcEntityType);
     invalidateRelationTargetCache(targetEntityType, update.targetsToAdd());
     invalidateRelationTargetCache(targetEntityType, update.targetsToRemove());
 
@@ -440,7 +495,7 @@ public class RelationalEntityStore
     // Invalidate after the backend write, not before: invalidating first 
opens a window where a
     // concurrent read could repopulate the cache with stale pre-commit data.
     Entity.EntityType targetEntityType = 
relationUpdateTargetType(update.relationType());
-    cache.invalidate(update.sourceIdentifier(), update.sourceEntityType());
+    invalidateCache(update.sourceIdentifier(), update.sourceEntityType());
     invalidateRelationTargetCache(targetEntityType, targetsToAdd);
     invalidateRelationTargetCache(targetEntityType, targetsToRemove);
 
@@ -463,7 +518,7 @@ public class RelationalEntityStore
   private void invalidateRelationTargetCache(
       Entity.EntityType targetEntityType, RelationEdgeTarget[] 
relationTargets) {
     for (RelationEdgeTarget relationTarget : relationTargets) {
-      cache.invalidate(relationTarget.nameIdentifier(), targetEntityType);
+      invalidateCache(relationTarget.nameIdentifier(), targetEntityType);
     }
   }
 
@@ -508,4 +563,17 @@ public class RelationalEntityStore
             String.format("Doesn't support the relation type %s", relType));
     }
   }
+
+  private void invalidateCache(NameIdentifier ident, Entity.EntityType type) {
+    // Advance before removing, so a batchGet() that samples the epoch after 
this point reads the
+    // backend after the change that triggered the invalidation is visible.
+    cacheInvalidationEpoch.incrementAndGet();
+    cache.invalidate(ident, type);
+  }
+
+  @VisibleForTesting
+  void clearCache() {
+    cacheInvalidationEpoch.incrementAndGet();
+    cache.clear();
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBatchGetLateFill.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBatchGetLateFill.java
new file mode 100644
index 0000000000..8ac5da1689
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreBatchGetLateFill.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+
+import java.time.Instant;
+import java.util.List;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.cache.CaffeineEntityCache;
+import org.apache.gravitino.cache.EntityCache;
+import org.apache.gravitino.cache.EntityCacheKey;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.RoleEntity;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
+import org.apache.gravitino.storage.relational.po.cache.OperateType;
+import org.apache.gravitino.utils.TestUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+/**
+ * Verifies that {@link RelationalEntityStore#batchGet} cannot write an entity 
back into the cache
+ * after that entity was invalidated while the backend read was in flight (the 
late-fill race).
+ *
+ * <p>The tests trigger invalidation during the backend read or during the 
cache write, so both
+ * sides of the write-back check are covered without relying on thread timing.
+ */
+public class TestRelationalEntityStoreBatchGetLateFill {
+
+  private static final Namespace SCHEMA_NS = Namespace.of("metalake", 
"catalog", "schema");
+
+  private RelationalEntityStore store;
+  private RelationalBackend backend;
+  private CaffeineEntityCache cache;
+
+  @BeforeEach
+  void setUp() throws IllegalAccessException {
+    store = new RelationalEntityStore();
+    backend = Mockito.mock(RelationalBackend.class);
+    cache = Mockito.spy(new CaffeineEntityCache(new Config() {}));
+    FieldUtils.writeField(store, "backend", backend, true);
+    FieldUtils.writeField(store, "cache", cache, true);
+  }
+
+  private static EntityChangeRecord dropRecord(NameIdentifier ident, 
Entity.EntityType type) {
+    return new EntityChangeRecord(
+        1L,
+        ident.namespace().level(0),
+        type.name(),
+        EntityChangeLogNameIdentifierCodec.encode(ident),
+        OperateType.DROP,
+        0L);
+  }
+
+  @Test
+  void testBatchGetWritesBackWhenNoInvalidationHappens() {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    Mockito.when(backend.batchGet(any(), 
eq(Entity.EntityType.TABLE))).thenReturn(List.of(table));
+
+    List<TableEntity> result =
+        store.batchGet(List.of(table.nameIdentifier()), 
Entity.EntityType.TABLE, TableEntity.class);
+
+    Assertions.assertEquals(List.of(table), result);
+    Assertions.assertTrue(cache.contains(table.nameIdentifier(), 
Entity.EntityType.TABLE));
+  }
+
+  @Test
+  void testBatchGetSkipsWriteBackWhenChangeLogInvalidatesDuringBackendRead() {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    NameIdentifier ident = table.nameIdentifier();
+    EntityChangeLogListener poller = store.newCacheChangeLogListener();
+    Mockito.when(backend.batchGet(any(), eq(Entity.EntityType.TABLE)))
+        .thenAnswer(
+            invocation -> {
+              poller.onEntityChange(List.of(dropRecord(ident, 
Entity.EntityType.TABLE)));
+              return List.of(table);
+            });
+
+    List<TableEntity> result =
+        store.batchGet(List.of(ident), Entity.EntityType.TABLE, 
TableEntity.class);
+
+    Assertions.assertEquals(List.of(table), result);
+    Assertions.assertFalse(
+        cache.contains(ident, Entity.EntityType.TABLE),
+        "a value invalidated during the backend read must not be written 
back");
+  }
+
+  @Test
+  void testBatchGetSkipsWriteBackWhenLocalDeleteInvalidatesDuringBackendRead() 
{
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    NameIdentifier ident = table.nameIdentifier();
+    Mockito.when(backend.batchGet(any(), eq(Entity.EntityType.TABLE)))
+        .thenAnswer(
+            invocation -> {
+              store.delete(ident, Entity.EntityType.TABLE, false);
+              return List.of(table);
+            });
+
+    store.batchGet(List.of(ident), Entity.EntityType.TABLE, TableEntity.class);
+
+    Assertions.assertFalse(cache.contains(ident, Entity.EntityType.TABLE));
+  }
+
+  @Test
+  void 
testBatchGetSkipsWriteBackWhenUnrelatedKeyInvalidatesDuringBackendRead() {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    NameIdentifier unrelatedIdent = NameIdentifier.of(SCHEMA_NS, "t2");
+    Mockito.when(backend.batchGet(any(), eq(Entity.EntityType.TABLE)))
+        .thenAnswer(
+            invocation -> {
+              store.delete(unrelatedIdent, Entity.EntityType.TABLE, false);
+              return List.of(table);
+            });
+
+    store.batchGet(List.of(table.nameIdentifier()), Entity.EntityType.TABLE, 
TableEntity.class);
+
+    Assertions.assertFalse(cache.contains(table.nameIdentifier(), 
Entity.EntityType.TABLE));
+  }
+
+  @Test
+  void testBatchGetDirectClearDuringBackendRead() {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    NameIdentifier ident = table.nameIdentifier();
+    Mockito.when(backend.batchGet(any(), eq(Entity.EntityType.TABLE)))
+        .thenAnswer(
+            invocation -> {
+              store.clearCache();
+              return List.of(table);
+            });
+
+    store.batchGet(List.of(ident), Entity.EntityType.TABLE, TableEntity.class);
+
+    Assertions.assertFalse(cache.contains(ident, Entity.EntityType.TABLE));
+  }
+
+  @Test
+  void testBatchGetRemovesValueWrittenAfterClear() throws 
IllegalAccessException {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    RecordingCache recordingCache = new RecordingCache();
+    recordingCache.beforePut = store::clearCache;
+    FieldUtils.writeField(store, "cache", recordingCache, true);
+    Mockito.when(backend.batchGet(any(), 
eq(Entity.EntityType.TABLE))).thenReturn(List.of(table));
+
+    List<TableEntity> result =
+        store.batchGet(List.of(table.nameIdentifier()), 
Entity.EntityType.TABLE, TableEntity.class);
+
+    Assertions.assertEquals(List.of(table), result);
+    Assertions.assertTrue(recordingCache.beforePutRan);
+    Assertions.assertFalse(
+        recordingCache.contains(table.nameIdentifier(), 
Entity.EntityType.TABLE));
+  }
+
+  @Test
+  void testBatchGetDoesNotLockNonCacheableRole() throws IllegalAccessException 
{
+    RoleEntity role =
+        RoleEntity.builder()
+            .withId(1L)
+            .withName("role")
+            .withNamespace(Namespace.of("metalake"))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.EPOCH).build())
+            .build();
+    RecordingCache recordingCache = new RecordingCache();
+    FieldUtils.writeField(store, "cache", recordingCache, true);
+    Mockito.when(backend.batchGet(any(), 
eq(Entity.EntityType.ROLE))).thenReturn(List.of(role));
+
+    List<RoleEntity> result =
+        store.batchGet(List.of(role.nameIdentifier()), Entity.EntityType.ROLE, 
RoleEntity.class);
+
+    Assertions.assertEquals(List.of(role), result);
+    Assertions.assertFalse(recordingCache.cacheLockUsed);
+    Assertions.assertTrue(recordingCache.keyChangeHookCalled);
+  }
+
+  @Test
+  void 
testBatchGetSkipsWriteBackWhenListenerFallsBackToClearDuringBackendRead() {
+    TableEntity table = TestUtil.getTestTableEntity(1L, "t1", SCHEMA_NS);
+    NameIdentifier ident = table.nameIdentifier();
+    EntityChangeLogListener poller = store.newCacheChangeLogListener();
+    // A failed targeted invalidation makes the listener clear the whole 
cache; that must fence
+    // in-flight fills too.
+    Mockito.doThrow(new RuntimeException("boom"))
+        .when(cache)
+        .invalidate(ident, Entity.EntityType.TABLE);
+    Mockito.when(backend.batchGet(any(), eq(Entity.EntityType.TABLE)))
+        .thenAnswer(
+            invocation -> {
+              poller.onEntityChange(List.of(dropRecord(ident, 
Entity.EntityType.TABLE)));
+              return List.of(table);
+            });
+
+    store.batchGet(List.of(ident), Entity.EntityType.TABLE, TableEntity.class);
+
+    Mockito.verify(cache).clear();
+    Assertions.assertFalse(cache.contains(ident, Entity.EntityType.TABLE));
+  }
+
+  private static class RecordingCache extends CaffeineEntityCache {
+    private boolean cacheLockUsed;
+    private boolean keyChangeHookCalled;
+    private boolean beforePutRan;
+    private Runnable beforePut;
+
+    RecordingCache() {
+      super(new Config() {});
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public <E extends Exception> void withCacheLock(
+        EntityCacheKey key, EntityCache.ThrowingRunnable<E> action) throws E {
+      cacheLockUsed = true;
+      super.withCacheLock(key, action);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public <E extends Entity & HasIdentifier> void invalidateOnKeyChange(E 
entity) {
+      keyChangeHookCalled = true;
+      super.invalidateOnKeyChange(entity);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected <E extends Entity & HasIdentifier> void doPut(E entity) {
+      if (beforePut != null) {
+        Runnable action = beforePut;
+        beforePut = null;
+        action.run();
+        beforePutRan = true;
+      }
+      super.doPut(entity);
+    }
+  }
+}

Reply via email to