This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 45d6f40210 [Cherry-pick to branch-1.3] [#11297] fix(cache): Propagate
role invalidation to METADATA_OBJECT_ROLE_REL cache (#11310) (#11331)
45d6f40210 is described below
commit 45d6f40210e2f853f85e8e26b1be34a79a0b064f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Jun 2 11:52:14 2026 +0800
[Cherry-pick to branch-1.3] [#11297] fix(cache): Propagate role
invalidation to METADATA_OBJECT_ROLE_REL cache (#11310) (#11331)
**Cherry-pick Information:**
- Original commit: a22610eb2ee6d79ac579d1925b60be2bf09bb6e6
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qi Yu <[email protected]>
---
.../apache/gravitino/cache/ReverseIndexRules.java | 10 +
.../cache/TestCaffeineEntityCacheInvalidation.java | 376 +++++++++++++++++++++
.../storage/TestEntityStorageRelationCache.java | 142 ++++++++
3 files changed, 528 insertions(+)
diff --git
a/core/src/main/java/org/apache/gravitino/cache/ReverseIndexRules.java
b/core/src/main/java/org/apache/gravitino/cache/ReverseIndexRules.java
index a58e31ed8a..822f0459fd 100644
--- a/core/src/main/java/org/apache/gravitino/cache/ReverseIndexRules.java
+++ b/core/src/main/java/org/apache/gravitino/cache/ReverseIndexRules.java
@@ -111,6 +111,16 @@ public class ReverseIndexRules {
reverseIndexCache.put(securableObjectIdent, entityType,
key);
});
}
+
+ // When this role entity is stored as part of a relation cache entry
(e.g.,
+ // METADATA_OBJECT_ROLE_REL keyed by a schema), also add a reverse
mapping from the
+ // role's own identifier to that relation cache key. Without this,
invalidating the
+ // role entity (e.g., after revokePrivilegesFromRole) would not
propagate to the
+ // schema's METADATA_OBJECT_ROLE_REL cache entry, leaving stale data
visible via
+ // listBindingRoleNames().
+ if (key.relationType() != null) {
+ reverseIndexCache.put(roleEntity.nameIdentifier(), EntityType.ROLE,
key);
+ }
};
// Keep policies/tags to objects reverse index for metadata objects, so the
key are objects and
diff --git
a/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
new file mode 100644
index 0000000000..58c0450852
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
@@ -0,0 +1,376 @@
+/*
+ * 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.cache;
+
+import com.google.common.collect.Lists;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObject;
+import org.apache.gravitino.authorization.SecurableObjects;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.RoleEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link CaffeineEntityCache} invalidation logic, specifically
covering the bug
+ * where invalidating a role entity did not propagate to the
METADATA_OBJECT_ROLE_REL relation cache
+ * entries for the role's securable objects.
+ *
+ * <p>See GitHub issue #11297: {@code listBindingRoleNames} returns stale role
after {@code
+ * revokePrivilegesFromRole} removes the last privilege.
+ */
+public class TestCaffeineEntityCacheInvalidation {
+
+ private CaffeineEntityCache cache;
+ private AuditInfo auditInfo;
+
+ @BeforeEach
+ void setUp() {
+ Config config = new Config(false) {};
+ cache = new CaffeineEntityCache(config);
+ auditInfo =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ }
+
+ /**
+ * Builds a RoleEntity that has the given schema as its securable object.
+ *
+ * @param metalake the metalake name
+ * @param roleName the role name
+ * @param catalogName the catalog the schema belongs to
+ * @param schemaName the schema name used as the role's securable object
+ * @return the constructed RoleEntity
+ */
+ private RoleEntity buildRoleWithSchemaObject(
+ String metalake, String roleName, String catalogName, String schemaName)
{
+ SecurableObject catalogObject = SecurableObjects.ofCatalog(catalogName,
Lists.newArrayList());
+ SecurableObject schemaObject =
+ SecurableObjects.ofSchema(
+ catalogObject, schemaName,
Lists.newArrayList(Privileges.UseSchema.allow()));
+ return RoleEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(roleName)
+ .withNamespace(AuthorizationUtils.ofRoleNamespace(metalake))
+ .withProperties(null)
+ .withAuditInfo(auditInfo)
+ .withSecurableObjects(Lists.newArrayList(schemaObject))
+ .build();
+ }
+
+ /**
+ * Core scenario reproducing GitHub issue #11297: after caching the
METADATA_OBJECT_ROLE_REL
+ * relation for a schema, invalidating the role entity must also remove the
stale relation cache.
+ *
+ * <p>Flow: grant → listBindingRoleNames (caches relation) → revoke
(invalidates role) →
+ * listBindingRoleNames must miss cache and re-query.
+ */
+ @Test
+ void testRoleInvalidationPropagatesToMetadataObjectRoleRelCache() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String schemaName = "test_schema";
+ String roleName = "test_role";
+
+ RoleEntity role = buildRoleWithSchemaObject(metalake, roleName,
catalogName, schemaName);
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalogName,
schemaName);
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(metalake, roleName);
+
+ // Simulate caching the METADATA_OBJECT_ROLE_REL result (e.g., after
listBindingRoleNames)
+ cache.put(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+
+ // Verify the relation cache is populated
+ Optional<List<RoleEntity>> cached =
+ cache.getIfPresent(
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ schemaIdent,
+ Entity.EntityType.SCHEMA);
+ Assertions.assertTrue(cached.isPresent(), "Relation cache should be
populated after put");
+ Assertions.assertEquals(1, cached.get().size());
+ Assertions.assertEquals(roleName, cached.get().get(0).name());
+
+ // Simulate revokePrivilegesFromRole: invalidate the role entity
+ cache.invalidate(roleIdent, Entity.EntityType.ROLE);
+
+ // After invalidation the METADATA_OBJECT_ROLE_REL cache for the schema
must be gone
+ Optional<List<RoleEntity>> afterRevoke =
+ cache.getIfPresent(
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ schemaIdent,
+ Entity.EntityType.SCHEMA);
+ Assertions.assertFalse(
+ afterRevoke.isPresent(),
+ "METADATA_OBJECT_ROLE_REL cache must be invalidated after revoking the
role");
+ }
+
+ /**
+ * When the role entity was also separately cached (via a previous get),
invalidating it must
+ * still propagate to the METADATA_OBJECT_ROLE_REL relation cache.
+ */
+ @Test
+ void testRoleInvalidationWithSeparatelyCachedRoleEntity() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String schemaName = "schema1";
+ String roleName = "role1";
+
+ RoleEntity role = buildRoleWithSchemaObject(metalake, roleName,
catalogName, schemaName);
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalogName,
schemaName);
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(metalake, roleName);
+
+ // Simulate separate entity get (e.g., loadRole)
+ cache.put(role);
+
+ // Simulate relation cache populated after listBindingRoleNames
+ cache.put(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+
+ // Both caches are present
+ Assertions.assertTrue(cache.contains(roleIdent, Entity.EntityType.ROLE));
+ Assertions.assertTrue(
+ cache.contains(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+
+ // Invalidate role (simulates revokePrivilegesFromRole)
+ cache.invalidate(roleIdent, Entity.EntityType.ROLE);
+
+ // Both the role entity cache and the relation cache must be gone
+ Assertions.assertFalse(
+ cache.contains(roleIdent, Entity.EntityType.ROLE),
+ "Role entity cache must be gone after invalidation");
+ Assertions.assertFalse(
+ cache.contains(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL),
+ "METADATA_OBJECT_ROLE_REL cache must be invalidated after revoking the
role");
+ }
+
+ /**
+ * When multiple roles share the same schema as a securable object,
invalidating one role must
+ * invalidate the METADATA_OBJECT_ROLE_REL cache for that schema (since the
full list is stale).
+ *
+ * <p>Note: The existing BFS propagation logic also cascades from the
cleared relation cache back
+ * to other role entities via the schema's reverse index (the same behaviour
that clears stale
+ * role-entity caches on schema rename). Clearing role2's entity cache is a
pre-existing
+ * performance trade-off, not a correctness bug: role2 will be re-fetched
fresh from the DB on the
+ * next access.
+ */
+ @Test
+ void testMultipleRolesInvalidationForSameSchema() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String schemaName = "schema";
+
+ RoleEntity role1 = buildRoleWithSchemaObject(metalake, "role1",
catalogName, schemaName);
+ RoleEntity role2 = buildRoleWithSchemaObject(metalake, "role2",
catalogName, schemaName);
+
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalogName,
schemaName);
+ NameIdentifier role1Ident = NameIdentifierUtil.ofRole(metalake, "role1");
+
+ // Cache both role entities separately and the relation list
+ cache.put(role1);
+ cache.put(role2);
+ cache.put(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role1, role2));
+
+ Assertions.assertTrue(
+ cache.contains(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+
+ // Revoke role1
+ cache.invalidate(role1Ident, Entity.EntityType.ROLE);
+
+ // The METADATA_OBJECT_ROLE_REL cache must be gone — this is the core
correctness fix
+ Assertions.assertFalse(
+ cache.contains(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL),
+ "Relation cache must be cleared when any of its roles is revoked");
+ }
+
+ /**
+ * When a role has securable objects on multiple schemas, invalidating the
role must invalidate
+ * the METADATA_OBJECT_ROLE_REL caches for ALL schemas.
+ */
+ @Test
+ void testRoleWithMultipleSchemasInvalidatesAllRelationCaches() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String roleName = "multi_schema_role";
+
+ SecurableObject cat = SecurableObjects.ofCatalog(catalogName,
Lists.newArrayList());
+ SecurableObject schema1Obj =
+ SecurableObjects.ofSchema(cat, "schema1",
Lists.newArrayList(Privileges.UseSchema.allow()));
+ SecurableObject schema2Obj =
+ SecurableObjects.ofSchema(cat, "schema2",
Lists.newArrayList(Privileges.UseSchema.allow()));
+
+ RoleEntity role =
+ RoleEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(roleName)
+ .withNamespace(AuthorizationUtils.ofRoleNamespace(metalake))
+ .withProperties(null)
+ .withAuditInfo(auditInfo)
+ .withSecurableObjects(Lists.newArrayList(schema1Obj, schema2Obj))
+ .build();
+
+ NameIdentifier schema1Ident = NameIdentifier.of(metalake, catalogName,
"schema1");
+ NameIdentifier schema2Ident = NameIdentifier.of(metalake, catalogName,
"schema2");
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(metalake, roleName);
+
+ // Cache METADATA_OBJECT_ROLE_REL for both schemas
+ cache.put(
+ schema1Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+ cache.put(
+ schema2Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+
+ Assertions.assertTrue(
+ cache.contains(
+ schema1Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+ Assertions.assertTrue(
+ cache.contains(
+ schema2Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+
+ // Revoke role (removes its last privilege on all schemas)
+ cache.invalidate(roleIdent, Entity.EntityType.ROLE);
+
+ Assertions.assertFalse(
+ cache.contains(
+ schema1Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL),
+ "schema1 METADATA_OBJECT_ROLE_REL cache must be cleared");
+ Assertions.assertFalse(
+ cache.contains(
+ schema2Ident,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL),
+ "schema2 METADATA_OBJECT_ROLE_REL cache must be cleared");
+ }
+
+ /**
+ * When the METADATA_OBJECT_ROLE_REL cache already contains the role (e.g.,
role was granted
+ * earlier and the relation was cached), granting an additional privilege
(invalidates the role)
+ * must also clear the stale relation cache. This is symmetric with the
revoke path.
+ */
+ @Test
+ void testGrantPathInvalidatesRelationCacheWhenRoleWasPreviouslyCached() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String schemaName = "schema_grant";
+ String roleName = "grant_role";
+
+ RoleEntity role = buildRoleWithSchemaObject(metalake, roleName,
catalogName, schemaName);
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalogName,
schemaName);
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(metalake, roleName);
+
+ // Simulate a prior listBindingRoleNames that already included the role
+ cache.put(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+
+ // Simulate grantPrivilegesToRole adding another privilege → role entity
is updated and
+ // invalidated
+ cache.invalidate(roleIdent, Entity.EntityType.ROLE);
+
+ // Relation cache should be gone, forcing a fresh DB query on next
listBindingRoleNames
+ Optional<List<RoleEntity>> afterGrant =
+ cache.getIfPresent(
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ schemaIdent,
+ Entity.EntityType.SCHEMA);
+ Assertions.assertFalse(
+ afterGrant.isPresent(),
+ "METADATA_OBJECT_ROLE_REL cache must be cleared after grant so next
read returns fresh data");
+ }
+
+ /**
+ * Verify the reverse index is cleaned up correctly after invalidation,
preventing memory leaks.
+ */
+ @Test
+ void testReverseIndexCleanupAfterRoleInvalidation() {
+ String metalake = "metalake";
+ String catalogName = "catalog";
+ String schemaName = "schema_cleanup";
+ String roleName = "cleanup_role";
+
+ RoleEntity role = buildRoleWithSchemaObject(metalake, roleName,
catalogName, schemaName);
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalogName,
schemaName);
+ NameIdentifier roleIdent = NameIdentifierUtil.ofRole(metalake, roleName);
+
+ cache.put(role);
+ cache.put(
+ schemaIdent,
+ Entity.EntityType.SCHEMA,
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ Lists.newArrayList(role));
+
+ long sizeBeforeInvalidation = cache.size();
+ cache.invalidate(roleIdent, Entity.EntityType.ROLE);
+ long sizeAfterInvalidation = cache.size();
+
+ // Both the role entity entry and the relation entry should be removed
+ Assertions.assertTrue(
+ sizeAfterInvalidation < sizeBeforeInvalidation,
+ "Cache size must decrease after invalidating role and its related
relation entries");
+
+ // Reverse index for role should be empty
+ ReverseIndexCache reverseIndex = cache.getReverseIndex();
+ List<EntityCacheKey> roleReverseKeys = reverseIndex.get(roleIdent,
Entity.EntityType.ROLE);
+ Assertions.assertNull(
+ roleReverseKeys, "Reverse index for role should be empty after
invalidation");
+ }
+}
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 2e75d6b684..8819d5c028 100644
---
a/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
+++
b/core/src/test/java/org/apache/gravitino/storage/TestEntityStorageRelationCache.java
@@ -1468,6 +1468,148 @@ public class TestEntityStorageRelationCache extends
AbstractEntityStorageTest {
}
}
+ /**
+ * Reproduces GitHub issue #11297: after {@code revokePrivilegesFromRole}
removes the last
+ * privilege from a role's securable schema, {@code
+ * listEntitiesByRelation(METADATA_OBJECT_ROLE_REL)} must not return the
stale role from cache.
+ *
+ * <p>Flow: create schema + role → list binding roles (populates cache) →
update role to remove
+ * securable object (simulates revoke) → list binding roles again → must
reflect the change.
+ */
+ @ParameterizedTest
+ @MethodSource("storageProvider")
+ void testRevokePrivilegeInvalidatesMetadataObjectRoleRelCache(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 up the METADATA_OBJECT_ROLE_REL cache (simulates
listBindingRoleNames after grant)
+ 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());
+
+ // Verify the relation is in cache when cache is enabled
+ if (enableCache && store instanceof RelationalEntityStore) {
+ RelationalEntityStore relStore = (RelationalEntityStore) store;
+ if (relStore.getCache() instanceof CaffeineEntityCache) {
+ CaffeineEntityCache caffeineCache = (CaffeineEntityCache)
relStore.getCache();
+ List<Entity> cachedRoles =
+ caffeineCache
+ .getCacheData()
+ .getIfPresent(
+ EntityCacheRelationKey.of(
+ schema.nameIdentifier(),
+ Entity.EntityType.SCHEMA,
+
SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+ Assertions.assertNotNull(cachedRoles, "Cache should be populated
after first fetch");
+ Assertions.assertEquals(1, cachedRoles.size());
+ }
+ }
+
+ // Simulate revokePrivilegesFromRole: update the role to remove the
schema securable object.
+ // RelationalEntityStore.update() calls cache.invalidate(roleIdent,
ROLE) afterwards,
+ // which must also invalidate the schema's METADATA_OBJECT_ROLE_REL
cache entry.
+ 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()) // all
privileges revoked
+ .build());
+
+ // Verify METADATA_OBJECT_ROLE_REL cache is gone when cache is enabled
+ if (enableCache && store instanceof RelationalEntityStore) {
+ RelationalEntityStore relStore = (RelationalEntityStore) store;
+ if (relStore.getCache() instanceof CaffeineEntityCache) {
+ CaffeineEntityCache caffeineCache = (CaffeineEntityCache)
relStore.getCache();
+ List<Entity> cachedAfterRevoke =
+ caffeineCache
+ .getCacheData()
+ .getIfPresent(
+ EntityCacheRelationKey.of(
+ schema.nameIdentifier(),
+ Entity.EntityType.SCHEMA,
+
SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL));
+ Assertions.assertNull(
+ cachedAfterRevoke,
+ "METADATA_OBJECT_ROLE_REL cache must be invalidated after role
update");
+ }
+ }
+
+ // The key correctness check: listEntitiesByRelation must not return
the revoked role
+ List<RoleEntity> rolesAfterRevoke =
+ relationOperations.listEntitiesByRelation(
+ SupportsRelationOperations.Type.METADATA_OBJECT_ROLE_REL,
+ schema.nameIdentifier(),
+ Entity.EntityType.SCHEMA,
+ true);
+ Assertions.assertTrue(
+ rolesAfterRevoke.isEmpty(),
+ "listEntitiesByRelation must return empty after revoking the last
privilege from role");
+
+ } finally {
+ destroy(type);
+ }
+ }
+ }
+
private FunctionEntity createFunctionEntity(
Long id, Namespace namespace, String name, AuditInfo auditInfo) {
FunctionParam param1 = FunctionParams.of("param1",
Types.IntegerType.get());