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 bf5f30f411 [Cherry-pick to branch-1.3] [#12416] fix(core): Cascade
cache invalidation to hierarchical schema descendants (#12417) (#12435)
bf5f30f411 is described below
commit bf5f30f411b52dffb2159c2f50fa05f382170d6c
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Aug 12 19:57:08 2026 +0800
[Cherry-pick to branch-1.3] [#12416] fix(core): Cascade cache invalidation
to hierarchical schema descendants (#12417) (#12435)
**Cherry-pick Information:**
- Original commit: bb93bf923433096735abdca9f248a4b3adfdfbcf
- Target branch: `branch-1.3`
- Status: ⚠️ **Has conflicts - manual resolution required**
**Do not merge** until conflict markers are resolved and the
`cherry-pick-conflict` label is removed.
Please review and resolve the conflicts before merging.
---------
Signed-off-by: yuqi <[email protected]>
Co-authored-by: Qi Yu <[email protected]>
---
.../gravitino/cache/CaffeineEntityCache.java | 45 +++-
.../storage/relational/RelationalEntityStore.java | 18 +-
.../cache/TestCaffeineEntityCacheInvalidation.java | 182 ++++++++++++++++
.../relational/TestRelationalEntityStore.java | 64 ++----
...TestRelationalEntityStoreHierarchicalCache.java | 229 +++++++++++++++++++++
5 files changed, 481 insertions(+), 57 deletions(-)
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 fd785edf5a..ee186f4cc5 100644
--- a/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
+++ b/core/src/main/java/org/apache/gravitino/cache/CaffeineEntityCache.java
@@ -54,6 +54,7 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.SupportsRelationOperations;
import org.apache.gravitino.meta.GenericEntity;
import org.apache.gravitino.meta.ModelVersionEntity;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -80,6 +81,12 @@ public class CaffeineEntityCache extends BaseEntityCache {
private static final Logger LOG =
LoggerFactory.getLogger(CaffeineEntityCache.class.getName());
+ /**
+ * Separates {@link NameIdentifier} levels in a cache key. See {@link
+ * #invalidationPrefixes(EntityCacheKey)} for why it is not the only child
boundary.
+ */
+ private static final String NAME_LEVEL_BOUNDARY = ".";
+
/** Segmented locking for better concurrency */
private final SegmentedLock segmentedLock;
@@ -486,17 +493,18 @@ public class CaffeineEntityCache extends BaseEntityCache {
cacheData.invalidate(currentKeyToRemove);
cacheIndex.remove(currentKeyToRemove.toString());
- // Remove related entity keys
- List<EntityCacheKey> relatedEntityKeysToRemove =
- Lists.newArrayList(
-
cacheIndex.getValuesForKeysStartingWith(currentKeyToRemove.identifier().toString()));
+ // Remove the current entity's relation entries and its descendants.
+ Set<EntityCacheKey> relatedEntityKeysToRemove = Sets.newHashSet();
+ for (String prefix : invalidationPrefixes(currentKeyToRemove)) {
+
cacheIndex.getValuesForKeysStartingWith(prefix).forEach(relatedEntityKeysToRemove::add);
+ }
queue.addAll(relatedEntityKeysToRemove);
// Look up from reverse index to go to next depth
- List<List<EntityCacheKey>> reverseKeysToRemove =
- Lists.newArrayList(
- reverseIndex.getValuesForKeysStartingWith(
- currentKeyToRemove.identifier().toString()));
+ List<List<EntityCacheKey>> reverseKeysToRemove = Lists.newArrayList();
+ for (String prefix : invalidationPrefixes(currentKeyToRemove)) {
+
reverseIndex.getValuesForKeysStartingWith(prefix).forEach(reverseKeysToRemove::add);
+ }
reverseKeysToRemove.forEach(
key -> {
@@ -520,6 +528,27 @@ public class CaffeineEntityCache extends BaseEntityCache {
return true;
}
+ /**
+ * Returns exact prefixes for the entity, its ordinary descendants, and
nested schemas.
+ *
+ * <p>The entity-type prefix includes all relation entries for the same
entity without matching an
+ * entity of another type. The name-level and schema-level prefixes use
explicit child boundaries,
+ * so identifiers that merely share a prefix remain cached.
+ *
+ * @param key the entity cache key
+ * @return prefixes identifying the entity and its descendants
+ */
+ private Set<String> invalidationPrefixes(EntityCacheKey key) {
+ String identifier = key.identifier().toString();
+ Set<String> prefixes = Sets.newLinkedHashSet();
+ prefixes.add(EntityCacheKey.of(key.identifier(),
key.entityType()).toString());
+ prefixes.add(identifier + NAME_LEVEL_BOUNDARY);
+ if (key.entityType() == Entity.EntityType.SCHEMA) {
+ prefixes.add(identifier + HierarchicalSchemaUtil.schemaSeparator());
+ }
+ return prefixes;
+ }
+
/** Starts the cache stats monitor. */
private void startCacheStatsMonitor() {
scheduler.scheduleAtFixedRate(
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 6f21491173..c710b778b0 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
@@ -395,13 +395,14 @@ public class RelationalEntityStore
backend.updateEntityRelations(
relType, srcEntityIdent, srcEntityType, destEntitiesToAdd,
destEntitiesToRemove);
+ Entity.EntityType destEntityType = relationDestinationEntityType(relType);
cache.invalidate(srcEntityIdent, srcEntityType, relType);
for (NameIdentifier destToAdd : destEntitiesToAdd) {
- cache.invalidate(destToAdd, srcEntityType, relType);
+ cache.invalidate(destToAdd, destEntityType, relType);
}
for (NameIdentifier destToRemove : destEntitiesToRemove) {
- cache.invalidate(destToRemove, srcEntityType, relType);
+ cache.invalidate(destToRemove, destEntityType, relType);
}
return result;
@@ -420,6 +421,19 @@ public class RelationalEntityStore
backend.batchPut(entities, overwritten);
}
+ private static Entity.EntityType relationDestinationEntityType(
+ SupportsRelationOperations.Type relType) {
+ switch (relType) {
+ case POLICY_METADATA_OBJECT_REL:
+ return Entity.EntityType.POLICY;
+ case TAG_METADATA_OBJECT_REL:
+ return Entity.EntityType.TAG;
+ default:
+ throw new IllegalArgumentException(
+ String.format("Doesn't support the relation type %s", relType));
+ }
+ }
+
private <E extends Entity & HasIdentifier>
Optional<List<RelationalEntity<?>>> getCachedRelations(
SupportsRelationOperations.Type relType,
NameIdentifier nameIdentifier,
diff --git
a/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
index 58c0450852..dcb66e5c7b 100644
---
a/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
+++
b/core/src/test/java/org/apache/gravitino/cache/TestCaffeineEntityCacheInvalidation.java
@@ -22,18 +22,28 @@ import com.google.common.collect.Lists;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
+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.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
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.CatalogEntity;
import org.apache.gravitino.meta.RoleEntity;
+import org.apache.gravitino.meta.SchemaEntity;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.meta.TopicEntity;
import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.TestUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,6 +58,8 @@ import org.junit.jupiter.api.Test;
*/
public class TestCaffeineEntityCacheInvalidation {
+ private static final Namespace CATALOG_NS = Namespace.of("metalake",
"catalog1");
+
private CaffeineEntityCache cache;
private AuditInfo auditInfo;
@@ -83,6 +95,18 @@ public class TestCaffeineEntityCacheInvalidation {
.build();
}
+ /**
+ * Joins nested schema levels the way they reach the cache. The cache sits
above the storage
+ * layer, so nested schema names still carry the configured external
separator.
+ */
+ private static String hierarchicalName(String... levels) {
+ return String.join(HierarchicalSchemaUtil.schemaSeparator(), levels);
+ }
+
+ private static Namespace schemaNamespace(String schemaName) {
+ return Namespace.of(CATALOG_NS.level(0), CATALOG_NS.level(1), schemaName);
+ }
+
/**
* 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.
@@ -373,4 +397,162 @@ public class TestCaffeineEntityCacheInvalidation {
Assertions.assertNull(
roleReverseKeys, "Reverse index for role should be empty after
invalidation");
}
+
+ @Test
+ void testInvalidateHierarchicalSchemaCascadesToNestedSchemas() {
+ // A HierarchicalSchema nests inside a single name level, joined by the
schema separator, so
+ // "raw:events:2024" is a child of "raw:events" without adding a
NameIdentifier level.
+ String parentName = hierarchicalName("raw", "events");
+ String childName = hierarchicalName("raw", "events", "2024");
+
+ SchemaEntity parent = TestUtil.getTestSchemaEntity(2L, parentName,
CATALOG_NS, "cmt");
+ SchemaEntity child = TestUtil.getTestSchemaEntity(3L, childName,
CATALOG_NS, "cmt");
+ TableEntity tableInParent =
+ TestUtil.getTestTableEntity(4L, "t_parent",
schemaNamespace(parentName));
+ TableEntity tableInChild =
+ TestUtil.getTestTableEntity(5L, "t_child", schemaNamespace(childName));
+
+ cache.put(parent);
+ cache.put(child);
+ cache.put(tableInParent);
+ cache.put(tableInChild);
+ Assertions.assertEquals(4, cache.size());
+
+ cache.invalidate(parent.nameIdentifier(), Entity.EntityType.SCHEMA);
+
+ Assertions.assertFalse(cache.contains(parent.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(cache.contains(tableInParent.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertFalse(cache.contains(child.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(cache.contains(tableInChild.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertEquals(0, cache.size());
+ }
+
+ @Test
+ void testInvalidateHierarchicalSchemaCascadesToAnyDepth() {
+ String level1 = hierarchicalName("raw");
+ String level2 = hierarchicalName("raw", "events");
+ String level3 = hierarchicalName("raw", "events", "2024");
+ String level4 = hierarchicalName("raw", "events", "2024", "q1");
+
+ cache.put(TestUtil.getTestSchemaEntity(2L, level1, CATALOG_NS, "cmt"));
+ cache.put(TestUtil.getTestSchemaEntity(3L, level2, CATALOG_NS, "cmt"));
+ cache.put(TestUtil.getTestSchemaEntity(4L, level3, CATALOG_NS, "cmt"));
+ SchemaEntity deepest = TestUtil.getTestSchemaEntity(5L, level4,
CATALOG_NS, "cmt");
+ cache.put(deepest);
+ TableEntity deepestTable = TestUtil.getTestTableEntity(6L, "t_deep",
schemaNamespace(level4));
+ cache.put(deepestTable);
+ Assertions.assertEquals(5, cache.size());
+
+ cache.invalidate(
+ TestUtil.getTestSchemaEntity(2L, level1, CATALOG_NS,
"cmt").nameIdentifier(),
+ Entity.EntityType.SCHEMA);
+
+ Assertions.assertFalse(cache.contains(deepest.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(cache.contains(deepestTable.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertEquals(0, cache.size());
+ }
+
+ @Test
+ void testInvalidateHierarchicalSchemaDoesNotTouchSiblings() {
+ // Guards against over-matching: "raw:events2" is a sibling of
"raw:events", not a descendant,
+ // exactly like the catalog1 / catalog10 case the "." boundary already
protects against.
+ String target = hierarchicalName("raw", "events");
+ String sibling = hierarchicalName("raw", "events2");
+ String siblingOfParent = hierarchicalName("raw2", "events");
+
+ SchemaEntity targetSchema = TestUtil.getTestSchemaEntity(2L, target,
CATALOG_NS, "cmt");
+ SchemaEntity siblingSchema = TestUtil.getTestSchemaEntity(3L, sibling,
CATALOG_NS, "cmt");
+ SchemaEntity otherBranch = TestUtil.getTestSchemaEntity(4L,
siblingOfParent, CATALOG_NS, "cmt");
+ TableEntity siblingTable =
+ TestUtil.getTestTableEntity(5L, "t_sibling", schemaNamespace(sibling));
+
+ cache.put(targetSchema);
+ cache.put(siblingSchema);
+ cache.put(otherBranch);
+ cache.put(siblingTable);
+
+ cache.invalidate(targetSchema.nameIdentifier(), Entity.EntityType.SCHEMA);
+
+ Assertions.assertFalse(cache.contains(targetSchema.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertTrue(cache.contains(siblingSchema.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertTrue(cache.contains(otherBranch.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertTrue(cache.contains(siblingTable.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertEquals(3, cache.size());
+ }
+
+ @Test
+ void testInvalidateCatalogCascadesToHierarchicalSchemas() {
+ CatalogEntity catalog =
+ TestUtil.getTestCatalogEntity(1L, "catalog1",
Namespace.of("metalake"), "hive", "cmt");
+ String nested = hierarchicalName("raw", "events", "2024");
+ SchemaEntity schema = TestUtil.getTestSchemaEntity(2L, nested, CATALOG_NS,
"cmt");
+ TableEntity table = TestUtil.getTestTableEntity(3L, "t1",
schemaNamespace(nested));
+
+ cache.put(catalog);
+ cache.put(schema);
+ cache.put(table);
+ Assertions.assertEquals(3, cache.size());
+
+ cache.invalidate(catalog.nameIdentifier(), Entity.EntityType.CATALOG);
+
+ Assertions.assertFalse(cache.contains(schema.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(cache.contains(table.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertEquals(0, cache.size());
+ }
+
+ @Test
+ void testInvalidateHierarchicalSchemaCascadesWithNonDefaultSeparator()
throws Exception {
+ Config separatorConfig = new Config(false) {};
+ separatorConfig.set(Configs.SCHEMA_SEPARATOR, "|");
+ Object previousConfig = FieldUtils.readField(GravitinoEnv.getInstance(),
"config", true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config",
separatorConfig, true);
+
+ try {
+ Assertions.assertEquals("|", HierarchicalSchemaUtil.schemaSeparator());
+
+ String parentName = hierarchicalName("raw", "events");
+ String childName = hierarchicalName("raw", "events", "2024");
+ String siblingName = hierarchicalName("raw", "events2");
+
+ SchemaEntity parent = TestUtil.getTestSchemaEntity(2L, parentName,
CATALOG_NS, "cmt");
+ SchemaEntity child = TestUtil.getTestSchemaEntity(3L, childName,
CATALOG_NS, "cmt");
+ SchemaEntity sibling = TestUtil.getTestSchemaEntity(4L, siblingName,
CATALOG_NS, "cmt");
+ TableEntity tableInChild =
+ TestUtil.getTestTableEntity(5L, "t_child",
schemaNamespace(childName));
+
+ cache.put(parent);
+ cache.put(child);
+ cache.put(sibling);
+ cache.put(tableInChild);
+
+ cache.invalidate(parent.nameIdentifier(), Entity.EntityType.SCHEMA);
+
+ Assertions.assertFalse(cache.contains(parent.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(cache.contains(child.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(
+ cache.contains(tableInChild.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertTrue(cache.contains(sibling.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertEquals(1, cache.size());
+ } finally {
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config",
previousConfig, true);
+ }
+ }
+
+ @Test
+ void testInvalidateLeafDoesNotEvictSameNameEntityOfAnotherType() {
+ // A cache key is "<identifier>:<type>", and ":" is also the default
schema separator. Only a
+ // schema can nest, so the schema-separator scan must not run for other
types, otherwise
+ // invalidating a table would also drop the topic of the same name.
+ Namespace schemaNs = schemaNamespace("schema1");
+ TableEntity table = TestUtil.getTestTableEntity(2L, "shared_name",
schemaNs);
+ TopicEntity topic = TestUtil.getTestTopicEntity(3L, "shared_name",
schemaNs, "cmt");
+
+ cache.put(table);
+ cache.put(topic);
+
+ cache.invalidate(table.nameIdentifier(), Entity.EntityType.TABLE);
+
+ Assertions.assertFalse(cache.contains(table.nameIdentifier(),
Entity.EntityType.TABLE));
+ Assertions.assertTrue(cache.contains(topic.nameIdentifier(),
Entity.EntityType.TOPIC));
+ }
}
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
index 042c29681b..c260b618db 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
@@ -36,6 +36,8 @@ 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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InOrder;
import org.mockito.Mockito;
@@ -157,13 +159,15 @@ public class TestRelationalEntityStore {
dst, Entity.EntityType.TAG,
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
}
- @Test
- void testUpdateEntityRelationsInvalidatesCacheAfterBackendUpdate()
+ @ParameterizedTest
+ @CsvSource({"TAG_METADATA_OBJECT_REL, TAG", "POLICY_METADATA_OBJECT_REL,
POLICY"})
+ void testUpdateEntityRelationsInvalidatesCacheAfterBackendUpdate(
+ SupportsRelationOperations.Type relationType, Entity.EntityType
destinationType)
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 destToAdd = NameIdentifier.of("metalake", "destination1");
+ NameIdentifier destToRemove = NameIdentifier.of("metalake",
"destination2");
NameIdentifier[] destEntitiesToAdd = new NameIdentifier[] {destToAdd};
NameIdentifier[] destEntitiesToRemove = new NameIdentifier[]
{destToRemove};
NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
@@ -171,61 +175,27 @@ public class TestRelationalEntityStore {
Mockito.doAnswer(
invocation -> {
Mockito.verify(cache, Mockito.never())
- .invalidate(
- src,
- Entity.EntityType.TABLE,
- SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+ .invalidate(src, Entity.EntityType.TABLE, relationType);
Mockito.verify(cache, Mockito.never())
- .invalidate(
- destToAdd,
- Entity.EntityType.TABLE,
- SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+ .invalidate(destToAdd, destinationType, relationType);
Mockito.verify(cache, Mockito.never())
- .invalidate(
- destToRemove,
- Entity.EntityType.TABLE,
- SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL);
+ .invalidate(destToRemove, destinationType, relationType);
return List.of();
})
.when(backend)
.updateEntityRelations(
- SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
- src,
- Entity.EntityType.TABLE,
- destEntitiesToAdd,
- destEntitiesToRemove);
+ relationType, src, Entity.EntityType.TABLE, destEntitiesToAdd,
destEntitiesToRemove);
store.updateEntityRelations(
- SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
- src,
- Entity.EntityType.TABLE,
- destEntitiesToAdd,
- destEntitiesToRemove);
+ relationType, 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);
+ relationType, src, Entity.EntityType.TABLE, destEntitiesToAdd,
destEntitiesToRemove);
+ inOrder.verify(cache).invalidate(src, Entity.EntityType.TABLE,
relationType);
+ inOrder.verify(cache).invalidate(destToAdd, destinationType, relationType);
+ inOrder.verify(cache).invalidate(destToRemove, destinationType,
relationType);
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
new file mode 100644
index 0000000000..77a8fd419f
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStoreHierarchicalCache.java
@@ -0,0 +1,229 @@
+/*
+ * 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 java.io.File;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.cache.CaffeineEntityCache;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.CatalogEntity;
+import org.apache.gravitino.meta.SchemaEntity;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+/**
+ * Verifies that dropping a hierarchical schema through a real {@link
RelationalEntityStore} also
+ * drops its nested descendants from the entity cache, for the default and a
non-default schema
+ * separator.
+ */
+public class TestRelationalEntityStoreHierarchicalCache {
+
+ private static final String METALAKE = "metalake_hs";
+ private static final String CATALOG = "catalog_hs";
+ private static final AuditInfo AUDIT_INFO =
+
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+
+ private RelationalEntityStore store;
+ private String dbPath;
+ private Object previousConfig;
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (store != null) {
+ store.close();
+ store = null;
+ }
+ if (dbPath != null) {
+ FileUtils.deleteQuietly(new File(dbPath));
+ dbPath = null;
+ }
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config",
previousConfig, true);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {":", "|"})
+ void testDropHierarchicalSchemaEvictsNestedDescendantsFromCache(String
separator)
+ throws Exception {
+ initStore(separator);
+
+ String parentName = String.join(separator, "raw", "events");
+ String childName = String.join(separator, "raw", "events", "2024");
+ String siblingName = String.join(separator, "raw", "events2");
+
+ store.put(metalake(), false);
+ store.put(catalog(), false);
+ SchemaEntity parent = schema(parentName);
+ SchemaEntity child = schema(childName);
+ SchemaEntity sibling = schema(siblingName);
+ store.put(parent, false);
+ store.put(child, false);
+ store.put(sibling, false);
+ TableEntity tableInChild = table("t_child", childName);
+ store.put(tableInChild, false);
+
+ // Read everything back so the cache is populated with the names the store
actually returns.
+ store.get(parent.nameIdentifier(), Entity.EntityType.SCHEMA,
SchemaEntity.class);
+ store.get(child.nameIdentifier(), Entity.EntityType.SCHEMA,
SchemaEntity.class);
+ store.get(sibling.nameIdentifier(), Entity.EntityType.SCHEMA,
SchemaEntity.class);
+ store.get(tableInChild.nameIdentifier(), Entity.EntityType.TABLE,
TableEntity.class);
+ Assertions.assertTrue(
+ store.getCache().contains(child.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertTrue(
+ store.getCache().contains(tableInChild.nameIdentifier(),
Entity.EntityType.TABLE));
+
+ // The cache is keyed by the identifier that reaches the store, which
still carries the
+ // configured external separator; the physical separator only exists in
the backend rows.
+ Set<String> cacheKeys =
+ ((CaffeineEntityCache) store.getCache())
+ .getCacheData().asMap().keySet().stream()
+ .map(Object::toString)
+ .collect(Collectors.toSet());
+ Assertions.assertTrue(
+ cacheKeys.stream().anyMatch(key -> key.contains(childName)),
+ "nested schema must be cached under its logical name, keys: " +
cacheKeys);
+ Assertions.assertTrue(
+ cacheKeys.stream()
+ .noneMatch(key ->
key.contains(HierarchicalSchemaUtil.physicalSeparator())),
+ "no cache key may carry the physical separator, keys: " + cacheKeys);
+
+ store.delete(parent.nameIdentifier(), Entity.EntityType.SCHEMA, true);
+
+ Assertions.assertFalse(
+ store.getCache().contains(parent.nameIdentifier(),
Entity.EntityType.SCHEMA));
+ Assertions.assertFalse(
+ store.getCache().contains(child.nameIdentifier(),
Entity.EntityType.SCHEMA),
+ "nested schema must not survive the drop of its parent");
+ Assertions.assertFalse(
+ store.getCache().contains(tableInChild.nameIdentifier(),
Entity.EntityType.TABLE),
+ "table of a nested schema must not survive the drop of the parent
schema");
+ Assertions.assertTrue(
+ store.getCache().contains(sibling.nameIdentifier(),
Entity.EntityType.SCHEMA),
+ "a sibling sharing a name prefix must not be invalidated");
+ }
+
+ private void initStore(String separator) throws Exception {
+ dbPath = "/tmp/gravitino_hs_cache_test_" +
UUID.randomUUID().toString().replace("-", "");
+ File dir = new File(dbPath);
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Failed to create test directory " + dbPath);
+ }
+
+ Config config = Mockito.mock(Config.class);
+
Mockito.when(config.get(Configs.ENTITY_STORE)).thenReturn(Configs.RELATIONAL_ENTITY_STORE);
+ Mockito.when(config.get(Configs.ENTITY_RELATIONAL_STORE))
+ .thenReturn(Configs.DEFAULT_ENTITY_RELATIONAL_STORE);
+ Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_URL))
+
.thenReturn(String.format("jdbc:h2:file:%s;DB_CLOSE_DELAY=-1;MODE=MYSQL",
dbPath));
+
Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_USER)).thenReturn("root");
+
Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PASSWORD)).thenReturn("123456");
+ Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_DRIVER))
+ .thenReturn("org.h2.Driver");
+
Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS))
+ .thenReturn(Configs.DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_CONNECTIONS);
+
Mockito.when(config.get(Configs.ENTITY_RELATIONAL_JDBC_BACKEND_WAIT_MILLISECONDS))
+
.thenReturn(Configs.DEFAULT_RELATIONAL_JDBC_BACKEND_MAX_WAIT_MILLISECONDS);
+ Mockito.when(config.get(Configs.STORE_DELETE_AFTER_TIME)).thenReturn(20 *
60 * 1000L);
+ Mockito.when(config.get(Configs.VERSION_RETENTION_COUNT)).thenReturn(1L);
+
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_POLL_INTERVAL_SECS)).thenReturn(3L);
+
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_RETENTION_SECS)).thenReturn(24
* 60 * 60L);
+
Mockito.when(config.get(Configs.ENTITY_CHANGE_LOG_CLEANUP_INTERVAL_SECS)).thenReturn(60
* 60L);
+ Mockito.when(config.get(Configs.CACHE_ENABLED)).thenReturn(true);
+
Mockito.when(config.get(Configs.CACHE_IMPLEMENTATION)).thenReturn("caffeine");
+ Mockito.when(config.get(Configs.CACHE_MAX_ENTRIES)).thenReturn(10_000);
+
Mockito.when(config.get(Configs.CACHE_EXPIRATION_TIME)).thenReturn(3_600_000L);
+ Mockito.when(config.get(Configs.CACHE_WEIGHER_ENABLED)).thenReturn(true);
+ Mockito.when(config.get(Configs.CACHE_STATS_ENABLED)).thenReturn(false);
+ Mockito.when(config.get(Configs.CACHE_LOCK_SEGMENTS)).thenReturn(16);
+ Mockito.when(config.get(Configs.SCHEMA_SEPARATOR)).thenReturn(separator);
+
+ previousConfig = FieldUtils.readField(GravitinoEnv.getInstance(),
"config", true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, true);
+ FieldUtils.writeField(
+ GravitinoEnv.getInstance(), "idGenerator", RandomIdGenerator.INSTANCE,
true);
+ Assertions.assertEquals(separator,
HierarchicalSchemaUtil.schemaSeparator());
+
+ store = new RelationalEntityStore();
+ store.initialize(config);
+ }
+
+ private static BaseMetalake metalake() {
+ return BaseMetalake.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(METALAKE)
+ .withAuditInfo(AUDIT_INFO)
+ .withComment("")
+ .withProperties(null)
+ .withVersion(SchemaVersion.V_0_1)
+ .build();
+ }
+
+ private static CatalogEntity catalog() {
+ return CatalogEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(CATALOG)
+ .withNamespace(Namespace.of(METALAKE))
+ .withType(Catalog.Type.RELATIONAL)
+ .withProvider("test")
+ .withComment("")
+ .withProperties(null)
+ .withAuditInfo(AUDIT_INFO)
+ .build();
+ }
+
+ private static SchemaEntity schema(String name) {
+ return SchemaEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(name)
+ .withNamespace(Namespace.of(METALAKE, CATALOG))
+ .withComment("")
+ .withProperties(null)
+ .withAuditInfo(AUDIT_INFO)
+ .build();
+ }
+
+ private static TableEntity table(String name, String schemaName) {
+ return TableEntity.builder()
+ .withId(RandomIdGenerator.INSTANCE.nextId())
+ .withName(name)
+ .withNamespace(Namespace.of(METALAKE, CATALOG, schemaName))
+ .withAuditInfo(AUDIT_INFO)
+ .build();
+ }
+}