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

yuqi1129 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 77d69d0316 [#10739] fix(core): Avoid stale cache refill during 
concurrent metadata updates (#10740)
77d69d0316 is described below

commit 77d69d0316537d1a25af4e56ba9a6692295c5216
Author: Yuhui <[email protected]>
AuthorDate: Thu May 7 22:11:56 2026 +0800

    [#10739] fix(core): Avoid stale cache refill during concurrent metadata 
updates (#10740)
    
    ### What changes were proposed in this pull request?
    
    Reorder cache invalidation in catalog and relational entity store write
    paths so concurrent reads do not refill cache with stale metadata.
    
    ### Why are the changes needed?
    
    Several write paths invalidate cache before the backend mutation
    completes.
    
    Under concurrent access, another thread can miss the cache, read old
    data from the backend, and write that stale result back into cache. This
    can leave outdated catalog or relation metadata visible after rename,
    drop, or relation updates.
    
    Fix: #10739
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    - added unit coverage in `TestCatalogManager`
    - added unit coverage in `TestRelationalEntityStore`
---
 .../apache/gravitino/catalog/CatalogManager.java   |  24 ++-
 .../storage/relational/RelationalEntityStore.java  |  25 ++-
 .../gravitino/catalog/TestCatalogManager.java      | 120 +++++++----
 .../relational/TestRelationalEntityStore.java      | 231 +++++++++++++++++++++
 4 files changed, 345 insertions(+), 55 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index b7d540e8d7..45d022fc71 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -47,7 +47,6 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Properties;
 import java.util.ServiceLoader;
@@ -717,7 +716,6 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
         nameIdentifierForLock,
         LockType.WRITE,
         () -> {
-          catalogCache.invalidate(ident);
           try {
             CatalogEntity updatedCatalog =
                 store.update(
@@ -736,15 +734,20 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
 
                       return newCatalogBuilder.build();
                     });
+            // Invalidate after store.update() so that any background thread 
that tries to reload
+            // the old catalog identifier from the store (after the 
invalidate) will get
+            // NoSuchCatalogException instead of stale data. Invalidating 
before the update creates
+            // a window where the background thread repopulates the cache with 
the old entity.
+            catalogCache.invalidate(ident);
             // The old fileset catalog's provider is "hadoop", whereas the new 
fileset catalog's
             // provider is "fileset", still using "hadoop" will lead to 
catalog loading issue. So
             // after reading the catalog entity, we convert it to the new 
fileset catalog entity.
             CatalogEntity convertedCatalog = 
convertFilesetCatalogEntity(updatedCatalog);
-            return Objects.requireNonNull(
-                    catalogCache.get(
-                        convertedCatalog.nameIdentifier(),
-                        id -> createCatalogWrapper(convertedCatalog, null)))
-                .catalog;
+            // Use put() instead of get() to force the updated wrapper into 
the cache, preventing
+            // a background thread from overwriting it with stale data between 
invalidate and put.
+            CatalogWrapper newWrapper = createCatalogWrapper(convertedCatalog, 
null);
+            catalogCache.put(convertedCatalog.nameIdentifier(), newWrapper);
+            return newWrapper.catalog();
 
           } catch (NoSuchEntityException ne) {
             LOG.warn("Catalog {} does not exist", ident, ne);
@@ -806,8 +809,11 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
             }
 
             // Finally, delete the catalog entity as well as all its 
sub-entities from the store.
+            // Invalidate after store.delete() to prevent a background thread 
from repopulating
+            // the cache with stale data between invalidate and delete.
+            boolean deleted = store.delete(ident, EntityType.CATALOG, true);
             catalogCache.invalidate(ident);
-            return store.delete(ident, EntityType.CATALOG, true);
+            return deleted;
 
           } catch (NoSuchMetalakeException | NoSuchCatalogException ignored) {
             return false;
@@ -1011,7 +1017,7 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
    * @param propsToValidate The properties to validate.
    * @return The created catalog wrapper.
    */
-  private CatalogWrapper createCatalogWrapper(
+  CatalogWrapper createCatalogWrapper(
       CatalogEntity entity, @Nullable Map<String, String> propsToValidate) {
     Map<String, String> conf = entity.getProperties();
     String provider = entity.getProvider();
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 9fdd2c7886..d89c6c678d 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
@@ -136,8 +136,9 @@ public class RelationalEntityStore implements EntityStore, 
SupportsRelationOpera
   public <E extends Entity & HasIdentifier> E update(
       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);
-    return backend.update(ident, entityType, updater);
+    return updatedEntity;
   }
 
   @Override
@@ -183,10 +184,12 @@ public class RelationalEntityStore implements 
EntityStore, SupportsRelationOpera
   public boolean delete(NameIdentifier ident, Entity.EntityType entityType, 
boolean cascade)
       throws IOException {
     try {
-      cache.invalidate(ident, entityType);
-      return backend.delete(ident, entityType, cascade);
+      boolean deleted = backend.delete(ident, entityType, cascade);
+      return deleted;
     } catch (NoSuchEntityException e) {
       return false;
+    } finally {
+      cache.invalidate(ident, entityType);
     }
   }
 
@@ -319,9 +322,9 @@ public class RelationalEntityStore implements EntityStore, 
SupportsRelationOpera
       Entity.EntityType dstType,
       boolean override)
       throws IOException {
+    backend.insertRelation(relType, srcIdentifier, srcType, dstIdentifier, 
dstType, override);
     cache.invalidate(srcIdentifier, srcType, relType);
     cache.invalidate(dstIdentifier, dstType, relType);
-    backend.insertRelation(relType, srcIdentifier, srcType, dstIdentifier, 
dstType, override);
   }
 
   @Override
@@ -333,11 +336,12 @@ public class RelationalEntityStore implements 
EntityStore, SupportsRelationOpera
       NameIdentifier[] destEntitiesToRemove)
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
 
-    // We need to clear the cache of the source entity and all destination 
entities being added or
-    // removed. This ensures that any subsequent reads will fetch the updated 
relations from the
-    // backend. For example, if we are adding a tag to table, we need to 
invalidate the cache for
-    // that table and the tag being added or removed. Otherwise, we might 
return stale data if we
-    // list all tags for that table or all tables for that tag.
+    // Invalidate after the backend write, not before. Invalidating before 
creates a window where
+    // a concurrent read can repopulate the cache with stale pre-commit data.
+    List<E> result =
+        backend.updateEntityRelations(
+            relType, srcEntityIdent, srcEntityType, destEntitiesToAdd, 
destEntitiesToRemove);
+
     cache.invalidate(srcEntityIdent, srcEntityType, relType);
     for (NameIdentifier destToAdd : destEntitiesToAdd) {
       cache.invalidate(destToAdd, srcEntityType, relType);
@@ -347,8 +351,7 @@ public class RelationalEntityStore implements EntityStore, 
SupportsRelationOpera
       cache.invalidate(destToRemove, srcEntityType, relType);
     }
 
-    return backend.updateEntityRelations(
-        relType, srcEntityIdent, srcEntityType, destEntitiesToAdd, 
destEntitiesToRemove);
+    return result;
   }
 
   @Override
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index 7f64423038..5582bcb027 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -27,6 +27,7 @@ import static 
org.apache.gravitino.TestCatalog.PROPERTY_KEY5_PREFIX;
 import static org.apache.gravitino.TestCatalog.PROPERTY_KEY6_PREFIX;
 import static org.awaitility.Awaitility.await;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
@@ -36,6 +37,7 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.CatalogChange;
@@ -47,10 +49,10 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.Schema;
+import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.connector.capability.CapabilityResult;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
-import org.apache.gravitino.exceptions.CatalogInUseException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
@@ -71,6 +73,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
 
 public class TestCatalogManager {
 
@@ -542,8 +545,8 @@ public class TestCatalogManager {
   }
 
   @Test
-  public void testDropCatalog() throws Exception {
-    NameIdentifier ident = NameIdentifier.of("metalake", "test41");
+  void testAlterCatalogRefreshesCacheAfterStoreUpdate() throws Exception {
+    NameIdentifier ident = NameIdentifier.of("metalake", "cache_race_test");
     Map<String, String> props =
         ImmutableMap.of(
             "provider",
@@ -554,41 +557,51 @@ public class TestCatalogManager {
             "value2",
             PROPERTY_KEY5_PREFIX + "1",
             "value3");
-    String comment = "comment";
 
     Catalog catalog =
-        catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
comment, props);
-
-    // Test drop catalog
-    Exception exception =
-        Assertions.assertThrows(
-            CatalogInUseException.class, () -> 
catalogManager.dropCatalog(ident));
-    Assertions.assertTrue(exception.getMessage().contains("Catalog 
metalake.test41 is in use"));
-
-    Assertions.assertDoesNotThrow(() -> catalogManager.disableCatalog(ident));
-
-    CatalogEntity oldEntity = entityStore.get(ident, EntityType.CATALOG, 
CatalogEntity.class);
-    FieldUtils.writeField(catalog, "entity", oldEntity, true);
-
-    CatalogManager.CatalogWrapper catalogWrapper =
-        Mockito.mock(CatalogManager.CatalogWrapper.class);
-    Capability capability = Mockito.mock(Capability.class);
-    CapabilityResult unsupportedResult = CapabilityResult.unsupported("Not 
managed");
-    
Mockito.doReturn(catalogWrapper).when(catalogManager).loadCatalogAndWrap(ident);
-    Mockito.doReturn(catalog).when(catalogWrapper).catalog();
-    Mockito.doReturn(capability).when(catalogWrapper).capabilities();
-    Mockito.doReturn(unsupportedResult).when(capability).managedStorage(any());
-
-    boolean dropped = catalogManager.dropCatalog(ident);
-    Assertions.assertTrue(dropped);
-
-    // Test drop non-existed catalog
-    NameIdentifier ident1 = NameIdentifier.of("metalake", "test42");
-    boolean dropped1 = catalogManager.dropCatalog(ident1);
-    Assertions.assertFalse(dropped1);
-
-    // Drop operation will update the cache
+        catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
"comment", props);
+    CatalogEntity originalEntity = entityStore.get(ident, EntityType.CATALOG, 
CatalogEntity.class);
+    FieldUtils.writeField(catalog, "entity", originalEntity, true);
+
+    CatalogManager.CatalogWrapper staleWrapper =
+        Mockito.mock(CatalogManager.CatalogWrapper.class, 
Mockito.RETURNS_DEEP_STUBS);
+    Mockito.doReturn(catalog).when(staleWrapper).catalog();
+
+    CatalogManager.CatalogWrapper freshWrapper =
+        Mockito.mock(CatalogManager.CatalogWrapper.class, 
Mockito.RETURNS_DEEP_STUBS);
+    BaseCatalog<?> freshCatalog = Mockito.mock(BaseCatalog.class);
+    Mockito.doReturn("cache_race_test_renamed").when(freshCatalog).name();
+    Mockito.doReturn(freshCatalog).when(freshWrapper).catalog();
+
+    AtomicBoolean staleInserted = new AtomicBoolean(false);
+    Answer<CatalogManager.CatalogWrapper> insertStaleWrapper =
+        invocation -> {
+          if (staleInserted.compareAndSet(false, true)) {
+            catalogManager
+                .getCatalogCache()
+                .put(NameIdentifier.of("metalake", "cache_race_test_renamed"), 
staleWrapper);
+          }
+          return freshWrapper;
+        };
+    Mockito.doAnswer(insertStaleWrapper)
+        .when(catalogManager)
+        .createCatalogWrapper(any(CatalogEntity.class), eq(null));
+
+    Catalog alteredCatalog =
+        catalogManager.alterCatalog(ident, 
CatalogChange.rename("cache_race_test_renamed"));
+
+    Assertions.assertEquals("cache_race_test_renamed", alteredCatalog.name());
+    CatalogManager.CatalogWrapper cachedWrapper =
+        catalogManager
+            .getCatalogCache()
+            .getIfPresent(NameIdentifier.of("metalake", 
"cache_race_test_renamed"));
+    Assertions.assertSame(freshWrapper, cachedWrapper);
     
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(ident));
+
+    // Restore real method so stub does not leak into subsequent tests.
+    Mockito.doCallRealMethod()
+        .when(catalogManager)
+        .createCatalogWrapper(any(CatalogEntity.class), eq(null));
   }
 
   @Test
@@ -794,6 +807,43 @@ public class TestCatalogManager {
     Assertions.assertTrue(catalogManager.dropCatalog(ident, true));
   }
 
+  @Test
+  void testDropCatalogInvalidatesCacheAfterStoreDelete() throws Exception {
+    NameIdentifier ident = NameIdentifier.of("metalake", "cache_drop_test");
+    Map<String, String> props =
+        ImmutableMap.of(
+            "provider",
+            "test",
+            PROPERTY_KEY1,
+            "value1",
+            PROPERTY_KEY2,
+            "value2",
+            PROPERTY_KEY5_PREFIX + "1",
+            "value3");
+
+    Catalog catalog =
+        catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
"comment", props);
+    Assertions.assertDoesNotThrow(() -> catalogManager.disableCatalog(ident));
+    CatalogEntity entity = entityStore.get(ident, EntityType.CATALOG, 
CatalogEntity.class);
+    FieldUtils.writeField(catalog, "entity", entity, true);
+
+    CatalogManager.CatalogWrapper catalogWrapper =
+        Mockito.mock(CatalogManager.CatalogWrapper.class, 
Mockito.RETURNS_DEEP_STUBS);
+    Capability capability = Mockito.mock(Capability.class);
+    CapabilityResult unsupportedResult = CapabilityResult.unsupported("Not 
managed");
+    
Mockito.doReturn(catalogWrapper).when(catalogManager).loadCatalogAndWrap(ident);
+    Mockito.doReturn(catalog).when(catalogWrapper).catalog();
+    Mockito.doReturn(capability).when(catalogWrapper).capabilities();
+    Mockito.doReturn(unsupportedResult).when(capability).managedStorage(any());
+
+    catalogManager.getCatalogCache().put(ident, catalogWrapper);
+    boolean dropped = catalogManager.dropCatalog(ident);
+
+    Assertions.assertTrue(dropped);
+    Assertions.assertFalse(entityStore.exists(ident, EntityType.CATALOG));
+    
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(ident));
+  }
+
   @Test
   void testAlterMutableProperties() {
     NameIdentifier ident = NameIdentifier.of("metalake", "test51");
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
new file mode 100644
index 0000000000..042c29681b
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
@@ -0,0 +1,231 @@
+/*
+ * 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.io.IOException;
+import java.util.List;
+import java.util.function.Function;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.cache.NoOpsCache;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+public class TestRelationalEntityStore {
+
+  private RelationalEntityStore store;
+  private RelationalBackend backend;
+
+  @BeforeEach
+  void setUp() throws IllegalAccessException {
+    store = new RelationalEntityStore();
+    backend = Mockito.mock(RelationalBackend.class);
+
+    Config config = new Config(false) {};
+    config.set(Configs.CACHE_ENABLED, false);
+
+    FieldUtils.writeField(store, "backend", backend, true);
+    FieldUtils.writeField(store, "cache", Mockito.spy(new NoOpsCache(config)), 
true);
+  }
+
+  @Test
+  void testUpdateInvalidatesCacheAfterBackendUpdate()
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException,
+          IllegalAccessException {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog");
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never()).invalidate(ident, 
Entity.EntityType.CATALOG);
+              return null;
+            })
+        .when(backend)
+        .update(eq(ident), eq(Entity.EntityType.CATALOG), any(Function.class));
+
+    store.update(ident, null, Entity.EntityType.CATALOG, entity -> entity);
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder.verify(backend).update(eq(ident), eq(Entity.EntityType.CATALOG), 
any(Function.class));
+    inOrder.verify(cache).invalidate(ident, Entity.EntityType.CATALOG);
+  }
+
+  @Test
+  void testDeleteInvalidatesCacheAfterBackendDelete()
+      throws IOException, NoSuchEntityException, IllegalAccessException {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog");
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never()).invalidate(ident, 
Entity.EntityType.CATALOG);
+              return true;
+            })
+        .when(backend)
+        .delete(ident, Entity.EntityType.CATALOG, true);
+
+    Assertions.assertTrue(store.delete(ident, Entity.EntityType.CATALOG, 
true));
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder.verify(backend).delete(ident, Entity.EntityType.CATALOG, true);
+    inOrder.verify(cache).invalidate(ident, Entity.EntityType.CATALOG);
+  }
+
+  @Test
+  void testInsertRelationInvalidatesCacheAfterBackendInsert()
+      throws IOException, IllegalAccessException {
+    NameIdentifier src = NameIdentifier.of("metalake", "catalog", "schema", 
"table1");
+    NameIdentifier dst = NameIdentifier.of("metalake", "tag1");
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      src,
+                      Entity.EntityType.TABLE,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      dst,
+                      Entity.EntityType.TAG,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              return null;
+            })
+        .when(backend)
+        .insertRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            src,
+            Entity.EntityType.TABLE,
+            dst,
+            Entity.EntityType.TAG,
+            true);
+
+    store.insertRelation(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+        src,
+        Entity.EntityType.TABLE,
+        dst,
+        Entity.EntityType.TAG,
+        true);
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder
+        .verify(backend)
+        .insertRelation(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            src,
+            Entity.EntityType.TABLE,
+            dst,
+            Entity.EntityType.TAG,
+            true);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            src, Entity.EntityType.TABLE, 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            dst, Entity.EntityType.TAG, 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+  }
+
+  @Test
+  void testUpdateEntityRelationsInvalidatesCacheAfterBackendUpdate()
+      throws IOException, NoSuchEntityException, EntityAlreadyExistsException,
+          IllegalAccessException {
+    NameIdentifier src = NameIdentifier.of("metalake", "catalog", "schema", 
"table1");
+    NameIdentifier destToAdd = NameIdentifier.of("metalake", "tag1");
+    NameIdentifier destToRemove = NameIdentifier.of("metalake", "tag2");
+    NameIdentifier[] destEntitiesToAdd = new NameIdentifier[] {destToAdd};
+    NameIdentifier[] destEntitiesToRemove = new NameIdentifier[] 
{destToRemove};
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      src,
+                      Entity.EntityType.TABLE,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      destToAdd,
+                      Entity.EntityType.TABLE,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              Mockito.verify(cache, Mockito.never())
+                  .invalidate(
+                      destToRemove,
+                      Entity.EntityType.TABLE,
+                      SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+              return List.of();
+            })
+        .when(backend)
+        .updateEntityRelations(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            src,
+            Entity.EntityType.TABLE,
+            destEntitiesToAdd,
+            destEntitiesToRemove);
+
+    store.updateEntityRelations(
+        SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+        src,
+        Entity.EntityType.TABLE,
+        destEntitiesToAdd,
+        destEntitiesToRemove);
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder
+        .verify(backend)
+        .updateEntityRelations(
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+            src,
+            Entity.EntityType.TABLE,
+            destEntitiesToAdd,
+            destEntitiesToRemove);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            src, Entity.EntityType.TABLE, 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            destToAdd,
+            Entity.EntityType.TABLE,
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+    inOrder
+        .verify(cache)
+        .invalidate(
+            destToRemove,
+            Entity.EntityType.TABLE,
+            SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+  }
+}

Reply via email to