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 d6c7a8a2e8 [#11701] fix(core): invalidate METADATA_OBJECT_ROLE_REL 
cache on role grant/override/create (#11702)
d6c7a8a2e8 is described below

commit d6c7a8a2e8c6d7f9bb87d40e22c8ccf6e60e56c7
Author: Sun Yuhan <[email protected]>
AuthorDate: Wed Jun 17 21:59:08 2026 +0800

    [#11701] fix(core): invalidate METADATA_OBJECT_ROLE_REL cache on role 
grant/override/create (#11702)
    
    ### What changes were proposed in this pull request?
    
    After writing a `RoleEntity`, `RelationalEntityStore.update()` and
    `put()` now invalidate the object-keyed `METADATA_OBJECT_ROLE_REL` cache
    entry for each of the role's securable objects (private helper
    `invalidateMetadataObjectRoleRelationCache`; no-op for non-ROLE entities
    or empty securable objects). Covers grant / override-add / createRole.
    
    The invalidation uses a new
    `SupportsRelationEntityCache.invalidateRelationEntry`, which drops only
    the cached relation result and its index entry **without cascading
    through the reverse index**. This matters because the reverse index is
    shared across all roles bound to one object; a full `invalidate(ident,
    type, relType)` would BFS through it and evict the other roles' mappings
    (which would break `testInvalidRelationCache`).
    
    ### Why are the changes needed?
    
    Fixes #11701: for a metadata object that was already queried,
    `listBindingRoleNames()` returns stale data (missing the newly granted
    role) after `grantPrivilegesToRole` (or `overridePrivilegesInRole`
    adding an object, or `createRole` with objects), until the relation
    cache TTL (default 1h) elapses.
    
    Root cause: the write path only calls `cache.invalidate(roleIdent,
    ROLE)` (a role-side BFS). Reaching the object-keyed
    `METADATA_OBJECT_ROLE_REL` entry requires the reverse index to already
    map `roleIdent -> objectKey`, which is only established when the role
    has previously been cached as a binding role of that object (the #11297
    patch). A newly granted role was never cached there, so the invalidation
    never reaches the entry. The subtraction side (revoke / override-remove
    / deleteRole) is already covered by the same role-side invalidation via
    the reverse index (the removed role was previously cached), so this PR
    does not touch it.
    
    Fix: #11701
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration change. Behavioral fix: "list binding roles by
    object" reflects grant/override/create immediately instead of being
    delayed until the cache entry expires.
    
    ### How was this patch tested?
    
    Added 5 parameterized tests in `TestEntityStorageRelationCache`
    (h2/mysql/postgresql × enableCache true/false):
    - `testGrantPrivilegeInvalidatesMetadataObjectRoleRelCache` — reproduces
    the bug (red before / green after);
    -
    `testCreateRoleWithSecurableObjectsInvalidatesMetadataObjectRoleRelCache`
    — covers the createRole (`store.put`) path;
    - `testRevokeAllPrivileges…` / `testOverrideRemoveObject…` /
    `testDeleteRole…` — three subtraction guards (no stale residue; pin
    coverage so a future reverse-index rule change cannot silently regress).
    
    Ran: `./gradlew :core:test --tests
    
'*TestEntityStorageRelationCache.test{Invalid,Grant,Revoke,RevokeAll,OverrideRemove,Delete,Create}*'
    -PskipDockerTests=false -PskipITs` → BUILD SUCCESSFUL, `tests=42
    skipped=0 failures=0 errors=0` (mysql ~10s/case, postgresql ~3s, h2
    ~0.1–2s; all three backends actually executed). `:core:spotlessCheck`
    passes.
    
    ---------
    
    Co-authored-by: Sun Yuhan <[email protected]>
---
 .../gravitino/cache/CaffeineEntityCache.java       |  22 +
 .../org/apache/gravitino/cache/NoOpsCache.java     |   7 +
 .../cache/SupportsRelationEntityCache.java         |  20 +
 .../storage/relational/RelationalEntityStore.java  |  75 +++
 .../storage/TestEntityStorageRelationCache.java    | 730 +++++++++++++++++++++
 5 files changed, 854 insertions(+)

diff --git 
a/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java 
b/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
index 952bb1e193..fd785edf5a 100644
--- a/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
+++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
@@ -195,6 +195,28 @@ public class CaffeineEntityCache extends BaseEntityCache {
         });
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public boolean invalidateRelationEntry(
+      NameIdentifier ident, Entity.EntityType type, 
SupportsRelationOperations.Type relType) {
+    checkArguments(ident, type, relType);
+    EntityCacheRelationKey key = EntityCacheRelationKey.of(ident, type, 
relType);
+    return segmentedLock.withLock(
+        key,
+        () -> {
+          // Drop the cached relation result, its index entry, and the 
reverse-index bookkeeping
+          // for this relation key only. Do NOT cascade through the reverse 
index to other
+          // entities: the reverse index is shared (e.g. all roles bound to 
one metadata object),
+          // and a BFS cascade would evict their mappings. 
cacheData.invalidate is explicit so it
+          // bypasses the removal listener; reverseIndex.remove(key) then 
cleans up only this
+          // entry's own bookkeeping (entityToReverseIndexMap + reverseIndex 
references to it).
+          cacheData.invalidate(key);
+          reverseIndex.remove(key);
+          cacheIndex.remove(key.toString());
+          return true;
+        });
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean invalidate(NameIdentifier ident, Entity.EntityType type) {
diff --git a/core/src/main/java/org/apache/gravitino/cache/NoOpsCache.java 
b/core/src/main/java/org/apache/gravitino/cache/NoOpsCache.java
index a9c9939bc1..fad08c2c2b 100644
--- a/core/src/main/java/org/apache/gravitino/cache/NoOpsCache.java
+++ b/core/src/main/java/org/apache/gravitino/cache/NoOpsCache.java
@@ -125,6 +125,13 @@ public class NoOpsCache extends BaseEntityCache {
     return false;
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public boolean invalidateRelationEntry(
+      NameIdentifier ident, Entity.EntityType type, 
SupportsRelationOperations.Type relType) {
+    return false;
+  }
+
   /** {@inheritDoc} */
   @Override
   public boolean contains(
diff --git 
a/core/src/main/java/org/apache/gravitino/cache/SupportsRelationEntityCache.java
 
b/core/src/main/java/org/apache/gravitino/cache/SupportsRelationEntityCache.java
index 98619db006..51b12113b5 100644
--- 
a/core/src/main/java/org/apache/gravitino/cache/SupportsRelationEntityCache.java
+++ 
b/core/src/main/java/org/apache/gravitino/cache/SupportsRelationEntityCache.java
@@ -56,6 +56,26 @@ public interface SupportsRelationEntityCache {
   boolean invalidate(
       NameIdentifier ident, Entity.EntityType type, 
SupportsRelationOperations.Type relType);
 
+  /**
+   * Invalidates the cached relation result for the given key and cleans up 
the reverse-index
+   * bookkeeping for that entry, without cascading through the reverse index 
to other entities.
+   *
+   * <p>Unlike {@link #invalidate(NameIdentifier, Entity.EntityType,
+   * SupportsRelationOperations.Type)}, this does NOT perform a BFS cascade: 
it leaves other
+   * entities' caches and the reverse-index mappings of the entities 
referenced by this relation
+   * (e.g. all roles bound to one metadata object) intact. It only drops this 
relation result and
+   * the reverse-index references that point at this relation key, so the 
entry is rebuilt on the
+   * next read. Use it when a relation result is known to be stale and a full 
cascade would
+   * incorrectly evict other entities' state.
+   *
+   * @param ident the name identifier
+   * @param type the entity type
+   * @param relType the relation type
+   * @return true if the cache entry was removed
+   */
+  boolean invalidateRelationEntry(
+      NameIdentifier ident, Entity.EntityType type, 
SupportsRelationOperations.Type relType);
+
   /**
    * Checks whether an entity with the given name identifier, type, and 
relation type is present in
    * the cache.
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 030700eb7e..6f21491173 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
@@ -41,6 +41,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.authorization.SecurableObject;
 import org.apache.gravitino.cache.CacheFactory;
 import org.apache.gravitino.cache.CachedEntityIdResolver;
 import org.apache.gravitino.cache.EntityCache;
@@ -48,8 +49,13 @@ import org.apache.gravitino.cache.EntityCacheKey;
 import org.apache.gravitino.cache.EntityCacheRelationKey;
 import org.apache.gravitino.cache.NoOpsCache;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.GroupEntity;
+import org.apache.gravitino.meta.RoleEntity;
+import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.storage.relational.service.EntityIdService;
 import org.apache.gravitino.utils.Executable;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -144,6 +150,7 @@ public class RelationalEntityStore
       throws IOException, EntityAlreadyExistsException {
     backend.insert(e, overwritten);
     cache.put(e);
+    invalidateAggregatedRoleRelationCache(e);
   }
 
   @Override
@@ -152,6 +159,7 @@ public class RelationalEntityStore
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
     E updatedEntity = backend.update(ident, entityType, updater);
     cache.invalidate(ident, entityType);
+    invalidateAggregatedRoleRelationCache(updatedEntity);
     return updatedEntity;
   }
 
@@ -451,4 +459,71 @@ public class RelationalEntityStore
       cache.put(sourceId, identType, relType, entityList);
     }
   }
+
+  /**
+   * Invalidates the relation cache entries keyed by the counterpart of a 
role-aggregating entity
+   * after that entity is written, so that reverse lookups reflect the change 
immediately.
+   *
+   * <p>Three entity types aggregate role relations and are mutated through 
{@code store.update} /
+   * {@code store.put}, which only invalidate the entity itself:
+   *
+   * <ul>
+   *   <li>{@link RoleEntity} via {@code securableObjects} -> {@code 
METADATA_OBJECT_ROLE_REL},
+   *       invalidated per metadata object (catalog/schema/table/...);
+   *   <li>{@link UserEntity} via {@code roleNames} -> {@code ROLE_USER_REL}, 
invalidated per role;
+   *   <li>{@link GroupEntity} via {@code roleNames} -> {@code 
ROLE_GROUP_REL}, invalidated per
+   *       role.
+   * </ul>
+   *
+   * <p>The role-side BFS invalidation ({@code invalidate(roleIdent, ROLE)}) 
only reaches a
+   * counterpart's relation entry when the entity had previously been cached 
against it; a freshly
+   * granted binding was never cached there, so without this explicit 
invalidation the stale
+   * relation result is served until the entry's TTL elapses. Each entry is 
dropped via {@link
+   * EntityCache#invalidateRelationEntry} (no BFS cascade), preserving other 
entities' mappings.
+   */
+  private void invalidateAggregatedRoleRelationCache(Entity entity) {
+    if (entity instanceof RoleEntity) {
+      RoleEntity roleEntity = (RoleEntity) entity;
+      List<SecurableObject> securableObjects = roleEntity.securableObjects();
+      if (securableObjects == null || securableObjects.isEmpty()) {
+        return;
+      }
+      String metalake = roleEntity.namespace().level(0);
+      for (SecurableObject securableObject : securableObjects) {
+        cache.invalidateRelationEntry(
+            MetadataObjectUtil.toEntityIdent(metalake, securableObject),
+            MetadataObjectUtil.toEntityType(securableObject.type()),
+            SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL);
+      }
+    } else if (entity instanceof UserEntity) {
+      UserEntity userEntity = (UserEntity) entity;
+      invalidateRoleGranteeRelations(
+          userEntity.namespace().level(0),
+          userEntity.roleNames(),
+          SupportsRelationOperations.Type.ROLE_USER_REL);
+    } else if (entity instanceof GroupEntity) {
+      GroupEntity groupEntity = (GroupEntity) entity;
+      invalidateRoleGranteeRelations(
+          groupEntity.namespace().level(0),
+          groupEntity.roleNames(),
+          SupportsRelationOperations.Type.ROLE_GROUP_REL);
+    }
+  }
+
+  /**
+   * Invalidates the {@code ROLE_USER_REL} / {@code ROLE_GROUP_REL} cache 
entries keyed by each role
+   * the grantee (user/group) is aggregated against.
+   */
+  private void invalidateRoleGranteeRelations(
+      String metalake, List<String> roleNames, SupportsRelationOperations.Type 
relType) {
+    if (roleNames == null || roleNames.isEmpty()) {
+      return;
+    }
+    for (String roleName : roleNames) {
+      cache.invalidateRelationEntry(
+          NameIdentifier.of(NamespaceUtil.ofRole(metalake), roleName),
+          Entity.EntityType.ROLE,
+          relType);
+    }
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
 
b/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
index 8819d5c028..5bbec6c5e3 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
@@ -58,6 +58,7 @@ import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.FilesetEntity;
 import org.apache.gravitino.meta.FunctionEntity;
 import org.apache.gravitino.meta.GenericEntity;
+import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.PolicyEntity;
 import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.meta.SchemaEntity;
@@ -1610,6 +1611,735 @@ public class TestEntityStorageRelationCache extends 
AbstractEntityStorageTest {
     }
   }
 
+  /**
+   * Reproduces the stale-read defect where granting a role access to an 
already-cached metadata
+   * object is not reflected by {@code 
listEntitiesByRelation(METADATA_OBJECT_ROLE_REL)} until the
+   * cache entry's TTL elapses.
+   *
+   * <p>Flow: create schema + roleA (bound to schema) + roleB (no securable 
object, never cached as
+   * a binding role) -> list binding roles (warms cache; the reverse index 
maps roleA but not roleB)
+   * -> update roleB to bind the schema (simulates grantPrivilegesToRole) -> 
list binding roles
+   * again -> must immediately contain roleB.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testGrantPrivilegeInvalidatesMetadataObjectRoleRelCache(String type, 
boolean enableCache)
+      throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        CatalogEntity catalog =
+            createCatalog(
+                RandomIdGenerator.INSTANCE.nextId(),
+                NamespaceUtil.ofCatalog("metalake"),
+                "catalog",
+                auditInfo);
+        store.put(catalog, false);
+
+        SchemaEntity schema =
+            createSchemaEntity(
+                RandomIdGenerator.INSTANCE.nextId(),
+                Namespace.of("metalake", "catalog"),
+                "test_schema",
+                auditInfo);
+        store.put(schema, false);
+
+        SecurableObject catalogObject = SecurableObjects.ofCatalog("catalog", 
Lists.newArrayList());
+        SecurableObject schemaObject =
+            SecurableObjects.ofSchema(
+                catalogObject, "test_schema", 
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+        // roleA is bound to the schema; roleB has no securable object, so it 
was never cached as a
+        // binding role of any metadata object (its identifier is absent from 
the reverse index).
+        RoleEntity roleA =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleA")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(schemaObject))
+                .build();
+        store.put(roleA, false);
+
+        RoleEntity roleB =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleB")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList())
+                .build();
+        store.put(roleB, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the METADATA_OBJECT_ROLE_REL cache: schema -> [roleA]. The 
reverse index now holds
+        // roleA -> schemaKey but NOT roleB, because roleB has never been 
served as a binding role.
+        List<RoleEntity> rolesBeforeGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertEquals(1, rolesBeforeGrant.size());
+        Assertions.assertEquals("roleA", rolesBeforeGrant.get(0).name());
+
+        // Simulate grantPrivilegesToRole: bind roleB to the schema. roleB was 
never cached, so the
+        // role-side invalidation cannot reach the schema's relation cache 
entry through the reverse
+        // index; RelationalEntityStore must explicitly invalidate it to avoid 
a stale read.
+        store.update(
+            roleB.nameIdentifier(),
+            RoleEntity.class,
+            Entity.EntityType.ROLE,
+            existing ->
+                RoleEntity.builder()
+                    .withId(existing.id())
+                    .withName(existing.name())
+                    .withNamespace(existing.namespace())
+                    .withProperties(existing.properties())
+                    .withAuditInfo(existing.auditInfo())
+                    .withSecurableObjects(Lists.newArrayList(schemaObject))
+                    .build());
+
+        // listBindingRoleNames(schema) must immediately reflect roleB after 
the grant.
+        List<RoleEntity> rolesAfterGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        List<String> roleNames =
+            
rolesAfterGrant.stream().map(RoleEntity::name).sorted().collect(Collectors.toList());
+        Assertions.assertEquals(
+            Lists.newArrayList("roleA", "roleB"),
+            roleNames,
+            "grant must be immediately visible via listBindingRoleNames");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Guards the subtraction side of {@link
+   * #testGrantPrivilegeInvalidatesMetadataObjectRoleRelCache}: after fully 
revoking a role's
+   * privilege on an already-warmed metadata object, listBindingRoleNames must 
immediately drop the
+   * role. The removed object is no longer in the updated entity, so this 
relies on the role-side
+   * {@code invalidate(roleIdent, ROLE)} propagating to the object's relation 
cache via the reverse
+   * index; the test pins that behavior so a future change to the 
reverse-index rule cannot silently
+   * regress revoke.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testRevokeAllPrivilegesInvalidatesMetadataObjectRoleRelCache(
+      String type, boolean enableCache) throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        CatalogEntity catalog =
+            createCatalog(
+                RandomIdGenerator.INSTANCE.nextId(),
+                NamespaceUtil.ofCatalog("metalake"),
+                "catalog",
+                auditInfo);
+        store.put(catalog, false);
+
+        SchemaEntity schema =
+            createSchemaEntity(
+                RandomIdGenerator.INSTANCE.nextId(),
+                Namespace.of("metalake", "catalog"),
+                "test_schema",
+                auditInfo);
+        store.put(schema, false);
+
+        SecurableObject catalogObject = SecurableObjects.ofCatalog("catalog", 
Lists.newArrayList());
+        SecurableObject schemaObject =
+            SecurableObjects.ofSchema(
+                catalogObject, "test_schema", 
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+        RoleEntity role =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("test_role")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(schemaObject))
+                .build();
+        store.put(role, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: schema -> [test_role].
+        List<RoleEntity> rolesBeforeRevoke =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertEquals(1, rolesBeforeRevoke.size());
+        Assertions.assertEquals("test_role", rolesBeforeRevoke.get(0).name());
+
+        // Revoke all privileges on the schema (the object is removed from the 
role). The removed
+        // object is absent from the updated entity, so it must be invalidated 
via the role side.
+        store.update(
+            role.nameIdentifier(),
+            RoleEntity.class,
+            Entity.EntityType.ROLE,
+            existing ->
+                RoleEntity.builder()
+                    .withId(existing.id())
+                    .withName(existing.name())
+                    .withNamespace(existing.namespace())
+                    .withProperties(existing.properties())
+                    .withAuditInfo(existing.auditInfo())
+                    .withSecurableObjects(Lists.newArrayList())
+                    .build());
+
+        List<RoleEntity> rolesAfterRevoke =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertTrue(
+            rolesAfterRevoke.stream().noneMatch(r -> 
r.name().equals("test_role")),
+            "revoke must be immediately visible: the role must no longer bind 
the schema");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Guards the subtraction side: overriding a role so that a 
previously-bound, already-warmed
+   * metadata object is dropped must make listBindingRoleNames immediately 
drop the role for that
+   * object. The removed object is absent from the updated entity, so 
invalidation must come from
+   * the role side via the reverse index.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testOverrideRemoveObjectInvalidatesMetadataObjectRoleRelCache(
+      String type, boolean enableCache) throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        CatalogEntity catalog =
+            createCatalog(
+                RandomIdGenerator.INSTANCE.nextId(),
+                NamespaceUtil.ofCatalog("metalake"),
+                "catalog",
+                auditInfo);
+        store.put(catalog, false);
+
+        SchemaEntity schema =
+            createSchemaEntity(
+                RandomIdGenerator.INSTANCE.nextId(),
+                Namespace.of("metalake", "catalog"),
+                "test_schema",
+                auditInfo);
+        store.put(schema, false);
+
+        SecurableObject catalogObject =
+            SecurableObjects.ofCatalog(
+                "catalog", Lists.newArrayList(Privileges.UseCatalog.allow()));
+        SecurableObject schemaObject =
+            SecurableObjects.ofSchema(
+                catalogObject, "test_schema", 
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+        // role binds both catalog and schema.
+        RoleEntity role =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("test_role")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(catalogObject, 
schemaObject))
+                .build();
+        store.put(role, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: schema -> [test_role].
+        List<RoleEntity> rolesBeforeOverride =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertEquals(1, rolesBeforeOverride.size());
+        Assertions.assertEquals("test_role", 
rolesBeforeOverride.get(0).name());
+
+        // Override to keep only the catalog object, dropping the schema 
object.
+        store.update(
+            role.nameIdentifier(),
+            RoleEntity.class,
+            Entity.EntityType.ROLE,
+            existing ->
+                RoleEntity.builder()
+                    .withId(existing.id())
+                    .withName(existing.name())
+                    .withNamespace(existing.namespace())
+                    .withProperties(existing.properties())
+                    .withAuditInfo(existing.auditInfo())
+                    .withSecurableObjects(Lists.newArrayList(catalogObject))
+                    .build());
+
+        List<RoleEntity> rolesAfterOverride =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertTrue(
+            rolesAfterOverride.stream().noneMatch(r -> 
r.name().equals("test_role")),
+            "override-remove must be immediately visible: the role must no 
longer bind the schema");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Guards the subtraction side: deleting a role that bound an already-warmed 
metadata object must
+   * make listBindingRoleNames immediately drop the role. deleteRole goes 
through {@code
+   * store.delete} (not put/update), so the object-side invalidation must come 
from the role-side
+   * {@code invalidate(roleIdent, ROLE)} via the reverse index.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testDeleteRoleInvalidatesMetadataObjectRoleRelCache(String type, 
boolean enableCache)
+      throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        CatalogEntity catalog =
+            createCatalog(
+                RandomIdGenerator.INSTANCE.nextId(),
+                NamespaceUtil.ofCatalog("metalake"),
+                "catalog",
+                auditInfo);
+        store.put(catalog, false);
+
+        SchemaEntity schema =
+            createSchemaEntity(
+                RandomIdGenerator.INSTANCE.nextId(),
+                Namespace.of("metalake", "catalog"),
+                "test_schema",
+                auditInfo);
+        store.put(schema, false);
+
+        SecurableObject catalogObject = SecurableObjects.ofCatalog("catalog", 
Lists.newArrayList());
+        SecurableObject schemaObject =
+            SecurableObjects.ofSchema(
+                catalogObject, "test_schema", 
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+        RoleEntity role =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("test_role")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(schemaObject))
+                .build();
+        store.put(role, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: schema -> [test_role].
+        List<RoleEntity> rolesBeforeDelete =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertEquals(1, rolesBeforeDelete.size());
+        Assertions.assertEquals("test_role", rolesBeforeDelete.get(0).name());
+
+        // Delete the role; its binding must vanish from the warmed schema 
cache.
+        store.delete(role.nameIdentifier(), Entity.EntityType.ROLE);
+
+        List<RoleEntity> rolesAfterDelete =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertTrue(
+            rolesAfterDelete.stream().noneMatch(r -> 
r.name().equals("test_role")),
+            "deleteRole must be immediately visible: the role must no longer 
bind the schema");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Covers the createRole path (which goes through {@code store.put}, not 
update): creating a role
+   * that already carries securable objects must make listBindingRoleNames on 
those objects
+   * immediately reflect the new role, even when the object's relation cache 
was warmed beforehand.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testCreateRoleWithSecurableObjectsInvalidatesMetadataObjectRoleRelCache(
+      String type, boolean enableCache) throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        CatalogEntity catalog =
+            createCatalog(
+                RandomIdGenerator.INSTANCE.nextId(),
+                NamespaceUtil.ofCatalog("metalake"),
+                "catalog",
+                auditInfo);
+        store.put(catalog, false);
+
+        SchemaEntity schema =
+            createSchemaEntity(
+                RandomIdGenerator.INSTANCE.nextId(),
+                Namespace.of("metalake", "catalog"),
+                "test_schema",
+                auditInfo);
+        store.put(schema, false);
+
+        SecurableObject catalogObject = SecurableObjects.ofCatalog("catalog", 
Lists.newArrayList());
+        SecurableObject schemaObject =
+            SecurableObjects.ofSchema(
+                catalogObject, "test_schema", 
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+        RoleEntity roleA =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleA")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(schemaObject))
+                .build();
+        store.put(roleA, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: schema -> [roleA].
+        List<RoleEntity> rolesBeforeCreate =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        Assertions.assertEquals(1, rolesBeforeCreate.size());
+        Assertions.assertEquals("roleA", rolesBeforeCreate.get(0).name());
+
+        // Create roleB already bound to the schema (createRole goes through 
store.put).
+        RoleEntity roleB =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleB")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList(schemaObject))
+                .build();
+        store.put(roleB, false);
+
+        // listBindingRoleNames(schema) must immediately reflect roleB.
+        List<RoleEntity> rolesAfterCreate =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+                schema.nameIdentifier(),
+                Entity.EntityType.SCHEMA,
+                true);
+        List<String> roleNames =
+            
rolesAfterCreate.stream().map(RoleEntity::name).sorted().collect(Collectors.toList());
+        Assertions.assertEquals(
+            Lists.newArrayList("roleA", "roleB"),
+            roleNames,
+            "createRole with securable objects must be immediately visible");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Covers the {@code grantRolesToUser} path: after a role is granted to a 
user that was never
+   * cached against that role, listEntitiesByRelation(ROLE_USER_REL, 
roleIdent) must immediately
+   * reflect the new user. Same defect shape as {@link
+   * #testGrantPrivilegeInvalidatesMetadataObjectRoleRelCache}, on 
user.roleNames instead of
+   * role.securableObjects.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testGrantRolesToUserInvalidatesRoleUserRelCache(String type, boolean 
enableCache)
+      throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        RoleEntity roleA =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleA")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList())
+                .build();
+        store.put(roleA, false);
+
+        UserEntity user1 =
+            UserEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("user1")
+                .withNamespace(AuthorizationUtils.ofUserNamespace("metalake"))
+                .withRoleNames(Lists.newArrayList("roleA"))
+                .withRoleIds(Lists.newArrayList(roleA.id()))
+                .withAuditInfo(auditInfo)
+                .build();
+        store.put(user1, false);
+
+        UserEntity user2 =
+            UserEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("user2")
+                .withNamespace(AuthorizationUtils.ofUserNamespace("metalake"))
+                .withRoleNames(Lists.newArrayList())
+                .withRoleIds(Lists.newArrayList())
+                .withAuditInfo(auditInfo)
+                .build();
+        store.put(user2, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: roleA -> [user1].
+        List<UserEntity> usersBeforeGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.ROLE_USER_REL,
+                roleA.nameIdentifier(),
+                Entity.EntityType.ROLE,
+                true);
+        Assertions.assertEquals(1, usersBeforeGrant.size());
+        Assertions.assertEquals("user1", usersBeforeGrant.get(0).name());
+
+        // Simulate grantRolesToUser(roleA -> user2): store.update(user2, 
roleNames=[roleA]).
+        store.update(
+            user2.nameIdentifier(),
+            UserEntity.class,
+            Entity.EntityType.USER,
+            existing ->
+                UserEntity.builder()
+                    .withId(existing.id())
+                    .withName(existing.name())
+                    .withNamespace(existing.namespace())
+                    .withRoleNames(Lists.newArrayList("roleA"))
+                    .withRoleIds(Lists.newArrayList(roleA.id()))
+                    .withAuditInfo(auditInfo)
+                    .build());
+
+        // roleA -> users must immediately reflect user2.
+        List<UserEntity> usersAfterGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.ROLE_USER_REL,
+                roleA.nameIdentifier(),
+                Entity.EntityType.ROLE,
+                true);
+        List<String> names =
+            
usersAfterGrant.stream().map(UserEntity::name).sorted().collect(Collectors.toList());
+        Assertions.assertEquals(
+            Lists.newArrayList("user1", "user2"),
+            names,
+            "grantRolesToUser must be immediately visible via role->users");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
+  /**
+   * Covers the {@code grantRolesToGroup} path: after a role is granted to a 
group that was never
+   * cached against that role, listEntitiesByRelation(ROLE_GROUP_REL, 
roleIdent) must immediately
+   * reflect the new group.
+   */
+  @ParameterizedTest
+  @MethodSource("storageProvider")
+  void testGrantRolesToGroupInvalidatesRoleGroupRelCache(String type, boolean 
enableCache)
+      throws Exception {
+    Config config = Mockito.mock(Config.class);
+    Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(enableCache);
+    init(type, config);
+
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+    try (EntityStore store = EntityStoreFactory.createEntityStore(config)) {
+      try {
+        store.initialize(config);
+
+        BaseMetalake metalake =
+            createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), 
"metalake", auditInfo);
+        store.put(metalake, false);
+
+        RoleEntity roleA =
+            RoleEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("roleA")
+                .withNamespace(AuthorizationUtils.ofRoleNamespace("metalake"))
+                .withProperties(null)
+                .withAuditInfo(auditInfo)
+                .withSecurableObjects(Lists.newArrayList())
+                .build();
+        store.put(roleA, false);
+
+        GroupEntity group1 =
+            GroupEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("group1")
+                .withNamespace(AuthorizationUtils.ofGroupNamespace("metalake"))
+                .withRoleNames(Lists.newArrayList("roleA"))
+                .withRoleIds(Lists.newArrayList(roleA.id()))
+                .withAuditInfo(auditInfo)
+                .build();
+        store.put(group1, false);
+
+        GroupEntity group2 =
+            GroupEntity.builder()
+                .withId(RandomIdGenerator.INSTANCE.nextId())
+                .withName("group2")
+                .withNamespace(AuthorizationUtils.ofGroupNamespace("metalake"))
+                .withRoleNames(Lists.newArrayList())
+                .withRoleIds(Lists.newArrayList())
+                .withAuditInfo(auditInfo)
+                .build();
+        store.put(group2, false);
+
+        SupportsRelationOperations relationOperations = 
(SupportsRelationOperations) store;
+
+        // Warm the cache: roleA -> [group1].
+        List<GroupEntity> groupsBeforeGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.ROLE_GROUP_REL,
+                roleA.nameIdentifier(),
+                Entity.EntityType.ROLE,
+                true);
+        Assertions.assertEquals(1, groupsBeforeGrant.size());
+        Assertions.assertEquals("group1", groupsBeforeGrant.get(0).name());
+
+        // Simulate grantRolesToGroup(roleA -> group2): store.update(group2, 
roleNames=[roleA]).
+        store.update(
+            group2.nameIdentifier(),
+            GroupEntity.class,
+            Entity.EntityType.GROUP,
+            existing ->
+                GroupEntity.builder()
+                    .withId(existing.id())
+                    .withName(existing.name())
+                    .withNamespace(existing.namespace())
+                    .withRoleNames(Lists.newArrayList("roleA"))
+                    .withRoleIds(Lists.newArrayList(roleA.id()))
+                    .withAuditInfo(auditInfo)
+                    .build());
+
+        // roleA -> groups must immediately reflect group2.
+        List<GroupEntity> groupsAfterGrant =
+            relationOperations.listEntitiesByRelation(
+                SupportsRelationOperations.Type.ROLE_GROUP_REL,
+                roleA.nameIdentifier(),
+                Entity.EntityType.ROLE,
+                true);
+        List<String> names =
+            
groupsAfterGrant.stream().map(GroupEntity::name).sorted().collect(Collectors.toList());
+        Assertions.assertEquals(
+            Lists.newArrayList("group1", "group2"),
+            names,
+            "grantRolesToGroup must be immediately visible via role->groups");
+
+      } finally {
+        destroy(type);
+      }
+    }
+  }
+
   private FunctionEntity createFunctionEntity(
       Long id, Namespace namespace, String name, AuditInfo auditInfo) {
     FunctionParam param1 = FunctionParams.of("param1", 
Types.IntegerType.get());


Reply via email to